Skip to content

fix(sso): redeem an SSO code for a user-management session - #70

Merged
gjtorikian merged 6 commits into
mainfrom
fix/sso-user-management-session
Aug 17, 2026
Merged

fix(sso): redeem an SSO code for a user-management session#70
gjtorikian merged 6 commits into
mainfrom
fix/sso-user-management-session

Conversation

@gjtorikian

Copy link
Copy Markdown
Collaborator

Fixes #67.

/sso/authorize inserted into ssoAuthorizations; the authorization_code grant read authCodes. So a code the emulator had just minted came back invalid_grant, and an app that starts SSO with sso.getAuthorizationUrl — deliberately skipping the hosted screen and sending people straight to their IdP — had no way to finish at AuthKit's callback.

You asked which way to do it, so: the grant falls back to ssoAuthorizations, rather than teaching /user_management/authorize to speak connection/organization. That is the smaller change, it keeps the IdP-first redirect the issue is about, and it is the only one that fixes the related half — no grant ever assigned authMethod = 'SSO', so AUTH_METHOD_SESSION_VALUES' SSO: 'sso' entry was unreachable and nothing gating on a federated session (hiding password management, say) could be exercised. Now it is the session's recorded method.

POST /sso/token is untouched and still redeems the same code for a bare profile and access token — the standalone SSO product, which creates no session. A code is spent by whichever endpoint gets it first.

Two decisions worth flagging:

  • A profile with no user-management account is provisioned one, email_verified: true (the IdP asserted the address), the way AuthKit provisions on a first SSO login. /sso/authorize mints a profile for any address it is handed, so the alternative was reporting a freshly issued code as invalid.
  • No membership is fabricated. The session is scoped to the connection's organization, so org_id is on the token, but a user with no membership gets no role claim. Say the word and I'll add it.

The succeeded and failed events carry the sso block the spec's event data requires (EVENT_DATA_REQUIREMENTS lists it as required on every authentication.sso_*), with the session_id no other SSO path can fill in.

Verification

bun test (854 pass, 3 new), bun run typecheck, bun run lint, bun run fmt:check.

/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
@greptile-apps

greptile-apps Bot commented Aug 17, 2026

Copy link
Copy Markdown

Greptile Summary

The PR allows SSO authorization codes to be redeemed through user-management authentication, producing an organization-scoped session with the SSO authentication method.

  • Falls back from ordinary authorization codes to pending SSO authorizations.
  • Resolves or provisions the corresponding user and records SSO context in authentication events.
  • Validates invitation recipients before consuming the SSO authorization or provisioning an account.
  • Documents the session flow and emulator trust model and adds focused SSO redemption coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
README.md Documents SSO-to-session redemption, first-login provisioning, standalone SSO token behavior, and the emulator's intentionally permissive identity model.
src/workos/routes/auth.ts Adds SSO authorization fallback redemption, user provisioning, invitation prevalidation, SSO session attribution, and required event context.
src/workos/routes/sso.spec.ts Covers SSO session issuance, first-login provisioning, one-time and expired-code handling, event payloads, and invitation retry behavior.

Sequence Diagram

sequenceDiagram
  participant App
  participant SSO as /sso/authorize
  participant Auth as /user_management/authenticate
  participant Store
  App->>SSO: Request SSO authorization
  SSO->>Store: Store profile and one-time SSO code
  SSO-->>App: Redirect with code
  App->>Auth: Redeem authorization_code
  Auth->>Store: Fall back to SSO authorization
  Auth->>Store: Resolve or provision user
  Auth->>Store: Create SSO-authenticated session
  Auth-->>App: User, session, and tokens
Loading

Reviews (5): Last reviewed commit: "docs(sso): name the provisioning-before-..." | Re-trigger Greptile

Comment thread src/workos/routes/auth.ts
Comment thread src/workos/routes/auth.ts
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.
Comment thread src/workos/routes/auth.ts
Comment on lines +326 to +330
ws.ssoAuthorizations.delete(ssoAuth.id);

const existing = findUserByEmail(ws, profile.email);
if (existing) return existing;
return ws.users.insert({

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.

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.
@gjtorikian
gjtorikian merged commit b0ae0e8 into main Aug 17, 2026
9 checks passed
@gjtorikian
gjtorikian deleted the fix/sso-user-management-session branch August 17, 2026 22:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

SSO login cannot produce a User Management session

1 participant