Repository navigation
Fix mobile login for cross-site apps; keep refresh tokens in first-party HTTP-only cookies - #261
Merged
Merged
Conversation
…uth-resource-server-codegen-templates:0.0.162, auth-server-sdk:0.32.10, trpc-backend-init:0.9.81, auth-server:0.55.8, e2e-auth-tests:0.12.9 - fix mobile login for apps on another site than the auth server
Logins to third-party client apps always failed on iPhones with "Did not
receive user data from auth server after login" (e.g. lorahworld.jalexw.ca
against auth.schemavaults.com), while desktop Chrome worked.
Outside development/test the React adapter asked the token endpoint for
`refresh_token_delivery=http_only_cookie`, so the refresh token was set as
an HTTP-only cookie on the AUTH SERVER's host in answer to a credentialed
fetch from the client's page. For a page on another site that is a
third-party cookie, which WebKit (Safari and every iOS browser) blocks
outright. The code exchange succeeded, the cookie was dropped, and the
follow-up whoami call got a 401, so handleSuccessfulAuthentication threw.
Desktop Chrome still accepts third-party cookies, hence "desktop only".
- auth-react-provider: `doesSupportHttpOnlyRefreshToken()` keeps the cookie
for the auth server's own app and for pages on the same site as the auth
server only. `isSameSiteAsAuthServer()` compares scheme + registrable
domain, finding the page's eTLD+1 by probing which suffix of its host
the browser accepts as a cookie Domain (the browser's own Public Suffix
List). Cross-site pages keep the rotating refresh token in localStorage,
the path dev/test already uses. Logout in that mode now revokes the token
at the auth server (Bearer) and clears any cookie an older SDK left.
Compiled tests are stripped from dist, as in auth-client-sdk.
- auth-client-sdk: Bearer-authenticated whoami calls send no cookies.
whoami tries the app's refresh-token cookie first, so a stale cookie from
the old SDK would answer for the session, or fail it once revoked.
- auth-server: POST /api/auth/logout/{client_app_id} also revokes a refresh
token presented as `Authorization: Bearer`, only when issued to that app.
Apps on another site than the auth server (lorahworld included) sign in
once more after upgrading: their old cookie session is no longer read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5jiRPDFNryfTq79Wyp5nT
…uth-resource-server-codegen-templates:0.0.163, auth-server-sdk:0.33.0, trpc-backend-init:0.9.82, auth-server:0.55.9, cypress-e2e-auth-tests-helper-commands:0.6.1, e2e-auth-tests:0.12.10 - same-origin session route keeps refresh tokens in first-party HTTP-only cookies Refresh tokens of apps on another site than the auth server could not stay in an HTTP-only cookie: the auth server's cookie is a third-party cookie there, which WebKit (every iOS browser) drops, so the previous commit moved them to localStorage, readable by any script on the page. Token endpoint gap (auth-server): a refresh grant that presented its refresh token as the app's HTTP-only cookie got the rotated token back inline unless it asked for cookie delivery, so a page script could make the browser send the cookie and read a fresh refresh token from the JSON body. Such grants now always rotate the token back into the cookie. Same-origin auth session route (auth-server-sdk/auth-session-route): createAuthSessionRouteHandler() serves <route>/token, /whoami and /logout on the app's own origin and forwards them to the auth server server-to-server. It keeps the refresh token in an HttpOnly, SameSite=Strict, Path=<route> cookie on the app's domain (__Secure- and Secure outside development/test), strips it from every response body, presents it as a Bearer to whoami / logout, and refuses cross-origin requests, other apps and other grant types. - auth-client-sdk: adapters may expose getSessionRouteUrl(); the SDK then sends token requests (openid-client customFetch), whoami and logout to the route. - auth-react-provider: `session_route_uri` prop on <AuthProvider>; the adapter treats it as HTTP-only-cookie mode and logs out through it (and revokes any session an older SDK left in the auth server's cookie). - codegen: writes auth/session/[action]/route.ts and the generated auth-provider.tsx enables it with "/auth/session" (null opts out). The templates gain a type-check-only devDependency on auth-server-sdk. - E2E: the example resource server now uses the route; its spec asserts the HTTP-only cookie instead of a localStorage refresh token, and a new spec pins that a cookie-presented refresh token is never answered inline. Verified in Chromium with third-party cookies blocked and allowed, against the real auth server and example app on two HTTPS sites: login, a refresh grant, the protected API call and logout (token revoked) all go through the route, and a page script gets no refresh token by any path. Known limit: refresh grants proxied through the route count against the app server's IP in the auth server's per-IP refresh rate limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M5jiRPDFNryfTq79Wyp5nT
…uth-resource-server-codegen-templates:0.0.164, auth-server-sdk:0.33.1, trpc-backend-init:0.9.83, auth-server:0.55.10 - one route file per auth session route, each with its own client path
The auth session route was a single catch-all (`auth/session/[action]/route.ts`)
whose handler parsed the action from the URL, and the client SDK derived each
route as `<base>/<action>` from one `/auth/session` base.
- auth-server-sdk: `createAuthSessionTokenRoute()` → `{ POST }`,
`createAuthSessionWhoamiRoute()` → `{ GET }` and
`createAuthSessionLogoutRoute()` → `{ POST }`, sharing
`createSessionRoute()` (same-origin check, config, session cookie,
upstream fetch). Next.js now routes methods (and answers 405) itself. The
cookie's Path is still the routes' common parent by default; a new
`cookie_path` option covers routes that share no parent.
`createAuthSessionRouteHandler()` (never published) is gone.
- codegen: writes `auth/session/token/route.ts`, `whoami/route.ts` and
`logout/route.ts` instead of the `[action]` catch-all.
- auth-client-sdk: the adapter's `getSessionRouteUrls()` returns each
route's own URL (`AuthSessionRoutes`: `{ token, whoami, logout }`) in place
of a base URL; `AUTH_SESSION_ROUTE_PATHS` names codegen's paths.
- auth-react-provider: `session_routes` (one path or URL per route) replaces
`session_route_uri`; the generated `auth-provider.tsx` passes
`AUTH_SESSION_ROUTE_PATHS` (`null` opts out).
Re-verified in Chromium with third-party cookies blocked and allowed against
the real auth server and example app on two HTTPS sites: login, refresh,
protected API call and logout all go through the three routes, and page
scripts get no refresh token.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M5jiRPDFNryfTq79Wyp5nT
…uth-resource-server-codegen-templates:0.0.165, auth-server-sdk:0.33.2, trpc-backend-init:0.9.84, auth-server:0.55.11 - drop same-site cookie probing; client apps use session routes or localStorage ReactAuthClientSdkAdapter decided whether a client app could keep its refresh token in the auth server's HTTP-only cookie by guessing whether the page and the auth server are the same site: it found the page's registrable domain by writing throwaway cookies on ever-shorter host suffixes and seeing which ones the browser accepted. That is fragile, and with the same-origin session routes it is no longer needed. doesSupportHttpOnlyRefreshToken() is now deterministic: true for the auth server's own app and for apps with session routes, false for every other client app (which keeps its rotating refresh token in localStorage, as it already did on another site and in development/test). The auth server's own cookie is never used for a client app any more: for an app on another site it is a third-party cookie, which WebKit drops. A same-site app without session routes moves from that cookie to localStorage; session routes give it an HTTP-only refresh token again. Removes same-site-as-auth-server.ts and its test (and auth-react-provider's now test-less `test` script: `bun test` fails without tests). Verified in Chromium with third-party cookies blocked and allowed against the real auth server and example app on two HTTPS sites, both with the session routes (default) and with them switched off (localStorage): logins succeed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M5jiRPDFNryfTq79Wyp5nT
… - rebuild auth-server-sdk when the codegen templates change auth-server-sdk's build copies the codegen templates' source into its dist (copy-codegen-templates.sh), but its turbo build inputs only listed its own src/**, package.json and tsconfig.json. A template-only edit (without the conventional auth-server-sdk version bump) was a turbo cache hit that kept the old templates in dist. The build inputs now also list the templates' src/** (via $TURBO_ROOT$) and the build's own scripts (copy-codegen-templates.sh, prepare-cli.sh). Verified with turbo dry runs: editing a template changes the auth-server-sdk#build hash, and reverting the edit restores it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M5jiRPDFNryfTq79Wyp5nT
…, e2e-auth-tests:0.12.11 - __Host- session cookie so sibling subdomains cannot plant one The session routes kept the refresh token in `__Secure-schemavaults_refresh_token_<app>` with `Path=/auth/session`. `__Secure-` still allows a `Domain` attribute, so a host controlling a sibling subdomain could plant the same name with `Domain=<parent>; Path=/auth/session/token`; the browser sends the more specific cookie first and `readCookie` took the first match, presenting the attacker's refresh token (session fixation). - The cookie is now `__Host-schemavaults_refresh_token_<app>` (Secure, `Path=/`, no `Domain`) outside development/test: browsers refuse a `__Host-` cookie that carries a `Domain` or a narrower `Path`, so no other host can set it. - `readCookie` treats a name sent more than once as no session instead of guessing which copy is the route's own. - The `cookie_path` route option is gone (the prefix requires `Path=/`). - The E2E session-cookie assertion accepts the `__Host-` form. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M5jiRPDFNryfTq79Wyp5nT
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Logging in to a third-party client app failed on every iPhone with
Did not receive user data from auth server after login, for example lorahworld.jalexw.ca against auth.schemavaults.com. Desktop Chrome worked.Outside development and test, the browser SDK asked the token endpoint for
refresh_token_delivery=http_only_cookie. The refresh token was then set as an HTTP-only cookie on the auth server's host, in answer to a credentialedfetchfrom the client's page. When the page is on another site, that is a third-party cookie. WebKit blocks third-party cookies outright, and every iOS browser is WebKit, so the cookie was dropped. The code exchange succeeded, the follow-upwhoamigot a 401, andhandleSuccessfulAuthenticationthrew. Desktop Chrome still accepts third-party cookies, which is why only phones failed.I reproduced this end to end:
Set-Cookie BLOCKED [ThirdPartyPhaseout]onPOST /api/oidc/token, thenGET /api/auth/whoami/<app>returned 401, then the same error.What changes
Where a client app's refresh token lives (auth-react-provider):
doesSupportHttpOnlyRefreshToken()is deterministic.Authorization: BearerThe auth server's own cookie is never used for a client app any more. An earlier commit on this branch guessed same-site-ness by probing which host suffixes accept cookies; commit
a025d4aremoved it.Same-origin session routes (
@schemavaults/auth-server-sdk/auth-session-route, written by codegen):one route file each:
auth/session/token/route.tscreateAuthSessionTokenRoute(){ POST }auth/session/whoami/route.tscreateAuthSessionWhoamiRoute(){ GET }auth/session/logout/route.tscreateAuthSessionLogoutRoute(){ POST }each forwards its request to the auth server server-to-server;
the refresh token lives in a host-only
HttpOnly; SameSite=Strict; Path=/cookie on the app's own host, named__Host-schemavaults_refresh_token_<app>withSecureoutside development and test:__Host-cookie that has aDomainor a narrowerPath, so a sibling subdomain can't plant one. Under__Secure-,evil.example.comcould setDomain=example.com; Path=/auth/session/token, and the browser would send that cookie first, so the route would present the attacker's refresh token (session fixation);Path=/, the cookie is no longer scoped to/auth/session, and thecookie_pathoption is gone;the refresh token is stripped from every response body;
cross-origin requests are refused, and the token route refuses other apps' clients and other grant types.
Client SDK configuration:
getSessionRouteUrls()returns each route's own URL (AuthSessionRoutes:{ token, whoami, logout }), andAUTH_SESSION_ROUTE_PATHSnames codegen's paths.<AuthProvider session_routes>takes one path or URL per route. The generatedauth-provider.tsxpassesAUTH_SESSION_ROUTE_PATHS;nullopts out.Auth server:
POST /api/auth/logout/{client_app_id}also revokes a refresh token presented asAuthorization: Bearer, but only one issued to that app. The SDK's logout uses it for localStorage tokens, and the session routes use it too.Other SDK changes:
whoamicalls send no cookies, so a stale cookie can't take the Bearer token's place.Build: auth-server-sdk's turbo build
inputsnow include the codegen templates'src/**(via$TURBO_ROOT$) and its build scripts. A template-only edit used to be a cache hit that kept the old templates indist.E2E: the example resource server uses the session routes.
ExampleResourceServer.cy.tsnow checks for the routes' HTTP-only cookie (with or without the__Host-prefix), where it used to look for a refresh token in localStorage. New specs cover cookie-presented refresh rotation and Bearer logout.Verification
__Host-, host-only, withPath=/;document.cookie, the token route and the auth server directly all came back without it.evil.<parent>tried to plant both the__Host-and the__Secure-session cookie withDomain=<parent>; Path=/auth/session/token:__Host-one (InvalidPrefix);__Secure-one and sent it first to the token route, but the route ignores that name;POST /auth/session/token 200) and API call still worked.bun run test; 106 in auth-server-sdk, including the host-only cookie and planted-duplicate cases). The example app's typecheck (afternext typegen) and route-catalogue test pass with the generated routes.auth-server-sdk#buildhash, and reverting the edit restores it.Notes
Path=/, the browser sends the HTTP-only refresh cookie with every request to the app's host, not only the session routes. Page scripts still can't read it, and other sites can't trigger it (SameSite=Strict), but the app's own server code and request logs can see it.@schemavaults/auth-react-provider@0.18.2and@schemavaults/auth-server-sdk@0.33.4.🤖 Generated with Claude Code
https://claude.ai/code/session_01M5jiRPDFNryfTq79Wyp5nT