Resource: Auth / Session
Tag: Auth · Modules: authentication + session (+ signups moved in from user)
Sign-in/up, OAuth & magic-link flows, token/session lifecycle, org selection/switching. Returns the shared
User DTO (defined in user.md).
Grounded: authentication/api/*, authentication/models/types.go, internal/domain/auth/types.go,
session/api/*, pkg/token/session_access.go (cookie), shared/authresponse/multiorg.go.
Token delivery (LOCKED — the cross-cutting auth model)
Auth success responses return the user, never tokens, in the body. Tokens are NOT in the body.
- The access token is set as an HttpOnly
access_tokencookie (pkg/token/session_access.go,SameSite=Lax) — this is thecookieAuthsecurity scheme the rest of the API authenticates with. - The refresh token is stored server-side (DB session), never sent to the client.
- So the internal
AuthTokensstruct is never serialized; login-ish ops respond with theUser(or theAuthResultunion where the flow can branch) + aSet-Cookie. The cookie is documented via the security scheme.
This applies to: finalize-oauth, finalize-magic-auth, select-organization,
switch-organization, and sign-up.
OAuth client model (web = BFF/confidential, desktop = public)
- Web is a Backend-for-Frontend (confidential client): the browser gets the
codeand POSTs it to the Go backend, which exchanges it with WorkOS using the API key and sets the cookie — the browser never sees tokens. This is the recommended SPA model (OAuth browser-apps BCP). PKCE is omitted on the web flow and that's spec-legal (RFC 9700: PKCE MUST for public clients, RECOMMENDED for confidential). - Desktop is a public client: it uses client-side PKCE (
codeChallengeat start,codeVerifierat finalize) — MUST, per RFC 9700. - DECIDED — backend-managed PKCE for web. The web flow uses PKCE with the verifier/challenge generated
and stored server-side (session keyed by
state), never the browser (defense-in-depth — the code can leak via history/referrer/logs). Sofinalize-oauth'scodeVerifieris desktop-only (public-client, client-side PKCE); the web client sends no PKCE fields — the backend handles it.
Refresh is transparent (no client endpoint)
There is no client-facing refresh op. The refresh token lives server-side (session); on access-token
expiry the auth middleware refreshes transparently (WorkOS AuthenticateWithRefreshToken) and re-sets
the cookie. The legacy refresh-auth-token + RefreshTokenRequest{refreshToken} (client-sent token) was a
pre-cookie leftover — removed (anti-pattern in a cookie/BFF model).
Flows
- Discovery —
sign-in(email) → returns which method to use (sso|magicAuth) + any redirect/PKCE data. - Magic link —
magic/resend(send code) →magic/finalize(email+code) → logged in. - SSO/OAuth —
oauth/{provider}/start→ returns the provider redirect → (client completes at provider) →oauth/finalize(code [+ PKCE verifier] [+ invite token]) → logged in. - Multi-org branch — if the user belongs to several orgs, finalize returns the
orgSelectionRequiredAuthResultvariant (pendingAuthenticationToken+ org list) instead ofauthenticated; the client callsorganization/selectto complete login. - Session —
sign-out,organization/switch(post-login). Refresh is transparent (no client op, see above).
Operations
| OperationID | Method + Path | Security | Notes |
|---|---|---|---|
sign-in | POST /auth/sign-in | public | discovery (email → method). Merges sign-in + web-sign-in (platform via header) |
start-oauth | POST /auth/oauth/{provider}/start | public | provider = google|microsoft (path param). Merges continue-with-google + continue-with-microsoft |
resend-magic-auth | POST /auth/magic/resend | public | send the OTP |
finalize-magic-auth | POST /auth/magic/finalize | public | email+code → {user} | org-selection |
finalize-oauth | POST /auth/oauth/finalize | public | code(+PKCE)(+invite) → {user} | org-selection |
select-organization | POST /auth/organization/select | public | pendingAuthToken + orgId → {user} (login-time) |
sign-up | POST /auth/sign-up | public | body {email, name?, inviteToken?, inviteType?}. Merges plain/admin/member signup |
switch-organization | POST /auth/organization/switch | cookie | post-login org change (was /organizations/switch-organization) |
sign-out | POST /auth/sign-out | cookie | clears cookie + server session |
dev-sign-in | POST /auth/dev-sign-in | public | dev/local only — gate by env, exclude from prod spec |
generate-pilot-invite (internal/admin tooling) stays a plain gin/internal route — not in the typed contract.
Consolidation rationale (parameter, not path)
Three legacy splits were merged because they differ only by a parameter — confirmed by the code and by
every vendor (WorkOS routes all auth through one /user_management/authenticate keyed by grant_type):
- platform (
sign-invsweb-sign-in) → one endpoint; platform is a header.WebSignInwas the same email→method op, just not returning the redirect data. - provider (
continue-with-google/-microsoft) → one route with{provider}as a path param (/auth/oauth/{provider}/start), not the body — the provider is a routing selector (distinct IdP / redirect / config), so it belongs in the URL (per-provider metrics/limits/logs; OAuth-ecosystem norm: Passport/NextAuth/Devise all path-encode it). The handler already parameterized it (Handle(c, "GoogleOAuth"|"MicrosoftOAuth")). - invite type (plain/admin/member signup) →
{inviteToken?, inviteType?}body.
What stays separate: oauth/finalize (code+PKCE) vs magic/finalize (email+OTP) take different credentials,
so distinct typed request bodies beat one polymorphic /authenticate{grant_type} for codegen. (They could
collapse into a single /auth/authenticate with a grantType discriminator à la WorkOS if fewer endpoints
is preferred — a deliberate alternative, not the default.)
DTOs
Response shape convention: single-object responses return the object/fields directly (no
datawrapper); only lists use{data, page}(C4); a wrapper is used only for a genuine multi-shape response (theAuthResultunion below). The oldauthData/userenvelopes are dropped.
AuthResult — shared auth-completion response (discriminated union)
// One of (the authenticated branch also sends Set-Cookie):
{ "status": "authenticated", "user": { /* User */ } }
{ "status": "orgSelectionRequired",
"pendingAuthenticationToken": "string", // transient, single-use, short-TTL (OAuth-code-class)
"organizations": [ { "id": "string", "name": "string" } ] } // pick one → select-organization
pendingAuthenticationTokenis a transient WorkOS credential the client echoes back toselect-organization(same class as an OAuthcode). Hold it in memory only (never persist/log); HTTPS-only; single-use. It is not on theUserDTO (see user.md).
Discovery — sign-in (merges sign-in + web-sign-in)
// Request
{ "email": "string" } // valid email; platform (web|desktop) via header if behavior differs
// Response — AuthData object, returned directly (no wrapper)
{ "authMethod": "sso | magicAuth", "redirectUrl?": "string", "state?": "string", "challenge?": "string" } // C8 — optional (sso only)
OAuth start — start-oauth (merges google + microsoft)
POST /auth/oauth/{provider}/start — {provider} is a path param (google | microsoft), a routing selector.
// Request body — web sends no PKCE (backend-managed); desktop (public client) sends codeChallenge
{ "state?": "string", "codeChallenge?": "string" } // C8 — both optional
// Response — AuthData, returned directly
{ "authMethod": "sso", "redirectUrl": "string", "state": "string", "challenge": "string" }
Finalize — finalize-magic-auth / finalize-oauth
// finalize-magic-auth Request
{ "email": "string", "code": "string" } // code = 6-digit OTP
// finalize-oauth Request
{ "code": "string", "codeVerifier?": "string", "inviteToken?": "string", "inviteType?": "admin | member" } // C8 — optional fields marked ?
// codeVerifier = DESKTOP only (public-client PKCE); web's PKCE is backend-managed (client sends none).
// Response — AuthResult (+ Set-Cookie on the authenticated branch)
select-organization (completes a multi-org login)
// Request
{ "pendingAuthenticationToken": "string", "organizationId": "string" }
// Response — User directly (+ Set-Cookie); always authenticated by this point
{ /* User */ }
Sign-up — sign-up (merges plain / admin / member)
// Request — invite type distinguishes the registration kind (was 3 endpoints)
{
"email": "string",
"name?": "string", // C8 — optional
"inviteToken?": "string", // present for invited registrations (WorkOS-native: invite rides the auth call)
"inviteType?": "admin | member" // disambiguates pilot-link vs org-invite namespaces
}
// Cleanup: ideally make the token self-describing so the backend derives the type — the client shouldn't
// declare `inviteType` (it can mismatch). Reconcile with the standalone accept-invitation (invitations.md):
// invite-on-auth = register-and-join; accept-invitation = already-authenticated user joins another org.
// Response — AuthResult (+ Set-Cookie)
Session — switch-organization / sign-out
// switch-organization Request
{ "organizationId": "string" } // WorkOS org id; → User directly + new Set-Cookie
// sign-out: empty body → 204, clears cookie + server session
// (refresh: no client op — transparent middleware, see "Refresh is transparent" above)
preview-organization(an unauthenticated join-page org lookup) was considered here but is deferred — not in the contract (no consumer today; the join route ignores the org slug). It will be designed if/when a branded join page or the guest-access feature needs it — see organizations.md "Deferred".
Public vs protected
- Public ops declare no
Security→ the API-wide auth middleware self-skips them (migration plan §4.1). This covers discovery, oauth/magic start+finalize, select-organization, signups, and dev-sign-in. - Cookie-protected:
sign-out,switch-organization. (Refresh is transparent middleware, not an op.)
Module / backend notes
authentication(flows) +session(switch/sign-out + transparent refresh middleware) merge under theAuthtag; signups move here from theuserfeature (they're registration ops).switch-organizationrelocates from/organizations/*(it's a session op reissuing the cookie).- Token plumbing already moves to Huma middleware in the migration plan (cookie read off
huma.Context).
Verify at implementation
- Backend-managed PKCE for web — implement server-side verifier/challenge (session keyed by
state) as defense-in-depth; the legacy clientrefresh/body-token shapes are removed (refresh is transparent middleware). - Self-describing invite token — fold
inviteTypeinto the token so the client doesn't declare it. - Auth path renames vs any external config (WorkOS redirect URIs target FE pages, but confirm) and the FE
RequestActionEndpointsmap. dev-sign-inmust be env-gated and excluded from the published prod spec.- Exact signup response on the multi-org branch.