diff --git a/README.md b/README.md index 17bbcd4..9bb5921 100644 --- a/README.md +++ b/README.md @@ -326,6 +326,36 @@ A JWT template reads the same fact as `user.identities` — a provider→`idp_id provider the user has not linked, so `{{ user.identities.GoogleOAuth }}` renders the id and an unlinked provider renders a claim the emulator drops. +### 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/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/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/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']; diff --git a/src/workos/config-validator.ts b/src/workos/config-validator.ts index 602895b..93dedd9 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -422,6 +422,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 6b71840..d1df24e 100644 --- a/src/workos/entities.ts +++ b/src/workos/entities.ts @@ -234,11 +234,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 bd3380c..6bd7360 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, @@ -587,8 +589,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 5978f7d..5467e34 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,12 +67,15 @@ import { formatFeatureFlag, generateClientId, findUserByEmail, + formatConnectedAccountEvent, + dataIntegrationIdFor, linkOAuthIdentity, } from './helpers.js'; import type { WorkOSConnectionType, PipeProvider, PipeConnectionStatus, + ConnectedAccountState, WorkOSApiKeyOwner, WorkOSJwtTemplate, } from './entities.js'; @@ -173,6 +177,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; @@ -268,6 +296,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[]; @@ -474,6 +504,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({ @@ -655,6 +710,7 @@ export const workosPlugin: ServicePlugin = { connectionRoutes(ctx); ssoRoutes(ctx); pipeRoutes(ctx); + connectedAccountRoutes(ctx); invitationRoutes(ctx); configRoutes(ctx); userFeatureRoutes(ctx); @@ -734,6 +790,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..27f3268 --- /dev/null +++ b/src/workos/routes/connected-accounts.spec.ts @@ -0,0 +1,381 @@ +/** + * 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('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`, { + 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..b81e57a --- /dev/null +++ b/src/workos/routes/connected-accounts.ts @@ -0,0 +1,182 @@ +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, 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 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 } : {}), + ...(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 9a6913d..aeea227 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);