Skip to content

fix(auth): align cookie lifetimes with session and refresh token boundaries - #3940

Open
rossnelson wants to merge 3 commits into
mainfrom
fix/auth-cookie-lifetimes
Open

rossnelson wants to merge 3 commits into
mainfrom
fix/auth-cookie-lifetimes

Conversation

@rossnelson

@rossnelson rossnelson commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Description & motivation 💭

Supersedes #3235, rebuilt from current main. That PR was opened from a fork's main branch, so it could not be updated in place, and SetUser has since gained a secure parameter. Thanks to @ralf157 for the original diagnosis and fix — the analysis in #3235 is what this is built on.

auth-cookie-lifetimes.mp4

Two bugs, both confirmed still present on main:

1. Refresh cookie expires with the access token (#3210)

The refresh cookie's MaxAge came from oauth2.Token.Expiry, which is populated from the token response's expires_in. Per RFC 6749 §5.1 and OIDC Core §3.2.2.5 that describes the access token. The cookie was therefore dropped at the exact moment the access token expired, so the refresh it exists to perform arrived with no cookie and came back 401. Reported against Keycloak, reproducible on any IdP whose refresh token outlives its access token.

The lifetime now resolves in this order:

  1. The refresh token's own exp claim, when the IdP issues a JWT refresh token (Keycloak and friends — no configuration needed).
  2. A new per-provider refreshTokenDuration, for IdPs that issue opaque refresh tokens whose lifetime the server cannot read.
  3. A 7-day default.

The existing 30-day cap is kept.

2. User cookies outlive the session boundary (#3223)

user* cookies were always issued for a flat 60 seconds. A refresh performed shortly before maxSessionDuration elapsed handed the browser a full minute of credentials the server had already stopped honouring — a UI that looks signed in while every API call behind it returns 401. They are now clamped to min(60s, time left in session).

3. maxSessionDuration unreachable in Docker (#3223)

The field was enforced server-side but absent from docker.yaml, so it could not be set without supplying a wholly custom config file. Both it and refreshTokenDuration are now exposed as environment variables.

Design Considerations 🎨

  • Both new settings default to unset, so existing deployments are unaffected. This is deliberate and load-bearing: #3235 defaulted TEMPORAL_MAX_SESSION_DURATION to 2m, copied from the local dev config, which would have silently started logging every Docker deployment out every two minutes. TestDockerConfigSessionDefaultsAreUnset pins this.
  • refreshTokenDuration sits on AuthProvider rather than Auth, since refresh token lifetime is a property of the IdP.
  • The JWT exp is read without signature verification, because it only chooses a cookie lifetime. Nothing is trusted on the strength of it — the IdP still validates the token on every refresh, and a forged exp can only make the browser drop a cookie earlier or later than it needed to. The comment on jwtExp says so.
  • SetUser's three positional booleans/durations would have grown to five, so the trailing arguments are now a CookieOptions struct. Only two call sites.
  • Drive-by: the docs and with-auth.yaml both advise maxSessionDuration: 0 for "unlimited", which yaml.v3 rejects (cannot unmarshal !!int 0 into time.Duration). Corrected to 0s.

Scope vs #3235

#3235 also added ~1100 lines of new E2E infrastructure: two docker-compose stacks, a Keycloak 26 realm import, two extra Playwright configs and a duplicate mock-OIDC config, none of it wired into CI. That is not carried over.

The equivalent coverage here reuses what the repo already has. The e2e-tests job already builds the Go server and runs Playwright against a real ui-server, and utilities/oidc-server is already a working identity provider, so the browser-level coverage cost one config file, one harness module and one spec, inside CI jobs that already run.

Testing 🧪

How was this tested 👻

  • Unit tests added
  • Integration tests added
  • End-to-end tests added, against a real identity provider
  • Every new test verified to fail against the previous behaviour

End to end (tests/e2e/auth-cookie-lifetimes.spec.ts)

Real Chromium, a real login at the repo's own mock OIDC provider, and a real auth-enabled ui-server process. The cookie lifetimes under test are decided by the Go server and reach the browser only as Set-Cookie headers, so the assertions read those headers directly.

tests/integration/oauth-flow.spec.ts could not be extended for this: it mocks /auth/sso and /auth/refresh with page.route, which is precisely what has to be real here.

The e2e-tests CI job already builds the Go server and runs Playwright, so this adds no new CI job and no new infrastructure. tests/global-setup.ts starts a second, auth-enabled ui-server on 8081 alongside the existing one on 8080, plus the mock provider, via utilities/auth-e2e-stack.ts and a new server/config/e2e-auth.yaml.

Test Asserts
refresh cookie outlives the access token refresh Max-Age is 24h, not the 5s access token lifetime
user cookies do not outlive the session user0 Max-Age ≤ the 45s maxSessionDuration, and under the 60s default
token refresh succeeds after the access token has expired waits out the access token, then GET /auth/refresh → 200
refresh is refused once the session has expired waits past maxSessionDuration, then /auth/refresh → 401

Against the previous behaviour, the first three fail and the fourth passes:

3 failed
  › refresh cookie outlives the access token
  › user cookies do not outlive the session
  › token refresh succeeds after the access token has expired
      Expected: 200
      Received: 401
1 passed

That fourth test is the guard: lengthening the cookies must not quietly turn maxSessionDuration into a limit that no longer ends a session, so it has to pass both before and after.

Full E2E suite: 37 passed, so the second server and the harness change break nothing.

Integration (server/server/route/auth_cookies_test.go)

Six Go tests that call the refreshTokens handler in process against an httptest identity provider — real HTTP token exchange, real oauth2 client, real cookie serialization. Faster than the browser tests and covers the opaque-token and default-fallback branches that the mock provider alone cannot reach.

Test Asserts
TestRefreshCookieOutlivesTheAccessToken IdP reports expires_in=5 while issuing a 7-day JWT refresh token → the cookie tracks the token's exp
TestRefreshCookieForOpaqueTokenUsesConfiguredDuration opaque refresh token → refreshTokenDuration is used
TestRefreshCookieForOpaqueTokenFallsBackToDefault opaque token, nothing configured → 7-day default
TestUserCookiesNeverOutliveTheSession 30s left in session → user0 Max-Age is 30, not 60
TestUserCookiesKeepDefaultWhenSessionHasRoom 8h session → user0 Max-Age stays 60
TestUserCookiesUnaffectedWithoutMaxSessionDuration no session limit → user0 Max-Age stays 60

The four bug-specific ones fail against the old logic; the two "nothing should change" ones pass against both.

Unit

server/server/auth/cookie_test.go covers jwtExp (valid, fractional exp, base64 padding, and ten rejection cases including opaque tokens and malformed JWTs), the full refreshCookieMaxAge priority chain including both cap paths and an already-expired exp, and the userCookieMaxAge boundaries.

server/plugins/fs_config_provider/loader_test.go loads the real docker.yaml through the real template pipeline, pinning the unset defaults and checking both environment variables take effect.

Not tested here

No live Keycloak. The Keycloak-specific behaviour is the JWT exp branch, exercised by TestRefreshCookieOutlivesTheAccessToken with a Keycloak-shaped refresh token, but that is a stand-in rather than the real server — the mock provider issues opaque refresh tokens, so the browser tests cover the configured-duration branch instead. @ralf157 tested #3235 against live Keycloak 26; a confirmation on this branch would be welcome.

Checklists

Merge Checklist

  • Both new config settings default to unset — no behaviour change for existing deployments
  • New tests verified to fail against the previous behaviour, at both the Go and browser level
  • Confirmation against a live Keycloak instance

Issue(s) closed

Closes #3210
Closes #3223

Docs

AUTHENTICATION.md gains a Refresh token lifetime section describing the priority chain and why expires_in is not used, a Docker Environment Variables table for the auth settings, a note that user* cookies are held to the session boundary, and a troubleshooting entry for refresh failing with 401 at access-token expiry.

The refresh cookie was given the access token's lifetime, and the user*
cookies a flat minute regardless of the session boundary. Both are fixed
here, along with the missing Docker settings that made the second one
impossible to configure.

Refresh cookie (#3210). Its MaxAge came from oauth2.Token.Expiry, which
is populated from the token response's expires_in. Per RFC 6749 5.1 and
OIDC Core 3.2.2.5 that describes the access token, not the refresh
token, so the cookie was dropped at the moment the access token expired
and the refresh it existed to perform came back 401. The lifetime now
comes from the refresh token's own exp claim where the provider issues a
JWT, then from a new per-provider refreshTokenDuration for providers
that issue opaque tokens, then a 7 day default. The existing 30 day cap
is kept.

User cookies (#3223). They were always issued for 60 seconds, so a
refresh shortly before the session boundary left the browser holding
credentials the server had already stopped honouring: a signed-in UI
whose every API call returned 401. They are now clamped to whatever is
left of the session.

Docker config (#3223). maxSessionDuration was enforced but absent from
docker.yaml, so it could not be set without a wholly custom config file.
Both it and refreshTokenDuration are now exposed, and both default to
unset so existing deployments are unaffected.

Also corrects docs advising `maxSessionDuration: 0`, which yaml.v3
rejects as an int rather than a duration. The working spelling is `0s`.
@rossnelson
rossnelson requested a review from a team as a code owner September 17, 2026 22:06
@vercel

vercel Bot commented Sep 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
holocene Ready Ready Preview Sep 17, 2026 11:04pm UTC

Request Review

@rossnelson

Copy link
Copy Markdown
Collaborator Author

Verified end to end against a running stack

Real Chrome → real ui-server → real OIDC provider (the repo's utilities/oidc-server), with a live Temporal dev server behind it. Same binary, same config, same login flow both times — the only difference is the two auth.go changes in this PR.

Demo config, chosen to make the failure quick rather than to be realistic: mock IdP access token 10s, refresh token 24h, maxSessionDuration: 45s, refreshTokenDuration: 24h.

refresh cookie Max-Age user0 cookie Max-Age GET /auth/refresh after the access token expired
Before (main) 9s — the access token's remaining life 60s — outlives the 45s session 401 Unauthorized
After (this PR) 86400s — the refresh token's life 44s — held inside the session 200 OK

Both numbers are read from the literal Set-Cookie headers on /auth/sso/callback, not derived in the browser.

That 401 is exactly #3210 as reported: the refresh cookie is gone at the moment it is needed, so the browser sends nothing and the user is signed out mid-session. The 60s vs 45s row is the #3223 cookie half — a signed-in UI whose every API call returns 401.

Recording of both runs to follow.

The Go tests added alongside the fix drive the refresh handler in process.
That covers the logic but stops short of the thing users actually hit: a
browser, a real identity provider, and cookies the Go server writes as
Set-Cookie headers. tests/integration/oauth-flow.spec.ts cannot fill the
gap either, since it mocks /auth/sso and /auth/refresh outright, which is
exactly what would need to be real.

This starts a second, auth-enabled ui-server on 8081 alongside the
existing E2E one, backed by the repo's own mock OIDC provider, and drives
a real login against it. The e2e CI job already builds the Go server and
runs the Playwright suite, so this needs no new job or infrastructure.

Three of the four tests fail against the previous behaviour: the refresh
cookie carries the access token's 5s lifetime rather than 24h, the user0
cookie is issued for 60s against a 45s session, and the refresh after the
access token expires returns 401. The fourth asserts that session expiry
still ends a session, and passes either way, so that lengthening the
cookies cannot quietly disable maxSessionDuration.

The ui-server test harness tracked one server in a module level variable,
which two concurrent servers would clobber, leaving the first without a
handle to shut down. It is now keyed by env.
@rossnelson

Copy link
Copy Markdown
Collaborator Author

Added a real end-to-end test

Correcting my earlier wording: the Go tests I first described as end-to-end are integration tests of the refresh handler. Useful, but they never touch a browser or a real identity provider. There is now a genuine E2E test as well.

tests/e2e/auth-cookie-lifetimes.spec.ts — real Chromium, real login at the repo's mock OIDC provider, real auth-enabled ui-server process, assertions read from the Set-Cookie headers the Go server actually sends.

I looked at extending tests/integration/oauth-flow.spec.ts first, but it mocks /auth/sso and /auth/refresh with page.route — which is exactly the thing that has to be real to see a cookie lifetime. So this goes in the e2e suite instead, where global-setup.ts already starts a real ui-server and the CI job already builds the Go binary. Net new infrastructure: one config file (server/config/e2e-auth.yaml), one harness module, one spec. No new CI job.

Against the previous behaviour, three of the four fail:

3 failed
  › refresh cookie outlives the access token
  › user cookies do not outlive the session
  › token refresh succeeds after the access token has expired
      Expected: 200
      Received: 401
1 passed

The one that passes both ways is deliberate — it waits past maxSessionDuration and asserts /auth/refresh still returns 401, so that lengthening the cookies cannot quietly disable session expiry.

Full E2E suite is 37 passed with this in, so the second server and the harness change break nothing.

One harness change worth a reviewer's eye: utilities/ui-server.ts tracked its server in a module-level variable. Two concurrent servers would clobber it and leave the first without a handle to shut down, so it is now keyed by env.

Map.get returns UIServer | undefined. The previous module level variable
was typed as UIServer and hid that, so keying the registry surfaced a
strict mode error the old shape had been papering over. The return type
now says what it returns, and the one caller that assumed a server
handles its absence.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BUG: Session expiry not configurable in Docker; user cookies outlive session boundary OIDC Refresh doesn't work due to bad expiration date

1 participant