Summary
Serving the admin console changes the origin that browser ceremonies carry, and for OAuth it changes the full redirect URI as well. Two separate allowlists validate these values, and neither requirement is documented. Both fail the same confusing way: the flow starts normally and only fails partway through, so the configuration looks correct right up to the point it does not work.
Both requirements apply to both console deployment shapes:
- Served directly by this API (
SERVE_ADMIN_DASHBOARD=true) at the API's own origin, for example https://auth.example.com/console.
- Reverse-proxied by an adopter's API through
createSeamlessConsoleProxy from @seamless-auth/express, so the console loads from the adopter's origin, for example https://api.example.com/console.
The second shape is the one adopters hit as soon as they follow the console wiring guidance, because the console deliberately runs same-origin with their /auth mount.
Requirement 1: the console's origin must be in ORIGINS
Passkey ceremonies started in the console carry the origin the console was loaded from, and WebAuthn verification checks it against origins in system_config (seeded from ORIGINS).
src/controllers/stepUp.ts:197 sets expectedOrigin: origins
src/controllers/webauthn.ts:224 and src/controllers/webauthn.ts:489 do the same
Reproduced with the console proxied to http://localhost:3000/console while ORIGINS=http://localhost:5173,http://localhost:5174:
POST /step-up/webauthn/start returned 200 with a challenge, rpId: localhost, and the user's registered credential
- the browser prompted normally
- the finish step failed, so step-up never completed and session revocation stayed blocked
Adding http://localhost:3000 to ORIGINS resolved it with no code change. RPID does not need to change, since the RP ID ignores the port.
Requirement 2: the console's callback must be in each provider's redirectUris
The dashboard builds its OAuth redirect with useHref("/oauth/callback"), which applies the router basename, so the /console build sends <origin>/console/oauth/callback rather than /oauth/callback. That is a different path as well as a different host from a typical web app.
allowedRedirect in src/services/oauthService.ts compares by exact string once a provider has any redirectUris:
if (allowedValues.length > 0) {
return allowedValues.some((allowedValue) => value === allowedValue);
}
So the console's callback URL has to be listed explicitly in each provider's redirectUris. A near miss on host or path is rejected.
Reproduced against the same provider and session, varying only the redirect URI:
http://localhost:3000/console/oauth/callback returned 400 with {"error":"OAuth start failed"}
http://localhost:5173/oauth/callback returned 200 with a full authorization URL
Adding http://localhost:3000/console/oauth/callback to the provider's redirectUris resolved it.
Worth stating explicitly in the docs: the identity provider must also have the same URL registered as an authorized redirect URI. This API accepting it is not sufficient, and the provider rejects with redirect_uri_mismatch at the consent screen otherwise.
Suggested documentation changes
docs/configuration.md, WebAuthn section (the ORIGINS row): state that the origin serving the admin console must be included, alongside the app origins.
docs/configuration.md, Admin dashboard section (near SERVE_ADMIN_DASHBOARD and ADMIN_DASHBOARD_DIR): list both prerequisites for both shapes above, since that is where someone enabling the console is reading.
docs/oauth.md: note the console callback shape (<origin>/console/oauth/callback), that it must appear in each provider's redirectUris, and that it must also be registered with the identity provider.
- Include the symptoms so they are searchable: passkey sign-in and step-up start normally and fail at finish, and OAuth start returns 400 before any redirect happens.
Summary
Serving the admin console changes the origin that browser ceremonies carry, and for OAuth it changes the full redirect URI as well. Two separate allowlists validate these values, and neither requirement is documented. Both fail the same confusing way: the flow starts normally and only fails partway through, so the configuration looks correct right up to the point it does not work.
Both requirements apply to both console deployment shapes:
SERVE_ADMIN_DASHBOARD=true) at the API's own origin, for examplehttps://auth.example.com/console.createSeamlessConsoleProxyfrom@seamless-auth/express, so the console loads from the adopter's origin, for examplehttps://api.example.com/console.The second shape is the one adopters hit as soon as they follow the console wiring guidance, because the console deliberately runs same-origin with their
/authmount.Requirement 1: the console's origin must be in
ORIGINSPasskey ceremonies started in the console carry the origin the console was loaded from, and WebAuthn verification checks it against
originsinsystem_config(seeded fromORIGINS).src/controllers/stepUp.ts:197setsexpectedOrigin: originssrc/controllers/webauthn.ts:224andsrc/controllers/webauthn.ts:489do the sameReproduced with the console proxied to
http://localhost:3000/consolewhileORIGINS=http://localhost:5173,http://localhost:5174:POST /step-up/webauthn/startreturned 200 with a challenge,rpId: localhost, and the user's registered credentialAdding
http://localhost:3000toORIGINSresolved it with no code change.RPIDdoes not need to change, since the RP ID ignores the port.Requirement 2: the console's callback must be in each provider's
redirectUrisThe dashboard builds its OAuth redirect with
useHref("/oauth/callback"), which applies the router basename, so the/consolebuild sends<origin>/console/oauth/callbackrather than/oauth/callback. That is a different path as well as a different host from a typical web app.allowedRedirectinsrc/services/oauthService.tscompares by exact string once a provider has anyredirectUris:So the console's callback URL has to be listed explicitly in each provider's
redirectUris. A near miss on host or path is rejected.Reproduced against the same provider and session, varying only the redirect URI:
http://localhost:3000/console/oauth/callbackreturned 400 with{"error":"OAuth start failed"}http://localhost:5173/oauth/callbackreturned 200 with a full authorization URLAdding
http://localhost:3000/console/oauth/callbackto the provider'sredirectUrisresolved it.Worth stating explicitly in the docs: the identity provider must also have the same URL registered as an authorized redirect URI. This API accepting it is not sufficient, and the provider rejects with
redirect_uri_mismatchat the consent screen otherwise.Suggested documentation changes
docs/configuration.md, WebAuthn section (theORIGINSrow): state that the origin serving the admin console must be included, alongside the app origins.docs/configuration.md, Admin dashboard section (nearSERVE_ADMIN_DASHBOARDandADMIN_DASHBOARD_DIR): list both prerequisites for both shapes above, since that is where someone enabling the console is reading.docs/oauth.md: note the console callback shape (<origin>/console/oauth/callback), that it must appear in each provider'sredirectUris, and that it must also be registered with the identity provider.