From 56aa48d770b341e31742d1a12f05d9fb0684bdcb Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Mon, 17 Aug 2026 17:23:07 -0400 Subject: [PATCH 1/3] fix(users): give connected accounts state, writes, and the spec shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing wrote to the connected_accounts collection, so GET /user_management/users/{id}/connected_accounts/{slug} answered every user, in every state, with a 404 — and the shape it would have returned (provider/provider_id) was not the spec's ConnectedAccount. The path is also specified with three more verbs the emulator did not serve. - GET returns the spec shape: user_id, organization_id, scopes, auth_method, api_key_last_4, state, and data_installation-prefixed ids. Accounts are keyed by (user, slug, organization scope), with organization_id validated when passed. - POST imports an account from OAuth tokens; an omitted state is derived from the token combination, a duplicate answers 409, and neither state nor token answers 422. - PUT updates tokens, scopes, or state; a scopes-only update does not silently reconnect a needs_reauthorization account. - DELETE disconnects by removing the account and its stored tokens, so a later import is a fresh 201. Deleting a user disconnects their accounts. - A connectedAccounts seed key references users by email and organizations by name, cross-validated at startup like memberships are. - State changes emit pipes.connected_account.connected / reauthorization_needed / disconnected via collection hooks, so seeded accounts fire the same events; payloads carry the event-only provider_slug and data_integration_id fields. --- README.md | 30 ++ src/core/id.ts | 5 +- src/workos/config-validator.ts | 100 ++++++ src/workos/entities.ts | 22 +- src/workos/helpers.ts | 42 ++- src/workos/index.ts | 74 ++++ src/workos/routes/connected-accounts.spec.ts | 350 +++++++++++++++++++ src/workos/routes/connected-accounts.ts | 172 +++++++++ src/workos/routes/user-features.spec.ts | 25 -- src/workos/routes/user-features.ts | 13 +- src/workos/routes/users.ts | 5 + 11 files changed, 798 insertions(+), 40 deletions(-) create mode 100644 src/workos/routes/connected-accounts.spec.ts create mode 100644 src/workos/routes/connected-accounts.ts diff --git a/README.md b/README.md index 8f2d12f..81736cd 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,36 @@ users: oauth_provider: GoogleOAuth # reported as authentication_method for this user's OAuth logins ``` +### Pipes connected accounts + +`GET|POST|PUT|DELETE /user_management/users/{id}/connected_accounts/{slug}` serve a user's +[connected accounts](https://workos.com/docs/reference/pipes/connected-account). Seed them +with `connectedAccounts`, referencing a user by email (the same join key memberships use) +and, for an org-scoped connection, an organization by name: + +```yaml +users: + - email: alice@acme.com +organizations: + - name: Acme Corp +connectedAccounts: + - email: alice@acme.com + provider: github # the slug requests address + scopes: [repo, user:email] + - email: alice@acme.com + provider: slack + organization: Acme Corp # resolvable only with ?organization_id= + state: needs_reauthorization # defaults to connected +``` + +Accounts are keyed by (user, provider, organization scope), exactly as the API addresses +them. `POST` imports an account from OAuth tokens — an omitted `state` is derived from the +token combination (an expired access token with no refresh token is `needs_reauthorization`) — +and answers `409` for a duplicate. `DELETE` disconnects by removing the account and its stored +tokens, so a later import is a fresh `201`. State changes emit the spec's +`pipes.connected_account.connected` / `reauthorization_needed` / `disconnected` events, +including for seeded accounts. + ### Machine-to-Machine (M2M) Applications Seed M2M Connect Applications so a service has a known `client_id` / client secret pair on diff --git a/src/core/id.ts b/src/core/id.ts index f2019ab..9857443 100644 --- a/src/core/id.ts +++ b/src/core/id.ts @@ -70,7 +70,10 @@ export const ID_PREFIXES = { redirect_uri: 'redir', cors_origin: 'cors', authorized_application: 'auth_app', - connected_account: 'conn_acct', + // Production connected-account ids are data installations (`data_installation_01…`), and + // every account of one provider installs the same environment-level data integration. + connected_account: 'data_installation', + data_integration: 'data_integration', role: 'role', permission: 'perm', role_permission: 'rp', diff --git a/src/workos/config-validator.ts b/src/workos/config-validator.ts index 45285cd..0f05187 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -403,6 +403,106 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe } } + // Validate connected accounts + if (config.connectedAccounts) { + if (!Array.isArray(config.connectedAccounts)) { + errors.push({ + path: 'connectedAccounts', + message: 'connectedAccounts must be an array', + value: config.connectedAccounts, + }); + } else { + // Organization name is the join key, the same one connections use. + const orgNames = new Set( + Array.isArray(config.organizations) + ? config.organizations.map((o) => o.name).filter((n): n is string => typeof n === 'string') + : [], + ); + // (user, provider, organization) is the key requests address; a duplicate would seed + // the pair POST answers 409 for, and every lookup would only ever resolve the first. + const seenAccounts = new Set(); + config.connectedAccounts.forEach((account, index) => { + // A non-object entry (e.g. `connectedAccounts: [null]` from a YAML typo) would throw + // on the property reads below; record a structured error instead of crashing startup. + if (account === null || typeof account !== 'object') { + errors.push({ + path: `connectedAccounts[${index}]`, + message: 'each connected account must be an object', + value: account, + }); + return; + } + const email = seedEmail(account.email); + if (!email.ok) { + errors.push({ + path: `connectedAccounts[${index}].email`, + message: + email.problem === 'malformed' + ? 'email must be a valid email address' + : 'email is required and must be the email of a user defined in users', + value: account.email, + }); + } else if (!userEmails.has(email.email.toLowerCase())) { + errors.push({ + path: `connectedAccounts[${index}].email`, + message: 'email must match a user defined in users', + value: account.email, + }); + } + if (!account.provider || typeof account.provider !== 'string') { + errors.push({ + path: `connectedAccounts[${index}].provider`, + message: 'provider is required and must be a non-empty string (the slug requests address, e.g. "github")', + value: account.provider, + }); + } + if ( + account.organization !== undefined && + (typeof account.organization !== 'string' || !orgNames.has(account.organization)) + ) { + errors.push({ + path: `connectedAccounts[${index}].organization`, + message: 'organization must name an organization defined in organizations', + value: account.organization, + }); + } + if ( + account.scopes !== undefined && + (!Array.isArray(account.scopes) || account.scopes.some((s) => typeof s !== 'string')) + ) { + errors.push({ + path: `connectedAccounts[${index}].scopes`, + message: 'scopes must be an array of strings if provided', + value: account.scopes, + }); + } + if (account.state && !['connected', 'needs_reauthorization'].includes(account.state)) { + errors.push({ + path: `connectedAccounts[${index}].state`, + message: + 'state must be "connected" or "needs_reauthorization" if provided — a disconnected account is a deleted one, so it cannot be seeded', + value: account.state, + }); + } + if (email.ok && typeof account.provider === 'string' && account.provider) { + const key = [ + email.email.toLowerCase(), + account.provider, + typeof account.organization === 'string' ? account.organization : '', + ].join('\u0000'); + if (seenAccounts.has(key)) { + errors.push({ + path: `connectedAccounts[${index}]`, + message: 'duplicate connected account for this user, provider, and organization', + value: { email: account.email, provider: account.provider, organization: account.organization }, + }); + } + seenAccounts.add(key); + } + }); + } + } + // Validate roles if (config.roles) { if (!Array.isArray(config.roles)) { diff --git a/src/workos/entities.ts b/src/workos/entities.ts index a652da4..e672bad 100644 --- a/src/workos/entities.ts +++ b/src/workos/entities.ts @@ -232,11 +232,31 @@ export interface WorkOSAuthorizedApplication extends Entity { redirect_uri: string; } +/** + * States a stored connected account can hold. The spec's enum also has `disconnected`, but + * disconnecting an account is deleting it — that value is observable only in the + * `pipes.connected_account.disconnected` event a deletion emits, never on a stored row, + * so a fresh import after a disconnect can't be answered 409 by a row that no longer works. + */ +export type ConnectedAccountState = 'connected' | 'needs_reauthorization'; + export interface WorkOSConnectedAccount extends Entity { object: 'connected_account'; user_id: string; + organization_id: string | null; + /** Provider slug the account is addressed by (`github`, `slack`, …). Not part of the spec's REST shape — requests carry it in the path, events as `provider_slug`. */ provider: string; - provider_id: string; + /** The environment's integration for this provider; every account of one slug shares it. */ + data_integration_id: string; + scopes: string[]; + /** The import DTO and seed only describe OAuth connections, so this is the one value the emulator can be told. */ + auth_method: 'oauth'; + api_key_last_4: null; + state: ConnectedAccountState; + /** Tokens the import/update endpoints were given. Kept because deleting the account is specified to remove them; never serialized. */ + access_token: string | null; + refresh_token: string | null; + token_expires_at: string | null; } export type PipeProvider = 'github' | 'slack' | 'google' | 'salesforce'; diff --git a/src/workos/helpers.ts b/src/workos/helpers.ts index d76498b..84ad37d 100644 --- a/src/workos/helpers.ts +++ b/src/workos/helpers.ts @@ -5,6 +5,7 @@ import { WorkOSApiError, validationError, generateId, + ID_PREFIXES, type CursorPaginatedResult, type Entity, type Store, @@ -32,6 +33,7 @@ import type { WorkOSCorsOrigin, WorkOSAuthorizedApplication, WorkOSConnectedAccount, + ConnectedAccountState, WorkOSAuthenticationChallenge, WorkOSDeviceAuthorization, WorkOSRole, @@ -565,8 +567,46 @@ export function formatAuthorizedApplication(a: WorkOSAuthorizedApplication): Rec return formatEntity(a); } +// The spec's ConnectedAccount names the provider only in the request path; the slug, the +// integration id, and any imported tokens are the emulator's own bookkeeping. +const CONNECTED_ACCOUNT_INTERNAL_FIELDS = new Set([ + 'provider', + 'data_integration_id', + 'access_token', + 'refresh_token', + 'token_expires_at', +]); + export function formatConnectedAccount(a: WorkOSConnectedAccount): Record { - return formatEntity(a); + return formatEntity(a, { exclude: CONNECTED_ACCOUNT_INTERNAL_FIELDS }); +} + +/** + * `pipes.connected_account.*` event payloads carry two fields the REST shape does not — the + * provider slug and the data integration id. `state` is overridable because `disconnected` + * exists only in the event a deletion emits; a stored row never holds it. + */ +export function formatConnectedAccountEvent( + a: WorkOSConnectedAccount, + state: ConnectedAccountState | 'disconnected' = a.state, +): Record { + return { + ...formatConnectedAccount(a), + state, + provider_slug: a.provider, + data_integration_id: a.data_integration_id, + }; +} + +/** + * One data integration per provider slug: an account is an installation of the environment's + * integration for that provider, so every account of one slug shares the id. Reused from any + * live account before minting, keeping the id stable for as long as any account of the slug + * exists rather than inventing a fresh integration per install. + */ +export function dataIntegrationIdFor(ws: WorkOSStore, slug: string): string { + const existing = ws.connectedAccounts.findBy('provider', slug)[0]; + return existing?.data_integration_id ?? generateId(ID_PREFIXES.data_integration); } /** Redirect URI hosts the emulator's authorize endpoints accept with no configuration. */ diff --git a/src/workos/index.ts b/src/workos/index.ts index a352539..7f56746 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -16,6 +16,7 @@ import { authRoutes } from './routes/auth.js'; import { connectionRoutes } from './routes/connections.js'; import { ssoRoutes } from './routes/sso.js'; import { pipeRoutes } from './routes/pipes.js'; +import { connectedAccountRoutes } from './routes/connected-accounts.js'; import { authChallengeRoutes } from './routes/auth-challenges.js'; import { invitationRoutes } from './routes/invitations.js'; import { configRoutes } from './routes/config.js'; @@ -66,11 +67,14 @@ import { formatFeatureFlag, generateClientId, findUserByEmail, + formatConnectedAccountEvent, + dataIntegrationIdFor, } from './helpers.js'; import type { WorkOSConnectionType, PipeProvider, PipeConnectionStatus, + ConnectedAccountState, WorkOSApiKeyOwner, WorkOSJwtTemplate, } from './entities.js'; @@ -162,6 +166,30 @@ export interface WorkOSSeedPipeConnection { external_account_id?: string; } +export interface WorkOSSeedConnectedAccount { + /** + * Email of a user defined in `users` — the same join key memberships use, because seeded + * user ids are generated at startup unless pinned. + */ + email: string; + /** Provider slug the account is addressed by in the API path (`github`, `slack`, `notion`, …). */ + provider: string; + /** + * Name of an organization defined in `organizations`, for a connection scoped to one. + * Requests must then carry the same scope (`?organization_id=…`) — an unscoped lookup + * does not resolve an org-scoped account, matching the API's keying. + */ + organization?: string; + /** OAuth scopes granted for the connection. */ + scopes?: string[]; + /** + * Defaults to `connected` — saying a user has a connected account is saying it is + * connected. `disconnected` is not seedable: a disconnected account is a deleted one, + * observable only in the event stream. + */ + state?: ConnectedAccountState; +} + export interface WorkOSSeedInvitation { email: string; organization_id?: string; @@ -257,6 +285,8 @@ export interface WorkOSSeedConfig { users?: WorkOSSeedUser[]; connections?: WorkOSSeedConnection[]; pipeConnections?: WorkOSSeedPipeConnection[]; + /** Pipes connected accounts, served by `/user_management/users/{id}/connected_accounts/{slug}`. */ + connectedAccounts?: WorkOSSeedConnectedAccount[]; invitations?: WorkOSSeedInvitation[]; roles?: WorkOSSeedRole[]; permissions?: WorkOSSeedPermission[]; @@ -451,6 +481,31 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee } } + if (config.connectedAccounts) { + for (const ca of config.connectedAccounts) { + // Both references were cross-checked by validateSeedConfig against this same config, + // so they resolve; the guards only keep a future validator gap from crashing startup. + const user = findUserByEmail(ws, ca.email); + if (!user) continue; + const org = ca.organization ? ws.organizations.findOneBy('name', ca.organization) : undefined; + if (ca.organization && !org) continue; + ws.connectedAccounts.insert({ + object: 'connected_account', + user_id: user.id, + organization_id: org?.id ?? null, + provider: ca.provider, + data_integration_id: dataIntegrationIdFor(ws, ca.provider), + scopes: ca.scopes ?? [], + auth_method: 'oauth', + api_key_last_4: null, + state: ca.state ?? 'connected', + access_token: null, + refresh_token: null, + token_expires_at: null, + }); + } + } + if (config.permissions) { for (const permConfig of config.permissions) { ws.permissions.insert({ @@ -632,6 +687,7 @@ export const workosPlugin: ServicePlugin = { connectionRoutes(ctx); ssoRoutes(ctx); pipeRoutes(ctx); + connectedAccountRoutes(ctx); invitationRoutes(ctx); configRoutes(ctx); userFeatureRoutes(ctx); @@ -711,6 +767,24 @@ export const workosPlugin: ServicePlugin = { data: { group_id: gm.group_id, organization_membership_id: gm.organization_membership_id }, }), }); + // Pipes connected accounts. The event is named by the state the row lands in, so the + // pair cannot drift; deletion is the only way an account disconnects, so onDelete emits + // the `disconnected` payload for a value no stored row ever holds. Hook-driven so seeded + // accounts fire the same events. + const connectedAccountEvent = (state: ConnectedAccountState) => + state === 'connected' ? EVENTS.pipesConnectedAccountConnected : EVENTS.pipesConnectedAccountReauthorizationNeeded; + ws.connectedAccounts.setHooks({ + onInsert: (a) => eventBus.emit({ event: connectedAccountEvent(a.state), data: formatConnectedAccountEvent(a) }), + onUpdate: (a, prev) => { + if (a.state === prev.state) return; + eventBus.emit({ event: connectedAccountEvent(a.state), data: formatConnectedAccountEvent(a) }); + }, + onDelete: (a) => + eventBus.emit({ + event: EVENTS.pipesConnectedAccountDisconnected, + data: formatConnectedAccountEvent(a, 'disconnected'), + }), + }); ws.connections.setHooks({ // The spec has no connection.created/updated — only activation state transitions onInsert: (c) => { diff --git a/src/workos/routes/connected-accounts.spec.ts b/src/workos/routes/connected-accounts.spec.ts new file mode 100644 index 0000000..aaee264 --- /dev/null +++ b/src/workos/routes/connected-accounts.spec.ts @@ -0,0 +1,350 @@ +/** + * Pipes connected accounts. The spec models them on + * `/user_management/users/{user_id}/connected_accounts/{slug}` with all four verbs: GET, + * POST (import with OAuth tokens), PUT (update tokens/scopes/state), DELETE (disconnect). + * Accounts are keyed by (user, slug, organization scope), can be seeded, and state + * transitions emit the spec's `pipes.connected_account.*` events. + */ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { createServer, type ApiKeyMap } from '../../core/index.js'; +import { workosPlugin, seedFromConfig, type WorkOSSeedConfig } from '../index.js'; +import { getWorkOSStore } from '../store.js'; +import { validateSeedConfig } from '../config-validator.js'; + +const apiKeys: ApiKeyMap = { sk_test_ca: { environment: 'test' } }; +const headers = { Authorization: 'Bearer sk_test_ca', 'Content-Type': 'application/json' }; + +// The spec's ConnectedAccount, exactly: no envelope, no provider (the URL names it), no tokens. +const RESPONSE_KEYS = [ + 'api_key_last_4', + 'auth_method', + 'created_at', + 'id', + 'object', + 'organization_id', + 'scopes', + 'state', + 'updated_at', + 'user_id', +]; + +function createTestApp() { + return createServer(workosPlugin, { port: 0, baseUrl: 'http://localhost:0', apiKeys }); +} + +describe('Connected account routes', () => { + let app: ReturnType['app']; + let store: ReturnType['store']; + + beforeEach(() => { + const result = createTestApp(); + app = result.app; + store = result.store; + }); + + const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); + const json = (res: Response) => res.json() as Promise; + const seed = (config: WorkOSSeedConfig) => seedFromConfig(store, 'http://localhost:0', config); + const eventsNamed = (name: string) => + getWorkOSStore(store) + .events.all() + .filter((e) => e.event === name); + + async function createUser(email: string) { + return json(await req('/user_management/users', { method: 'POST', body: JSON.stringify({ email }) })); + } + + describe('seeding', () => { + it('answers the endpoint from a seeded account, in the spec shape', async () => { + seed({ + users: [{ email: 'pipes@acme.test' }], + connectedAccounts: [{ email: 'pipes@acme.test', provider: 'github', scopes: ['repo', 'user:email'] }], + }); + const user = getWorkOSStore(store).users.findOneBy('email', 'pipes@acme.test')!; + + const res = await req(`/user_management/users/${user.id}/connected_accounts/github`); + expect(res.status).toBe(200); + const account = await json(res); + expect(Object.keys(account).sort()).toEqual(RESPONSE_KEYS); + expect(account.object).toBe('connected_account'); + expect(account.id).toStartWith('data_installation_'); + expect(account.user_id).toBe(user.id); + expect(account.organization_id).toBeNull(); + expect(account.scopes).toEqual(['repo', 'user:email']); + expect(account.auth_method).toBe('oauth'); + expect(account.api_key_last_4).toBeNull(); + expect(account.state).toBe('connected'); + }); + + it('keys an org-scoped seed by the organization_id query parameter', async () => { + seed({ + users: [{ email: 'scoped@acme.test' }], + organizations: [{ name: 'Acme' }], + connectedAccounts: [{ email: 'scoped@acme.test', provider: 'slack', organization: 'Acme' }], + }); + const ws = getWorkOSStore(store); + const user = ws.users.findOneBy('email', 'scoped@acme.test')!; + const org = ws.organizations.findOneBy('name', 'Acme')!; + + // The scope is part of the key: an unscoped lookup does not resolve an org-scoped account. + expect((await req(`/user_management/users/${user.id}/connected_accounts/slack`)).status).toBe(404); + + const res = await req(`/user_management/users/${user.id}/connected_accounts/slack?organization_id=${org.id}`); + expect(res.status).toBe(200); + expect((await json(res)).organization_id).toBe(org.id); + }); + + it('preserves a seeded needs_reauthorization state and emits its event', async () => { + seed({ + users: [{ email: 'stale@acme.test' }], + connectedAccounts: [{ email: 'stale@acme.test', provider: 'github', state: 'needs_reauthorization' }], + }); + const user = getWorkOSStore(store).users.findOneBy('email', 'stale@acme.test')!; + + const account = await json(await req(`/user_management/users/${user.id}/connected_accounts/github`)); + expect(account.state).toBe('needs_reauthorization'); + + const events = eventsNamed('pipes.connected_account.reauthorization_needed'); + expect(events).toHaveLength(1); + }); + + it('emits pipes.connected_account.connected with the event-only fields', async () => { + seed({ + users: [{ email: 'events@acme.test' }], + connectedAccounts: [{ email: 'events@acme.test', provider: 'notion' }], + }); + + const events = eventsNamed('pipes.connected_account.connected'); + expect(events).toHaveLength(1); + const data = events[0].data as Record; + expect(data.provider_slug).toBe('notion'); + expect(data.data_integration_id).toStartWith('data_integration_'); + expect(data.state).toBe('connected'); + // No leakage of internal storage into the event either. + expect(data).not.toContainKey('provider'); + expect(data).not.toContainKey('access_token'); + }); + + it('rejects references and states the emulator could not honour', () => { + const base = { users: [{ email: 'known@acme.test' }] }; + + const unknownUser = validateSeedConfig({ + ...base, + connectedAccounts: [{ email: 'ghost@acme.test', provider: 'github' }], + }); + expect(unknownUser.valid).toBe(false); + expect(unknownUser.errors[0].path).toBe('connectedAccounts[0].email'); + + const unknownOrg = validateSeedConfig({ + ...base, + connectedAccounts: [{ email: 'known@acme.test', provider: 'github', organization: 'Nowhere' }], + }); + expect(unknownOrg.valid).toBe(false); + expect(unknownOrg.errors[0].path).toBe('connectedAccounts[0].organization'); + + const badState = validateSeedConfig({ + ...base, + connectedAccounts: [{ email: 'known@acme.test', provider: 'github', state: 'disconnected' as never }], + }); + expect(badState.valid).toBe(false); + expect(badState.errors[0].path).toBe('connectedAccounts[0].state'); + + const duplicate = validateSeedConfig({ + ...base, + connectedAccounts: [ + { email: 'known@acme.test', provider: 'github' }, + { email: 'known@acme.test', provider: 'github' }, + ], + }); + expect(duplicate.valid).toBe(false); + expect(duplicate.errors[0].path).toBe('connectedAccounts[1]'); + }); + }); + + describe('GET', () => { + it('404s on an unknown user, a missing account, and an unknown organization', async () => { + expect((await req('/user_management/users/user_none/connected_accounts/github')).status).toBe(404); + + const user = await createUser('bare@test.com'); + expect((await req(`/user_management/users/${user.id}/connected_accounts/github`)).status).toBe(404); + + const res = await req(`/user_management/users/${user.id}/connected_accounts/github?organization_id=org_none`); + expect(res.status).toBe(404); + expect((await json(res)).message).toBe('Organization not found'); + }); + }); + + describe('POST (import)', () => { + it('imports an account from an access token and derives connected', async () => { + const user = await createUser('import@test.com'); + const res = await req(`/user_management/users/${user.id}/connected_accounts/github`, { + method: 'POST', + body: JSON.stringify({ access_token: 'gho_abc123', scopes: ['repo'] }), + }); + expect(res.status).toBe(201); + const account = await json(res); + expect(Object.keys(account).sort()).toEqual(RESPONSE_KEYS); + expect(account.state).toBe('connected'); + expect(account.scopes).toEqual(['repo']); + + const found = await json(await req(`/user_management/users/${user.id}/connected_accounts/github`)); + expect(found.id).toBe(account.id); + + expect(eventsNamed('pipes.connected_account.connected')).toHaveLength(1); + }); + + it('derives needs_reauthorization from an expired token with no refresh token', async () => { + const user = await createUser('expired@test.com'); + const res = await req(`/user_management/users/${user.id}/connected_accounts/github`, { + method: 'POST', + body: JSON.stringify({ access_token: 'gho_old', expires_at: '2020-01-01T00:00:00.000Z' }), + }); + expect((await json(res)).state).toBe('needs_reauthorization'); + expect(eventsNamed('pipes.connected_account.reauthorization_needed')).toHaveLength(1); + }); + + it('derives connected from an expired token that can refresh', async () => { + const user = await createUser('refresh@test.com'); + const res = await req(`/user_management/users/${user.id}/connected_accounts/github`, { + method: 'POST', + body: JSON.stringify({ + access_token: 'gho_old', + refresh_token: 'ghr_new', + expires_at: '2020-01-01T00:00:00.000Z', + }), + }); + expect((await json(res)).state).toBe('connected'); + }); + + it('honours an explicit state over derivation', async () => { + const user = await createUser('explicit@test.com'); + const res = await req(`/user_management/users/${user.id}/connected_accounts/github`, { + method: 'POST', + body: JSON.stringify({ access_token: 'gho_fresh', state: 'needs_reauthorization' }), + }); + expect((await json(res)).state).toBe('needs_reauthorization'); + }); + + it('422s an import that carries neither a state nor a token', async () => { + const user = await createUser('empty@test.com'); + const res = await req(`/user_management/users/${user.id}/connected_accounts/github`, { + method: 'POST', + body: JSON.stringify({ scopes: ['repo'] }), + }); + expect(res.status).toBe(422); + }); + + it('409s a second import for the same user, provider, and scope', async () => { + const user = await createUser('dupe@test.com'); + const body = { method: 'POST', body: JSON.stringify({ access_token: 'gho_x' }) }; + expect((await req(`/user_management/users/${user.id}/connected_accounts/github`, body)).status).toBe(201); + const res = await req(`/user_management/users/${user.id}/connected_accounts/github`, body); + expect(res.status).toBe(409); + }); + + it('keeps org-scoped and unscoped accounts of one provider apart, sharing the integration', async () => { + seed({ organizations: [{ name: 'Acme' }] }); + const ws = getWorkOSStore(store); + const org = ws.organizations.findOneBy('name', 'Acme')!; + const user = await createUser('both@test.com'); + + const post = (qs: string) => + req(`/user_management/users/${user.id}/connected_accounts/github${qs}`, { + method: 'POST', + body: JSON.stringify({ access_token: 'gho_x' }), + }); + expect((await post('')).status).toBe(201); + expect((await post(`?organization_id=${org.id}`)).status).toBe(201); + + const unscoped = await json(await req(`/user_management/users/${user.id}/connected_accounts/github`)); + const scoped = await json( + await req(`/user_management/users/${user.id}/connected_accounts/github?organization_id=${org.id}`), + ); + expect(unscoped.id).not.toBe(scoped.id); + expect(unscoped.organization_id).toBeNull(); + expect(scoped.organization_id).toBe(org.id); + + // One provider, one integration: both installations install the same data integration. + const rows = ws.connectedAccounts.findBy('user_id', user.id); + expect(new Set(rows.map((r) => r.data_integration_id)).size).toBe(1); + }); + }); + + describe('PUT (update)', () => { + it('updates scopes without touching state or emitting a transition event', async () => { + const user = await createUser('scopes@test.com'); + await req(`/user_management/users/${user.id}/connected_accounts/github`, { + method: 'POST', + body: JSON.stringify({ access_token: 'gho_old', expires_at: '2020-01-01T00:00:00.000Z' }), + }); + + const res = await req(`/user_management/users/${user.id}/connected_accounts/github`, { + method: 'PUT', + body: JSON.stringify({ scopes: ['repo', 'workflow'] }), + }); + expect(res.status).toBe(200); + const account = await json(res); + expect(account.scopes).toEqual(['repo', 'workflow']); + expect(account.state).toBe('needs_reauthorization'); + + // Import emitted one reauthorization event; the scopes-only update must not add another. + expect(eventsNamed('pipes.connected_account.reauthorization_needed')).toHaveLength(1); + expect(eventsNamed('pipes.connected_account.connected')).toHaveLength(0); + }); + + it('reconnects a needs_reauthorization account given a fresh token, emitting connected', async () => { + const user = await createUser('reconnect@test.com'); + await req(`/user_management/users/${user.id}/connected_accounts/github`, { + method: 'POST', + body: JSON.stringify({ access_token: 'gho_old', expires_at: '2020-01-01T00:00:00.000Z' }), + }); + + const res = await req(`/user_management/users/${user.id}/connected_accounts/github`, { + method: 'PUT', + body: JSON.stringify({ access_token: 'gho_new' }), + }); + expect((await json(res)).state).toBe('connected'); + expect(eventsNamed('pipes.connected_account.connected')).toHaveLength(1); + }); + + it('404s an update for an account that does not exist', async () => { + const user = await createUser('noacct@test.com'); + const res = await req(`/user_management/users/${user.id}/connected_accounts/github`, { + method: 'PUT', + body: JSON.stringify({ state: 'connected' }), + }); + expect(res.status).toBe(404); + }); + }); + + describe('DELETE (disconnect)', () => { + it('removes the account so a fresh import succeeds, and emits disconnected', async () => { + const user = await createUser('disconnect@test.com'); + const path = `/user_management/users/${user.id}/connected_accounts/github`; + await req(path, { method: 'POST', body: JSON.stringify({ access_token: 'gho_x' }) }); + + expect((await req(path, { method: 'DELETE' })).status).toBe(204); + expect((await req(path)).status).toBe(404); + expect((await req(path, { method: 'DELETE' })).status).toBe(404); + + const events = eventsNamed('pipes.connected_account.disconnected'); + expect(events).toHaveLength(1); + expect((events[0].data as Record).state).toBe('disconnected'); + + // Disconnecting removed the account, not the right to reconnect. + expect((await req(path, { method: 'POST', body: JSON.stringify({ access_token: 'gho_y' }) })).status).toBe(201); + }); + + it('disconnects all of a deleted user’s accounts', async () => { + const user = await createUser('cascade@test.com'); + await req(`/user_management/users/${user.id}/connected_accounts/github`, { + method: 'POST', + body: JSON.stringify({ access_token: 'gho_x' }), + }); + + expect((await req(`/user_management/users/${user.id}`, { method: 'DELETE' })).status).toBe(204); + expect(getWorkOSStore(store).connectedAccounts.findBy('user_id', user.id)).toHaveLength(0); + expect(eventsNamed('pipes.connected_account.disconnected')).toHaveLength(1); + }); + }); +}); diff --git a/src/workos/routes/connected-accounts.ts b/src/workos/routes/connected-accounts.ts new file mode 100644 index 0000000..3fcc3fc --- /dev/null +++ b/src/workos/routes/connected-accounts.ts @@ -0,0 +1,172 @@ +import type { Context } from 'hono'; +import { + type RouteContext, + type WorkOSAppEnv, + notFound, + validationError, + parseJsonBody, + WorkOSApiError, +} from '../../core/index.js'; +import { getWorkOSStore } from '../store.js'; +import { formatConnectedAccount, dataIntegrationIdFor } from '../helpers.js'; +import type { ConnectedAccountState, WorkOSConnectedAccount } from '../entities.js'; + +/** + * The spec's ConnectedAccountDto, shared by import (POST) and update (PUT). Every field is + * optional in the schema; which combinations are usable is decided per verb. + */ +interface ConnectedAccountDto { + access_token?: string; + refresh_token?: string; + expires_at?: string; + scopes?: string[]; + state?: ConnectedAccountState; +} + +const DTO_STATES: ConnectedAccountState[] = ['connected', 'needs_reauthorization']; + +// All four verbs live on the one spec path; naming it once keeps the param typing exact. +const ACCOUNT_PATH = '/user_management/users/:user_id/connected_accounts/:slug'; + +function parseDto(body: Record): ConnectedAccountDto { + const dto: ConnectedAccountDto = {}; + for (const field of ['access_token', 'refresh_token'] as const) { + const value = body[field]; + if (value === undefined) continue; + if (typeof value !== 'string' || value.length === 0) { + throw validationError(`${field} must be a non-empty string`, [{ field, code: 'invalid' }]); + } + dto[field] = value; + } + if (body.expires_at !== undefined) { + if (typeof body.expires_at !== 'string' || Number.isNaN(Date.parse(body.expires_at))) { + throw validationError('expires_at must be an ISO-8601 timestamp', [{ field: 'expires_at', code: 'invalid' }]); + } + dto.expires_at = body.expires_at; + } + if (body.scopes !== undefined) { + if (!Array.isArray(body.scopes) || body.scopes.some((s) => typeof s !== 'string')) { + throw validationError('scopes must be an array of strings', [{ field: 'scopes', code: 'invalid' }]); + } + dto.scopes = body.scopes as string[]; + } + if (body.state !== undefined) { + if (!DTO_STATES.includes(body.state as ConnectedAccountState)) { + throw validationError(`state must be one of: ${DTO_STATES.join(', ')}`, [{ field: 'state', code: 'invalid' }]); + } + dto.state = body.state as ConnectedAccountState; + } + return dto; +} + +/** + * The spec derives an omitted `state` "from the token combination provided": a token that still + * works — unexpired, or expired but refreshable — is `connected`; an expired access token with + * no refresh token means the user must step back through the provider. A DTO carrying neither a + * state nor any token is the invalid combination its 422 describes. + */ +function deriveState(dto: ConnectedAccountDto): ConnectedAccountState { + if (dto.state) return dto.state; + if (dto.access_token) { + const expired = dto.expires_at !== undefined && Date.parse(dto.expires_at) <= Date.now(); + return expired && !dto.refresh_token ? 'needs_reauthorization' : 'connected'; + } + if (dto.refresh_token) return 'connected'; + throw validationError('a state or at least one token is required'); +} + +export function connectedAccountRoutes(ctx: RouteContext): void { + const { app, store } = ctx; + const ws = getWorkOSStore(store); + + /** + * The account a request addresses. The user in the path and the organization in the query + * (when given) must both exist — the spec 404s on either before the account itself — and the + * account is keyed by (user, slug, organization): a connection made without an organization + * scope is a different account from one made with it, so the lookups never cross. + */ + function resolveTarget(c: Context): { + userId: string; + slug: string; + organizationId: string | null; + account: WorkOSConnectedAccount | undefined; + } { + const user = ws.users.get(c.req.param('user_id')); + if (!user) throw notFound('User'); + + const slug = c.req.param('slug'); + const organizationId = new URL(c.req.url).searchParams.get('organization_id'); + if (organizationId && !ws.organizations.get(organizationId)) throw notFound('Organization'); + + const account = ws.connectedAccounts + .findBy('user_id', user.id) + .find((a) => a.provider === slug && a.organization_id === (organizationId ?? null)); + return { userId: user.id, slug, organizationId: organizationId ?? null, account }; + } + + app.get(ACCOUNT_PATH, (c) => { + const { account } = resolveTarget(c); + if (!account) throw notFound('Connected Account'); + return c.json(formatConnectedAccount(account)); + }); + + app.post(ACCOUNT_PATH, async (c) => { + const { userId, slug, organizationId, account } = resolveTarget(c); + if (account) { + throw new WorkOSApiError( + 409, + `Connected account already exists for provider '${slug}'${organizationId ? ` in organization '${organizationId}'` : ''}`, + 'conflict', + ); + } + + const dto = parseDto(await parseJsonBody(c)); + const state = deriveState(dto); + + const created = ws.connectedAccounts.insert({ + object: 'connected_account', + user_id: userId, + organization_id: organizationId, + provider: slug, + data_integration_id: dataIntegrationIdFor(ws, slug), + scopes: dto.scopes ?? [], + auth_method: 'oauth', + api_key_last_4: null, + state, + access_token: dto.access_token ?? null, + refresh_token: dto.refresh_token ?? null, + token_expires_at: dto.expires_at ?? null, + }); + return c.json(formatConnectedAccount(created), 201); + }); + + app.put(ACCOUNT_PATH, async (c) => { + const { account } = resolveTarget(c); + if (!account) throw notFound('Connected Account'); + + const dto = parseDto(await parseJsonBody(c)); + // Without an explicit state, only an update that carries tokens re-derives it — a + // scopes-only update must not silently reconnect a needs_reauthorization account. + const tokensProvided = dto.access_token !== undefined || dto.refresh_token !== undefined; + const state = dto.state ?? (tokensProvided ? deriveState(dto) : account.state); + + const updated = ws.connectedAccounts.update(account.id, { + ...(dto.scopes !== undefined ? { scopes: dto.scopes } : {}), + ...(dto.access_token !== undefined ? { access_token: dto.access_token } : {}), + ...(dto.refresh_token !== undefined ? { refresh_token: dto.refresh_token } : {}), + ...(dto.expires_at !== undefined ? { token_expires_at: dto.expires_at } : {}), + state, + }); + return c.json(formatConnectedAccount(updated!)); + }); + + app.delete(ACCOUNT_PATH, (c) => { + const { account } = resolveTarget(c); + if (!account) throw notFound('Connected Account'); + // Deleting is what "disconnects": the row (and the tokens the spec says are removed with + // it) goes away, so a later import is a fresh 201 rather than a 409 against a dead link. + // The pipes.connected_account.disconnected event is emitted by the collection hook. + ws.connectedAccounts.delete(account.id); + return c.body(null, 204); + }); +} diff --git a/src/workos/routes/user-features.spec.ts b/src/workos/routes/user-features.spec.ts index 9e2faa5..615826d 100644 --- a/src/workos/routes/user-features.spec.ts +++ b/src/workos/routes/user-features.spec.ts @@ -75,31 +75,6 @@ describe('User feature routes', () => { }); }); - describe('Connected Accounts', () => { - it('gets connected account by provider slug', async () => { - const user = await createUser('connected@test.com'); - const ws = getWorkOSStore(store); - ws.connectedAccounts.insert({ - object: 'connected_account', - user_id: user.id, - provider: 'github', - provider_id: 'gh_123', - }); - - const res = await req(`/user_management/users/${user.id}/connected_accounts/github`); - expect(res.status).toBe(200); - const data = await json(res); - expect(data.provider).toBe('github'); - expect(data.provider_id).toBe('gh_123'); - }); - - it('returns 404 for unknown provider', async () => { - const user = await createUser('no-provider@test.com'); - const res = await req(`/user_management/users/${user.id}/connected_accounts/unknown`); - expect(res.status).toBe(404); - }); - }); - describe('Data Providers', () => { it('lists data providers from pipe connections', async () => { const user = await createUser('pipes@test.com'); diff --git a/src/workos/routes/user-features.ts b/src/workos/routes/user-features.ts index e1cd1db..eac3c70 100644 --- a/src/workos/routes/user-features.ts +++ b/src/workos/routes/user-features.ts @@ -1,6 +1,6 @@ import { type RouteContext, notFound } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; -import { formatAuthorizedApplication, formatConnectedAccount, formatPipeConnection } from '../helpers.js'; +import { formatAuthorizedApplication, formatPipeConnection } from '../helpers.js'; export function userFeatureRoutes(ctx: RouteContext): void { const { app, store } = ctx; @@ -29,17 +29,6 @@ export function userFeatureRoutes(ctx: RouteContext): void { return c.body(null, 204); }); - app.get('/user_management/users/:user_id/connected_accounts/:slug', (c) => { - const user = ws.users.get(c.req.param('user_id')); - if (!user) throw notFound('User'); - - const slug = c.req.param('slug'); - const account = ws.connectedAccounts.findBy('user_id', user.id).find((a) => a.provider === slug); - - if (!account) throw notFound('Connected Account'); - return c.json(formatConnectedAccount(account)); - }); - app.get('/user_management/users/:user_id/data_providers', (c) => { const user = ws.users.get(c.req.param('user_id')); if (!user) throw notFound('User'); diff --git a/src/workos/routes/users.ts b/src/workos/routes/users.ts index aac86f2..da9553b 100644 --- a/src/workos/routes/users.ts +++ b/src/workos/routes/users.ts @@ -152,6 +152,11 @@ export function userRoutes(ctx: RouteContext): void { for (const ma of ws.magicAuths.findBy('user_id', user.id)) { ws.magicAuths.delete(ma.id); } + // Through the delete hook this also emits pipes.connected_account.disconnected for each: + // the accounts stop existing with the user, and their stored tokens go with them. + for (const ca of ws.connectedAccounts.findBy('user_id', user.id)) { + ws.connectedAccounts.delete(ca.id); + } ws.users.delete(user.id); return c.body(null, 204); From 9a9e3f8741259e19c3c90baf21cd3679b4a85538 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Mon, 17 Aug 2026 18:02:33 -0400 Subject: [PATCH 2/3] fix(codegen): count routes registered via consts The support-matrix scanner only resolved literal and template strings, so the connected-account verbs registered through the shared ACCOUNT_PATH const were invisible: SUPPORTED.md read as if this branch removed a Pipes endpoint instead of adding three, and the drift check failed on a table that under-counted the routes. EmulatorSeedConfig also gains the connectedAccounts key the README already documents, so library consumers can pass the seed and the matrix can claim it instead of reporting Pipes as API-only setup. --- SUPPORTED.md | 4 ++-- scripts/gen-supported-lib.spec.ts | 22 ++++++++++++++++++++++ scripts/gen-supported-lib.ts | 10 +++++++++- src/index.ts | 1 + 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/SUPPORTED.md b/SUPPORTED.md index 0b37470..7bbde66 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -2,7 +2,7 @@ # Supported Features -The emulator implements **140 of 212** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.59.0`) (**66.0%**). +The emulator implements **143 of 212** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.59.0`) (**67.5%**). Endpoint coverage says whether a route exists, not whether a feature is usable; for example, Directory Sync implements every endpoint the spec defines for it and is @@ -33,7 +33,7 @@ answers "can I actually emulate this?". | Vault | ❌ 0/5 | ❌ 0/6 | ❌ none | Not implemented. | | Feature Flags | ✅ 4/4 | ⚠️ 1/4 | ⚠️ API only | Enable/disable and targeting exist, but under different verbs than the spec (`POST /feature-flags/:slug/enable` where the spec says `PUT`), so they do not count toward coverage. | | API Keys | ⚠️ 1/2 | ⚠️ 2/5 | ✅ seed `apiKeys` | Seeded keys authenticate real requests. User-scoped API key endpoints are not implemented. | -| Pipes / Connected Apps | ⚠️ 2/5 | ⚠️ 1/12 | ⚠️ API only | Connection CRUD and access-token minting are emulator-specific routes under `/pipes/connections`. | +| Pipes / Connected Apps | ⚠️ 2/5 | ⚠️ 4/12 | ✅ seed `connectedAccounts` | Connection CRUD and access-token minting are emulator-specific routes under `/pipes/connections`. | | Applications | ⚠️ 4/5 | ⚠️ 4/8 | ✅ seed `connectApplications` | | | JWT Templates | ✅ 1/1 | ✅ 1/1 | ✅ seed `jwtTemplate` | Claims render into every access token. Filters, conditionals, and loops are not supported. | | Webhooks | ✅ 1/1 | ⚠️ 2/3 | ✅ seed `webhookEndpoints` | Delivery is fire-and-forget with a 5s timeout and no retries. Endpoints registered in a seed file do not receive events from that same seed file. | diff --git a/scripts/gen-supported-lib.spec.ts b/scripts/gen-supported-lib.spec.ts index 95395b3..70e98c0 100644 --- a/scripts/gen-supported-lib.spec.ts +++ b/scripts/gen-supported-lib.spec.ts @@ -128,6 +128,28 @@ describe('parseEmulatorRoutes', () => { }); }); + it('resolves bare identifier paths declared as const strings', () => { + const source = [ + `const ACCOUNT_PATH = '/user_management/users/:user_id/connected_accounts/:slug';`, + `app.get(ACCOUNT_PATH, (c) => {});`, + `app.post(ACCOUNT_PATH, async (c) => {});`, + `app.put(ACCOUNT_PATH, async (c) => {});`, + `app.delete(ACCOUNT_PATH, (c) => {});`, + ].join('\n'); + const routes = parseEmulatorRoutes([source]); + expect(routes.map((r) => `${r.method} ${r.path}`)).toEqual([ + 'GET /user_management/users/:user_id/connected_accounts/:slug', + 'POST /user_management/users/:user_id/connected_accounts/:slug', + 'PUT /user_management/users/:user_id/connected_accounts/:slug', + 'DELETE /user_management/users/:user_id/connected_accounts/:slug', + ]); + }); + + it('ignores bare identifiers with no matching const declaration', () => { + const routes = parseEmulatorRoutes([`app.get(unknownPath, (c) => {});`]); + expect(routes).toHaveLength(0); + }); + it('skips template literals with unresolved interpolations', () => { const source = `app.get(\`${'${unknown}'}/path\`, (c) => {});`; const routes = parseEmulatorRoutes([source]); diff --git a/scripts/gen-supported-lib.ts b/scripts/gen-supported-lib.ts index c181bf7..ac8e5a5 100644 --- a/scripts/gen-supported-lib.ts +++ b/scripts/gen-supported-lib.ts @@ -187,6 +187,7 @@ export const FEATURES: FeatureDef[] = [ name: 'Pipes / Connected Apps', tags: ['pipes', 'pipes.provider', 'user-management.data-providers'], emulatorCreateRoutes: ['/pipes'], + seedKeys: ['connectedAccounts'], notes: 'Connection CRUD and access-token minting are emulator-specific routes under `/pipes/connections`.', }, { @@ -324,6 +325,7 @@ export function parseEmulatorRoutes(sources: string[]): EmulatorRoute[] { const routes: EmulatorRoute[] = []; const literalPattern = /app\.(get|post|put|patch|delete)\('([^']+)'/g; const templatePattern = /app\.(get|post|put|patch|delete)\(`([^`]+)`/g; + const identifierPattern = /app\.(get|post|put|patch|delete)\((\w+)\s*,/g; const helperPattern = /pathPrefix:\s*([^,}\n]+)/g; for (const source of sources) { @@ -346,7 +348,13 @@ export function parseEmulatorRoutes(sources: string[]): EmulatorRoute[] { routes.push({ method: match[1].toUpperCase(), path: raw }); } - // 3. registerRoleRoutes helper — expand the known routes from pathPrefix + // 3. Bare identifier routes — a shared `const PATH = '...'` registered on several verbs + for (const match of source.matchAll(identifierPattern)) { + const path = vars.get(match[2]); + if (path) routes.push({ method: match[1].toUpperCase(), path }); + } + + // 4. registerRoleRoutes helper — expand the known routes from pathPrefix for (const match of source.matchAll(helperPattern)) { let prefix = match[1].trim(); if (prefix.startsWith("'") && prefix.endsWith("'")) { diff --git a/src/index.ts b/src/index.ts index ffb914e..bd97d4a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,7 @@ export interface EmulatorSeedConfig { organizations?: WorkOSSeedConfig['organizations']; users?: WorkOSSeedConfig['users']; connections?: WorkOSSeedConfig['connections']; + connectedAccounts?: WorkOSSeedConfig['connectedAccounts']; invitations?: WorkOSSeedConfig['invitations']; roles?: WorkOSSeedConfig['roles']; permissions?: WorkOSSeedConfig['permissions']; From 18a9b1427b401f46efe2ddd7ba75d853a28c6ae5 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Mon, 17 Aug 2026 18:02:33 -0400 Subject: [PATCH 3/3] fix(users): derive PUT state from merged credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deriving from the update DTO alone could not see what the account already holds: an expired replacement token was reported as needs_reauthorization even when a stored refresh token still refreshes it, and an expiry-only update never re-derived at all, leaving an unrefreshable account connected — each emitting (or suppressing) the wrong transition event. A replacement access token sent without an expiry deliberately drops the stored one: that expiry described the token being replaced. --- src/workos/routes/connected-accounts.spec.ts | 31 ++++++++++++++++++++ src/workos/routes/connected-accounts.ts | 16 ++++++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/workos/routes/connected-accounts.spec.ts b/src/workos/routes/connected-accounts.spec.ts index aaee264..27f3268 100644 --- a/src/workos/routes/connected-accounts.spec.ts +++ b/src/workos/routes/connected-accounts.spec.ts @@ -307,6 +307,37 @@ describe('Connected account routes', () => { expect(eventsNamed('pipes.connected_account.connected')).toHaveLength(1); }); + it('keeps a retained refresh token in view when an update re-derives state', async () => { + const user = await createUser('retained@test.com'); + await req(`/user_management/users/${user.id}/connected_accounts/github`, { + method: 'POST', + body: JSON.stringify({ access_token: 'gho_1', refresh_token: 'ghr_1' }), + }); + + // The replacement access token is already expired, but the stored refresh token still refreshes it. + const res = await req(`/user_management/users/${user.id}/connected_accounts/github`, { + method: 'PUT', + body: JSON.stringify({ access_token: 'gho_2', expires_at: '2020-01-01T00:00:00.000Z' }), + }); + expect((await json(res)).state).toBe('connected'); + expect(eventsNamed('pipes.connected_account.reauthorization_needed')).toHaveLength(0); + }); + + it('derives needs_reauthorization from an expiry-only update that leaves no working token', async () => { + const user = await createUser('expiry@test.com'); + await req(`/user_management/users/${user.id}/connected_accounts/github`, { + method: 'POST', + body: JSON.stringify({ access_token: 'gho_1' }), + }); + + const res = await req(`/user_management/users/${user.id}/connected_accounts/github`, { + method: 'PUT', + body: JSON.stringify({ expires_at: '2020-01-01T00:00:00.000Z' }), + }); + expect((await json(res)).state).toBe('needs_reauthorization'); + expect(eventsNamed('pipes.connected_account.reauthorization_needed')).toHaveLength(1); + }); + it('404s an update for an account that does not exist', async () => { const user = await createUser('noacct@test.com'); const res = await req(`/user_management/users/${user.id}/connected_accounts/github`, { diff --git a/src/workos/routes/connected-accounts.ts b/src/workos/routes/connected-accounts.ts index 3fcc3fc..b81e57a 100644 --- a/src/workos/routes/connected-accounts.ts +++ b/src/workos/routes/connected-accounts.ts @@ -145,10 +145,20 @@ export function connectedAccountRoutes(ctx: RouteContext): void { if (!account) throw notFound('Connected Account'); const dto = parseDto(await parseJsonBody(c)); - // Without an explicit state, only an update that carries tokens re-derives it — a + // Without an explicit state, an update touching the credentials re-derives it from the + // merged token set — the DTO alone cannot see a retained refresh token — while a // scopes-only update must not silently reconnect a needs_reauthorization account. - const tokensProvided = dto.access_token !== undefined || dto.refresh_token !== undefined; - const state = dto.state ?? (tokensProvided ? deriveState(dto) : account.state); + const credentialsTouched = + dto.access_token !== undefined || dto.refresh_token !== undefined || dto.expires_at !== undefined; + const merged: ConnectedAccountDto = { + access_token: dto.access_token ?? account.access_token ?? undefined, + refresh_token: dto.refresh_token ?? account.refresh_token ?? undefined, + // An expiry describes its access token: a replacement token sent without one is unexpired. + expires_at: + dto.expires_at ?? (dto.access_token === undefined ? (account.token_expires_at ?? undefined) : undefined), + }; + const canDerive = merged.access_token !== undefined || merged.refresh_token !== undefined; + const state = dto.state ?? (credentialsTouched && canDerive ? deriveState(merged) : account.state); const updated = ws.connectedAccounts.update(account.id, { ...(dto.scopes !== undefined ? { scopes: dto.scopes } : {}),