From ec801800460e7eee522b36b88136d5d913e34c38 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Mon, 17 Aug 2026 14:37:56 -0400 Subject: [PATCH 1/5] fix(sso): redeem an SSO code for a user-management session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /sso/authorize wrote to ssoAuthorizations while the authorization_code grant read authCodes, so a code the emulator had just issued came back invalid_grant at /user_management/authenticate. An app that starts SSO with sso.getAuthorizationUrl — sending people straight to their IdP rather than through a hosted screen — had no way to finish at AuthKit's callback. The grant now falls back to ssoAuthorizations, which is also the first path to record a session with auth_method 'sso': AUTH_METHOD_SESSION_VALUES mapped it, but no grant ever assigned it, so nothing gating on a federated session could be exercised. /sso/token still redeems the same code for a bare profile; a code is spent by whichever endpoint gets it first. A profile with no user-management account is provisioned one, verified, the way AuthKit does on a first SSO login — /sso/authorize mints a profile for any address it is handed, so refusing would report a freshly issued code as invalid. The succeeded and failed events carry the `sso` block the spec's event data requires, with the session_id no other SSO path has. Fixes #67 --- README.md | 6 +++ src/workos/routes/auth.ts | 86 ++++++++++++++++++++++++++++++- src/workos/routes/sso.spec.ts | 95 +++++++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8f2d12f..559d370 100644 --- a/README.md +++ b/README.md @@ -512,6 +512,12 @@ curl -X POST http://localhost:4100/user_management/authenticate \ Only `active` memberships count — an unaccepted invitation or a deactivated member is never selected. Passing `invitation_token` to the `authorization_code`, `password`, or Magic Auth grants accepts the invitation as part of the login, joining the user to the invited organization and scoping the session to it, so there is no selection step; a token that is unknown, expired, or already used is rejected with `invitation_invalid`, and one addressed to somebody else with `invitation_cannot_be_used_for_email`. Once a session exists, only an explicit `organization_id` on a refresh (`switchToOrganization`) moves it between organizations. +### SSO logins produce a session + +A code from `GET /sso/authorize` redeems at `POST /user_management/authenticate` with `grant_type=authorization_code`, so an app that sends people straight to their IdP with `sso.getAuthorizationUrl` and finishes at AuthKit's callback gets a real session — one whose `auth_method` is `sso`, so authorization code that hides password management for federated users can be exercised. `POST /sso/token` still redeems the same code for a bare profile and access token, which is the standalone SSO product and creates no session; a code is spent by whichever endpoint gets it first. + +The session is scoped to the connection's organization. A profile with no user-management account yet gets one, verified — the IdP asserted the address — the way AuthKit provisions on a first SSO login. + ### Refresh tokens always rotate The emulator issues a new refresh token on every refresh and invalidates the one you presented, so replaying it returns `{"error": "invalid_grant", "error_description": "Invalid refresh token."}`. WorkOS documents that refresh tokens _may_ be rotated after use, so production is free to hand back the same token and leave it valid. The emulator always takes the stricter path: a client that forgets to store the newly returned `refresh_token` fails locally instead of in production. diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index cffdf0e..542461e 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -31,7 +31,7 @@ import { } from '../helpers.js'; import { renderConfiguredJwtTemplate } from '../jwt-template.js'; import type { EventBus } from '../event-bus.js'; -import type { WorkOSInvitation } from '../entities.js'; +import type { WorkOSInvitation, WorkOSSSOAuthorization, WorkOSUser } from '../entities.js'; import { STORE_KEYS, STORE_KEY_PREFIXES } from '../constants.js'; import { renderLoginPage, renderDeviceVerifyPage } from '../login-page.js'; @@ -257,7 +257,12 @@ export function authRoutes(ctx: RouteContext): void { /** Emit the spec's authentication.*_failed event for a credential failure, then throw. */ const failAuth: ( method: string, - info: { email?: string | null; userId?: string | null }, + info: { + email?: string | null; + userId?: string | null; + /** Required on every authentication.sso_* event, per the spec's event data. */ + sso?: { organization_id: string | null; connection_id: string | null; session_id: string | null }; + }, error: WorkOSApiError, ) => never = (method, info, error) => { emitAuthenticationEvent({ @@ -269,10 +274,64 @@ export function authRoutes(ctx: RouteContext): void { ipAddress: requestIp, userAgent: requestUserAgent, error: { code: error.code, message: error.message }, + sso: info.sso, }); throw error; }; + /** + * Redeem an /sso/authorize code into the user-management user it signs in, provisioning one + * when the federated profile has no account yet — AuthKit does the same on a first SSO login, + * and /sso/authorize mints a profile for any address it is handed, so refusing here would + * report a code the emulator had just issued as invalid. + */ + const redeemSsoAuthorization = (ssoAuth: WorkOSSSOAuthorization, code: string): WorkOSUser => { + const profile = ws.ssoProfiles.get(ssoAuth.profile_id); + + if (isExpired(ssoAuth.expires_at)) { + ws.ssoAuthorizations.delete(ssoAuth.id); + failAuth( + 'SSO', + { + email: profile?.email, + userId: profile ? findUserByEmail(ws, profile.email)?.id : null, + sso: { + organization_id: ssoAuth.organization_id, + connection_id: ssoAuth.connection_id, + session_id: null, + }, + }, + new OauthApiError(400, 'invalid_grant', `The code '${code}' has expired or is invalid.`), + ); + } + + // The same emulator-state failure /sso/token names, for the same reason: an authorization + // pointing at a profile that no longer exists is not a request anyone can fix by sending + // something else, so it stays plain rather than OAuth-shaped. + if (!profile) throw new WorkOSApiError(500, 'Profile not found', 'server_error'); + + ws.ssoAuthorizations.delete(ssoAuth.id); + + const existing = findUserByEmail(ws, profile.email); + if (existing) return existing; + return ws.users.insert({ + object: 'user', + email: profile.email, + name: null, + first_name: profile.first_name, + last_name: profile.last_name, + // The IdP asserted the address, which is what verification proves. + email_verified: true, + profile_picture_url: null, + last_sign_in_at: null, + external_id: null, + metadata: {}, + locale: null, + password_hash: null, + impersonator: null, + }); + }; + /** * Initiate the MFA second factor. Records the primary method on a pending-auth token so * the eventual session reports it (not 'unknown'), creates a challenge for the factor, and @@ -329,6 +388,9 @@ export function authRoutes(ctx: RouteContext): void { // a leniency production doesn't permit — store null. In both cases the redemption request // is the only client identity the emulator ever has. let grantClientId: string | undefined; + // The connection an SSO sign-in came through, carried to the authentication.sso_succeeded + // event, whose spec payload requires an `sso` block. Null for every other grant. + let ssoContext: { organization_id: string | null; connection_id: string | null } | null = null; switch (grantType) { case 'authorization_code': { @@ -339,6 +401,23 @@ export function authRoutes(ctx: RouteContext): void { // as invalid_grant with the same description. const authCode = ws.authCodes.findOneBy('code', code); if (!authCode) { + // A code minted by /sso/authorize is redeemable here too. The two endpoints wrote to + // different stores, so an app that starts SSO with `sso.getAuthorizationUrl` — sending + // people straight to their IdP rather than through a hosted screen — and finishes at + // AuthKit's callback got invalid_grant for a code the emulator had just issued. + // /sso/token still redeems the same code for a bare profile; this is the path that + // produces a session, and the only one that records auth_method 'sso'. + const ssoAuth = ws.ssoAuthorizations.findOneBy('code', code); + if (ssoAuth) { + user = redeemSsoAuthorization(ssoAuth, code); + organizationId = ssoAuth.organization_id; + ssoContext = { organization_id: ssoAuth.organization_id, connection_id: ssoAuth.connection_id }; + // An SSO authorization records no client_id, so the redeeming request is the only + // client identity there is — the same fallback a client-less /authorize gets. + grantClientId = clientId; + authMethod = 'SSO'; + break; + } failAuth( 'OAuth', {}, @@ -906,6 +985,9 @@ export function authRoutes(ctx: RouteContext): void { email: updatedUser.email, ipAddress: session.ip_address, userAgent: session.user_agent, + // Required on authentication.sso_* by the spec's event data. This is the only SSO path + // that reaches a session, so it is also the only one that can report a session_id. + sso: ssoContext ? { ...ssoContext, session_id: session.id } : undefined, }); } diff --git a/src/workos/routes/sso.spec.ts b/src/workos/routes/sso.spec.ts index 6c0c306..50eaa26 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -107,6 +107,101 @@ describe('SSO routes', () => { expect(new Set(profiles.map((p) => p.connection_id))).toEqual(new Set([conn.id, conn2.id])); }); + describe('SSO code redeemed for a user-management session', () => { + /** Start SSO through `conn` and return the code the redirect carries. */ + async function ssoCode(connId: string, loginHint?: string) { + const res = await app.request( + `/sso/authorize?connection=${connId}&redirect_uri=http://localhost:3000/callback` + + (loginHint ? `&login_hint=${encodeURIComponent(loginHint)}` : ''), + ); + return new URL(res.headers.get('location')!).searchParams.get('code')!; + } + + const authenticate = (code: string) => + app.request('/user_management/authenticate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ grant_type: 'authorization_code', code, client_id: 'client_x' }), + }); + + it('signs the profile in, keying the session to sso', async () => { + const { org, conn } = await createOrgWithConnection(); + const ws = getWorkOSStore(store); + const user = ws.users.insert({ + object: 'user', + email: 'alice@sso.example.com', + name: null, + first_name: null, + last_name: null, + email_verified: true, + profile_picture_url: null, + last_sign_in_at: null, + external_id: null, + metadata: {}, + locale: null, + password_hash: null, + impersonator: null, + }); + + const res = await authenticate(await ssoCode(conn.id, 'alice@sso.example.com')); + + expect(res.status).toBe(200); + const body = await json(res); + expect(body.user.id).toBe(user.id); + expect(body.organization_id).toBe(org.id); + expect(body.authentication_method).toBe('SSO'); + const [session] = ws.sessions.findBy('user_id', user.id); + expect(session.auth_method).toBe('sso'); + + // The spec requires an `sso` block on authentication.sso_succeeded; this is the only SSO + // path that reaches a session, so it is the only one that can fill in session_id. + const [event] = ws.events.all().filter((e) => e.event === 'authentication.sso_succeeded'); + expect(event.data).toMatchObject({ + type: 'sso', + status: 'succeeded', + user_id: user.id, + sso: { organization_id: org.id, connection_id: conn.id, session_id: session.id }, + }); + }); + + it('provisions a user the federated profile has no account for', async () => { + const { conn } = await createOrgWithConnection(); + + const body = await json(await authenticate(await ssoCode(conn.id, 'newcomer@sso.example.com'))); + + expect(body.user.email).toBe('newcomer@sso.example.com'); + // The IdP asserted the address, which is what verification proves. + expect(body.user.email_verified).toBe(true); + expect(getWorkOSStore(store).users.all()).toHaveLength(1); + }); + + it('spends the code once, and reports an expired one as invalid_grant', async () => { + const { org, conn } = await createOrgWithConnection(); + const ws = getWorkOSStore(store); + + const code = await ssoCode(conn.id, 'once@sso.example.com'); + expect((await authenticate(code)).status).toBe(200); + const replay = await authenticate(code); + expect(replay.status).toBe(400); + expect((await json(replay)).error).toBe('invalid_grant'); + + const expired = await ssoCode(conn.id, 'stale@sso.example.com'); + const stored = ws.ssoAuthorizations.findOneBy('code', expired)!; + ws.ssoAuthorizations.update(stored.id, { expires_at: new Date(Date.now() - 1000).toISOString() }); + + const res = await authenticate(expired); + expect(res.status).toBe(400); + expect((await json(res)).error).toBe('invalid_grant'); + const [failed] = ws.events.all().filter((e) => e.event === 'authentication.sso_failed'); + expect(failed.data).toMatchObject({ + type: 'sso', + status: 'failed', + email: 'stale@sso.example.com', + sso: { organization_id: org.id, connection_id: conn.id, session_id: null }, + }); + }); + }); + it('sso token exchange returns profile and access_token', async () => { const { conn } = await createOrgWithConnection(); From a6d577437348d6857d4ea463d29c92b3639f3767 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Mon, 17 Aug 2026 16:22:45 -0400 Subject: [PATCH 2/5] refactor(sso): resolve the failed-event user the way sso.ts already does --- src/workos/routes/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index 542461e..15bdff5 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -294,7 +294,7 @@ export function authRoutes(ctx: RouteContext): void { 'SSO', { email: profile?.email, - userId: profile ? findUserByEmail(ws, profile.email)?.id : null, + userId: findUserByEmail(ws, profile?.email ?? '')?.id ?? null, sso: { organization_id: ssoAuth.organization_id, connection_id: ssoAuth.connection_id, From b9e1b8797f60fa2a9c10d508b9882508320712a0 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Mon, 17 Aug 2026 16:32:44 -0400 Subject: [PATCH 3/5] docs(sso): name the trust model the authorize endpoints have always had --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 559d370..e8c0014 100644 --- a/README.md +++ b/README.md @@ -518,6 +518,8 @@ A code from `GET /sso/authorize` redeems at `POST /user_management/authenticate` The session is scoped to the connection's organization. A profile with no user-management account yet gets one, verified — the IdP asserted the address — the way AuthKit provisions on a first SSO login. +As with `/user_management/authorize`, `/sso/authorize` is public and signs in whoever `login_hint` names: there is no IdP here to prove an identity with, and inventing a profile for any address is what lets a test drive an SSO login at all. Both endpoints will therefore hand out a session for any account you ask them for. That is the emulator being a test double, not an authorization server — do not point anything at it that you would not also let sign in as your users. + ### Refresh tokens always rotate The emulator issues a new refresh token on every refresh and invalidates the one you presented, so replaying it returns `{"error": "invalid_grant", "error_description": "Invalid refresh token."}`. WorkOS documents that refresh tokens _may_ be rotated after use, so production is free to hand back the same token and leave it valid. The emulator always takes the stricter path: a client that forgets to store the newly returned `refresh_token` fails locally instead of in production. From 58b04b3cf42ed038e70ef0164743e379cfadba98 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Mon, 17 Aug 2026 17:03:36 -0400 Subject: [PATCH 4/5] fix(sso): reject a mismatched invitation before redeeming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared recipient check runs only after the grant, and by then the SSO helper had already spent the one-time authorization and possibly provisioned an account — so a request failing with invitation_cannot_be_used_for_email burned a code the caller could not get back and left a freshly created user (and its user.created event) behind with no session. The other grants burn their credential the same way, but only this path could also invent an account on the way down. The profile already names who is signing in before anything is consumed, so ask first: a rejected caller keeps the code and retries without the invitation. Raised by review on the PR. --- src/workos/routes/auth.ts | 13 +++++++++++++ src/workos/routes/sso.spec.ts | 24 ++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index 15bdff5..8bb330d 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -310,6 +310,19 @@ export function authRoutes(ctx: RouteContext): void { // something else, so it stays plain rather than OAuth-shaped. if (!profile) throw new WorkOSApiError(500, 'Profile not found', 'server_error'); + // The shared recipient check below runs only after the grant, and by then this helper has + // spent the one-time authorization and possibly provisioned an account — a mismatched + // invitation would fail the request yet leave a user behind with no session. The profile + // already names who is signing in, so ask before anything is consumed; a rejected caller + // keeps the code and retries without the invitation. + if (invitation && !emailsMatch(invitation.email, profile.email)) { + throw new WorkOSApiError( + 400, + 'The invitation was issued for a different email address', + 'invitation_cannot_be_used_for_email', + ); + } + ws.ssoAuthorizations.delete(ssoAuth.id); const existing = findUserByEmail(ws, profile.email); diff --git a/src/workos/routes/sso.spec.ts b/src/workos/routes/sso.spec.ts index 50eaa26..f46caa3 100644 --- a/src/workos/routes/sso.spec.ts +++ b/src/workos/routes/sso.spec.ts @@ -117,11 +117,11 @@ describe('SSO routes', () => { return new URL(res.headers.get('location')!).searchParams.get('code')!; } - const authenticate = (code: string) => + const authenticate = (code: string, extra?: Record) => app.request('/user_management/authenticate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ grant_type: 'authorization_code', code, client_id: 'client_x' }), + body: JSON.stringify({ grant_type: 'authorization_code', code, client_id: 'client_x', ...extra }), }); it('signs the profile in, keying the session to sso', async () => { @@ -200,6 +200,26 @@ describe('SSO routes', () => { sso: { organization_id: org.id, connection_id: conn.id, session_id: null }, }); }); + + it('rejects a mismatched invitation before the code is spent or a user provisioned', async () => { + const { org, conn } = await createOrgWithConnection(); + const invitation = await json( + await req('/user_management/invitations', { + method: 'POST', + body: JSON.stringify({ email: 'recipient@sso.example.com', organization_id: org.id }), + }), + ); + + const code = await ssoCode(conn.id, 'interloper@sso.example.com'); + const res = await authenticate(code, { invitation_token: invitation.token }); + + expect(res.status).toBe(400); + expect((await json(res)).code).toBe('invitation_cannot_be_used_for_email'); + // Nothing was consumed: no account was provisioned for the interloper, and the same code + // still signs in once the invitation is dropped. + expect(getWorkOSStore(store).users.all()).toHaveLength(0); + expect((await authenticate(code)).status).toBe(200); + }); }); it('sso token exchange returns profile and access_token', async () => { From 965f2887a39c66f161ad5399b0f187e7f115a21f Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Mon, 17 Aug 2026 18:04:09 -0400 Subject: [PATCH 5/5] docs(sso): name the provisioning-before-template-gate trade-off A JWT template that cannot render fails the request after the SSO helper has spent the code and provisioned a first-time user. That is deliberate, not an oversight: the template gate already keeps the membership acceptInvitation persists, the provisioned user is the exact record a successful retry would create, and the burned code is what a template failure costs every other one-time grant. Raised by review on the PR; recording the reasoning where the next reader will look. --- src/workos/routes/auth.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/workos/routes/auth.ts b/src/workos/routes/auth.ts index 72ca990..477caff 100644 --- a/src/workos/routes/auth.ts +++ b/src/workos/routes/auth.ts @@ -284,6 +284,12 @@ export function authRoutes(ctx: RouteContext): void { * when the federated profile has no account yet — AuthKit does the same on a first SSO login, * and /sso/authorize mints a profile for any address it is handed, so refusing here would * report a code the emulator had just issued as invalid. + * + * Provisioning deliberately lands before the shared template gate below: a JWT template that + * cannot render fails the request but keeps the user, the same way the gate already keeps the + * membership acceptInvitation persists. Both are real domain progress — the user is the exact + * record a successful retry would create — and the burned code matches what a template failure + * costs every other one-time grant. */ const redeemSsoAuthorization = (ssoAuth: WorkOSSSOAuthorization, code: string): WorkOSUser => { const profile = ws.ssoProfiles.get(ssoAuth.profile_id);