From fa89850f0c19b094f5e444d67189f72248ffa34a Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 11 Sep 2026 12:34:24 -0400 Subject: [PATCH] fix(connect): complete Standalone Connect logins Standalone login clients could not finish authentication because the completion endpoint was missing, making unsupported routes look like expired sessions. Provide the minimal authorize-to-token loop so tests can exercise successful logins and distinguish expiry from completion replay, while keeping Connect codes out of AuthKit's separate exchange. Fixes #109 --- README.md | 58 ++++ SUPPORTED.md | 4 +- src/core/id.ts | 1 + src/workos/entities.ts | 12 + src/workos/index.ts | 5 + src/workos/routes/auth.ts | 4 +- src/workos/routes/connect.spec.ts | 20 ++ src/workos/routes/connect.ts | 5 + src/workos/routes/oauth.spec.ts | 15 + src/workos/routes/oauth.ts | 73 +++- src/workos/routes/standalone-connect.spec.ts | 333 +++++++++++++++++++ src/workos/routes/standalone-connect.ts | 135 ++++++++ src/workos/store.ts | 7 + 13 files changed, 663 insertions(+), 9 deletions(-) create mode 100644 src/workos/routes/standalone-connect.spec.ts create mode 100644 src/workos/routes/standalone-connect.ts diff --git a/README.md b/README.md index 394d0f0..99ed6b2 100644 --- a/README.md +++ b/README.md @@ -458,6 +458,64 @@ requesting a scope the application does not have returns `400 invalid_scope`, so authorization can be exercised locally. Unknown credentials return `401 invalid_client`, and an `oauth`-type application returns `400 unauthorized_client`. +### Standalone Connect + +Bridge your application's own login to Connect with an OAuth application and an emulator-only +`connectApplications[].login_url` (the stand-in for the login page configured in the WorkOS dashboard): + +```yaml +connectApplications: + - name: Standalone App + type: oauth + client_id: client_local_standalone + client_secret: secret_local_standalone + login_url: http://localhost:3000/login + redirect_uris: [http://localhost:3000/callback] + scopes: [profile, email] +``` + +1. Send the browser to + `http://localhost:4100/oauth2/authorize?client_id=client_local_standalone&response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&state=my-state`. + The emulator redirects to `login_url` with a fresh `external_auth_id` valid for ten minutes. +2. Authenticate the user in your application, then call from your backend: + + ```bash + curl -s http://localhost:4100/authkit/oauth2/complete \ + -H "Authorization: Bearer sk_test_default" \ + -H "Content-Type: application/json" \ + -d '{"external_auth_id":"ext_auth_FROM_LOGIN_URL","user":{"id":"user_12345","email":"marcelina.davis@example.com"}}' + ``` + + This creates or updates the AuthKit user by `external_id = user.id`, marks their email verified, + emits `user.created` or `user.updated`, and returns `{"redirect_uri":"..."}`. Optional `name`, + `first_name`, `last_name`, and `metadata` update when supplied; omitted fields are preserved. + +3. Redirect the browser to that returned URL (`GET /oauth2/authorize/complete`). It is single-use + and redirects to the original client callback with `code` and the original `state`. +4. Exchange the code at `POST /oauth2/token` with `grant_type=authorization_code`, `code`, the exact + original `redirect_uri`, `client_id`, and `client_secret` (form-encoded or JSON; Basic credentials + also work). Codes expire after ten minutes and are consumed on exchange. The response contains + an access token, `token_type`, `expires_in`, and `scope`. Its JWT `sub` is the AuthKit user ID; + `aud` is the application's `audience`, falling back to its `client_id`. + +Reusing a completed ID returns `400 external_auth_session_already_completed`; unknown or expired IDs +return `404 not_found`. Missing required fields return `422`, malformed email returns `400 invalid_email`, +and an email owned by another user returns `400 email_not_available` (including on updates). A failed +validation does not consume the session. Browser redemption of an incomplete or already redeemed ID +returns `404`. + +`login_url` can also be set on `POST /connect/applications`, but is not included in API application +responses. Both browser destinations must pass the emulator's redirect-host policy (localhost by +default; configure `--redirect-hosts` for other hosts). When `redirect_uris` is non-empty, the callback +must also match an entry exactly. + +**Deliberate limitations:** no PKCE, refresh tokens, ID tokens, or consent UI. `user_consent_options` +is ignored; the `email_change_not_allowed` policy is not modeled. The authorize request's `scope` +is not tracked: tokens default to the application's configured scopes, optionally narrowed by `scope` +at token exchange. The emulator's completion URL uses `/oauth2/authorize/complete?external_auth_id=...`, +not production's AuthKit-domain `/oauth/authorize/complete?state=...`; always follow the returned URL +rather than constructing it. This is a local testing flow, not a replacement authentication service. + ### API Keys Seed organization- or user-owned API keys. Each seeded key is created as an `api_key` resource diff --git a/SUPPORTED.md b/SUPPORTED.md index f9a11df..91247cc 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -2,7 +2,7 @@ # Supported Features -The emulator implements **179 of 250** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.80.0`) (**71.6%**). +The emulator implements **180 of 250** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.80.0`) (**72.0%**). 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 @@ -34,7 +34,7 @@ answers "can I actually emulate this?". | Feature Flags | ✅ 4/4 | ✅ 4/4 | ✅ seed `featureFlags` | Every spec endpoint is implemented at its documented verb; the emulator additionally accepts `POST` on enable/disable and `PUT` on target creation as aliases, which production rejects. Flags resolve into the `feature_flags` access-token claim, the per-user and per-organization list endpoints, and `GET /sdk/feature-flags` — the Node SDK runtime client's polling endpoint, which the spec does not define. Production has no create-flag endpoint, so flags come from the `featureFlags` seed key. | | API Keys | ✅ 2/2 | ✅ 5/5 | ✅ seed `apiKeys` | Created and seeded keys authenticate real requests. | | 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` | | +| Applications | ⚠️ 4/5 | ⚠️ 5/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. | | Events | ✅ 1/1 | — | ✅ automatic | Emitted as a side effect of every other operation. All are queryable at `GET /events`, including those with no registered webhook endpoint. | diff --git a/src/core/id.ts b/src/core/id.ts index 490cab4..8d6bfc2 100644 --- a/src/core/id.ts +++ b/src/core/id.ts @@ -60,6 +60,7 @@ export const ID_PREFIXES = { authentication_factor: 'auth_factor', authentication_challenge: 'auth_challenge', authorization_code: 'auth_code', + external_auth_session: 'ext_auth', identity: 'identity', sso_authorization: 'sso_auth', refresh_token: 'ref', diff --git a/src/workos/entities.ts b/src/workos/entities.ts index ce78249..04c66a3 100644 --- a/src/workos/entities.ts +++ b/src/workos/entities.ts @@ -137,6 +137,16 @@ export interface WorkOSAuthenticationFactor extends Entity { }; } +export interface WorkOSExternalAuthSession extends Entity { + client_id: string; + redirect_uri: string; + state: string | null; + expires_at: string; + completed_at: string | null; + redeemed_at: string | null; + user_id: string | null; +} + export interface WorkOSAuthorizationCode extends Entity { user_id: string; organization_id: string | null; @@ -481,6 +491,8 @@ export interface WorkOSConnectApplication extends Entity { /** The `aud` claim minted into m2m tokens. Falls back to client_id when null. */ audience: string | null; redirect_uris: string[]; + /** Emulator-only Standalone Connect login page; never serialized on the API application. */ + login_url: string | null; client_id: string; logo_url: string | null; } diff --git a/src/workos/index.ts b/src/workos/index.ts index 60004c8..d622e13 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -35,6 +35,7 @@ import { vaultRoutes } from './routes/vault.js'; import { radarRoutes } from './routes/radar.js'; import { connectRoutes } from './routes/connect.js'; import { oauthRoutes } from './routes/oauth.js'; +import { standaloneConnectRoutes } from './routes/standalone-connect.js'; import { directoryRoutes } from './routes/directories.js'; import { auditLogRoutes } from './routes/audit-logs.js'; import { featureFlagRoutes } from './routes/feature-flags.js'; @@ -284,6 +285,8 @@ export interface WorkOSSeedConnectApplication { client_secret?: string; /** OAuth redirect URIs. Ignored for `m2m` applications. */ redirect_uris?: string[]; + /** Emulator-only Standalone Connect login page, receiving an external_auth_id. */ + login_url?: string | null; } export interface WorkOSSeedApiKey { @@ -749,6 +752,7 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee scopes: appConfig.scopes ?? [], audience: appConfig.audience ?? null, redirect_uris: appConfig.redirect_uris ?? [], + login_url: appConfig.login_url ?? null, client_id: appConfig.client_id ?? generateClientId(), logo_url: null, }); @@ -945,6 +949,7 @@ export const workosPlugin: ServicePlugin = { radarRoutes(ctx); connectRoutes(ctx); oauthRoutes(ctx); + standaloneConnectRoutes(ctx); directoryRoutes(ctx); auditLogRoutes(ctx); featureFlagRoutes(ctx); diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index 9d7de31..58063d7 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -835,7 +835,9 @@ export function authRoutes(ctx: RouteContext): void { new OauthApiError(400, 'invalid_grant', `The code '${code}' has expired or is invalid.`), ); } - if (isExpired(authCode.expires_at)) { + // Standalone Connect codes belong to /oauth2/token, which enforces the Connect + // client's secret and redirect_uri. Reject them here without consuming the code. + if (authCode.auth_method === 'external_auth' || isExpired(authCode.expires_at)) { failAuth( 'OAuth', { userId: authCode.user_id, email: ws.users.get(authCode.user_id)?.email }, diff --git a/src/workos/routes/connect.spec.ts b/src/workos/routes/connect.spec.ts index 2f81718..8c44842 100644 --- a/src/workos/routes/connect.spec.ts +++ b/src/workos/routes/connect.spec.ts @@ -36,6 +36,26 @@ describe('Connect routes', () => { expect(app.id).toMatch(/^connect_app_/); }); + it('stores the emulator-only login_url without adding it to the API response', async () => { + const res = await req('/connect/applications', { + method: 'POST', + body: JSON.stringify({ name: 'Standalone', login_url: 'http://localhost:3000/login' }), + }); + expect(res.status).toBe(201); + const created = await json(res); + expect(created.login_url).toBeUndefined(); + expect(getWorkOSStore(store).connectApplications.get(created.id)?.login_url).toBe('http://localhost:3000/login'); + expect((await json(await req(`/connect/applications/${created.id}`))).login_url).toBeUndefined(); + }); + + it('rejects a non-string login_url', async () => { + const res = await req('/connect/applications', { + method: 'POST', + body: JSON.stringify({ name: 'Standalone', login_url: 123 }), + }); + expect(res.status).toBe(422); + }); + it('rejects empty name', async () => { const res = await req('/connect/applications', { method: 'POST', diff --git a/src/workos/routes/connect.ts b/src/workos/routes/connect.ts index ef6574e..4cb1743 100644 --- a/src/workos/routes/connect.ts +++ b/src/workos/routes/connect.ts @@ -41,6 +41,10 @@ export function connectRoutes(ctx: RouteContext): void { throw validationError('scopes must be an array of strings', [{ field: 'scopes', code: 'invalid' }]); } + if (body.login_url !== undefined && body.login_url !== null && typeof body.login_url !== 'string') { + throw validationError('login_url must be a string or null', [{ field: 'login_url', code: 'invalid' }]); + } + const applicationType = body.application_type === 'm2m' ? 'm2m' : 'oauth'; const organizationId = (body.organization_id as string) ?? null; // m2m applications are owned by an organization; reject a null or dangling owner so @@ -68,6 +72,7 @@ export function connectRoutes(ctx: RouteContext): void { scopes: (body.scopes as string[]) ?? [], audience: (body.audience as string) ?? null, redirect_uris: (body.redirect_uris as string[]) ?? [], + login_url: (body.login_url as string) ?? null, client_id: generateClientId(), logo_url: (body.logo_url as string) ?? null, }); diff --git a/src/workos/routes/oauth.spec.ts b/src/workos/routes/oauth.spec.ts index 130bd83..2ab9b18 100644 --- a/src/workos/routes/oauth.spec.ts +++ b/src/workos/routes/oauth.spec.ts @@ -201,6 +201,18 @@ describe('OAuth M2M token routes', () => { expect((await json(res)).error).toBe('unauthorized_client'); }); + it('rejects authorization_code for an m2m application', async () => { + const res = await form({ + grant_type: 'authorization_code', + client_id: 'client_billing', + client_secret: 'secret_billing_value', + code: 'any_code', + redirect_uri: 'http://localhost:3000/cb', + }); + expect(res.status).toBe(400); + expect((await json(res)).error).toBe('unauthorized_client'); + }); + it('requires no API key (token endpoint is public)', async () => { // No Authorization header at all — must not be rejected by the auth middleware. const res = await form({ @@ -234,6 +246,7 @@ describe('OAuth M2M token routes', () => { redirect_uris: [], client_id: 'client_aud', logo_url: null, + login_url: null, }); ws.clientSecrets.insert({ object: 'client_secret', @@ -261,6 +274,7 @@ describe('OAuth M2M token routes', () => { redirect_uris: [], client_id: 'client_percent', logo_url: null, + login_url: null, }); ws.clientSecrets.insert({ object: 'client_secret', @@ -295,6 +309,7 @@ describe('OAuth M2M token routes', () => { redirect_uris: [], client_id: 'client_malformed', logo_url: null, + login_url: null, }); ws.clientSecrets.insert({ object: 'client_secret', diff --git a/src/workos/routes/oauth.ts b/src/workos/routes/oauth.ts index 2bf1a0a..fafac95 100644 --- a/src/workos/routes/oauth.ts +++ b/src/workos/routes/oauth.ts @@ -1,9 +1,10 @@ import type { Context } from 'hono'; import { type RouteContext, OauthApiError, generateUlid } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; +import { assertAllowedRedirectUri, expiresIn, isExpired } from '../helpers.js'; /** - * M2M token exchange (OAuth 2.0 `client_credentials`). + * Connect token exchange (OAuth 2.0 `client_credentials` and `authorization_code`). * * This endpoint is deliberately hand-authored: it is absent from the WorkOS OpenAPI * spec at every version (the spec's `/sso/token` only documents `authorization_code`), @@ -36,6 +37,8 @@ interface TokenParams { clientId?: string; clientSecret?: string; scope?: string; + code?: string; + redirectUri?: string; } /** @@ -94,6 +97,8 @@ async function readTokenParams(c: Context): Promise { clientId, clientSecret, scope: str(raw.scope), + code: str(raw.code), + redirectUri: str(raw.redirect_uri), }; } @@ -101,10 +106,44 @@ export function oauthRoutes(ctx: RouteContext): void { const { app, store, jwt } = ctx; const ws = getWorkOSStore(store); + // Standalone Connect's browser entry point. Like /oauth2/token, this route is + // hand-authored because the public spec only describes the server-side completion. + app.get('/oauth2/authorize', (c) => { + const { client_id: clientId, redirect_uri: redirectUri, response_type: responseType, state } = c.req.query(); + if (!clientId || !redirectUri) { + throw new OauthApiError(400, 'invalid_request', 'client_id and redirect_uri are required.'); + } + if (responseType !== 'code') { + throw new OauthApiError(400, 'unsupported_response_type', 'response_type must be code.'); + } + const application = ws.connectApplications.findOneBy('client_id', clientId); + if (!application) throw new OauthApiError(400, 'invalid_client', 'Invalid client ID.'); + if (application.application_type !== 'oauth' || !application.login_url) { + throw new OauthApiError(400, 'unauthorized_client', 'The client must be an OAuth application with login_url.'); + } + if (application.redirect_uris.length > 0 && !application.redirect_uris.includes(redirectUri)) { + throw new OauthApiError(400, 'invalid_request', 'redirect_uri is not registered for this application.'); + } + assertAllowedRedirectUri(redirectUri, store); + assertAllowedRedirectUri(application.login_url, store); + const login = new URL(application.login_url); + const session = ws.externalAuthSessions.insert({ + client_id: clientId, + redirect_uri: redirectUri, + state: state ?? null, + expires_at: expiresIn(10), + completed_at: null, + redeemed_at: null, + user_id: null, + }); + login.searchParams.set('external_auth_id', session.id); + return c.redirect(login.toString(), 302); + }); + app.post('/oauth2/token', async (c) => { - const { grantType, clientId, clientSecret, scope } = await readTokenParams(c); + const { grantType, clientId, clientSecret, scope, code, redirectUri } = await readTokenParams(c); - if (grantType !== 'client_credentials') { + if (grantType !== 'client_credentials' && grantType !== 'authorization_code') { throw new OauthApiError( 400, 'unsupported_grant_type', @@ -121,14 +160,34 @@ export function oauthRoutes(ctx: RouteContext): void { if (!application || !secretMatches) { throw new OauthApiError(401, 'invalid_client', 'Invalid client ID or secret.'); } - if (application.application_type !== 'm2m') { + const expectedType = grantType === 'client_credentials' ? 'm2m' : 'oauth'; + if (application.application_type !== expectedType) { throw new OauthApiError( 400, 'unauthorized_client', - 'The client is not authorized to use the client_credentials grant type.', + `The client is not authorized to use the ${grantType} grant type.`, ); } + // Minimal Standalone Connect exchange: no refresh token, id_token, or PKCE. + // Bind the code to this flow, client, and exact callback before consuming it. + const authCode = grantType === 'authorization_code' && code ? ws.authCodes.findOneBy('code', code) : undefined; + if (grantType === 'authorization_code') { + if (!code || !redirectUri) { + throw new OauthApiError(400, 'invalid_request', 'code and redirect_uri are required.'); + } + if ( + !authCode || + isExpired(authCode.expires_at) || + authCode.auth_method !== 'external_auth' || + authCode.client_id !== clientId || + authCode.redirect_uri !== redirectUri || + !ws.users.get(authCode.user_id) + ) { + throw new OauthApiError(400, 'invalid_grant', 'The authorization code has expired or is invalid.'); + } + } + // Grant the requested scopes, defaulting to all of the application's scopes. A // request may narrow to a subset (space-delimited, per RFC 6749 §3.3); requesting // a scope the application does not have is rejected so authz logic can be tested. @@ -154,7 +213,7 @@ export function oauthRoutes(ctx: RouteContext): void { // aud defaults to the client_id; pin `audience` on the app to match production. const accessToken = jwt.sign( { - sub: clientId, + sub: authCode?.user_id ?? clientId, aud: application.audience ?? clientId, jti: generateUlid(), org_id: application.organization_id ?? undefined, @@ -163,6 +222,8 @@ export function oauthRoutes(ctx: RouteContext): void { { expiresIn: TOKEN_TTL_SECONDS }, ); + if (authCode) ws.authCodes.delete(authCode.id); + return c.json({ access_token: accessToken, token_type: 'Bearer', diff --git a/src/workos/routes/standalone-connect.spec.ts b/src/workos/routes/standalone-connect.spec.ts new file mode 100644 index 0000000..0fe62d5 --- /dev/null +++ b/src/workos/routes/standalone-connect.spec.ts @@ -0,0 +1,333 @@ +import { beforeEach, describe, expect, it } from 'bun:test'; +import { createServer } from '../../core/index.js'; +import { seedFromConfig, workosPlugin } from '../index.js'; +import { getWorkOSStore } from '../store.js'; + +const baseUrl = 'http://localhost:4100'; +const callback = 'http://localhost:3000/callback?existing=1'; +const headers = { Authorization: 'Bearer sk_test_default', 'Content-Type': 'application/json' }; +const user = { id: 'user_12345', email: 'marcelina.davis@example.com' }; +const json = (res: Response) => res.json() as Promise; + +function createTestApp() { + const server = createServer(workosPlugin, { + port: 0, + baseUrl, + apiKeys: { sk_test_default: { environment: 'test' } }, + }); + seedFromConfig(server.store, baseUrl, { + connectApplications: [ + { + name: 'Standalone', + type: 'oauth', + client_id: 'client_standalone', + client_secret: 'secret_standalone', + login_url: 'http://localhost:3000/login?existing=1', + redirect_uris: [callback], + scopes: ['profile', 'email'], + audience: 'https://api.example.test', + }, + ], + }); + return server; +} + +describe('Standalone Connect', () => { + let server: ReturnType; + let ws: ReturnType; + + beforeEach(() => { + server = createTestApp(); + ws = getWorkOSStore(server.store); + }); + + const authorize = (overrides: Record = {}) => + server.app.request( + `/oauth2/authorize?${new URLSearchParams({ + client_id: 'client_standalone', + redirect_uri: callback, + response_type: 'code', + state: 'state + /?&=', + ...overrides, + })}`, + ); + const mint = async () => { + const res = await authorize(); + expect(res.status).toBe(302); + const login = new URL(res.headers.get('location')!); + expect(login.origin + login.pathname).toBe('http://localhost:3000/login'); + expect(login.searchParams.get('existing')).toBe('1'); + const id = login.searchParams.get('external_auth_id')!; + expect(id).toMatch(/^ext_auth_[0-9A-HJKMNP-TV-Z]{26}$/); + return id; + }; + const complete = (id: string, input: Record = user) => + server.app.request('/authkit/oauth2/complete', { + method: 'POST', + headers, + body: JSON.stringify({ external_auth_id: id, user: input }), + }); + const issueCode = async () => { + const id = await mint(); + const res = await complete(id); + expect(res.status).toBe(200); + const { redirect_uri } = await json(res); + const redirect = await server.app.request(redirect_uri); + expect(redirect.status).toBe(302); + return { id, redirect_uri, callbackUrl: new URL(redirect.headers.get('location')!) }; + }; + const exchange = (code: string, overrides: Record = {}) => + server.app.request('/oauth2/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: 'client_standalone', + client_secret: 'secret_standalone', + redirect_uri: callback, + code, + ...overrides, + }), + }); + + it('finishes authorize → login → complete → callback → signed access token', async () => { + const { id, callbackUrl } = await issueCode(); + expect(callbackUrl.origin + callbackUrl.pathname).toBe('http://localhost:3000/callback'); + expect(callbackUrl.searchParams.get('existing')).toBe('1'); + expect(callbackUrl.searchParams.get('state')).toBe('state + /?&='); + const created = ws.users.findOneBy('external_id', user.id)!; + expect(created.email).toBe(user.email); + expect(created.email_verified).toBe(true); + expect(created.id).not.toBe(user.id); + expect(ws.events.findBy('event', 'user.created').some((e) => e.data.id === created.id)).toBe(true); + expect(ws.externalAuthSessions.get(id)?.user_id).toBe(created.id); + const code = callbackUrl.searchParams.get('code')!; + const tokenRes = await exchange(code); + expect(tokenRes.status).toBe(200); + const token = await json(tokenRes); + expect(token.token_type).toBe('Bearer'); + expect(token.expires_in).toBe(3600); + expect(token.refresh_token).toBeUndefined(); + expect(token.id_token).toBeUndefined(); + const claims = server.jwt.verify(token.access_token); + expect(claims.sub).toBe(created.id); + expect(claims.aud).toBe('https://api.example.test'); + expect(claims.iss).toBe(baseUrl); + expect(claims.scope).toBe('profile email'); + expect(claims.jti).toBeDefined(); + expect(ws.authCodes.findOneBy('code', code)).toBeUndefined(); + const replay = await exchange(code); + expect(replay.status).toBe(400); + expect((await json(replay)).error).toBe('invalid_grant'); + }); + + it('requires API-key authentication for server-side completion', async () => { + const id = await mint(); + for (const authorization of ['', 'Bearer sk_invalid']) { + const res = await server.app.request('/authkit/oauth2/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: authorization }, + body: JSON.stringify({ external_auth_id: id, user }), + }); + expect(res.status).toBe(401); + } + expect(ws.externalAuthSessions.get(id)?.completed_at).toBeNull(); + }); + + it('rejects repeat completion with the specified error without updating the user', async () => { + const id = await mint(); + expect((await complete(id)).status).toBe(200); + const res = await complete(id, { ...user, email: 'changed@example.com' }); + expect(res.status).toBe(400); + expect((await json(res)).code).toBe('external_auth_session_already_completed'); + expect(ws.users.findOneBy('external_id', user.id)?.email).toBe(user.email); + }); + + it('returns 404 for unknown and expired external auth ids', async () => { + const id = await mint(); + ws.externalAuthSessions.update(id, { expires_at: new Date(Date.now() - 1000).toISOString() }); + for (const invalid of ['ext_auth_unknown', id]) { + const res = await complete(invalid); + expect(res.status).toBe(404); + expect((await json(res)).code).toBe('not_found'); + } + expect(ws.users.findOneBy('external_id', user.id)).toBeUndefined(); + }); + + it('requires external_auth_id and user.id/email with 422, including wrong JSON types', async () => { + const id = await mint(); + for (const body of [ + {}, + { user }, + { external_auth_id: id }, + { external_auth_id: id, user: [] }, + { external_auth_id: id, user: { email: user.email } }, + { external_auth_id: id, user: { id: user.id } }, + { external_auth_id: id, user: { ...user, id: 123 } }, + { external_auth_id: id, user: { ...user, email: ' ' } }, + ]) { + const res = await server.app.request('/authkit/oauth2/complete', { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + expect(res.status).toBe(422); + expect((await json(res)).code).toBe('unprocessable_entity'); + } + }); + + it('rejects malformed email without consuming the session', async () => { + const id = await mint(); + const res = await complete(id, { ...user, email: 'not-an-email' }); + expect(res.status).toBe(400); + expect((await json(res)).code).toBe('invalid_email'); + expect(ws.externalAuthSessions.get(id)?.completed_at).toBeNull(); + expect((await complete(id)).status).toBe(200); + }); + + it('rejects another external id claiming an owned email case-insensitively', async () => { + expect((await complete(await mint())).status).toBe(200); + const id = await mint(); + const res = await complete(id, { id: 'different-user', email: user.email.toUpperCase() }); + expect(res.status).toBe(400); + expect((await json(res)).code).toBe('email_not_available'); + expect(ws.externalAuthSessions.get(id)?.completed_at).toBeNull(); + expect(ws.users.findOneBy('external_id', 'different-user')).toBeUndefined(); + }); + + it('updates by external_id, preserves omitted fields, and emits user.updated', async () => { + expect( + ( + await complete(await mint(), { + ...user, + name: 'Marcelina Davis', + first_name: 'Marcelina', + metadata: { team: 'dev' }, + }) + ).status, + ).toBe(200); + const original = ws.users.findOneBy('external_id', user.id)!; + const res = await complete(await mint(), { ...user, email: 'new@example.com', last_name: 'Davis' }); + expect(res.status).toBe(200); + expect(ws.users.findBy('external_id', user.id)).toHaveLength(1); + const updated = ws.users.get(original.id)!; + expect(updated.email).toBe('new@example.com'); + expect(updated.name).toBe('Marcelina Davis'); + expect(updated.first_name).toBe('Marcelina'); + expect(updated.last_name).toBe('Davis'); + expect(updated.metadata).toEqual({ team: 'dev' }); + expect(ws.events.findBy('event', 'user.updated').some((e) => e.data.id === original.id)).toBe(true); + }); + + it('rejects email conflicts on updates as well as creates', async () => { + await complete(await mint()); + await complete(await mint(), { id: 'other', email: 'other@example.com' }); + const res = await complete(await mint(), { ...user, email: 'other@example.com' }); + expect(res.status).toBe(400); + expect((await json(res)).code).toBe('email_not_available'); + }); + + it('rejects redirect replay and incomplete, unknown, and expired sessions', async () => { + const { id, redirect_uri } = await issueCode(); + expect((await server.app.request(redirect_uri)).status).toBe(404); + expect(ws.authCodes.all()).toHaveLength(1); + const pending = await mint(); + for (const invalid of [pending, 'ext_auth_unknown']) { + expect((await server.app.request(`/oauth2/authorize/complete?external_auth_id=${invalid}`)).status).toBe(404); + } + ws.externalAuthSessions.update(id, { expires_at: new Date(Date.now() - 1000).toISOString(), redeemed_at: null }); + expect((await server.app.request(redirect_uri)).status).toBe(404); + }); + + it('rejects invalid authorize parameters before minting a session', async () => { + for (const params of [ + { client_id: '' }, + { client_id: 'unknown' }, + { redirect_uri: '' }, + { response_type: 'token' }, + { redirect_uri: 'http://localhost:3000/unregistered' }, + ] as Record[]) { + expect((await authorize(params)).status).toBe(400); + } + expect(ws.externalAuthSessions.all()).toHaveLength(0); + }); + + it('requires an OAuth application configured with login_url', async () => { + const application = ws.connectApplications.findOneBy('client_id', 'client_standalone')!; + ws.connectApplications.update(application.id, { application_type: 'm2m' }); + expect((await authorize()).status).toBe(400); + ws.connectApplications.update(application.id, { application_type: 'oauth', login_url: null }); + expect((await authorize()).status).toBe(400); + expect(ws.externalAuthSessions.all()).toHaveLength(0); + }); + + it('applies redirect-host and unsafe-scheme guards to both browser destinations', async () => { + const application = ws.connectApplications.findOneBy('client_id', 'client_standalone')!; + ws.connectApplications.update(application.id, { redirect_uris: [] }); + for (const uri of ['https://untrusted.example/cb', 'javascript:alert(1)', 'not-a-url']) { + expect((await authorize({ redirect_uri: uri })).status).toBe(400); + } + ws.connectApplications.update(application.id, { login_url: 'https://untrusted.example/login' }); + expect((await authorize()).status).toBe(400); + expect(ws.externalAuthSessions.all()).toHaveLength(0); + }); + + it('binds token exchange to the secret, client, redirect_uri, and unexpired code', async () => { + const { callbackUrl } = await issueCode(); + const code = callbackUrl.searchParams.get('code')!; + const wrongSecret = await exchange(code, { client_secret: 'wrong' }); + expect(wrongSecret.status).toBe(401); + expect((await json(wrongSecret)).error).toBe('invalid_client'); + const wrongRedirect = await exchange(code, { redirect_uri: 'http://localhost:3000/other' }); + expect(wrongRedirect.status).toBe(400); + expect((await json(wrongRedirect)).error).toBe('invalid_grant'); + seedFromConfig(server.store, baseUrl, { + connectApplications: [{ name: 'Other', type: 'oauth', client_id: 'client_other', client_secret: 'secret_other' }], + }); + const wrongClient = await exchange(code, { client_id: 'client_other', client_secret: 'secret_other' }); + expect(wrongClient.status).toBe(400); + expect((await json(wrongClient)).error).toBe('invalid_grant'); + expect((await exchange('unknown')).status).toBe(400); + for (const params of [{ code: '' }, { redirect_uri: '' }] as Record[]) { + const res = await exchange(code, params); + expect(res.status).toBe(400); + expect((await json(res)).error).toBe('invalid_request'); + } + const record = ws.authCodes.findOneBy('code', code)!; + ws.authCodes.update(record.id, { expires_at: new Date(Date.now() - 1000).toISOString() }); + const expired = await exchange(code); + expect(expired.status).toBe(400); + expect((await json(expired)).error).toBe('invalid_grant'); + }); + + it('rejects Standalone Connect codes at AuthKit authenticate without consuming them', async () => { + const { callbackUrl } = await issueCode(); + const code = callbackUrl.searchParams.get('code')!; + const record = ws.authCodes.findOneBy('code', code)!; + for (const clientId of ['client_standalone', 'client_unrelated']) { + const res = await server.app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'authorization_code', client_id: clientId, code }), + }); + expect(res.status).toBe(400); + expect((await json(res)).error).toBe('invalid_grant'); + expect(ws.authCodes.findOneBy('code', code)).toEqual(record); + expect(ws.sessions.all()).toHaveLength(0); + expect(ws.refreshTokens.all()).toHaveLength(0); + } + const tokenRes = await exchange(code); + expect(tokenRes.status).toBe(200); + expect(server.jwt.verify((await json(tokenRes)).access_token).sub).toBe(record.user_id); + expect(ws.authCodes.findOneBy('code', code)).toBeUndefined(); + }); + + it('rejects codes minted by another authentication flow', async () => { + const { callbackUrl } = await issueCode(); + const code = callbackUrl.searchParams.get('code')!; + ws.authCodes.update(ws.authCodes.findOneBy('code', code)!.id, { auth_method: 'Password' }); + const res = await exchange(code); + expect(res.status).toBe(400); + expect((await json(res)).error).toBe('invalid_grant'); + }); +}); diff --git a/src/workos/routes/standalone-connect.ts b/src/workos/routes/standalone-connect.ts new file mode 100644 index 0000000..f369d6b --- /dev/null +++ b/src/workos/routes/standalone-connect.ts @@ -0,0 +1,135 @@ +import { + type RouteContext, + WorkOSApiError, + generateId, + notFound, + parseJsonBody, + validationError, +} from '../../core/index.js'; +import { assertAllowedRedirectUri, expiresIn, findUserByEmail, isEmailShaped, isExpired } from '../helpers.js'; +import { getWorkOSStore } from '../store.js'; +import type { WorkOSUser } from '../entities.js'; + +export function standaloneConnectRoutes(ctx: RouteContext): void { + const { app, store } = ctx; + const ws = getWorkOSStore(store); + + // Server-to-server: unlike the /oauth2 browser routes, this requires an API key. + app.post('/authkit/oauth2/complete', async (c) => { + const body = await parseJsonBody(c); + const externalAuthId = body.external_auth_id; + if (typeof externalAuthId !== 'string' || !externalAuthId.trim()) { + throw validationError('external_auth_id is required', [{ field: 'external_auth_id', code: 'required' }]); + } + if (!body.user || typeof body.user !== 'object' || Array.isArray(body.user)) { + throw validationError('user is required', [{ field: 'user', code: 'required' }]); + } + const input = body.user as Record; + for (const field of ['id', 'email']) { + if (typeof input[field] !== 'string' || !input[field].trim()) { + throw validationError(`user.${field} is required`, [{ field: `user.${field}`, code: 'required' }]); + } + } + const externalId = input.id as string; + const email = (input.email as string).trim(); + if (!isEmailShaped(email)) { + throw new WorkOSApiError(400, 'Invalid email address', 'invalid_email'); + } + + const profile: Partial> = {}; + for (const field of ['name', 'first_name', 'last_name'] as const) { + if (input[field] !== undefined) { + if (typeof input[field] !== 'string') { + throw validationError(`user.${field} must be a string`, [{ field: `user.${field}`, code: 'invalid' }]); + } + profile[field] = input[field]; + } + } + if (input.metadata !== undefined) { + if ( + !input.metadata || + typeof input.metadata !== 'object' || + Array.isArray(input.metadata) || + !Object.values(input.metadata).every((value) => typeof value === 'string') + ) { + throw validationError('user.metadata must be an object of strings', [ + { field: 'user.metadata', code: 'invalid' }, + ]); + } + profile.metadata = input.metadata as Record; + } + + const session = ws.externalAuthSessions.get(externalAuthId); + if (!session || isExpired(session.expires_at)) throw notFound('External authentication session'); + if (session.completed_at) { + throw new WorkOSApiError( + 400, + 'External authentication session already completed', + 'external_auth_session_already_completed', + ); + } + + const existing = ws.users.findOneBy('external_id', externalId); + const emailOwner = findUserByEmail(ws, email); + if (emailOwner && emailOwner.id !== existing?.id) { + throw new WorkOSApiError(400, 'Email belongs to another user', 'email_not_available'); + } + // Collection hooks emit user.created/user.updated; omitted profile fields survive an update. + const user = existing + ? ws.users.update(existing.id, { ...profile, email, email_verified: true })! + : ws.users.insert({ + object: 'user', + email, + external_id: externalId, + email_verified: true, + name: null, + first_name: null, + last_name: null, + metadata: {}, + profile_picture_url: null, + last_sign_in_at: null, + locale: null, + password_hash: null, + impersonator: null, + ...profile, + }); + ws.externalAuthSessions.update(session.id, { user_id: user.id, completed_at: new Date().toISOString() }); + + // Read baseUrl at request time so an ephemeral-port server returns its bound address. + const redirect = new URL(`${ctx.baseUrl}/oauth2/authorize/complete`); + redirect.searchParams.set('external_auth_id', session.id); + return c.json({ redirect_uri: redirect.toString() }); + }); + + app.get('/oauth2/authorize/complete', (c) => { + const session = ws.externalAuthSessions.get(c.req.query('external_auth_id') ?? ''); + if ( + !session || + isExpired(session.expires_at) || + !session.completed_at || + session.redeemed_at || + !session.user_id || + !ws.users.get(session.user_id) + ) { + throw notFound('External authentication session'); + } + assertAllowedRedirectUri(session.redirect_uri, store); + const redirect = new URL(session.redirect_uri); + const authCode = ws.authCodes.insert({ + user_id: session.user_id, + organization_id: null, + code: generateId('auth_code'), + redirect_uri: session.redirect_uri, + client_id: session.client_id, + expires_at: expiresIn(10), + auth_method: 'external_auth', + step_up_method: null, + code_challenge: null, + code_challenge_method: null, + }); + ws.externalAuthSessions.update(session.id, { redeemed_at: new Date().toISOString() }); + redirect.searchParams.set('code', authCode.code); + if (session.state !== null) redirect.searchParams.set('state', session.state); + return c.redirect(redirect.toString(), 302); + }); +} diff --git a/src/workos/store.ts b/src/workos/store.ts index 8648589..917cb5e 100644 --- a/src/workos/store.ts +++ b/src/workos/store.ts @@ -13,6 +13,7 @@ import type { WorkOSMagicAuth, WorkOSAuthenticationFactor, WorkOSAuthorizationCode, + WorkOSExternalAuthSession, WorkOSIdentity, WorkOSConnection, WorkOSSSOProfile, @@ -65,6 +66,7 @@ export interface WorkOSStore { magicAuths: Collection; authFactors: Collection; authCodes: Collection; + externalAuthSessions: Collection; identities: Collection; connections: Collection; ssoProfiles: Collection; @@ -149,6 +151,11 @@ export function getWorkOSStore(store: Store): WorkOSStore { 'user_id', 'code', ]), + externalAuthSessions: store.collection( + 'workos.external_auth_sessions', + ID_PREFIXES.external_auth_session, + ['client_id'], + ), identities: store.collection('workos.identities', ID_PREFIXES.identity, ['user_id']), connections: store.collection('workos.connections', ID_PREFIXES.connection, ['organization_id']), ssoProfiles: store.collection('workos.sso_profiles', ID_PREFIXES.profile, [