Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,14 @@ curl -X POST http://localhost:4100/user_management/authenticate \

The resulting session records the method that was gated (`password`), not the verification step. Driving the grant with `user_id` instead of a pending token still works, and that session reports `unknown` — there is no primary method to recover. Fixtures that sign in with a password want `email_verified: true`, in a seed file or on `POST /user_management/users`.

### 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.

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.
Expand Down
105 changes: 103 additions & 2 deletions src/workos/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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({
Expand All @@ -269,10 +274,83 @@ 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.
*
* 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);

if (isExpired(ssoAuth.expires_at)) {
ws.ssoAuthorizations.delete(ssoAuth.id);
failAuth(
'SSO',
{
email: profile?.email,
userId: 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');

// 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);
if (existing) return existing;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
return ws.users.insert({
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment on lines +332 to +336

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Template failure leaves provisioned user

When a first-time SSO profile makes a configured JWT template exceed the rendered-claims limit, this code deletes the authorization and inserts the user before template rendering returns 422. The user and user.created event remain without a session, and retrying the consumed code returns invalid_grant.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/workos/routes/auth.ts
Line: 326-330

Comment:
**Template failure leaves provisioned user**

When a first-time SSO profile makes a configured JWT template exceed the rendered-claims limit, this code deletes the authorization and inserts the user before template rendering returns 422. The user and `user.created` event remain without a session, and retrying the consumed code returns `invalid_grant`.

**Knowledge Base Used:**
- [Authentication and session flows](https://app.greptile.com/workos/-/custom-context/knowledge-base/workos/emulate/-/docs/authentication-and-session-flows.md)
- [Enterprise SSO and OAuth through the emulator](https://app.greptile.com/workos/-/custom-context/knowledge-base/workos/emulate/-/docs/enterprise-sso-and-oauth.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accurate, and deliberate — declining. Two halves:

  • The burned code isn't SSO-specific. Under a template render failure the regular authorization_code and magic-auth arms burn their one-time credential identically, and a broken JWT template is an environment-wide config error that fails every grant until fixed. A fresh SSO code is one redirect away.
  • The provisioned user is the same record a successful retry would create. The template gate already tolerates exactly this class of persisted progress: it must run after acceptInvitation, so an accepted invitation and its membership survive a render failure too, by design. JIT provisioning is real domain progress, not orphaned state, and user.created reports a user that genuinely exists.

The full fix (deferring users.insert past the gate via a candidate user) would still leave the orphan in the SSO + invitation + broken-template intersection and adds a commit hook to the shared path for a misconfiguration edge — not worth it in a test double. 965f288 records the trade-off in the helper's doc comment.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a solid argument on both counts. The parity with the other grant arms is the decisive part — singling out SSO for a deferred-insert pattern while authorization_code and magic-auth leave the same artifact under the same failure would be an inconsistency, not a fix. And the template gate's existing contract (invitations + memberships survive) already encodes the principle that progress past business-logic checkpoints is real state. Provisioning a user is in the same category.

The doc comment at 965f288 capturing the trade-off is the right call. No further changes needed here.

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
Expand Down Expand Up @@ -371,6 +449,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': {
Expand All @@ -381,6 +462,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',
{},
Expand Down Expand Up @@ -988,6 +1086,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,
});
}

Expand Down
115 changes: 115 additions & 0 deletions src/workos/routes/sso.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,121 @@ 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, extra?: Record<string, unknown>) =>
app.request('/user_management/authenticate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'authorization_code', code, client_id: 'client_x', ...extra }),
});

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('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 () => {
const { conn } = await createOrgWithConnection();

Expand Down
Loading