From 85d015cafab7ae5687e7730548d46511e2f04429 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 21:26:36 -0500 Subject: [PATCH 01/12] test: reproduce refresh replay, Lax origin bypass, empty allowlist and http JWKSURI Refs #21 Refs #23 --- origin_test.go | 119 +++++++++++++++++++++++++++++++++++++++++++++++ refresh_test.go | 67 ++++++++++++++++++++++++++ verifier_test.go | 11 +++++ 3 files changed, 197 insertions(+) create mode 100644 refresh_test.go diff --git a/origin_test.go b/origin_test.go index c81f043..c87087d 100644 --- a/origin_test.go +++ b/origin_test.go @@ -91,3 +91,122 @@ func TestACookieSessionGetsTheSameSiteAllowlistCheck(t *testing.T) { t.Fatalf("listed origin: status %d body %s", rec.Code, rec.Body.String()) } } + +// A sibling subdomain is same-site, so SameSite=Lax cookies ride along on its +// form POST. The origin check has to run whatever the SameSite setting. +func TestASameSiteFormPostIsCheckedUnderLaxCookies(t *testing.T) { + api := newFakeAPI(t) + api.on("POST", "/recovery-codes", respond(200, map[string]any{"codes": []string{"a"}})) + a := newTestAdapter(t, api, func(o *Options) { + o.CookieSameSite = http.SameSiteLaxMode + o.AllowedOrigins = []string{"https://app.example.com"} + }) + cookie := withCookie(signedCookie(t, "seamless-access", map[string]any{"sub": "u1", "token": apiToken(t, api, "u1", "access")})) + + r := do(t, a, "POST", "/recovery-codes", "", cookie, + withHeader("Sec-Fetch-Site", "same-site"), + withHeader("Origin", "https://evil.example.com"), + withHeader("Content-Type", "application/x-www-form-urlencoded")) + + if r.status != http.StatusForbidden || r.body["error"] != "cross_site_request_blocked" { + t.Fatalf("status %d body %v", r.status, r.body) + } + if n := len(api.callsTo("POST", "/recovery-codes")); n != 0 { + t.Fatalf("reached the auth API %d times", n) + } +} + +func TestACrossSiteRequestIsRefusedUnderLaxCookies(t *testing.T) { + api := newFakeAPI(t) + a := newTestAdapter(t, api, func(o *Options) { o.CookieSameSite = http.SameSiteLaxMode }) + for _, setup := range [][]func(*http.Request){ + {withHeader("Sec-Fetch-Site", "cross-site")}, + {withHeader("Origin", "null")}, + } { + if r := do(t, a, "POST", "/magic-link", `{}`, setup...); r.status != http.StatusForbidden { + t.Fatalf("status %d body %v", r.status, r.body) + } + } +} + +func TestTheGuardChecksASameSiteCookieRequestUnderLaxCookies(t *testing.T) { + api := newFakeAPI(t) + a := newTestAdapter(t, api, func(o *Options) { + o.CookieSameSite = http.SameSiteLaxMode + o.AllowedOrigins = []string{"https://app.example.com"} + }) + cookie := withCookie(signedCookie(t, "seamless-access", map[string]any{"sub": "u1", "token": apiToken(t, api, "u1", "access")})) + post := func(r *http.Request) { r.Method = http.MethodPost } + + rec := call(guarded(a), post, cookie, withHeader("Sec-Fetch-Site", "same-site"), withHeader("Origin", "https://evil.example.com")) + if rec.Code != 403 || !strings.Contains(rec.Body.String(), "cross_site_request_blocked") { + t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) + } +} + +// A body-less form POST carries no JSON body for the content type check to look +// at. Without an allowlist, and from a sibling subdomain or a browser that sends +// no fetch metadata, nothing else stops it reaching a cookie-authenticated route. +func TestABodyLessFormPostNeedsAJSONContentTypeOrACustomHeader(t *testing.T) { + for name, mutate := range map[string]func(*Options){ + "lax": func(o *Options) { o.CookieSameSite = http.SameSiteLaxMode }, + "none": func(o *Options) {}, + } { + for _, routePath := range []string{"/recovery-codes", "/totp/enroll/start", "/admin/applications/app-1/secret"} { + for _, site := range []string{"same-site", ""} { + t.Run(name+routePath+" "+site, func(t *testing.T) { + api := newFakeAPI(t) + api.on("POST", routePath, respond(200, map[string]any{"ok": true})) + a := newTestAdapter(t, api, mutate) + setup := []func(*http.Request){ + withCookie(signedCookie(t, "seamless-access", map[string]any{"sub": "u1", "token": apiToken(t, api, "u1", "access")})), + withHeader("Content-Type", "application/x-www-form-urlencoded"), + } + if site != "" { + setup = append(setup, withHeader("Sec-Fetch-Site", site)) + } + + r := do(t, a, "POST", routePath, "", setup...) + if r.status != http.StatusUnsupportedMediaType || r.body["error"] != "unsupported_media_type" { + t.Fatalf("status %d body %v", r.status, r.body) + } + if n := len(api.callsTo("POST", routePath)); n != 0 { + t.Fatalf("reached the auth API %d times", n) + } + }) + } + } + } +} + +func TestABodyLessUnsafeRequestPassesWithAJSONContentTypeOrTheTransportHeader(t *testing.T) { + api := newFakeAPI(t) + api.on("POST", "/recovery-codes", respond(200, map[string]any{"ok": true})) + a := newTestAdapter(t, api) + cookie := withCookie(signedCookie(t, "seamless-access", map[string]any{"sub": "u1", "token": apiToken(t, api, "u1", "access")})) + + for _, setup := range [][]func(*http.Request){ + {withHeader("Content-Type", "application/json")}, + {withHeader(transportHeader, "cookie")}, + } { + if r := do(t, a, "POST", "/recovery-codes", "", append(setup, cookie)...); r.status != 200 { + t.Fatalf("status %d body %v", r.status, r.body) + } + } +} + +// An empty allowlist is configured, and allows no origin. +func TestAnEmptyAllowedOriginsIsConfiguredAndStrict(t *testing.T) { + api := newFakeAPI(t) + a := newTestAdapter(t, api, func(o *Options) { o.AllowedOrigins = []string{} }) + + for _, setup := range [][]func(*http.Request){ + {withHeader("Origin", "https://app.example.com")}, + {withHeader("Sec-Fetch-Site", "same-site"), withHeader("Origin", "https://app.example.com")}, + } { + r := do(t, a, "POST", "/magic-link", `{}`, setup...) + if r.status != http.StatusForbidden || r.body["error"] != "cross_site_request_blocked" { + t.Fatalf("status %d body %v", r.status, r.body) + } + } +} diff --git a/refresh_test.go b/refresh_test.go new file mode 100644 index 0000000..aa8dd02 --- /dev/null +++ b/refresh_test.go @@ -0,0 +1,67 @@ +package seamlessauth + +import ( + "net/http" + "sync/atomic" + "testing" +) + +// rotatingOnceAPI rotates a refresh token the first time it is presented and +// answers refresh_token_reused after that, as the auth API's reuse detection does. +func rotatingOnceAPI(t *testing.T, api *fakeAPI) *atomic.Int32 { + var n atomic.Int32 + api.on("POST", "/refresh", func(w http.ResponseWriter, _ *http.Request) { + if n.Add(1) > 1 { + writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "refresh_token_reused"}) + return + } + writeJSON(w, http.StatusOK, session(t, api, "u1")) + }) + return &n +} + +// A refresh token replayed after its rotation finished must reach the auth API, +// so its reuse detection sees the replay, rather than be handed the new session. +func TestACompletedCookieRotationIsNotReplayedToALaterCaller(t *testing.T) { + api := newFakeAPI(t) + refreshes := rotatingOnceAPI(t, api) + api.on("GET", "/users/me", respond(200, map[string]any{"user": map[string]any{"id": "u1"}})) + a := newTestAdapter(t, api) + stolen := signedCookie(t, "seamless-refresh", map[string]any{"sub": "u1", "refreshToken": "r1"}) + + if r := do(t, a, "GET", "/users/me", "", withCookie(stolen)); r.status != 200 { + t.Fatalf("first caller: status %d body %v", r.status, r.body) + } + + r := do(t, a, "GET", "/users/me", "", withCookie(stolen)) + if refreshes.Load() != 2 { + t.Fatalf("the replay did not reach the auth API: %d refresh calls, replay got status %d, new access cookie issued %v", + refreshes.Load(), r.status, r.cookies["seamless-access"] != nil && r.cookies["seamless-access"].MaxAge > 0) + } + if r.status != 401 || r.cookies["seamless-access"] == nil || r.cookies["seamless-access"].MaxAge >= 0 { + t.Fatalf("replay: status %d cookies %v", r.status, r.cookies) + } +} + +func TestACompletedBearerRotationIsNotReplayedToALaterCaller(t *testing.T) { + api := newFakeAPI(t) + refreshes := rotatingOnceAPI(t, api) + a := newTestAdapter(t, api) + refresh := func() reply { + return do(t, a, "POST", "/refresh", "", + withHeader(transportHeader, "bearer"), withHeader("Authorization", "Bearer r1")) + } + + if r := refresh(); r.status != 200 || r.body["refreshToken"] == nil { + t.Fatalf("first caller: status %d body %v", r.status, r.body) + } + + r := refresh() + if refreshes.Load() != 2 { + t.Fatalf("the replay did not reach the auth API: %d refresh calls, replay got status %d, refreshToken %v", + refreshes.Load(), r.status, r.body["refreshToken"]) + } + if r.status != 401 || r.body["error"] != "refresh_token_reused" { + t.Fatalf("replay: status %d body %v", r.status, r.body) + } +} diff --git a/verifier_test.go b/verifier_test.go index 4a90952..bc7daf1 100644 --- a/verifier_test.go +++ b/verifier_test.go @@ -581,3 +581,14 @@ func TestRoleGrantsAccess(t *testing.T) { } } } + +// Keys fetched over plain http can be swapped in transit, which would let anyone +// on the path mint tokens for an https issuer. +func TestVerifierRefusesAnHTTPJWKSURIUnderAnHTTPSIssuer(t *testing.T) { + if _, err := NewVerifier(VerifierOptions{Issuer: "https://login.example", Audience: "a", JWKSURI: "http://keys.internal/jwks"}); err == nil { + t.Fatal("accepted an http JWKSURI under an https issuer") + } + if _, err := NewVerifier(VerifierOptions{Issuer: "http://login.example", Audience: "a", JWKSURI: "http://keys.internal/jwks"}); err != nil { + t.Fatalf("refused an http JWKSURI under an http issuer: %v", err) + } +} From a569544a816cb17ff754d9105b12c31663e4d7d4 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 21:27:03 -0500 Subject: [PATCH 02/12] fix: share a refresh rotation only while it is in flight A completed rotation was handed to any caller presenting the same old refresh token or cookie for the next 5 seconds, without calling the auth API. A stolen refresh token replayed inside that window got the new session, and the API's reuse detection never saw the replay. Parallel requests that arrive while a rotation runs still share it; a refresh token presented after it finished now goes to the auth API, for cookie and bearer transport alike. Refs #21 --- AGENTS.md | 2 +- adapter_test.go | 4 ++++ refresh.go | 31 ++++++++----------------------- 3 files changed, 13 insertions(+), 24 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a9d7ea9..fa4a617 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,7 @@ The module targets Go 1.22. Do not use standard library APIs newer than that (CI - `adapter.go`: `Adapter`, the request pipeline, the upstream call, writing results. - `routes.go`: manifest routes, credential resolution (with silent refresh), session verification, what a cookie-transport body may contain. -- `refresh.go`: refresh sharing (one result per refresh token for 5 seconds), `POST /refresh`, +- `refresh.go`: refresh sharing (one rotation per refresh token while it is in flight, never after), `POST /refresh`, logout. - `manifest.go`: parsing, matching, the live and embedded manifest. - `guard.go`: `RequireAuth`, `Authenticate`. diff --git a/adapter_test.go b/adapter_test.go index 0cdf172..5d8992f 100644 --- a/adapter_test.go +++ b/adapter_test.go @@ -8,6 +8,7 @@ import ( "sync" "sync/atomic" "testing" + "time" ) func TestLoginStoresThePreAuthTokenAndPicksTheBody(t *testing.T) { @@ -101,8 +102,11 @@ func TestBearerTransportReturnsTheWholeBodyAndNoCookies(t *testing.T) { func TestAMissingAccessCookieIsRestoredFromTheRefreshCookieOnce(t *testing.T) { api := newFakeAPI(t) var refreshes atomic.Int32 + // Held open so every tab arrives while the rotation is in flight, which is + // the only time it is shared. api.on("POST", "/refresh", func(w http.ResponseWriter, _ *http.Request) { refreshes.Add(1) + time.Sleep(300 * time.Millisecond) writeJSON(w, 200, session(t, api, "u1")) }) api.on("GET", "/users/me", respond(200, map[string]any{"user": map[string]any{"id": "u1"}})) diff --git a/refresh.go b/refresh.go index e94c163..4dafde9 100644 --- a/refresh.go +++ b/refresh.go @@ -5,14 +5,8 @@ import ( "fmt" "net/http" "sync" - "time" ) -// A refresh token presented again within this window gets the rotation it already -// got, so parallel requests from several tabs with one expired session all succeed -// instead of tripping the auth API's reuse detection. -const refreshReuseWindow = 5 * time.Second - var errNoRefreshCookie = errors.New("no refresh cookie") type refreshOutcome struct { @@ -24,9 +18,8 @@ type refreshOutcome struct { } type refreshFlight struct { - done chan struct{} - outcome refreshOutcome - finished time.Time + done chan struct{} + outcome refreshOutcome } type refresher struct { @@ -38,17 +31,13 @@ func newRefresher() *refresher { return &refresher{flights: map[string]*refreshFlight{}} } -// do runs fn once per key at a time, and hands a successful outcome to every -// caller with the same key for refreshReuseWindow afterwards. A failure is not -// kept, so each caller sees it from the auth API itself. +// do runs fn once per key at a time, so parallel requests from several tabs with +// one expired session share a rotation instead of tripping the auth API's reuse +// detection. Only callers that arrive while fn runs share its outcome: a refresh +// token presented after its rotation finished goes to the auth API, which treats +// it as reuse. func (f *refresher) do(key string, fn func() refreshOutcome) refreshOutcome { f.mu.Lock() - now := time.Now() - for k, flight := range f.flights { - if !flight.finished.IsZero() && now.Sub(flight.finished) > refreshReuseWindow { - delete(f.flights, k) - } - } if flight, ok := f.flights[key]; ok { f.mu.Unlock() <-flight.done @@ -62,11 +51,7 @@ func (f *refresher) do(key string, fn func() refreshOutcome) refreshOutcome { f.mu.Lock() flight.outcome = outcome - if outcome.session != nil { - flight.finished = time.Now() - } else { - delete(f.flights, key) - } + delete(f.flights, key) f.mu.Unlock() close(flight.done) From d8e5a40955394f574b6aa77e63420f0aae76ddd5 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 21:28:50 -0500 Subject: [PATCH 03/12] fix: check Origin on unsafe methods whatever the SameSite setting The cross-site check on state-changing requests only ran while cookies were SameSite=None, and the JSON content type check only ran when a body was present. A body-less form POST from a sibling subdomain, which is same-site and so carries SameSite=Lax cookies, or from a browser that sends no fetch metadata, reached cookie-authenticated routes such as /recovery-codes, /totp/enroll/start and /admin/applications/{id}/secret. - The origin check runs on every unsafe method, whatever CookieSameSite is. With AllowedOrigins set it is enforced for same-site requests and for requests without fetch metadata that carry an Origin. - An unsafe request must declare a JSON Content-Type or carry the X-Seamless-Auth-Transport header even when its body is empty, so a cross-origin browser request needs a CORS preflight. Otherwise it answers 415 unsupported_media_type. - A non-nil empty AllowedOrigins is configured and allows no origin. - NewVerifier refuses a plain http JWKSURI when the Issuer is https. Refs #23 --- README.md | 21 ++++++++++++++------- adapter.go | 4 ++++ helpers_test.go | 4 +++- options.go | 8 +++++--- origin.go | 38 ++++++++++++++++++++++++++++---------- origin_test.go | 20 +++++++++++++++++--- verifier.go | 8 +++++++- verifier_test.go | 20 ++++++++------------ 8 files changed, 86 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 5ddee87..980f613 100644 --- a/README.md +++ b/README.md @@ -132,12 +132,13 @@ new access cookie no longer carries it. A bearer client holds its own refresh to `POST /auth/refresh` after enrolling. A request `RequireAuth` admits on the session cookie also gets the auth routes' cross-site -check: while cookies are `SameSite=None`, a state-changing request from another site answers -403 `cross_site_request_blocked`. That is `Sec-Fetch-Site: cross-site`; or, with -`AllowedOrigins` set, `Sec-Fetch-Site: same-site` (which also covers a sibling subdomain) from an +check: whatever `CookieSameSite` is, a state-changing request from another site answers 403 +`cross_site_request_blocked` (a sibling subdomain is same-site, so even `SameSite=Lax` cookies +ride along on its form POST). That is `Sec-Fetch-Site: cross-site`; or an `Origin` of `null`; or, +with `AllowedOrigins` set, `Sec-Fetch-Site: same-site` (which also covers a sibling subdomain) from an `Origin` that is missing or not on the list; or, from a browser that sends no `Sec-Fetch-Site`, an `Origin` outside `AllowedOrigins`. Without `AllowedOrigins`, a same-site request passes. A -bearer token is never attached by a browser, so it is not checked. `Authenticate` makes no such +non-nil empty `AllowedOrigins` is set, and allows no origin. A bearer token is never attached by a browser, so it is not checked. `Authenticate` makes no such check. ### Request bodies @@ -145,7 +146,13 @@ check. The auth routes forward a request body to the auth API as JSON, so a body must be sent as `application/json` (or a `+json` type). Anything else answers 415 `unsupported_media_type`: a cross-site form can post a `text/plain` body shaped like JSON with no CORS preflight, and read as -JSON it would be a sign-in the user never made. The client SDKs already send JSON. +JSON it would be a sign-in the user never made. + +A state-changing request (anything but `GET`, `HEAD` and `OPTIONS`) must declare a JSON +`Content-Type` or carry the `X-Seamless-Auth-Transport` header (any value, for example `cookie`) +even when it has no body, as `POST /auth/refresh` and `DELETE /auth/logout` usually do. Either one +makes a cross-origin browser request send a CORS preflight, which a form cannot. Without one it +answers 415 `unsupported_media_type`. ## Verify-only mode (resource servers) @@ -181,7 +188,7 @@ guard but never a role or step-up guard. `Authenticate` does the same check with The keys come from `/.well-known/openid-configuration`, whose `issuer` must equal `Issuer` exactly and whose `jwks_uri` must be https when the issuer is. Set `JWKSURI` to skip -discovery. If discovery or the key set cannot be fetched, the request is refused with 401 +discovery; it too must be https when the issuer is, or `NewVerifier` returns an error. If discovery or the key set cannot be fetched, the request is refused with 401 (discovery is retried at most every 5 seconds); it never answers 500. A token is accepted only when it is RS256 with `typ` `at+jwt`, a `kid` the key set publishes for @@ -236,7 +243,7 @@ g.GET("/orders", wrap(rs.RequireToken), wrap(rs.RequireScope("orders:read")), li | `CookieDomain` | none | Cookie `Domain` | | `InsecureCookies` | `false` | Drops `Secure`, for local development over HTTP. Also lets placeholder secrets through, with a warning | | `CookieSameSite` | `None`, or `Lax` with `InsecureCookies` | Cookie `SameSite` | -| `AllowedOrigins` | none | The only cross-origin and same-site callers allowed to change state while cookies are `SameSite=None` | +| `AllowedOrigins` | none (nil) | The only cross-origin and same-site callers allowed to change state, whatever `CookieSameSite` is. An empty non-nil list allows none | | `AccessCookieName`, `RefreshCookieName` | `seamless-access`, `seamless-refresh` | Session cookie names | | `RegistrationCookieName`, `PreAuthCookieName` | `seamless-ephemeral` | Sign-in flow cookie names. Logout clears both | | `Deliver` | none | Sends OTP codes and magic links through your own transports | diff --git a/adapter.go b/adapter.go index 7900a0d..f8c222f 100644 --- a/adapter.go +++ b/adapter.go @@ -74,6 +74,10 @@ func (a *Adapter) serve(w http.ResponseWriter, r *http.Request) { a.write(w, res, false) return } + if res, refused := a.checkContentType(r); refused { + a.write(w, res, false) + return + } bearer := strings.EqualFold(r.Header.Get(transportHeader), "bearer") path := r.URL.EscapedPath() diff --git a/helpers_test.go b/helpers_test.go index cf96d4c..6e4ed82 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -202,7 +202,9 @@ func do(t *testing.T, a *Adapter, method, target string, body string, setup ...f reader = strings.NewReader(body) } req := httptest.NewRequest(method, target, reader) - if body != "" { + // As the client sends it, even with no body: an unsafe request needs a JSON + // content type or the transport header. Remove it with withoutHeader. + if body != "" || isUnsafeMethod(method) { req.Header.Set("Content-Type", "application/json") } for _, s := range setup { diff --git a/options.go b/options.go index 5d5cda3..23d7d66 100644 --- a/options.go +++ b/options.go @@ -39,9 +39,11 @@ type Options struct { InsecureCookies bool // CookieSameSite defaults to None for secure cookies and Lax otherwise. CookieSameSite http.SameSite - // AllowedOrigins, when set, is the only cross-origin callers allowed to make - // state-changing requests while cookies are SameSite=None. A request marked - // Sec-Fetch-Site: same-site must then carry an Origin on the list as well. + // AllowedOrigins, when set, is the only origins allowed to make + // state-changing requests, whatever CookieSameSite is. A request marked + // Sec-Fetch-Site: same-site, or one without Sec-Fetch-Site that carries an + // Origin, must then carry an Origin on the list. A non-nil empty slice is set + // and allows no origin; leave it nil to not configure one. AllowedOrigins []string AccessCookieName string diff --git a/origin.go b/origin.go index 48891d5..553a6c5 100644 --- a/origin.go +++ b/origin.go @@ -6,30 +6,29 @@ import ( "strings" ) -// checkOrigin blocks cross-site state changes while cookies are SameSite=None, -// which is when the browser would otherwise attach them to such a request. +// checkOrigin blocks cross-site state changes, whatever the SameSite setting: +// a sibling subdomain is same-site, so even SameSite=Lax cookies ride along on +// its form POST. // // Sec-Fetch-Site is the primary signal, since page script cannot forge it: // cross-site is refused. same-site also covers a sibling subdomain, so with // AllowedOrigins set its Origin must be on the list too. Without the header, -// Origin is matched against AllowedOrigins when they are set. +// Origin is matched against AllowedOrigins when they are set. A non-nil empty +// AllowedOrigins is set, and allows no origin. func (a *Adapter) checkOrigin(r *http.Request) (result, bool) { - if a.opts.CookieSameSite != http.SameSiteNoneMode { - return result{}, false - } - switch r.Method { - case http.MethodGet, http.MethodHead, http.MethodOptions: + if !isUnsafeMethod(r.Method) { return result{}, false } blocked := errorResult(http.StatusForbidden, "cross_site_request_blocked") origin := r.Header.Get("Origin") + listed := a.opts.AllowedOrigins != nil if site := r.Header.Get("Sec-Fetch-Site"); site != "" { switch { case strings.EqualFold(site, "cross-site"): return blocked, true - case strings.EqualFold(site, "same-site") && len(a.opts.AllowedOrigins) > 0: + case strings.EqualFold(site, "same-site") && listed: return blocked, !a.isAllowedOrigin(origin) default: return result{}, false @@ -42,12 +41,31 @@ func (a *Adapter) checkOrigin(r *http.Request) (result, bool) { if origin == "null" { return blocked, true } - if len(a.opts.AllowedOrigins) == 0 { + if !listed { return result{}, false } return blocked, !a.isAllowedOrigin(origin) } +// checkContentType refuses an unsafe request that neither declares a JSON body +// nor carries the transport header. Either one makes a cross-origin browser +// request send a CORS preflight, which a form cannot, so this holds even when +// the body is empty and there is nothing for the body check to read. +func (a *Adapter) checkContentType(r *http.Request) (result, bool) { + if !isUnsafeMethod(r.Method) || isExplicitJSON(r.Header.Get("Content-Type")) || r.Header.Get(transportHeader) != "" { + return result{}, false + } + return errorResult(http.StatusUnsupportedMediaType, "unsupported_media_type"), true +} + +func isUnsafeMethod(method string) bool { + switch method { + case http.MethodGet, http.MethodHead, http.MethodOptions: + return false + } + return true +} + // isAllowedOrigin matches origin against AllowedOrigins. An origin that does not // normalize, or is missing, matches nothing, including an allowlist entry that // does not normalize either. diff --git a/origin_test.go b/origin_test.go index c87087d..2730b1f 100644 --- a/origin_test.go +++ b/origin_test.go @@ -45,8 +45,8 @@ func TestTheOriginGuard(t *testing.T) { {"same-origin, unlisted origin", listed, "POST", []func(*http.Request){site("same-origin"), origin("https://auth-adapter.example.com")}, false}, {"cross-site, listed origin", listed, "POST", []func(*http.Request){site("cross-site"), origin("https://app.example.com")}, true}, - {"lax cookies, cross-site", lax, "POST", []func(*http.Request){site("cross-site")}, false}, - {"lax cookies, same-site other origin", lax, "POST", []func(*http.Request){site("same-site"), origin("https://evil.example.com")}, false}, + {"lax cookies, cross-site", lax, "POST", []func(*http.Request){site("cross-site")}, true}, + {"lax cookies, same-site other origin", lax, "POST", []func(*http.Request){site("same-site"), origin("https://evil.example.com")}, true}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -187,7 +187,7 @@ func TestABodyLessUnsafeRequestPassesWithAJSONContentTypeOrTheTransportHeader(t for _, setup := range [][]func(*http.Request){ {withHeader("Content-Type", "application/json")}, - {withHeader(transportHeader, "cookie")}, + {withoutHeader("Content-Type"), withHeader(transportHeader, "cookie")}, } { if r := do(t, a, "POST", "/recovery-codes", "", append(setup, cookie)...); r.status != 200 { t.Fatalf("status %d body %v", r.status, r.body) @@ -195,6 +195,20 @@ func TestABodyLessUnsafeRequestPassesWithAJSONContentTypeOrTheTransportHeader(t } } +func TestABodyLessLogoutWithoutAContentTypeIsRefused(t *testing.T) { + api := newFakeAPI(t) + api.on("DELETE", "/logout", respond(200, map[string]any{"message": "ok"})) + a := newTestAdapter(t, api) + cookie := withCookie(signedCookie(t, "seamless-access", map[string]any{"sub": "u1", "token": apiToken(t, api, "u1", "access")})) + + if r := do(t, a, "DELETE", "/logout", "", cookie, withoutHeader("Content-Type")); r.status != http.StatusUnsupportedMediaType { + t.Fatalf("status %d body %v", r.status, r.body) + } + if n := len(api.callsTo("DELETE", "/logout")); n != 0 { + t.Fatalf("reached the auth API %d times", n) + } +} + // An empty allowlist is configured, and allows no origin. func TestAnEmptyAllowedOriginsIsConfiguredAndStrict(t *testing.T) { api := newFakeAPI(t) diff --git a/verifier.go b/verifier.go index 9bfabb5..efc0488 100644 --- a/verifier.go +++ b/verifier.go @@ -29,7 +29,8 @@ type VerifierOptions struct { // Audience is this API's resource identifier. A token's aud must name it. Audience string // JWKSURI fetches the provider's keys from here instead of discovering them - // from Issuer's /.well-known/openid-configuration. + // from Issuer's /.well-known/openid-configuration. It must be https when + // Issuer is. JWKSURI string // Confirm accepts a token bound to a key by a cnf claim (RFC 7800), for // example a DPoP proof's jkt. It is called only for a token that otherwise @@ -107,6 +108,11 @@ func NewVerifier(opts VerifierOptions) (*Verifier, error) { if opts.JWKSURI != "" && !isHTTPURL(opts.JWKSURI) { return nil, errors.New("seamlessauth: JWKSURI must be an absolute http or https URL") } + // Keys fetched over plain http can be swapped in transit, which would let + // anyone on the path mint tokens for an https issuer. + if u, _ := url.Parse(opts.JWKSURI); opts.JWKSURI != "" && issuer.Scheme == "https" && u.Scheme != "https" { + return nil, errors.New("seamlessauth: JWKSURI must be https when Issuer is https") + } if opts.Logf == nil { opts.Logf = log.Printf } diff --git a/verifier_test.go b/verifier_test.go index bc7daf1..5794b57 100644 --- a/verifier_test.go +++ b/verifier_test.go @@ -80,17 +80,19 @@ func loadVectors(t *testing.T) verifyOnlyVectors { func vectorVerifier(t *testing.T, vectors verifyOnlyVectors, confirm func(*http.Request, map[string]any) bool) *Verifier { t.Helper() - jwks := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // The vectors' issuer is https, so the keys must be too. + jwks := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write(vectors.JWKS) })) t.Cleanup(jwks.Close) v, err := NewVerifier(VerifierOptions{ - Issuer: vectors.Issuer, - Audience: vectors.Audience, - JWKSURI: jwks.URL, - Confirm: confirm, - Logf: func(string, ...any) {}, + Issuer: vectors.Issuer, + Audience: vectors.Audience, + JWKSURI: jwks.URL, + Confirm: confirm, + HTTPClient: jwks.Client(), + Logf: func(string, ...any) {}, }) if err != nil { t.Fatal(err) @@ -476,12 +478,6 @@ func TestVerifierClaimTypes(t *testing.T) { } } -func TestVerifierAcceptsAnHTTPJWKSURIUnderAnHTTPSIssuer(t *testing.T) { - if _, err := NewVerifier(VerifierOptions{Issuer: "https://login.example", Audience: "a", JWKSURI: "http://keys.internal/jwks"}); err != nil { - t.Fatal(err) - } -} - func TestVerifierReadsHeaderMembersByExactName(t *testing.T) { p := newFakeProvider(t, false) v := p.verifier(t) From 5447b2e5985dcf065fa0c861f913b83ec0524352 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 21:30:44 -0500 Subject: [PATCH 04/12] fix: let body-less refresh and logout through the content type gate The current client sends POST /refresh and DELETE /logout (and /logout/all) with no body and neither a JSON Content-Type nor the transport header, so refusing them would sign users out when the adapter upgrades ahead of the client. They are exempt from the content type gate for now and keep the origin check. Every other unsafe auth route still answers 415 without one. Refs #23 --- README.md | 12 ++++++----- adapter.go | 8 +++---- origin.go | 18 +++++++++++++++- origin_test.go | 57 ++++++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 81 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 980f613..21c1be9 100644 --- a/README.md +++ b/README.md @@ -148,11 +148,13 @@ The auth routes forward a request body to the auth API as JSON, so a body must b cross-site form can post a `text/plain` body shaped like JSON with no CORS preflight, and read as JSON it would be a sign-in the user never made. -A state-changing request (anything but `GET`, `HEAD` and `OPTIONS`) must declare a JSON -`Content-Type` or carry the `X-Seamless-Auth-Transport` header (any value, for example `cookie`) -even when it has no body, as `POST /auth/refresh` and `DELETE /auth/logout` usually do. Either one -makes a cross-origin browser request send a CORS preflight, which a form cannot. Without one it -answers 415 `unsupported_media_type`. +A state-changing request (anything but `GET`, `HEAD` and `OPTIONS`) to the auth routes must +declare a JSON `Content-Type` or carry the `X-Seamless-Auth-Transport` header (any value, for +example `cookie`) even when it has no body. Either one makes a cross-origin browser request send a +CORS preflight, which a form cannot. Without one it answers 415 `unsupported_media_type`. +`POST /auth/refresh`, `DELETE /auth/logout` and `DELETE /auth/logout/all` are exempt for now, +because current clients send them with no body and neither header; they still get the +cross-site check. ## Verify-only mode (resource servers) diff --git a/adapter.go b/adapter.go index f8c222f..31a8eb8 100644 --- a/adapter.go +++ b/adapter.go @@ -74,19 +74,19 @@ func (a *Adapter) serve(w http.ResponseWriter, r *http.Request) { a.write(w, res, false) return } - if res, refused := a.checkContentType(r); refused { + path := r.URL.EscapedPath() + if res, refused := a.checkContentType(r, path); refused { a.write(w, res, false) return } bearer := strings.EqualFold(r.Header.Get(transportHeader), "bearer") - path := r.URL.EscapedPath() var res result switch { - case r.Method == http.MethodPost && strings.EqualFold(path, "/refresh"): + case isRefreshRoute(r.Method, path): res = a.refreshRoute(r, bearer) - case r.Method == http.MethodDelete && (strings.EqualFold(path, "/logout") || strings.EqualFold(path, "/logout/all")): + case isLogoutRoute(r.Method, path): res = a.logoutRoute(r, bearer, path) default: match, ok := a.manifest.get(r.Context()).match(r.Method, path) diff --git a/origin.go b/origin.go index 553a6c5..91258da 100644 --- a/origin.go +++ b/origin.go @@ -51,13 +51,29 @@ func (a *Adapter) checkOrigin(r *http.Request) (result, bool) { // nor carries the transport header. Either one makes a cross-origin browser // request send a CORS preflight, which a form cannot, so this holds even when // the body is empty and there is nothing for the body check to read. -func (a *Adapter) checkContentType(r *http.Request) (result, bool) { +// +// POST /refresh and DELETE /logout (and /logout/all) are exempt for now: the +// client sends them with no body and neither header, and refusing them would +// sign users out when the adapter upgrades ahead of the client. They keep the +// origin check. +func (a *Adapter) checkContentType(r *http.Request, path string) (result, bool) { if !isUnsafeMethod(r.Method) || isExplicitJSON(r.Header.Get("Content-Type")) || r.Header.Get(transportHeader) != "" { return result{}, false } + if isRefreshRoute(r.Method, path) || isLogoutRoute(r.Method, path) { + return result{}, false + } return errorResult(http.StatusUnsupportedMediaType, "unsupported_media_type"), true } +func isRefreshRoute(method, path string) bool { + return method == http.MethodPost && strings.EqualFold(path, "/refresh") +} + +func isLogoutRoute(method, path string) bool { + return method == http.MethodDelete && (strings.EqualFold(path, "/logout") || strings.EqualFold(path, "/logout/all")) +} + func isUnsafeMethod(method string) bool { switch method { case http.MethodGet, http.MethodHead, http.MethodOptions: diff --git a/origin_test.go b/origin_test.go index 2730b1f..5142098 100644 --- a/origin_test.go +++ b/origin_test.go @@ -195,16 +195,65 @@ func TestABodyLessUnsafeRequestPassesWithAJSONContentTypeOrTheTransportHeader(t } } -func TestABodyLessLogoutWithoutAContentTypeIsRefused(t *testing.T) { +// The client sends refresh and logout with no body and neither header, so they +// pass the content type gate, but still get the origin check. +func TestBodyLessRefreshAndLogoutSkipTheContentTypeGateButNotTheOriginCheck(t *testing.T) { api := newFakeAPI(t) api.on("DELETE", "/logout", respond(200, map[string]any{"message": "ok"})) + api.on("DELETE", "/logout/all", respond(200, map[string]any{"message": "ok"})) + refreshes := refreshingAPI(t, api) + a := newTestAdapter(t, api, func(o *Options) { + o.CookieSameSite = http.SameSiteLaxMode + o.AllowedOrigins = []string{"https://app.example.com"} + }) + access := withCookie(signedCookie(t, "seamless-access", map[string]any{"sub": "u1", "token": apiToken(t, api, "u1", "access")})) + refresh := withCookie(signedCookie(t, "seamless-refresh", map[string]any{"sub": "u1", "refreshToken": "r1"})) + noType := withoutHeader("Content-Type") + + for _, path := range []string{"/logout", "/logout/all"} { + if r := do(t, a, "DELETE", path, "", access, noType); r.status != http.StatusNoContent { + t.Fatalf("%s: status %d body %v", path, r.status, r.body) + } + } + if r := do(t, a, "POST", "/refresh", "", refresh, noType); r.status != http.StatusOK { + t.Fatalf("refresh: status %d body %v", r.status, r.body) + } + + for _, origin := range [][]func(*http.Request){ + {withHeader("Sec-Fetch-Site", "cross-site")}, + {withHeader("Sec-Fetch-Site", "same-site"), withHeader("Origin", "https://evil.example.com")}, + } { + for _, req := range []struct{ method, path string }{{"DELETE", "/logout"}, {"DELETE", "/logout/all"}, {"POST", "/refresh"}} { + r := do(t, a, req.method, req.path, "", append([]func(*http.Request){access, refresh, noType}, origin...)...) + if r.status != http.StatusForbidden || r.body["error"] != "cross_site_request_blocked" { + t.Fatalf("%s %s: status %d body %v", req.method, req.path, r.status, r.body) + } + } + } + if n := len(api.callsTo("DELETE", "/logout")); n != 1 { + t.Fatalf("logout reached the auth API %d times", n) + } + if n := refreshes.Load(); n != 1 { + t.Fatalf("refresh reached the auth API %d times", n) + } +} + +func TestABodyLessFormPostToAnotherRouteIsStillRefused(t *testing.T) { + api := newFakeAPI(t) + api.on("POST", "/recovery-codes", respond(200, map[string]any{"ok": true})) a := newTestAdapter(t, api) cookie := withCookie(signedCookie(t, "seamless-access", map[string]any{"sub": "u1", "token": apiToken(t, api, "u1", "access")})) - if r := do(t, a, "DELETE", "/logout", "", cookie, withoutHeader("Content-Type")); r.status != http.StatusUnsupportedMediaType { - t.Fatalf("status %d body %v", r.status, r.body) + for _, setup := range [][]func(*http.Request){ + {withoutHeader("Content-Type")}, + {withHeader("Content-Type", "application/x-www-form-urlencoded")}, + } { + r := do(t, a, "POST", "/recovery-codes", "", append(setup, cookie)...) + if r.status != http.StatusUnsupportedMediaType || r.body["error"] != "unsupported_media_type" { + t.Fatalf("status %d body %v", r.status, r.body) + } } - if n := len(api.callsTo("DELETE", "/logout")); n != 0 { + if n := len(api.callsTo("POST", "/recovery-codes")); n != 0 { t.Fatalf("reached the auth API %d times", n) } } From 6bf02d71d3129a284d88f2e8c66cc257eb703b0b Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 21:30:54 -0500 Subject: [PATCH 05/12] fix: sync the bundled manifest with seamless-auth-api 0.22.0 Adds the admin application link routes (GET /admin/applications/{id}/links, PUT and DELETE /admin/applications/{id}/links/{linkedId}). --- manifest.json | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 05919e8..372a6ad 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "apiVersion": "0.21.1", + "apiVersion": "0.22.0", "session": { "subject": "sub", "token": "token", @@ -15,6 +15,17 @@ { "method": "GET", "path": "/admin/applications/{id}", "credential": "access" }, { "method": "PATCH", "path": "/admin/applications/{id}", "credential": "access" }, { "method": "PUT", "path": "/admin/applications/{id}/keys", "credential": "access" }, + { "method": "GET", "path": "/admin/applications/{id}/links", "credential": "access" }, + { + "method": "DELETE", + "path": "/admin/applications/{id}/links/{linkedId}", + "credential": "access" + }, + { + "method": "PUT", + "path": "/admin/applications/{id}/links/{linkedId}", + "credential": "access" + }, { "method": "GET", "path": "/admin/applications/{id}/resources", "credential": "access" }, { "method": "PUT", "path": "/admin/applications/{id}/resources", "credential": "access" }, { "method": "POST", "path": "/admin/applications/{id}/secret", "credential": "access" }, From 28a1bef805e085b27d2811cc4f0003b8bdadbf41 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 21:48:21 -0500 Subject: [PATCH 06/12] test: reproduce a null Origin passing with a non cross-site Sec-Fetch-Site Refs #23 --- origin_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/origin_test.go b/origin_test.go index 5142098..ac56d88 100644 --- a/origin_test.go +++ b/origin_test.go @@ -273,3 +273,21 @@ func TestAnEmptyAllowedOriginsIsConfiguredAndStrict(t *testing.T) { } } } + +// An opaque or sandboxed origin is cross-site whatever else the request says. +func TestANullOriginIsRefusedBeforeSecFetchSite(t *testing.T) { + api := newFakeAPI(t) + a := newTestAdapter(t, api) + for _, site := range []string{"same-site", "same-origin", "none", ""} { + for _, origin := range []string{"null", " NULL "} { + setup := []func(*http.Request){withHeader("Origin", origin)} + if site != "" { + setup = append(setup, withHeader("Sec-Fetch-Site", site)) + } + r := do(t, a, "POST", "/magic-link", `{}`, setup...) + if r.status != http.StatusForbidden || r.body["error"] != "cross_site_request_blocked" { + t.Errorf("Sec-Fetch-Site %s, Origin %q: status %d body %v", site, origin, r.status, r.body) + } + } + } +} From a4d4708ab63196d6ca6db526555f630591c948de Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 21:48:47 -0500 Subject: [PATCH 07/12] fix: refuse a null Origin before checking Sec-Fetch-Site An Origin of null (trimmed, any case) is an opaque or sandboxed context and is cross-site whatever Sec-Fetch-Site says. It passed with same-site (no allowlist), same-origin or none, and with surrounding space or another case when the header was absent. It is now refused first on every unsafe request. Refs #23 --- origin.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/origin.go b/origin.go index 91258da..229385b 100644 --- a/origin.go +++ b/origin.go @@ -11,7 +11,8 @@ import ( // its form POST. // // Sec-Fetch-Site is the primary signal, since page script cannot forge it: -// cross-site is refused. same-site also covers a sibling subdomain, so with +// cross-site is refused, as is an Origin of null before anything else. +// same-site also covers a sibling subdomain, so with // AllowedOrigins set its Origin must be on the list too. Without the header, // Origin is matched against AllowedOrigins when they are set. A non-nil empty // AllowedOrigins is set, and allows no origin. @@ -24,6 +25,11 @@ func (a *Adapter) checkOrigin(r *http.Request) (result, bool) { origin := r.Header.Get("Origin") listed := a.opts.AllowedOrigins != nil + // An opaque or sandboxed origin is cross-site, whatever Sec-Fetch-Site says. + if strings.EqualFold(strings.TrimSpace(origin), "null") { + return blocked, true + } + if site := r.Header.Get("Sec-Fetch-Site"); site != "" { switch { case strings.EqualFold(site, "cross-site"): @@ -38,9 +44,6 @@ func (a *Adapter) checkOrigin(r *http.Request) (result, bool) { if origin == "" { return result{}, false } - if origin == "null" { - return blocked, true - } if !listed { return result{}, false } From 89b933a7ae20b776f36500aa9ca99c3a1445c814 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 22:08:04 -0500 Subject: [PATCH 08/12] test: reproduce a cancelled caller or failed transport ending a refresh The caller that leads a shared rotation cancels it for every waiter when its request goes away, and a transport error or 5xx during a refresh clears the session cookies as if the auth API had refused the token. Refs #22 --- refresh_test.go | 132 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/refresh_test.go b/refresh_test.go index aa8dd02..405b5c7 100644 --- a/refresh_test.go +++ b/refresh_test.go @@ -1,9 +1,11 @@ package seamlessauth import ( + "context" "net/http" "sync/atomic" "testing" + "time" ) // rotatingOnceAPI rotates a refresh token the first time it is presented and @@ -65,3 +67,133 @@ func TestACompletedBearerRotationIsNotReplayedToALaterCaller(t *testing.T) { t.Fatalf("replay: status %d body %v", r.status, r.body) } } + +// heldRotationAPI holds the first refresh open until release is closed, so a +// test can act while the rotation is in flight, then rotates it. +func heldRotationAPI(t *testing.T, api *fakeAPI) (started, release chan struct{}, refreshes *atomic.Int32) { + started, release = make(chan struct{}), make(chan struct{}) + refreshes = &atomic.Int32{} + api.on("POST", "/refresh", func(w http.ResponseWriter, _ *http.Request) { + if refreshes.Add(1) > 1 { + writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "refresh_token_reused"}) + return + } + close(started) + <-release + writeJSON(w, http.StatusOK, session(t, api, "u1")) + }) + return started, release, refreshes +} + +func withContext(ctx context.Context) func(*http.Request) { + return func(r *http.Request) { *r = *r.WithContext(ctx) } +} + +// The caller that starts a rotation can go away (a closed tab) while others wait +// on it. The rotation must finish for them, or the token the auth API already +// spent leaves every tab signed out. +func TestALeadingCallerGoingAwayDoesNotCancelTheSharedRotation(t *testing.T) { + cases := map[string]func(t *testing.T, a *Adapter, setup ...func(*http.Request)) reply{ + "cookie": func(t *testing.T, a *Adapter, setup ...func(*http.Request)) reply { + refresh := signedCookie(t, "seamless-refresh", map[string]any{"sub": "u1", "refreshToken": "r1"}) + return do(t, a, "GET", "/users/me", "", append(setup, withCookie(refresh))...) + }, + "bearer": func(t *testing.T, a *Adapter, setup ...func(*http.Request)) reply { + return do(t, a, "POST", "/refresh", "", append(setup, + withHeader(transportHeader, "bearer"), withHeader("Authorization", "Bearer r1"))...) + }, + } + for name, call := range cases { + t.Run(name, func(t *testing.T) { + api := newFakeAPI(t) + started, release, refreshes := heldRotationAPI(t, api) + api.on("GET", "/users/me", respond(200, map[string]any{"user": map[string]any{"id": "u1"}})) + a := newTestAdapter(t, api) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + leader := make(chan reply, 1) + go func() { leader <- call(t, a, withContext(ctx)) }() + <-started + + waiter := make(chan reply, 1) + go func() { waiter <- call(t, a) }() + // Let the waiter join the flight before the leader goes away. + time.Sleep(100 * time.Millisecond) + cancel() + time.Sleep(100 * time.Millisecond) + close(release) + + <-leader + r := <-waiter + if r.status != 200 { + t.Fatalf("waiter: status %d body %v cookies %v (%d refresh calls)", r.status, r.body, r.cookies, refreshes.Load()) + } + if refreshes.Load() != 1 { + t.Fatalf("refreshed %d times", refreshes.Load()) + } + }) + } +} + +// Only the auth API answering 401 says a refresh token is dead. A network +// failure or an API error leaves it as it was (or rotated with the answer lost), +// so the cookies are kept for the next attempt. +func TestARefreshThatNeverGotAnAnswerKeepsTheCookies(t *testing.T) { + failures := map[string]http.HandlerFunc{ + "transport error": func(w http.ResponseWriter, _ *http.Request) { + conn, _, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + conn.Close() + }, + "server error": respond(http.StatusServiceUnavailable, map[string]any{"error": "unavailable"}), + } + routes := map[string]func(t *testing.T, a *Adapter) reply{ + "silent refresh": func(t *testing.T, a *Adapter) reply { + return do(t, a, "GET", "/users/me", "", + withCookie(signedCookie(t, "seamless-refresh", map[string]any{"sub": "u1", "refreshToken": "r1"}))) + }, + "POST /refresh": func(t *testing.T, a *Adapter) reply { + return do(t, a, "POST", "/refresh", "", + withCookie(signedCookie(t, "seamless-refresh", map[string]any{"sub": "u1", "refreshToken": "r1"}))) + }, + } + for failure, handler := range failures { + for route, call := range routes { + t.Run(failure+"/"+route, func(t *testing.T) { + api := newFakeAPI(t) + api.on("POST", "/refresh", handler) + r := call(t, newTestAdapter(t, api)) + + if r.status == 200 || r.status == 401 { + t.Fatalf("status %d body %v", r.status, r.body) + } + for _, name := range []string{"seamless-access", "seamless-refresh", "seamless-ephemeral"} { + if c := r.cookies[name]; c != nil { + t.Fatalf("%s written: %+v (status %d body %v)", name, c, r.status, r.body) + } + } + }) + } + } +} + +func TestARefreshTheAuthAPIRefusesClearsTheCookies(t *testing.T) { + api := newFakeAPI(t) + api.on("POST", "/refresh", respond(401, map[string]any{"error": "invalid_refresh_token"})) + + r := do(t, newTestAdapter(t, api), "POST", "/refresh", "", + withCookie(signedCookie(t, "seamless-refresh", map[string]any{"sub": "u1", "refreshToken": "r1"}))) + + if r.status != 401 { + t.Fatalf("status %d body %v", r.status, r.body) + } + for _, name := range []string{"seamless-access", "seamless-refresh"} { + if c := r.cookies[name]; c == nil || c.MaxAge >= 0 { + t.Fatalf("%s not cleared: %+v", name, c) + } + } +} From e8ba2f0875802e9f6df04a2b0801072624e2c35f Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 2 Nov 2026 22:09:04 -0500 Subject: [PATCH 09/12] fix: run refresh rotation detached and keep cookies on transport errors A refresh rotation ran on the leading request's context, so a closed tab cancelled it for every caller sharing it, though the auth API may already have spent the old token. It now runs detached from the request, under the upstream timeout. The JWKS fetch is detached too: a first fetch failing on a cancelled caller held off verification for every waiter. Only the auth API answering 401 (or an unreadable refresh cookie) now clears the session cookies. A transport error answers 502 upstream_unavailable and another API answer is passed through, both with the cookies kept, on a silent refresh and on POST /refresh. Refs #22 --- jwks.go | 4 +++- refresh.go | 59 +++++++++++++++++++++++++++++++++++++++++++++++------- routes.go | 5 +++-- 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/jwks.go b/jwks.go index 44073b9..9361300 100644 --- a/jwks.go +++ b/jwks.go @@ -90,8 +90,10 @@ type jwk struct { Alg json.RawMessage `json:"alg"` } +// fetch runs detached from the request that asks for it: a failed fetch holds +// off every caller for a while, so one caller going away must not fail it. func (c *jwksCache) fetch(ctx context.Context) error { - ctx, cancel := context.WithTimeout(ctx, jwksFetchTimeout) + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), jwksFetchTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.url, nil) diff --git a/refresh.go b/refresh.go index 4dafde9..067ddd3 100644 --- a/refresh.go +++ b/refresh.go @@ -1,13 +1,52 @@ package seamlessauth import ( + "context" "errors" "fmt" "net/http" "sync" ) -var errNoRefreshCookie = errors.New("no refresh cookie") +var ( + errNoRefreshCookie = errors.New("no refresh cookie") + errInvalidRefreshCookie = errors.New("invalid refresh cookie") +) + +// refreshAnswered is a refresh the auth API answered without a session. +type refreshAnswered struct { + status int + body any + isJSON bool +} + +func (e *refreshAnswered) Error() string { return fmt.Sprintf("refresh answered %d", e.status) } + +// refreshRejected reports whether a refresh failed for good: the auth API +// refused the token, or the refresh cookie cannot be read. After a network +// failure or any other answer the token may still be good, or rotated with the +// answer lost, so the session is kept for the next attempt. +func refreshRejected(err error) bool { + var answered *refreshAnswered + if errors.As(err, &answered) { + return answered.status == http.StatusUnauthorized + } + return errors.Is(err, errInvalidRefreshCookie) +} + +// refreshFailure answers a refresh that produced no session: rejected when it +// failed for good, else the auth API's answer or upstream_unavailable, with the +// cookies left alone. +func refreshFailure(err error, rejected result) result { + if refreshRejected(err) { + return rejected + } + var answered *refreshAnswered + if errors.As(err, &answered) { + return failure(answered.status, answered.body, answered.isJSON) + } + return errorResult(http.StatusBadGateway, "upstream_unavailable") +} type refreshOutcome struct { session map[string]any @@ -81,7 +120,7 @@ func (a *Adapter) refreshSession(r *http.Request, key string, held func() (sub, outcome := a.refreshes.do(key, func() refreshOutcome { sub, refreshToken := held() if refreshToken == "" { - return refreshOutcome{err: errors.New("invalid refresh cookie")} + return refreshOutcome{err: errInvalidRefreshCookie} } service, err := a.tokens.sign(sub, map[string]any{"refreshToken": refreshToken}) @@ -92,7 +131,7 @@ func (a *Adapter) refreshSession(r *http.Request, key string, held func() (sub, }) if outcome.session == nil { if outcome.err == nil { - outcome.err = fmt.Errorf("refresh answered %d", outcome.status) + outcome.err = &refreshAnswered{status: outcome.status, body: outcome.body, isJSON: outcome.isJSON} } return nil, nil, outcome.err } @@ -108,8 +147,14 @@ func (a *Adapter) refreshSession(r *http.Request, key string, held func() (sub, return outcome.session, cookies, nil } +// rotate runs detached from the request that leads it: callers sharing the +// rotation wait on it, and once the auth API rotates the token the old one is +// spent, so the new session must not be lost to that caller going away. func (a *Adapter) rotate(r *http.Request, refreshToken, service string) refreshOutcome { - res, err := a.call(r.Context(), r, upstreamCall{ + ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), upstreamTimeout) + defer cancel() + + res, err := a.call(ctx, r, upstreamCall{ method: http.MethodPost, path: "/refresh", authorization: "Bearer " + refreshToken, @@ -157,9 +202,9 @@ func (a *Adapter) refreshRoute(r *http.Request, bearer bool) result { return errorResult(http.StatusUnauthorized, "refresh token required") } if err != nil { - res := errorResult(http.StatusUnauthorized, "invalid_refresh_token") - res.clear = []string{a.opts.AccessCookieName, a.opts.RefreshCookieName} - return res + rejected := errorResult(http.StatusUnauthorized, "invalid_refresh_token") + rejected.clear = []string{a.opts.AccessCookieName, a.opts.RefreshCookieName} + return refreshFailure(err, rejected) } return result{ diff --git a/routes.go b/routes.go index 2456992..86c9695 100644 --- a/routes.go +++ b/routes.go @@ -52,8 +52,9 @@ func (a *Adapter) credentialFor(r *http.Request, kind string, bearer bool) (cred return credential{authorization: "Bearer " + stringClaim(session, "token"), set: cookies}, nil } if !errors.Is(err, errNoRefreshCookie) { - res := errorResult(http.StatusUnauthorized, "Refresh failed") - res.clear = []string{a.opts.AccessCookieName, a.opts.RegistrationCookieName, a.opts.RefreshCookieName} + rejected := errorResult(http.StatusUnauthorized, "Refresh failed") + rejected.clear = []string{a.opts.AccessCookieName, a.opts.RegistrationCookieName, a.opts.RefreshCookieName} + res := refreshFailure(err, rejected) return credential{}, &res } } From b0962d7fbfef806f1dbf06bf82aca0d29defa14d Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 3 Nov 2026 10:56:49 -0500 Subject: [PATCH 10/12] ci: hold conformance to the content-type gate contract --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a661d5e..7918cfc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,9 @@ jobs: - name: Conformance env: SEAMLESS_API_DIR: ${{ github.workspace }}/seamless-auth-api + # This adapter ships the content-type gate, so hold it to the pending + # contract (seamless-cli verify/harness/lib/pendingContracts.ts). + SEAMLESS_VERIFY_CONTRACTS: content-type-gate run: node seamless-cli/dist/index.js verify --adapter-url=http://localhost:8080 - name: Reference app log if: always() From 8c5568998ba5ae8c1961586e75eeb5722167703e Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 3 Nov 2026 11:39:12 -0500 Subject: [PATCH 11/12] test: reproduce a cookie replay just after its rotation signing a tab out Several tabs can hold one refresh cookie. A tab presenting it a moment after another tab's rotation finished reaches the auth API, which treats it as reuse, so the tab is signed out. A bearer replay must still reach the auth API, and a failed rotation must never be shared. Refs #21 --- refresh_test.go | 109 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 98 insertions(+), 11 deletions(-) diff --git a/refresh_test.go b/refresh_test.go index 405b5c7..85caada 100644 --- a/refresh_test.go +++ b/refresh_test.go @@ -22,29 +22,116 @@ func rotatingOnceAPI(t *testing.T, api *fakeAPI) *atomic.Int32 { return &n } -// A refresh token replayed after its rotation finished must reach the auth API, -// so its reuse detection sees the replay, rather than be handed the new session. -func TestACompletedCookieRotationIsNotReplayedToALaterCaller(t *testing.T) { +func cookieClaims(t *testing.T, c *http.Cookie) map[string]any { + t.Helper() + if c == nil || c.MaxAge <= 0 { + t.Fatalf("cookie not set: %+v", c) + } + parsed, err := parseJWT(c.Value) + if err != nil { + t.Fatal(err) + } + return parsed.claims +} + +// Several tabs can hold one refresh cookie, and one finishing its rotation a +// moment before another presents the cookie must not sign the others out. A +// cookie replayed within the window gets the rotated session without a call. +func TestACookieReplayInsideTheWindowSharesTheCompletedRotation(t *testing.T) { api := newFakeAPI(t) refreshes := rotatingOnceAPI(t, api) api.on("GET", "/users/me", respond(200, map[string]any{"user": map[string]any{"id": "u1"}})) a := newTestAdapter(t, api) - stolen := signedCookie(t, "seamless-refresh", map[string]any{"sub": "u1", "refreshToken": "r1"}) + held := signedCookie(t, "seamless-refresh", map[string]any{"sub": "u1", "refreshToken": "r1"}) - if r := do(t, a, "GET", "/users/me", "", withCookie(stolen)); r.status != 200 { - t.Fatalf("first caller: status %d body %v", r.status, r.body) + first := do(t, a, "GET", "/users/me", "", withCookie(held)) + if first.status != 200 { + t.Fatalf("first caller: status %d body %v", first.status, first.body) + } + + r := do(t, a, "GET", "/users/me", "", withCookie(held)) + if refreshes.Load() != 1 { + t.Fatalf("the replay reached the auth API: %d refresh calls, status %d", refreshes.Load(), r.status) + } + if r.status != 200 { + t.Fatalf("replay: status %d body %v", r.status, r.body) + } + for _, name := range []string{"seamless-access", "seamless-refresh"} { + want, got := cookieClaims(t, first.cookies[name]), cookieClaims(t, r.cookies[name]) + for _, claim := range []string{"sub", "token", "refreshToken", "sessionId"} { + if want[claim] != got[claim] { + t.Fatalf("%s %s: first %v, replay %v", name, claim, want[claim], got[claim]) + } + } + } +} + +func TestACookieReplayInsideTheWindowSharesTheCompletedRotationOnPOSTRefresh(t *testing.T) { + api := newFakeAPI(t) + refreshes := rotatingOnceAPI(t, api) + a := newTestAdapter(t, api) + held := signedCookie(t, "seamless-refresh", map[string]any{"sub": "u1", "refreshToken": "r1"}) + + first := do(t, a, "POST", "/refresh", "", withCookie(held)) + r := do(t, a, "POST", "/refresh", "", withCookie(held)) + if first.status != 200 || r.status != 200 || refreshes.Load() != 1 { + t.Fatalf("first %d, replay %d body %v, %d refresh calls", first.status, r.status, r.body, refreshes.Load()) + } + if want, got := cookieClaims(t, first.cookies["seamless-refresh"]), cookieClaims(t, r.cookies["seamless-refresh"]); want["refreshToken"] != got["refreshToken"] { + t.Fatalf("refreshToken: first %v, replay %v", want["refreshToken"], got["refreshToken"]) + } +} + +// The completed window is for cookie transport only: the same refresh token +// presented as a bearer goes to the auth API, whose reuse detection sees it. +func TestABearerReplayInsideTheCookieWindowStillReachesTheAuthAPI(t *testing.T) { + api := newFakeAPI(t) + refreshes := rotatingOnceAPI(t, api) + api.on("GET", "/users/me", respond(200, map[string]any{"user": map[string]any{"id": "u1"}})) + a := newTestAdapter(t, api) + held := signedCookie(t, "seamless-refresh", map[string]any{"sub": "u1", "refreshToken": "r1"}) + + if r := do(t, a, "GET", "/users/me", "", withCookie(held)); r.status != 200 { + t.Fatalf("cookie caller: status %d body %v", r.status, r.body) } - r := do(t, a, "GET", "/users/me", "", withCookie(stolen)) + r := do(t, a, "POST", "/refresh", "", + withHeader(transportHeader, "bearer"), withHeader("Authorization", "Bearer r1")) if refreshes.Load() != 2 { - t.Fatalf("the replay did not reach the auth API: %d refresh calls, replay got status %d, new access cookie issued %v", - refreshes.Load(), r.status, r.cookies["seamless-access"] != nil && r.cookies["seamless-access"].MaxAge > 0) + t.Fatalf("the bearer replay did not reach the auth API: %d refresh calls, status %d body %v", + refreshes.Load(), r.status, r.body) + } + if r.status != 401 || r.body["error"] != "refresh_token_reused" { + t.Fatalf("bearer replay: status %d body %v", r.status, r.body) + } +} + +// A rotation that failed is never kept: the next caller gets a fresh attempt. +func TestAFailedCookieRotationIsNotKept(t *testing.T) { + api := newFakeAPI(t) + var n atomic.Int32 + api.on("POST", "/refresh", func(w http.ResponseWriter, _ *http.Request) { + if n.Add(1) == 1 { + writeJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "unavailable"}) + return + } + writeJSON(w, http.StatusOK, session(t, api, "u1")) + }) + api.on("GET", "/users/me", respond(200, map[string]any{"user": map[string]any{"id": "u1"}})) + a := newTestAdapter(t, api) + held := signedCookie(t, "seamless-refresh", map[string]any{"sub": "u1", "refreshToken": "r1"}) + + if r := do(t, a, "GET", "/users/me", "", withCookie(held)); r.status == 200 { + t.Fatalf("first caller: status %d", r.status) } - if r.status != 401 || r.cookies["seamless-access"] == nil || r.cookies["seamless-access"].MaxAge >= 0 { - t.Fatalf("replay: status %d cookies %v", r.status, r.cookies) + if r := do(t, a, "GET", "/users/me", "", withCookie(held)); r.status != 200 || n.Load() != 2 { + t.Fatalf("retry: status %d body %v, %d refresh calls", r.status, r.body, n.Load()) } } +// A bearer refresh token replayed after its rotation finished must reach the +// auth API, so its reuse detection sees the replay, rather than be handed the +// new session. func TestACompletedBearerRotationIsNotReplayedToALaterCaller(t *testing.T) { api := newFakeAPI(t) refreshes := rotatingOnceAPI(t, api) From eb9524fe851ae50ce2e46ca650a22c634355b655 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 3 Nov 2026 11:40:20 -0500 Subject: [PATCH 12/12] fix: keep a completed refresh for cookie transport for five seconds Tabs holding one refresh cookie shared a rotation only while it was in flight, so a tab presenting the cookie a moment after another tab's rotation finished reached the auth API as reuse and was signed out. A successful cookie-transport rotation is now kept for 5 seconds, keyed as the in-flight map keys it, and a cookie presenting the same old refresh token within that window gets the same session without calling the auth API. Bearer transport never uses the window: a bearer refresh token presented after its rotation finished still goes to the auth API. Failed rotations are never kept, and expired entries are pruned in expiry order on every refresh. Refs #21 --- AGENTS.md | 3 +- README.md | 5 +++ refresh.go | 65 ++++++++++++++++++++++++++++++----- refresh_test.go | 90 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 153 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fa4a617..ab61ebb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,8 @@ The module targets Go 1.22. Do not use standard library APIs newer than that (CI - `adapter.go`: `Adapter`, the request pipeline, the upstream call, writing results. - `routes.go`: manifest routes, credential resolution (with silent refresh), session verification, what a cookie-transport body may contain. -- `refresh.go`: refresh sharing (one rotation per refresh token while it is in flight, never after), `POST /refresh`, +- `refresh.go`: refresh sharing (one rotation per refresh token while it is in flight; for cookie transport only, a + successful rotation is also kept for 5 seconds for a tab replaying the same cookie, never for bearer), `POST /refresh`, logout. - `manifest.go`: parsing, matching, the live and embedded manifest. - `guard.go`: `RequireAuth`, `Authenticate`. diff --git a/README.md b/README.md index 21c1be9..1f35d37 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,11 @@ both are present. It reads the `Cookie` header itself, so it works on any route. refresh: the auth routes refresh a browser session silently, and a bearer client calls `POST /auth/refresh` itself. `Authenticate` does the same check without answering. +Tabs holding one refresh cookie share its rotation: requests that arrive while it runs wait +for it, and a request presenting the same refresh cookie up to 5 seconds after it succeeded gets +the same session without another call. A bearer refresh token is shared only while its rotation +runs; presented again afterwards it goes to the auth API, which treats it as reuse. + A session cookie is signed with `CookieSecret`, but the auth API access token inside it is verified too, the same way as a bearer token: RS256 against the API's JWKS, with the expected issuer and audience, in date, of type `access`, and for the cookie's subject. `User.Roles` (and diff --git a/refresh.go b/refresh.go index 067ddd3..565e7eb 100644 --- a/refresh.go +++ b/refresh.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "sync" + "time" ) var ( @@ -61,27 +62,55 @@ type refreshFlight struct { outcome refreshOutcome } +// refreshKeepWindow is how long a completed cookie-transport rotation is kept +// for a tab that presents the same refresh cookie a moment later. +const refreshKeepWindow = 5 * time.Second + +type keptRefresh struct { + outcome refreshOutcome + expires time.Time +} + +type keptKey struct { + key string + expires time.Time +} + type refresher struct { mu sync.Mutex flights map[string]*refreshFlight + kept map[string]keptRefresh + // order holds kept keys by expiry, so pruning stops at the first live one. + order []keptKey + now func() time.Time } func newRefresher() *refresher { - return &refresher{flights: map[string]*refreshFlight{}} + return &refresher{flights: map[string]*refreshFlight{}, kept: map[string]keptRefresh{}, now: time.Now} } // do runs fn once per key at a time, so parallel requests from several tabs with // one expired session share a rotation instead of tripping the auth API's reuse -// detection. Only callers that arrive while fn runs share its outcome: a refresh -// token presented after its rotation finished goes to the auth API, which treats -// it as reuse. -func (f *refresher) do(key string, fn func() refreshOutcome) refreshOutcome { +// detection. Callers that arrive while fn runs share its outcome. With keep (cookie +// transport only), a successful outcome is also kept for refreshKeepWindow, for a +// tab presenting the same refresh cookie just after the rotation finished. Without +// it, a refresh token presented after its rotation finished goes to the auth API, +// which treats it as reuse. +func (f *refresher) do(key string, keep bool, fn func() refreshOutcome) refreshOutcome { f.mu.Lock() + now := f.now() + f.prune(now) if flight, ok := f.flights[key]; ok { f.mu.Unlock() <-flight.done return flight.outcome } + if keep { + if kept, ok := f.kept[key]; ok { + f.mu.Unlock() + return kept.outcome + } + } flight := &refreshFlight{done: make(chan struct{})} f.flights[key] = flight f.mu.Unlock() @@ -91,12 +120,30 @@ func (f *refresher) do(key string, fn func() refreshOutcome) refreshOutcome { f.mu.Lock() flight.outcome = outcome delete(f.flights, key) + if keep && outcome.session != nil { + expires := f.now().Add(refreshKeepWindow) + f.kept[key] = keptRefresh{outcome: outcome, expires: expires} + f.order = append(f.order, keptKey{key: key, expires: expires}) + } f.mu.Unlock() close(flight.done) return outcome } +// prune drops kept rotations that have expired. f.mu must be held. +func (f *refresher) prune(now time.Time) { + i := 0 + for ; i < len(f.order) && !now.Before(f.order[i].expires); i++ { + if kept, ok := f.kept[f.order[i].key]; ok && kept.expires.Equal(f.order[i].expires) { + delete(f.kept, f.order[i].key) + } + } + if i > 0 { + f.order = append(f.order[:0], f.order[i:]...) + } +} + // silentRefresh rotates the session held in the refresh cookie. func (a *Adapter) silentRefresh(r *http.Request) (map[string]any, []cookieWrite, error) { c, err := r.Cookie(a.opts.RefreshCookieName) @@ -113,11 +160,11 @@ func (a *Adapter) silentRefresh(r *http.Request) (map[string]any, []cookieWrite, }) } -// refreshSession rotates a refresh token once per key, as refresher.do shares it, -// and builds the cookies for the session it returns. held is read only by the +// refreshSession rotates a cookie-transport refresh token once per key, as +// refresher.do shares and keeps it, and builds the cookies for the session it returns. held is read only by the // caller that makes the call. func (a *Adapter) refreshSession(r *http.Request, key string, held func() (sub, refreshToken string)) (map[string]any, []cookieWrite, error) { - outcome := a.refreshes.do(key, func() refreshOutcome { + outcome := a.refreshes.do(key, true, func() refreshOutcome { sub, refreshToken := held() if refreshToken == "" { return refreshOutcome{err: errInvalidRefreshCookie} @@ -182,7 +229,7 @@ func (a *Adapter) refreshRoute(r *http.Request, bearer bool) result { return errorResult(http.StatusUnauthorized, "refresh token required") } - outcome := a.refreshes.do("bearer:"+token, func() refreshOutcome { + outcome := a.refreshes.do("bearer:"+token, false, func() refreshOutcome { return a.rotate(r, token, a.tokens.proxyAuthorization()) }) if outcome.session == nil { diff --git a/refresh_test.go b/refresh_test.go index 85caada..1736bc1 100644 --- a/refresh_test.go +++ b/refresh_test.go @@ -2,7 +2,9 @@ package seamlessauth import ( "context" + "fmt" "net/http" + "sync" "sync/atomic" "testing" "time" @@ -82,6 +84,94 @@ func TestACookieReplayInsideTheWindowSharesTheCompletedRotationOnPOSTRefresh(t * } } +// testClock is a clock a test moves by hand. +type testClock struct { + mu sync.Mutex + t time.Time +} + +func (c *testClock) now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.t +} + +func (c *testClock) advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.t = c.t.Add(d) +} + +func withTestClock(a *Adapter) *testClock { + clock := &testClock{t: time.Unix(1_700_000_000, 0)} + a.refreshes.now = clock.now + return clock +} + +// Once the window has passed, a replayed refresh cookie reaches the auth API, +// so its reuse detection sees the replay. +func TestACookieReplayAfterTheWindowReachesTheAuthAPI(t *testing.T) { + api := newFakeAPI(t) + refreshes := rotatingOnceAPI(t, api) + api.on("GET", "/users/me", respond(200, map[string]any{"user": map[string]any{"id": "u1"}})) + a := newTestAdapter(t, api) + clock := withTestClock(a) + stolen := signedCookie(t, "seamless-refresh", map[string]any{"sub": "u1", "refreshToken": "r1"}) + + if r := do(t, a, "GET", "/users/me", "", withCookie(stolen)); r.status != 200 { + t.Fatalf("first caller: status %d body %v", r.status, r.body) + } + clock.advance(refreshKeepWindow - time.Millisecond) + if r := do(t, a, "GET", "/users/me", "", withCookie(stolen)); r.status != 200 || refreshes.Load() != 1 { + t.Fatalf("inside the window: status %d, %d refresh calls", r.status, refreshes.Load()) + } + clock.advance(time.Millisecond) + + r := do(t, a, "GET", "/users/me", "", withCookie(stolen)) + if refreshes.Load() != 2 { + t.Fatalf("the replay did not reach the auth API: %d refresh calls, replay got status %d, new access cookie issued %v", + refreshes.Load(), r.status, r.cookies["seamless-access"] != nil && r.cookies["seamless-access"].MaxAge > 0) + } + if r.status != 401 || r.cookies["seamless-access"] == nil || r.cookies["seamless-access"].MaxAge >= 0 { + t.Fatalf("replay: status %d cookies %v", r.status, r.cookies) + } +} + +// Kept rotations are dropped once they expire, so the map holds at most the +// rotations of the last window. +func TestKeptRotationsArePruned(t *testing.T) { + api := newFakeAPI(t) + api.on("POST", "/refresh", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, session(t, api, "u1")) + }) + a := newTestAdapter(t, api) + clock := withTestClock(a) + refresh := func(token string) { + r := do(t, a, "POST", "/refresh", "", + withCookie(signedCookie(t, "seamless-refresh", map[string]any{"sub": "u1", "refreshToken": token}))) + if r.status != 200 { + t.Fatalf("status %d body %v", r.status, r.body) + } + } + kept := func() (int, int) { + a.refreshes.mu.Lock() + defer a.refreshes.mu.Unlock() + return len(a.refreshes.kept), len(a.refreshes.order) + } + + for i := 0; i < 50; i++ { + refresh(fmt.Sprintf("r%d", i)) + } + if n, o := kept(); n != 50 || o != 50 { + t.Fatalf("kept %d, order %d", n, o) + } + clock.advance(refreshKeepWindow) + refresh("fresh") + if n, o := kept(); n != 1 || o != 1 { + t.Fatalf("after the window: kept %d, order %d", n, o) + } +} + // The completed window is for cookie transport only: the same refresh token // presented as a bearer goes to the auth API, whose reuse detection sees it. func TestABearerReplayInsideTheCookieWindowStillReachesTheAuthAPI(t *testing.T) {