Skip to content

fix(auth): scope AuthKit sessions to an organization - #31

Merged
gjtorikian merged 4 commits into
mainfrom
fix/authkit-org-scoped-sessions
Jul 27, 2026
Merged

fix(auth): scope AuthKit sessions to an organization#31
gjtorikian merged 4 commits into
mainfrom
fix/authkit-org-scoped-sessions

Conversation

@gjtorikian

Copy link
Copy Markdown
Collaborator

Summary

  • Fresh logins now resolve an organization. Every grant except mfa-totp, organization-selection, and a refresh carrying an explicit organization_id could only mint an unscoped session. Tokens therefore never carried org_id, role, or permissions, so any API authorizing on the org claim rejected every token the emulator issued. A single active membership is now selected implicitly; several return organization_selection_required (403) as production does; none leaves organization_id null rather than inventing one.
  • The organization-selection grant is reachable and validated. organization_selection_required was never emitted, so the grant was unreachable except after an MFA challenge; it also never checked that the user was an active member of the organization it was handed. It does now, reporting organization_membership_not_found (the spec's code for that case, previously mis-reported as invalid_organization_id), and it checks before consuming the pending token so a wrong pick can be retried.
  • invitation_token is honored on authorization_code, password, and Magic Auth. It is the only way a caller can name an organization on a grant that takes no organization_id. The emulator ignored the field entirely, so an invited user landed in an unscoped session, the invitation stayed pending forever, and a multi-organization user hit a selection prompt they should never have seen. The token is validated before the grant runs so a bad invitation can't burn the one-time code in the same request, and it's carried across an MFA challenge rather than accepted early, so nothing is consumed until the second factor proves who is signing in.
  • Invitation acceptance is shared with the invitations REST route, which previously duplicated a membership when one already existed and could not readmit a deactivated member.
  • Access tokens now carry roles alongside role. Production emits both and the WorkOS SDKs read the plural; a token with only role passed locally and lost the claim in production.

Breaking change

Multi-organization users now receive a 403 organization_selection_required where they previously received a 200 with a null organization. This matches production, but any test fixture relying on the old unscoped response will need to complete the selection step.

Every grant except mfa-totp, organization-selection, and a refresh
carrying an explicit organization_id could only mint an unscoped
session, and the authorization_code path read its org off a code
minted with organization_id: null. Tokens therefore never carried
org_id, role, or permissions, so any API authorizing on the org
claim rejected every token the emulator issued — it could not stand
in for WorkOS when testing an org-scoped API. Nor was there a way
around it: organization_selection_required was never emitted, so the
organization-selection grant was unreachable except after an MFA
challenge.

Multi-organization users now receive a 403 selection error where
they previously received a 200 with a null organization, matching
production. The organization-selection grant also now verifies the
user is an active member of the organization it is handed, which it
never did while unreachable.
The spec puts invitation_token on the authorization_code, password and
magic-auth grants, and it is the only way a caller can name an
organization on a grant that takes no organization_id — accepting an
invitation is what joins the user and scopes the session. The emulator
ignored the field entirely, so an invited user authenticated into an
unscoped session and the invitation stayed pending forever, which also
left a multi-organization user stuck at a selection prompt they should
never have seen.

The token is validated before the grant runs so a bad invitation cannot
burn the one-time code the same request carries, and it is carried
across an MFA challenge rather than accepted early, so nothing is
consumed until the second factor has proven who is signing in.

Acceptance is now shared with the invitations REST route, which
previously duplicated a membership when one already existed and could
not readmit a deactivated member.

While reading the same error enum: selecting an organization the user
does not belong to reported invalid_organization_id, but the spec has
organization_membership_not_found for exactly that case.
Both behaviors are ones a reader hits at runtime with nothing in the
README to check against. Organization resolution now has three distinct
outcomes, one of which is a 403 a client must handle. Refresh rotation
is deliberately stricter than production, which documents rotation as
optional — worth stating so it reads as a choice rather than a bug when
a replayed token stops working.
@greptile-apps

greptile-apps Bot commented Jul 27, 2026

Copy link
Copy Markdown

Greptile Summary

The PR scopes fresh AuthKit sessions to organizations and completes the invitation-continuation fixes.

  • Resolves a single active organization automatically and requires explicit selection when several are available.
  • Validates organization membership before issuing organization-scoped sessions.
  • Defers invitation acceptance across MFA and organization-selection continuations until authentication can complete.
  • Reuses or reactivates memberships when invitations are accepted.
  • Adds the plural roles access-token claim and documents the new behavior.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; current code revalidates deferred invitations before consuming continuation state and postpones invitation acceptance until organization selection has completed.

Important Files Changed

Filename Overview
src/workos/routes/auth.ts Adds organization resolution, membership validation, continuation-safe invitation handling, and organization-scoped token issuance; the previously reported continuation-state defects are fixed.
src/workos/helpers.ts Centralizes invitation acceptance while reusing existing memberships and reactivating inactive ones.
src/workos/routes/invitations.ts Replaces duplicated invitation-acceptance logic with the shared helper.
src/core/jwt.ts Extends access-token payloads with the plural roles claim.
src/workos/routes/auth.spec.ts Adds coverage for organization resolution, selection, invitation deferral, MFA continuation, membership reactivation, and token claims.
README.md Documents organization-scoped sessions, invitation behavior, organization selection, and refresh-token rotation.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Auth as Authenticate route
    participant Pending as Pending auth store
    participant Invite as Invitation store
    Client->>Auth: Fresh login with invitation_token
    Auth->>Invite: Validate pending invitation
    alt MFA required
        Auth->>Pending: Store user, method, org, invitation token
        Auth-->>Client: mfa_challenge
        Client->>Auth: MFA code + pending token
        Auth->>Invite: Revalidate invitation
    end
    alt Multiple active organizations and no selected org
        Auth->>Pending: Store continuation including invitation token
        Auth-->>Client: organization_selection_required
        Client->>Auth: Selected organization + pending token
        Auth->>Auth: Validate active membership
        Auth->>Invite: Revalidate invitation
    end
    Auth->>Invite: Accept invitation
    Auth-->>Client: Organization-scoped session and tokens
Loading

Reviews (2): Last reviewed commit: "fix(auth): spend an invitation only once..." | Re-trigger Greptile

Comment thread src/workos/routes/auth.ts Outdated
Comment thread src/workos/routes/auth.ts Outdated
Both continuation responses consumed state a client might still need.
The MFA grant deleted the challenge and pending token before
revalidating a deferred invitation, so an invitation revoked mid-challenge
turned a retry into invalid_pending_authentication_token instead of
reporting the actual cause. And an invitation naming no organization was
accepted before the route worked out that organization selection was
still required, spending a one-time invitation on a 403 the client may
never return from — and never accepting it if the client did return,
since the selection grant carried no invitation.

Acceptance now happens once every continuation check has passed and the
request is known to be issuing a session, still ahead of the role lookup
that reads the membership it creates. Revalidation moved ahead of the
state it invalidates in both grants, and the selection pending token
carries any unspent invitation the way the MFA one already did.

This is the rule the rest of the PR already followed for one-time codes,
applied to the two paths that were missing it.
@gjtorikian
gjtorikian merged commit c3fe11a into main Jul 27, 2026
7 checks passed
@gjtorikian
gjtorikian deleted the fix/authkit-org-scoped-sessions branch July 27, 2026 21:30
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.

1 participant