From df9ebd418bce59715e8b8cc70d61954922ade259 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Wed, 15 Jul 2026 10:35:47 -0400 Subject: [PATCH] feat(api): add credential-backed auto re-login for browser automations Browser automations can now sign back in on their own when a vendor session expires, instead of always requiring a person to re-authenticate. - Store a profile's login (username, password, optional authenticator setup key) in a per-organization 1Password vault via a new credential-storage service and a POST profiles/:id/credentials endpoint. - Resolve those credentials just-in-time during a run through the previously-stubbed credential vault adapter, now backed by 1Password, and drive sign-in with Stagehand variable substitution so secret values are never sent to the model. - Retry once with a freshly generated one-time code to cover the rotation boundary; fall back to human re-authentication when a login needs a step the agent cannot complete (SMS/email/push/SSO). - Let the scheduler attempt a run for a needs-reauth profile that has stored credentials, and mark a profile verified again after a successful run. - Load the 1Password SDK lazily and mark it external for Trigger.dev builds; the feature stays inert unless OP_SERVICE_ACCOUNT_TOKEN is set. Adds unit tests for the credential resolver and the re-login flow. --- apps/api/.env.example | 4 + apps/api/package.json | 1 + .../browser-auth-profile.service.ts | 18 ++ .../browser-auth-profiles.controller.ts | 25 +++ .../browser-automation-execution.service.ts | 17 +- .../browser-credential-login.spec.ts | 186 ++++++++++++++++++ .../browserbase/browser-credential-login.ts | 153 ++++++++++++++ .../browser-credential-storage.service.ts | 148 ++++++++++++++ .../browser-credential-vault.factory.ts | 18 ++ .../browserbase/browser-evidence-execution.ts | 41 +++- .../browser-evidence-runner.service.ts | 10 +- .../api/src/browserbase/browserbase.module.ts | 8 + .../browserbase/browserbase.service.spec.ts | 13 ++ .../src/browserbase/browserbase.service.ts | 12 ++ .../src/browserbase/dto/browserbase.dto.ts | 20 ++ .../api/src/browserbase/onepassword-client.ts | 57 ++++++ .../onepassword-credential-item.ts | 32 +++ ...epassword-credential-vault.adapter.spec.ts | 104 ++++++++++ .../onepassword-credential-vault.adapter.ts | 78 ++++++++ apps/api/trigger.config.ts | 3 + bun.lock | 5 + 21 files changed, 947 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/browserbase/browser-credential-login.spec.ts create mode 100644 apps/api/src/browserbase/browser-credential-login.ts create mode 100644 apps/api/src/browserbase/browser-credential-storage.service.ts create mode 100644 apps/api/src/browserbase/browser-credential-vault.factory.ts create mode 100644 apps/api/src/browserbase/onepassword-client.ts create mode 100644 apps/api/src/browserbase/onepassword-credential-item.ts create mode 100644 apps/api/src/browserbase/onepassword-credential-vault.adapter.spec.ts create mode 100644 apps/api/src/browserbase/onepassword-credential-vault.adapter.ts diff --git a/apps/api/.env.example b/apps/api/.env.example index f2b813f1c1..19fe3dc066 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -48,6 +48,10 @@ OPENAI_API_KEY= ANTHROPIC_API_KEY= GROQ_API_KEY= +# 1Password service account token for browser-automation credential storage. +# Optional: when unset, browser automations fall back to human re-authentication. +OP_SERVICE_ACCOUNT_TOKEN= + # Inference.net Catalyst tracing (optional — no-op if CATALYST_OTLP_TOKEN is unset) CATALYST_OTLP_TOKEN= CATALYST_OTLP_ENDPOINT=https://telemetry.inference.net diff --git a/apps/api/package.json b/apps/api/package.json index 423679e678..2a23a80361 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -4,6 +4,7 @@ "version": "0.0.1", "author": "", "dependencies": { + "@1password/sdk": "0.4.0", "@ai-sdk/anthropic": "^3.0.75", "@ai-sdk/groq": "^3.0.38", "@ai-sdk/openai": "^3.0.62", diff --git a/apps/api/src/browserbase/browser-auth-profile.service.ts b/apps/api/src/browserbase/browser-auth-profile.service.ts index 0d18c60932..a21363be13 100644 --- a/apps/api/src/browserbase/browser-auth-profile.service.ts +++ b/apps/api/src/browserbase/browser-auth-profile.service.ts @@ -206,6 +206,24 @@ export class BrowserAuthProfileService { return { profile: updated, auth }; } + async markVerified(input: { organizationId: string; profileId: string }) { + const profile = await this.getProfile(input); + if (!profile) { + throw new NotFoundException('Browser auth profile not found'); + } + // Avoid a needless write when the profile is already verified. + if (profile.status === 'verified') return profile; + + return db.browserAuthProfile.update({ + where: { id: profile.id }, + data: { + status: 'verified', + lastVerifiedAt: new Date(), + blockedReason: null, + }, + }); + } + async markNeedsReauth(input: { organizationId: string; profileId: string; diff --git a/apps/api/src/browserbase/browser-auth-profiles.controller.ts b/apps/api/src/browserbase/browser-auth-profiles.controller.ts index 10d2c2b347..0c4f821a3f 100644 --- a/apps/api/src/browserbase/browser-auth-profiles.controller.ts +++ b/apps/api/src/browserbase/browser-auth-profiles.controller.ts @@ -18,6 +18,7 @@ import { ResolveAuthProfileDto, ResolveAuthProfileResponseDto, SessionResponseDto, + StoreAuthProfileCredentialsDto, VerifyAuthProfileResponseDto, VerifyAuthProfileSessionDto, } from './dto/browserbase.dto'; @@ -112,6 +113,30 @@ export class BrowserAuthProfilesController { })) as VerifyAuthProfileResponseDto; } + @Post('profiles/:profileId/credentials') + @RequirePermission('integration', 'update') + @ApiOperation({ + summary: 'Store browser auth profile credentials', + description: + 'Store the login (username, password, optional authenticator setup key) for a browser auth profile in the organization vault so scheduled and manual runs can sign in automatically when a session expires.', + }) + @ApiParam({ name: 'profileId', description: 'Browser auth profile ID' }) + @ApiBody({ type: StoreAuthProfileCredentialsDto }) + @ApiResponse({ status: 200, type: BrowserAuthProfileResponseDto }) + async storeProfileCredentials( + @OrganizationId() organizationId: string, + @Param('profileId') profileId: string, + @Body() dto: StoreAuthProfileCredentialsDto, + ): Promise { + return (await this.browserbaseService.storeAuthProfileCredentials({ + organizationId, + profileId, + username: dto.username, + password: dto.password, + totpSeed: dto.totpSeed, + })) as BrowserAuthProfileResponseDto; + } + @Post('profiles/:profileId/needs-reauth') @RequirePermission('integration', 'update') @ApiOperation({ diff --git a/apps/api/src/browserbase/browser-automation-execution.service.ts b/apps/api/src/browserbase/browser-automation-execution.service.ts index 098082fd50..fb752d0802 100644 --- a/apps/api/src/browserbase/browser-automation-execution.service.ts +++ b/apps/api/src/browserbase/browser-automation-execution.service.ts @@ -137,7 +137,12 @@ export class BrowserAutomationExecutionService { profileId: profile.id, }); - if (profile.status !== 'verified') { + // A profile with stored credentials can recover an expired session on its + // own, so let a needs_reauth profile attempt the run instead of blocking it. + // Profiles that are blocked or never verified still require a human. + const canAutoRelogin = + profile.status === 'needs_reauth' && Boolean(profile.vaultProvider); + if (profile.status !== 'verified' && !canAutoRelogin) { const result = this.profileBlockedResult(profile.status); await this.runs.finishRun({ runId: run.id, @@ -188,6 +193,16 @@ export class BrowserAutomationExecutionService { profileId: string; result: BrowserEvidenceRunResult; }) { + // A successful run proves the session is valid again — clear any prior + // needs_reauth/blocked state (e.g. after a credential-backed auto sign-in). + if (input.result.success) { + await this.profiles.markVerified({ + organizationId: input.organizationId, + profileId: input.profileId, + }); + return; + } + if (input.result.failureCode === 'needs_reauth') { await this.profiles.markNeedsReauth({ organizationId: input.organizationId, diff --git a/apps/api/src/browserbase/browser-credential-login.spec.ts b/apps/api/src/browserbase/browser-credential-login.spec.ts new file mode 100644 index 0000000000..415721a857 --- /dev/null +++ b/apps/api/src/browserbase/browser-credential-login.spec.ts @@ -0,0 +1,186 @@ +import { + performCredentialLogin, + reloginWithStoredCredentials, +} from './browser-credential-login'; +import type { BrowserCredentialVaultAdapter } from './credential-vault'; +import type { BrowserbaseSessionService } from './browserbase-session.service'; +import type { BrowserEvidenceSessionInput } from './browser-evidence-runner.service'; + +type Stagehand = import('@browserbasehq/stagehand').Stagehand; + +const makeStagehand = () => ({ act: jest.fn().mockResolvedValue(undefined) }); +const makePage = () => ({ goto: jest.fn().mockResolvedValue(undefined) }); +const makeSessions = (page: ReturnType) => ({ + ensureActivePage: jest.fn().mockResolvedValue(page), +}); + +const baseInput: BrowserEvidenceSessionInput = { + organizationId: 'org_1', + automationId: 'bau_1', + runId: 'bar_1', + targetUrl: 'https://vendor.example.com/app', + instruction: 'capture evidence', + profile: { + id: 'bap_1', + hostname: 'vendor.example.com', + contextId: 'ctx_1', + vaultProvider: '1password', + vaultExternalItemRef: 'op://v/i', + vaultConnectionId: 'v', + }, + sessionId: 'sess_1', +}; + +describe('performCredentialLogin', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('passes secrets through act variables and never in the prompt text', async () => { + const stagehand = makeStagehand(); + const password = 'sup3r-s3cret-passphrase'; + + const promise = performCredentialLogin({ + stagehand: stagehand as unknown as Stagehand, + credentials: { username: 'alice', password, totpCode: '424242' }, + log: jest.fn(), + }); + await jest.runAllTimersAsync(); + await promise; + + expect(stagehand.act).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('%username%'), + { variables: { username: 'alice', password } }, + ); + expect(stagehand.act).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('%code%'), + { variables: { code: '424242' } }, + ); + + // The actual secret values must not appear in the instruction sent to the LLM. + const credentialInstruction = stagehand.act.mock.calls[0][0] as string; + expect(credentialInstruction).not.toContain(password); + expect(credentialInstruction).not.toContain('alice'); + }); +}); + +describe('reloginWithStoredCredentials', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + const runRelogin = async (args: { + stagehand: ReturnType; + sessions: ReturnType; + vault: BrowserCredentialVaultAdapter; + verifyLoggedIn: jest.Mock; + }) => { + const promise = reloginWithStoredCredentials({ + stagehand: args.stagehand as unknown as Stagehand, + sessions: args.sessions as unknown as BrowserbaseSessionService, + vault: args.vault, + input: baseInput, + verifyLoggedIn: args.verifyLoggedIn, + log: jest.fn(), + }); + await jest.runAllTimersAsync(); + return promise; + }; + + it('does not attempt a login when no credentials are stored', async () => { + const stagehand = makeStagehand(); + const page = makePage(); + const vault: BrowserCredentialVaultAdapter = { + resolveCredentialReference: jest.fn().mockResolvedValue(null), + }; + + const result = await runRelogin({ + stagehand, + sessions: makeSessions(page), + vault, + verifyLoggedIn: jest.fn().mockResolvedValue(false), + }); + + expect(result.isLoggedIn).toBe(false); + expect(result.reason).toMatch(/no stored credentials/i); + expect(stagehand.act).not.toHaveBeenCalled(); + }); + + it('signs in and returns to the target URL when verification passes', async () => { + const stagehand = makeStagehand(); + const page = makePage(); + const vault: BrowserCredentialVaultAdapter = { + resolveCredentialReference: jest + .fn() + .mockResolvedValue({ username: 'alice', password: 'pw' }), + }; + + const result = await runRelogin({ + stagehand, + sessions: makeSessions(page), + vault, + verifyLoggedIn: jest.fn().mockResolvedValue(true), + }); + + expect(result.isLoggedIn).toBe(true); + expect(stagehand.act).toHaveBeenCalledTimes(1); + expect(page.goto).toHaveBeenCalledWith( + baseInput.targetUrl, + expect.objectContaining({ waitUntil: 'domcontentloaded' }), + ); + }); + + it('retries once with a freshly resolved TOTP code', async () => { + const stagehand = makeStagehand(); + const page = makePage(); + const resolveCredentialReference = jest + .fn() + .mockResolvedValueOnce({ + username: 'alice', + password: 'pw', + totpCode: '111111', + }) + .mockResolvedValueOnce({ + username: 'alice', + password: 'pw', + totpCode: '222222', + }); + const vault: BrowserCredentialVaultAdapter = { resolveCredentialReference }; + const verifyLoggedIn = jest + .fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + + const result = await runRelogin({ + stagehand, + sessions: makeSessions(page), + vault, + verifyLoggedIn, + }); + + expect(result.isLoggedIn).toBe(true); + expect(resolveCredentialReference).toHaveBeenCalledTimes(2); + // Two login attempts: each enters credentials + a TOTP code (2 acts each). + expect(stagehand.act).toHaveBeenCalledTimes(4); + }); + + it('gives up (user action required) when sign-in never authenticates', async () => { + const stagehand = makeStagehand(); + const page = makePage(); + const vault: BrowserCredentialVaultAdapter = { + resolveCredentialReference: jest + .fn() + .mockResolvedValue({ username: 'alice', password: 'pw' }), + }; + + const result = await runRelogin({ + stagehand, + sessions: makeSessions(page), + vault, + verifyLoggedIn: jest.fn().mockResolvedValue(false), + }); + + expect(result.isLoggedIn).toBe(false); + expect(result.reason).toMatch(/user action/i); + }); +}); diff --git a/apps/api/src/browserbase/browser-credential-login.ts b/apps/api/src/browserbase/browser-credential-login.ts new file mode 100644 index 0000000000..d7f6859bda --- /dev/null +++ b/apps/api/src/browserbase/browser-credential-login.ts @@ -0,0 +1,153 @@ +import type { + BrowserCredentialVaultAdapter, + RuntimeCredentialMaterial, +} from './credential-vault'; +import type { BrowserbaseSessionService } from './browserbase-session.service'; +import type { BrowserEvidenceSessionInput } from './browser-evidence-runner.service'; + +type Stagehand = import('@browserbasehq/stagehand').Stagehand; +type ActivePage = Awaited< + ReturnType +>; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Drives an automated sign-in using stored credentials. Secret values are passed + * through Stagehand's `variables` substitution, so they are injected into the page + * at execution time and are never sent to the model as prompt text. + */ +export async function performCredentialLogin({ + stagehand, + credentials, + log, +}: { + stagehand: Stagehand; + credentials: RuntimeCredentialMaterial; + log: (message: string) => void; +}): Promise { + const variables: Record = {}; + if (credentials.username) variables.username = credentials.username; + if (credentials.password) variables.password = credentials.password; + + if (credentials.username || credentials.password) { + log('Entering stored credentials.'); + await stagehand.act( + 'Enter the username %username% and the password %password% into the sign-in form and submit it. If only one field is visible at a time, fill it and continue to the next step.', + { variables }, + ); + await delay(2000); + } + + if (credentials.totpCode) { + log('Entering one-time passcode.'); + await stagehand.act( + 'If a one-time passcode, two-factor authentication, or verification code field is shown, enter %code% and submit. If no such field is present, do nothing.', + { variables: { code: credentials.totpCode } }, + ); + await delay(2000); + } +} + +export interface CredentialReloginResult { + isLoggedIn: boolean; + page: ActivePage; + reason?: string; +} + +/** + * Attempts to recover an expired session using credentials stored for the profile. + * Returns `isLoggedIn: false` (with a reason) when no credentials exist or the + * automated sign-in couldn't complete — e.g. a login step needs a human (SMS, + * email code, push approval), which is left to the existing re-auth fallback. + */ +export async function reloginWithStoredCredentials({ + stagehand, + sessions, + vault, + input, + verifyLoggedIn, + log, +}: { + stagehand: Stagehand; + sessions: BrowserbaseSessionService; + vault: BrowserCredentialVaultAdapter; + input: BrowserEvidenceSessionInput; + verifyLoggedIn: () => Promise; + log: (message: string) => void; +}): Promise { + const resolveCredentials = () => + vault.resolveCredentialReference({ + profileId: input.profile.id, + provider: input.profile.vaultProvider, + externalItemRef: input.profile.vaultExternalItemRef, + connectionId: input.profile.vaultConnectionId, + }); + + const credentials = await resolveCredentials(); + let page = await sessions.ensureActivePage(stagehand); + + if (!credentials) { + return { + isLoggedIn: false, + page, + reason: 'Session expired and no stored credentials are available.', + }; + } + + page = await runLoginAttempt({ + stagehand, + sessions, + credentials, + input, + log, + }); + if (await verifyLoggedIn()) return { isLoggedIn: true, page }; + + // Retry once with a freshly resolved TOTP code, which guards against the ~30s + // rotation boundary landing between resolve and submit. + if (credentials.totpCode) { + const fresh = await resolveCredentials(); + if (fresh?.totpCode) { + page = await runLoginAttempt({ + stagehand, + sessions, + credentials: fresh, + input, + log, + }); + if (await verifyLoggedIn()) return { isLoggedIn: true, page }; + } + } + + return { + isLoggedIn: false, + page, + reason: 'Automated sign-in did not complete; user action may be required.', + }; +} + +async function runLoginAttempt({ + stagehand, + sessions, + credentials, + input, + log, +}: { + stagehand: Stagehand; + sessions: BrowserbaseSessionService; + credentials: RuntimeCredentialMaterial; + input: BrowserEvidenceSessionInput; + log: (message: string) => void; +}): Promise { + await performCredentialLogin({ stagehand, credentials, log }); + const page = await sessions.ensureActivePage(stagehand); + // Return to the target URL so the evidence step always starts from the same + // place, regardless of where the login flow redirected. + await page.goto(input.targetUrl, { + waitUntil: 'domcontentloaded', + timeoutMs: 30000, + }); + await delay(1500); + return page; +} diff --git a/apps/api/src/browserbase/browser-credential-storage.service.ts b/apps/api/src/browserbase/browser-credential-storage.service.ts new file mode 100644 index 0000000000..39fd19ad66 --- /dev/null +++ b/apps/api/src/browserbase/browser-credential-storage.service.ts @@ -0,0 +1,148 @@ +import { + Injectable, + Logger, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { db } from '@db'; +import type { ItemField } from '@1password/sdk'; +import { + getOnePasswordClient, + isOnePasswordConfigured, + loadOnePasswordModule, + type OnePasswordClient, +} from './onepassword-client'; +import { + ONEPASSWORD_PROVIDER, + PASSWORD_FIELD_TITLE, + TOTP_FIELD_TITLE, + USERNAME_FIELD_TITLE, + buildItemReference, + buildOrgVaultTitle, +} from './onepassword-credential-item'; + +export interface StoreProfileCredentialsInput { + organizationId: string; + profileId: string; + username: string; + password: string; + totpSeed?: string; +} + +/** + * Writes a browser auth profile's login (username, password, optional TOTP seed) + * into a per-organization 1Password vault and points the profile at the created + * item. Comp never persists the raw secrets itself — only the `op://` reference. + */ +@Injectable() +export class BrowserCredentialStorageService { + private readonly logger = new Logger(BrowserCredentialStorageService.name); + + async storeProfileCredentials(input: StoreProfileCredentialsInput) { + if (!isOnePasswordConfigured()) { + throw new ServiceUnavailableException( + 'Credential storage is not configured for this environment.', + ); + } + + const profile = await db.browserAuthProfile.findFirst({ + where: { id: input.profileId, organizationId: input.organizationId }, + }); + if (!profile) { + throw new NotFoundException('Browser auth profile not found'); + } + + const client = await getOnePasswordClient(); + const vaultId = await this.ensureOrgVault({ + client, + organizationId: input.organizationId, + }); + + const fields = await this.buildLoginFields({ + username: input.username, + password: input.password, + totpSeed: input.totpSeed, + }); + + const { ItemCategory } = await loadOnePasswordModule(); + const item = await client.items.create({ + category: ItemCategory.Login, + vaultId, + title: `${profile.displayName} (${profile.hostname})`, + fields, + }); + + const itemRef = buildItemReference(vaultId, item.id); + this.logger.log( + `Stored browser credentials for profile ${profile.id} in 1Password.`, + ); + + return db.browserAuthProfile.update({ + where: { id: profile.id }, + data: { + vaultProvider: ONEPASSWORD_PROVIDER, + vaultExternalItemRef: itemRef, + vaultConnectionId: vaultId, + }, + }); + } + + private async ensureOrgVault({ + client, + organizationId, + }: { + client: OnePasswordClient; + organizationId: string; + }): Promise { + const title = buildOrgVaultTitle(organizationId); + const existing = await client.vaults.list(); + const match = existing.find((vault) => vault.title === title); + if (match) return match.id; + + const created = await client.vaults.create({ + title, + description: `Browser automation logins for organization ${organizationId}.`, + }); + this.logger.log( + `Created 1Password vault for organization ${organizationId}.`, + ); + return created.id; + } + + private async buildLoginFields({ + username, + password, + totpSeed, + }: { + username: string; + password: string; + totpSeed?: string; + }): Promise { + const { ItemFieldType } = await loadOnePasswordModule(); + const fields: ItemField[] = [ + { + id: USERNAME_FIELD_TITLE, + title: USERNAME_FIELD_TITLE, + fieldType: ItemFieldType.Text, + value: username, + }, + { + id: PASSWORD_FIELD_TITLE, + title: PASSWORD_FIELD_TITLE, + fieldType: ItemFieldType.Concealed, + value: password, + }, + ]; + + if (totpSeed?.trim()) { + fields.push({ + id: TOTP_FIELD_TITLE, + title: TOTP_FIELD_TITLE, + fieldType: ItemFieldType.Totp, + value: totpSeed.trim(), + }); + } + + return fields; + } +} diff --git a/apps/api/src/browserbase/browser-credential-vault.factory.ts b/apps/api/src/browserbase/browser-credential-vault.factory.ts new file mode 100644 index 0000000000..01a4795a69 --- /dev/null +++ b/apps/api/src/browserbase/browser-credential-vault.factory.ts @@ -0,0 +1,18 @@ +import { + type BrowserCredentialVaultAdapter, + NoopBrowserCredentialVaultAdapter, +} from './credential-vault'; +import { isOnePasswordConfigured } from './onepassword-client'; +import { OnePasswordCredentialVaultAdapter } from './onepassword-credential-vault.adapter'; + +/** + * Picks the credential vault adapter for the current runtime: the 1Password + * adapter when a service account token is configured, otherwise the Noop adapter + * (which resolves nothing, preserving today's human re-auth behavior). + */ +export function resolveBrowserCredentialVaultAdapter(): BrowserCredentialVaultAdapter { + if (isOnePasswordConfigured()) { + return new OnePasswordCredentialVaultAdapter(); + } + return new NoopBrowserCredentialVaultAdapter(); +} diff --git a/apps/api/src/browserbase/browser-evidence-execution.ts b/apps/api/src/browserbase/browser-evidence-execution.ts index 85cc9c6560..d7fe85ad59 100644 --- a/apps/api/src/browserbase/browser-evidence-execution.ts +++ b/apps/api/src/browserbase/browser-evidence-execution.ts @@ -14,6 +14,8 @@ import { classifyBrowserAutomationError, } from './browser-automation-errors'; import type { BrowserEvidenceSessionInput } from './browser-evidence-runner.service'; +import type { BrowserCredentialVaultAdapter } from './credential-vault'; +import { reloginWithStoredCredentials } from './browser-credential-login'; type Stagehand = import('@browserbasehq/stagehand').Stagehand; @@ -44,10 +46,12 @@ export async function executeBrowserEvidence({ input, sessions, logger, + vault, }: { input: BrowserEvidenceSessionInput; sessions: BrowserbaseSessionService; logger: Logger; + vault: BrowserCredentialVaultAdapter; }): Promise { const logs: BrowserEvidenceLog[] = []; const log = (stage: string, message: string) => { @@ -59,6 +63,9 @@ export async function executeBrowserEvidence({ try { log('session', 'Initializing Stagehand session.'); stagehand = await sessions.createStagehand(input.sessionId); + // Stable non-null handle for use inside async closures below, where the + // `let stagehand` binding would otherwise widen back to `Stagehand | null`. + const activeStagehand = stagehand; const initialPage = await sessions.ensureActivePage(stagehand); let page = initialPage; @@ -74,12 +81,38 @@ export async function executeBrowserEvidence({ const authCheck = await checkAuth(stagehand); if (!authCheck.isLoggedIn) { - const classified = classifyBrowserAutomationError( - new Error('Session expired. User is not logged in.'), + log( 'auth', + 'Session expired; attempting sign-in with stored credentials.', ); - log('auth', classified.userFacing); - return toExecutionFailure({ classified, logs }); + const relogin = await reloginWithStoredCredentials({ + stagehand: activeStagehand, + sessions, + vault, + input, + verifyLoggedIn: async () => + (await checkAuth(activeStagehand)).isLoggedIn, + log: (message) => log('auth', message), + }); + page = relogin.page ?? page; + + if (!relogin.isLoggedIn) { + // Classify our own known outcome directly rather than relying on string + // matching: auto sign-in couldn't establish a session, so the profile + // needs a human to reconnect (e.g. SMS/email/push/SSO login). + const classified: ClassifiedBrowserAutomationError = { + code: 'needs_reauth', + stage: 'auth', + userFacing: + relogin.reason ?? + 'Authentication is no longer valid. Reconnect this browser profile.', + needsReauth: true, + blockedReason: 'Automated sign-in could not establish a session.', + }; + log('auth', classified.userFacing); + return toExecutionFailure({ classified, logs }); + } + log('auth', 'Re-authenticated with stored credentials.'); } currentStage = 'action'; diff --git a/apps/api/src/browserbase/browser-evidence-runner.service.ts b/apps/api/src/browserbase/browser-evidence-runner.service.ts index 0e07b19582..84aa1b8487 100644 --- a/apps/api/src/browserbase/browser-evidence-runner.service.ts +++ b/apps/api/src/browserbase/browser-evidence-runner.service.ts @@ -1,7 +1,12 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import { Prisma } from '@db'; import { BrowserbaseSessionService } from './browserbase-session.service'; import { BrowserbaseScreenshotService } from './browserbase-screenshot.service'; +import { + BROWSER_CREDENTIAL_VAULT_ADAPTER, + type BrowserCredentialVaultAdapter, +} from './credential-vault'; +import { resolveBrowserCredentialVaultAdapter } from './browser-credential-vault.factory'; import { type BrowserAutomationFailureCode, type BrowserAutomationFailureStage, @@ -68,6 +73,8 @@ export class BrowserEvidenceRunnerService { constructor( private readonly sessions: BrowserbaseSessionService = new BrowserbaseSessionService(), private readonly screenshots: BrowserbaseScreenshotService = new BrowserbaseScreenshotService(), + @Inject(BROWSER_CREDENTIAL_VAULT_ADAPTER) + private readonly vault: BrowserCredentialVaultAdapter = resolveBrowserCredentialVaultAdapter(), ) {} async runEvidence( @@ -112,6 +119,7 @@ export class BrowserEvidenceRunnerService { input, sessions: this.sessions, logger: this.logger, + vault: this.vault, }); let uploaded: { screenshotKey: string; screenshotUrl: string } | null = null; diff --git a/apps/api/src/browserbase/browserbase.module.ts b/apps/api/src/browserbase/browserbase.module.ts index f0497ab123..2346ad2130 100644 --- a/apps/api/src/browserbase/browserbase.module.ts +++ b/apps/api/src/browserbase/browserbase.module.ts @@ -11,6 +11,9 @@ import { BrowserbaseOrgContextService } from './browserbase-org-context.service' import { BrowserbaseScreenshotService } from './browserbase-screenshot.service'; import { BrowserbaseSessionService } from './browserbase-session.service'; import { BrowserbaseService } from './browserbase.service'; +import { BrowserCredentialStorageService } from './browser-credential-storage.service'; +import { BROWSER_CREDENTIAL_VAULT_ADAPTER } from './credential-vault'; +import { resolveBrowserCredentialVaultAdapter } from './browser-credential-vault.factory'; import { AuthModule } from '../auth/auth.module'; @Module({ @@ -27,6 +30,11 @@ import { AuthModule } from '../auth/auth.module'; BrowserbaseOrgContextService, BrowserbaseScreenshotService, BrowserEvidenceRunnerService, + BrowserCredentialStorageService, + { + provide: BROWSER_CREDENTIAL_VAULT_ADAPTER, + useFactory: resolveBrowserCredentialVaultAdapter, + }, ], exports: [BrowserbaseService], }) diff --git a/apps/api/src/browserbase/browserbase.service.spec.ts b/apps/api/src/browserbase/browserbase.service.spec.ts index 29d21ee2f0..62e7cc16b2 100644 --- a/apps/api/src/browserbase/browserbase.service.spec.ts +++ b/apps/api/src/browserbase/browserbase.service.spec.ts @@ -6,11 +6,14 @@ import { BrowserAutomationExecutionService } from './browser-automation-executio import { BrowserAutomationRunStoreService } from './browser-automation-run-store.service'; import { BrowserAuthProfileContextService } from './browser-auth-profile-context.service'; import { BrowserAuthProfileService } from './browser-auth-profile.service'; +import { BrowserCredentialStorageService } from './browser-credential-storage.service'; import { BrowserEvidenceRunnerService } from './browser-evidence-runner.service'; import { BrowserbaseOrgContextService } from './browserbase-org-context.service'; import { BrowserbaseScreenshotService } from './browserbase-screenshot.service'; import { BrowserbaseSessionService } from './browserbase-session.service'; import { BrowserbaseService } from './browserbase.service'; +import { BROWSER_CREDENTIAL_VAULT_ADAPTER } from './credential-vault'; +import { resolveBrowserCredentialVaultAdapter } from './browser-credential-vault.factory'; jest.mock('@db', () => ({ db: { @@ -57,6 +60,11 @@ describe('BrowserbaseService.getScreenshotRedirectUrl', () => { BrowserbaseOrgContextService, BrowserbaseScreenshotService, BrowserEvidenceRunnerService, + BrowserCredentialStorageService, + { + provide: BROWSER_CREDENTIAL_VAULT_ADAPTER, + useFactory: resolveBrowserCredentialVaultAdapter, + }, ], }).compile(); service = moduleRef.get(BrowserbaseService); @@ -175,6 +183,11 @@ describe('BrowserbaseService schedule frequency passthrough', () => { BrowserbaseOrgContextService, BrowserbaseScreenshotService, BrowserEvidenceRunnerService, + BrowserCredentialStorageService, + { + provide: BROWSER_CREDENTIAL_VAULT_ADAPTER, + useFactory: resolveBrowserCredentialVaultAdapter, + }, ], }).compile(); service = moduleRef.get(BrowserbaseService); diff --git a/apps/api/src/browserbase/browserbase.service.ts b/apps/api/src/browserbase/browserbase.service.ts index 696133553f..444b0e0fba 100644 --- a/apps/api/src/browserbase/browserbase.service.ts +++ b/apps/api/src/browserbase/browserbase.service.ts @@ -3,6 +3,7 @@ import { TaskFrequency } from '@db'; import { BrowserAutomationCrudService } from './browser-automation-crud.service'; import { BrowserAutomationExecutionService } from './browser-automation-execution.service'; import { BrowserAuthProfileService } from './browser-auth-profile.service'; +import { BrowserCredentialStorageService } from './browser-credential-storage.service'; import { BrowserEvidenceRunnerService } from './browser-evidence-runner.service'; import { BrowserbaseScreenshotService } from './browserbase-screenshot.service'; import { BrowserbaseSessionService } from './browserbase-session.service'; @@ -25,6 +26,7 @@ export class BrowserbaseService { ), private readonly automationExecution: BrowserAutomationExecutionService = new BrowserAutomationExecutionService(sessions, profiles, runner), + private readonly credentialStorage: BrowserCredentialStorageService = new BrowserCredentialStorageService(), ) {} async listAuthProfiles(organizationId: string) { @@ -67,6 +69,16 @@ export class BrowserbaseService { return this.profiles.markNeedsReauth(input); } + async storeAuthProfileCredentials(input: { + organizationId: string; + profileId: string; + username: string; + password: string; + totpSeed?: string; + }) { + return this.credentialStorage.storeProfileCredentials(input); + } + async getOrCreateOrgContext(organizationId: string) { return this.profiles.getOrCreateOrgContext(organizationId); } diff --git a/apps/api/src/browserbase/dto/browserbase.dto.ts b/apps/api/src/browserbase/dto/browserbase.dto.ts index 1fb7b29b18..bf927c4b42 100644 --- a/apps/api/src/browserbase/dto/browserbase.dto.ts +++ b/apps/api/src/browserbase/dto/browserbase.dto.ts @@ -121,6 +121,26 @@ export class MarkAuthProfileNeedsReauthDto { reason?: string; } +export class StoreAuthProfileCredentialsDto { + @ApiProperty({ description: 'Username or email for the vendor login' }) + @IsString() + @IsNotEmpty() + username: string; + + @ApiProperty({ description: 'Password for the vendor login' }) + @IsString() + @IsNotEmpty() + password: string; + + @ApiPropertyOptional({ + description: + 'Authenticator app setup key (TOTP secret or otpauth:// URI). When provided, one-time codes are generated for automated sign-in.', + }) + @IsString() + @IsOptional() + totpSeed?: string; +} + export class BrowserAuthProfileResponseDto { @ApiProperty() id: string; diff --git a/apps/api/src/browserbase/onepassword-client.ts b/apps/api/src/browserbase/onepassword-client.ts new file mode 100644 index 0000000000..5edd97e900 --- /dev/null +++ b/apps/api/src/browserbase/onepassword-client.ts @@ -0,0 +1,57 @@ +import { Logger } from '@nestjs/common'; + +type OnePasswordModule = typeof import('@1password/sdk'); +export type OnePasswordClient = Awaited< + ReturnType +>; + +const OP_INTEGRATION_NAME = 'Comp AI Browser Automations'; +const OP_INTEGRATION_VERSION = '1.0.0'; + +const logger = new Logger('OnePasswordClient'); +let clientPromise: Promise | null = null; + +export function getOnePasswordServiceAccountToken(): string | undefined { + const token = process.env.OP_SERVICE_ACCOUNT_TOKEN?.trim(); + return token ? token : undefined; +} + +export function isOnePasswordConfigured(): boolean { + return Boolean(getOnePasswordServiceAccountToken()); +} + +// Dynamic import keeps the WASM-backed SDK out of the module graph until it is +// actually needed, so runtimes that never touch 1Password don't pay to load it +// and bundlers don't have to resolve the WASM core eagerly. Exported so the +// write path can read the SDK's runtime enums (ItemCategory/ItemFieldType) +// without a static import that would defeat the lazy load. +export async function loadOnePasswordModule(): Promise { + return import('@1password/sdk'); +} + +export async function getOnePasswordClient(): Promise { + const token = getOnePasswordServiceAccountToken(); + if (!token) { + throw new Error( + 'OP_SERVICE_ACCOUNT_TOKEN is not configured; cannot reach 1Password.', + ); + } + + if (!clientPromise) { + clientPromise = (async () => { + const { createClient } = await loadOnePasswordModule(); + logger.log('Initializing 1Password service account client.'); + return createClient({ + auth: token, + integrationName: OP_INTEGRATION_NAME, + integrationVersion: OP_INTEGRATION_VERSION, + }); + })().catch((error: unknown) => { + // Reset so a transient failure doesn't permanently poison the singleton. + clientPromise = null; + throw error; + }); + } + + return clientPromise; +} diff --git a/apps/api/src/browserbase/onepassword-credential-item.ts b/apps/api/src/browserbase/onepassword-credential-item.ts new file mode 100644 index 0000000000..62a1c283ab --- /dev/null +++ b/apps/api/src/browserbase/onepassword-credential-item.ts @@ -0,0 +1,32 @@ +// Shared contract between the write path (storing a login into 1Password) and the +// read path (resolving it at run time). Both sides MUST use the same field titles +// and reference format, so they live here as the single source of truth. + +export const ONEPASSWORD_PROVIDER = '1password'; + +// Field titles match 1Password's built-in Login item fields so secret references +// (`op:////`) resolve them by label. +export const USERNAME_FIELD_TITLE = 'username'; +export const PASSWORD_FIELD_TITLE = 'password'; +export const TOTP_FIELD_TITLE = 'one-time password'; + +export function buildOrgVaultTitle(organizationId: string): string { + return `Comp AI Browser Automations — ${organizationId}`; +} + +export function buildItemReference(vaultId: string, itemId: string): string { + return `op://${vaultId}/${itemId}`; +} + +export function buildFieldReference( + itemRef: string, + fieldTitle: string, +): string { + return `${itemRef}/${fieldTitle}`; +} + +// The `?attribute=otp` modifier makes 1Password return the *computed* 6-digit +// code for a Totp field rather than the stored seed. +export function buildTotpReference(itemRef: string): string { + return `${itemRef}/${TOTP_FIELD_TITLE}?attribute=otp`; +} diff --git a/apps/api/src/browserbase/onepassword-credential-vault.adapter.spec.ts b/apps/api/src/browserbase/onepassword-credential-vault.adapter.spec.ts new file mode 100644 index 0000000000..3cac1a1171 --- /dev/null +++ b/apps/api/src/browserbase/onepassword-credential-vault.adapter.spec.ts @@ -0,0 +1,104 @@ +import { OnePasswordCredentialVaultAdapter } from './onepassword-credential-vault.adapter'; +import { + getOnePasswordClient, + type OnePasswordClient, +} from './onepassword-client'; + +jest.mock('./onepassword-client', () => ({ + getOnePasswordClient: jest.fn(), +})); + +const mockedGetClient = jest.mocked(getOnePasswordClient); + +function clientWith(resolve: jest.Mock): OnePasswordClient { + return { secrets: { resolve } } as unknown as OnePasswordClient; +} + +describe('OnePasswordCredentialVaultAdapter', () => { + const adapter = new OnePasswordCredentialVaultAdapter(); + + beforeEach(() => jest.clearAllMocks()); + + it('returns null (and never touches 1Password) when provider is not 1password', async () => { + const result = await adapter.resolveCredentialReference({ + profileId: 'bap_1', + provider: 'lastpass', + externalItemRef: 'op://v/i', + }); + + expect(result).toBeNull(); + expect(mockedGetClient).not.toHaveBeenCalled(); + }); + + it('returns null when the item reference is missing', async () => { + const result = await adapter.resolveCredentialReference({ + profileId: 'bap_1', + provider: '1password', + externalItemRef: ' ', + }); + + expect(result).toBeNull(); + expect(mockedGetClient).not.toHaveBeenCalled(); + }); + + it('resolves username, password, and the live TOTP code', async () => { + const resolve = jest.fn((reference: string) => { + if (reference.endsWith('/username')) return 'alice@example.com'; + if (reference.endsWith('/password')) return 's3cret'; + if (reference.includes('one-time password')) return '123456'; + throw new Error(`unexpected reference: ${reference}`); + }); + mockedGetClient.mockResolvedValue(clientWith(resolve)); + + const result = await adapter.resolveCredentialReference({ + profileId: 'bap_1', + provider: '1password', + externalItemRef: 'op://vault123/item456', + }); + + expect(result).toEqual({ + username: 'alice@example.com', + password: 's3cret', + totpCode: '123456', + }); + expect(resolve).toHaveBeenCalledWith( + 'op://vault123/item456/one-time password?attribute=otp', + ); + }); + + it('treats a missing TOTP field as absent rather than failing', async () => { + const resolve = jest.fn((reference: string) => { + if (reference.endsWith('/username')) return 'alice'; + if (reference.endsWith('/password')) return 'pw'; + throw new Error('field not found'); + }); + mockedGetClient.mockResolvedValue(clientWith(resolve)); + + const result = await adapter.resolveCredentialReference({ + profileId: 'bap_1', + provider: '1password', + externalItemRef: 'op://v/i', + }); + + expect(result).toEqual({ + username: 'alice', + password: 'pw', + totpCode: undefined, + }); + }); + + it('returns null when no fields resolve', async () => { + const resolve = jest.fn(() => { + throw new Error('nothing here'); + }); + mockedGetClient.mockResolvedValue(clientWith(resolve)); + + const result = await adapter.resolveCredentialReference({ + profileId: 'bap_1', + provider: '1password', + externalItemRef: 'op://v/i', + }); + + expect(result).toBeNull(); + }); +}); diff --git a/apps/api/src/browserbase/onepassword-credential-vault.adapter.ts b/apps/api/src/browserbase/onepassword-credential-vault.adapter.ts new file mode 100644 index 0000000000..959ff1cab5 --- /dev/null +++ b/apps/api/src/browserbase/onepassword-credential-vault.adapter.ts @@ -0,0 +1,78 @@ +import { Logger } from '@nestjs/common'; +import { + type BrowserCredentialVaultAdapter, + type RuntimeCredentialMaterial, +} from './credential-vault'; +import { + getOnePasswordClient, + type OnePasswordClient, +} from './onepassword-client'; +import { + ONEPASSWORD_PROVIDER, + buildFieldReference, + buildTotpReference, + PASSWORD_FIELD_TITLE, + USERNAME_FIELD_TITLE, +} from './onepassword-credential-item'; + +/** + * Resolves a browser auth profile's stored login (username, password, live TOTP) + * from 1Password at run time using its `op:///` reference. Secrets + * are fetched just-in-time and never persisted or logged. + */ +export class OnePasswordCredentialVaultAdapter implements BrowserCredentialVaultAdapter { + private readonly logger = new Logger(OnePasswordCredentialVaultAdapter.name); + + async resolveCredentialReference(params: { + profileId: string; + provider?: string | null; + externalItemRef?: string | null; + connectionId?: string | null; + }): Promise { + if (params.provider !== ONEPASSWORD_PROVIDER) return null; + + const itemRef = params.externalItemRef?.trim(); + if (!itemRef) return null; + + const client = await getOnePasswordClient(); + + const [username, password] = await Promise.all([ + this.resolveField( + client, + buildFieldReference(itemRef, USERNAME_FIELD_TITLE), + ), + this.resolveField( + client, + buildFieldReference(itemRef, PASSWORD_FIELD_TITLE), + ), + ]); + // Resolve the OTP separately so its rotation window is as fresh as possible. + const totpCode = await this.resolveField( + client, + buildTotpReference(itemRef), + ); + + if (!username && !password && !totpCode) return null; + + return { + username: username ?? undefined, + password: password ?? undefined, + totpCode: totpCode ?? undefined, + }; + } + + private async resolveField( + client: OnePasswordClient, + reference: string, + ): Promise { + try { + const value = await client.secrets.resolve(reference); + return value?.trim() ? value : null; + } catch { + // A missing optional field (e.g. a login with no TOTP configured) resolves + // as an error; treat it as absent rather than failing the whole sign-in. + this.logger.debug(`1Password reference not resolvable: ${reference}`); + return null; + } + } +} diff --git a/apps/api/trigger.config.ts b/apps/api/trigger.config.ts index 44f4e88364..1a54130e05 100644 --- a/apps/api/trigger.config.ts +++ b/apps/api/trigger.config.ts @@ -10,6 +10,9 @@ export default defineConfig({ logLevel: 'log', maxDuration: 300, // 5 minutes build: { + // The 1Password SDK ships a native WASM core; keep it external so it's + // resolved from node_modules at runtime instead of being bundled by esbuild. + external: ['@1password/sdk'], extensions: [ caBundleExtension(), prismaExtension({ diff --git a/bun.lock b/bun.lock index f882aecab8..f093923b25 100644 --- a/bun.lock +++ b/bun.lock @@ -68,6 +68,7 @@ "name": "@trycompai/api", "version": "0.0.1", "dependencies": { + "@1password/sdk": "0.4.0", "@ai-sdk/anthropic": "^3.0.75", "@ai-sdk/groq": "^3.0.38", "@ai-sdk/openai": "^3.0.62", @@ -871,6 +872,10 @@ "packages": { "7zip-bin": ["7zip-bin@5.2.0", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="], + "@1password/sdk": ["@1password/sdk@0.4.0", "", { "dependencies": { "@1password/sdk-core": "0.4.0" } }, "sha512-RIypujc9R/UeUaobjyClTYokqRFpcaIkHq+EO/X9XoHId98Vg+SbjwGV+yygRC4MyHwYNo1KP1iEbZcqJ4ZTdw=="], + + "@1password/sdk-core": ["@1password/sdk-core@0.4.0", "", {}, "sha512-vjeI1o4wiONY+t1naA4dtUp6HktdLH1D2S+tN1Lh4l41S9XIUHxrljov9B5u6G+VHr7f2MUoxmzXA9zT3aokQQ=="], + "@actions/core": ["@actions/core@3.0.1", "", { "dependencies": { "@actions/exec": "^3.0.0", "@actions/http-client": "^4.0.0" } }, "sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA=="], "@actions/exec": ["@actions/exec@3.0.0", "", { "dependencies": { "@actions/io": "^3.0.2" } }, "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw=="],