From 9777d2959daf04078294b69ac2c0eed74aecf01f Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 1 Nov 2026 16:03:14 -0500 Subject: [PATCH 1/2] fix: clear a custom pre-auth cookie on logout Logout cleared the access, registration and refresh cookies only, so a PreAuthCookieName that differs from RegistrationCookieName was left behind. Refs #25 --- adapter_test.go | 14 ++++++++++++++ refresh.go | 3 +++ 2 files changed, 17 insertions(+) diff --git a/adapter_test.go b/adapter_test.go index 7026274..0cdf172 100644 --- a/adapter_test.go +++ b/adapter_test.go @@ -192,6 +192,20 @@ func TestLogoutAnswers204AndClearsEvenWhenTheAPIRefuses(t *testing.T) { } } +func TestLogoutClearsACustomPreAuthCookie(t *testing.T) { + api := newFakeAPI(t) + api.on("DELETE", "/logout", respond(200, map[string]any{"message": "ok"})) + a := newTestAdapter(t, api, func(o *Options) { o.PreAuthCookieName = "app-pre-auth" }) + access := signedCookie(t, "seamless-access", map[string]any{"sub": "u1", "token": "t"}) + + r := do(t, a, "DELETE", "/logout", "", withCookie(access)) + for _, name := range []string{"seamless-access", "seamless-ephemeral", "seamless-refresh", "app-pre-auth"} { + if c := r.cookies[name]; c == nil || c.MaxAge >= 0 { + t.Fatalf("%s not cleared", name) + } + } +} + func TestDeliveryRoutesHandTheMessageToTheApplication(t *testing.T) { api := newFakeAPI(t) api.on("POST", "/otp/generate-login-email-otp", respond(200, map[string]any{ diff --git a/refresh.go b/refresh.go index a58e9a6..e94c163 100644 --- a/refresh.go +++ b/refresh.go @@ -188,6 +188,9 @@ func (a *Adapter) refreshRoute(r *http.Request, bearer bool) result { // refuses, so a browser is never left holding a session it asked to end. func (a *Adapter) logoutRoute(r *http.Request, bearer bool, path string) result { clears := []string{a.opts.AccessCookieName, a.opts.RegistrationCookieName, a.opts.RefreshCookieName} + if a.opts.PreAuthCookieName != a.opts.RegistrationCookieName { + clears = append(clears, a.opts.PreAuthCookieName) + } cred, rejected := a.credentialFor(r, "access", bearer) if rejected != nil { From 81f1972c4974b0d3af781de4e30f0ba215926853 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 1 Nov 2026 16:04:10 -0500 Subject: [PATCH 2/2] feat: add a first-party role guard and the organization on User Adapter.RequireRole guards a route on the session's roles, matched as RoleGrantsAccess does, with the roles of the verified access token. It answers 401 unauthenticated without a session, 403 enrollment_required for a recovery session, 403 cross_site_request_blocked for a cross-site cookie request and 403 insufficient_role when no listed role is granted, and panics when given no role. User.OrganizationID is the org_id claim of the verified access token, for cookie and bearer sessions alike. Closes #25 --- README.md | 25 +++++++++- guard.go | 42 +++++++++++++++++ guard_test.go | 127 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f976b84..5ddee87 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,27 @@ issuer and audience, in date, of type `access`, and for the cookie's subject. `U leaked `CookieSecret` cannot grant itself roles. If the JWKS cannot be fetched and no key set is cached, the session is refused with 401. +`User.OrganizationID` is the active organization: the `org_id` claim of that verified access +token, for a cookie or a bearer session alike, and empty when the session has none. The copy of +the organization in the cookie is never read. + +### Roles + +`RequireRole` is `RequireAuth` that also checks the session's roles. It admits the request when +any of the roles it lists is granted by any of the user's roles, matched as `RoleGrantsAccess` +does (`billing:*` grants `billing:read`, `billing:write` grants `billing:read`): + +```go +mux.Handle("GET /api/reports", auth.RequireRole("admin", "reports:read")(reports)) +``` + +The roles come from the verified access token, never from the cookie. It answers the way +`RequireAuth` does for a missing session (401 `unauthenticated`), a recovery session (403 +`enrollment_required`, unless `AllowRecoverySessions` is set) and a cross-site cookie request +(403 `cross_site_request_blocked`), and 403 `insufficient_role` when no listed role is granted. +No body names the user's roles or the required ones. `RequireRole` panics when given no role or +an empty one. + ### Recovery sessions Redeeming an account recovery code issues a recovery session: its access token carries @@ -217,10 +238,10 @@ g.GET("/orders", wrap(rs.RequireToken), wrap(rs.RequireScope("orders:read")), li | `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` | | `AccessCookieName`, `RefreshCookieName` | `seamless-access`, `seamless-refresh` | Session cookie names | -| `RegistrationCookieName`, `PreAuthCookieName` | `seamless-ephemeral` | Sign-in flow 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 | | `DeliverAccountNotices` | `false` | `Deliver` also sends account notices. See below | -| `AllowRecoverySessions` | `false` | `RequireAuth` admits a recovery session | +| `AllowRecoverySessions` | `false` | `RequireAuth` and `RequireRole` admit a recovery session | | `ResolveClientIP` | the connecting peer | The end user's address. See below | | `DisableManifestFetch` | `false` | Use only the manifest bundled with this version | | `HTTPClient`, `Logf` | defaults | Outbound client and logger. The client never follows a redirect: a supplied one is copied with a `CheckRedirect` that refuses them, and gets a 15 second timeout if it has none | diff --git a/guard.go b/guard.go index 3e2e1a5..c696e6e 100644 --- a/guard.go +++ b/guard.go @@ -12,6 +12,9 @@ type User struct { Roles []string Email string Phone string + // OrganizationID is the active organization, the org_id claim of the verified + // access token. Empty when the session has none. + OrganizationID string // Token is the auth API access token, for calling the API on the user's behalf. Token string // Recovery is a session a recovery code issued, before the user enrolled a @@ -71,6 +74,7 @@ func (a *Adapter) session(r *http.Request) (*User, bool, error) { } user := userFrom(claims, token) user.Roles = rolesFrom(inner) + user.OrganizationID = stringClaim(inner, "org_id") user.Recovery = inner["recovery"] == true return user, true, nil } @@ -84,6 +88,7 @@ func (a *Adapter) session(r *http.Request) (*User, bool, error) { return nil, false, errUnauthenticated } user := userFrom(claims, token) + user.OrganizationID = stringClaim(claims, "org_id") user.Recovery = claims["recovery"] == true return user, false, nil } @@ -130,6 +135,39 @@ func rolesFrom(claims map[string]any) []string { // for a cross-site state change, as on the auth routes, and a recovery session // with 403 enrollment_required unless AllowRecoverySessions is set. func (a *Adapter) RequireAuth(next http.Handler) http.Handler { + return a.requireSession(nil, next) +} + +// RequireRole is RequireAuth that also answers 403 insufficient_role unless the +// session's roles grant at least one of roles, matched as RoleGrantsAccess does. +// The roles are those of the verified access token. It panics when given no +// role, or an empty one. +func (a *Adapter) RequireRole(roles ...string) func(http.Handler) http.Handler { + if len(roles) == 0 { + panic("seamlessauth: RequireRole needs at least one role") + } + for _, role := range roles { + if trimJS(role) == "" { + panic("seamlessauth: RequireRole was given an empty role") + } + } + roles = append([]string(nil), roles...) + allowed := func(user *User) bool { + for _, required := range roles { + for _, granted := range user.Roles { + if RoleGrantsAccess(granted, required) { + return true + } + } + } + return false + } + return func(next http.Handler) http.Handler { + return a.requireSession(allowed, next) + } +} + +func (a *Adapter) requireSession(allowed func(*User) bool, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, byCookie, err := a.authenticate(r) if errors.Is(err, ErrEnrollmentRequired) { @@ -148,6 +186,10 @@ func (a *Adapter) RequireAuth(next http.Handler) http.Handler { return } } + if allowed != nil && !allowed(user) { + writeJSON(w, http.StatusForbidden, map[string]any{"error": "insufficient_role"}) + return + } next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userKey{}, user))) }) } diff --git a/guard_test.go b/guard_test.go index 6956048..d87c222 100644 --- a/guard_test.go +++ b/guard_test.go @@ -208,3 +208,130 @@ func TestACookieSessionFailsClosedWhenTheKeySetIsUnreachable(t *testing.T) { t.Fatalf("status %d body %s", rec.Code, rec.Body.String()) } } + +func TestRequireRole(t *testing.T) { + api := newFakeAPI(t) + a := newTestAdapter(t, api) + token := func(claims map[string]any) string { + body := map[string]any{"sub": "u1", "typ": "access", "iss": api.server.URL, "aud": api.server.URL} + for k, v := range claims { + body[k] = v + } + return signRS256(t, body) + } + bearer := func(claims map[string]any) func(*http.Request) { + return withHeader("Authorization", "Bearer "+token(claims)) + } + h := a.RequireRole("admin", "billing:read")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, _ := UserFromContext(r.Context()) + writeJSON(w, 200, map[string]any{"id": user.ID}) + })) + post := func(r *http.Request) { r.Method = http.MethodPost } + + cases := []struct { + name string + setup []func(*http.Request) + status int + body string + }{ + {"granted", []func(*http.Request){bearer(map[string]any{"roles": []string{"admin"}})}, 200, ""}, + {"granted by another listed role", []func(*http.Request){bearer(map[string]any{"roles": []string{"billing:write"}})}, 200, ""}, + {"granted by a wildcard", []func(*http.Request){bearer(map[string]any{"roles": []string{"billing:*"}})}, 200, ""}, + {"granted on a cookie session", []func(*http.Request){withCookie(signedCookie(t, "seamless-access", map[string]any{ + "sub": "u1", "token": token(map[string]any{"roles": []string{"admin"}}), + }))}, 200, ""}, + {"refused", []func(*http.Request){bearer(map[string]any{"roles": []string{"user"}})}, 403, "insufficient_role"}, + {"no roles", []func(*http.Request){bearer(nil)}, 403, "insufficient_role"}, + // The cookie's own roles are signed only by the cookie secret. + {"cookie roles are not trusted", []func(*http.Request){withCookie(signedCookie(t, "seamless-access", map[string]any{ + "sub": "u1", "token": token(map[string]any{"roles": []string{"user"}}), "roles": []string{"admin"}, + }))}, 403, "insufficient_role"}, + {"no session", nil, 401, "unauthenticated"}, + {"ephemeral token", []func(*http.Request){bearer(map[string]any{"typ": "ephemeral", "roles": []string{"admin"}})}, 401, "unauthenticated"}, + {"recovery session", []func(*http.Request){bearer(map[string]any{"roles": []string{"admin"}, "recovery": true})}, 403, "enrollment_required"}, + {"cookie session, cross-site", []func(*http.Request){post, withHeader("Sec-Fetch-Site", "cross-site"), withCookie(signedCookie(t, "seamless-access", map[string]any{ + "sub": "u1", "token": token(map[string]any{"roles": []string{"admin"}}), + }))}, 403, "cross_site_request_blocked"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := call(h, c.setup...) + if rec.Code != c.status { + t.Fatalf("status %d, want %d: %s", rec.Code, c.status, rec.Body.String()) + } + if c.body != "" { + var got map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &got) + if len(got) != 1 || got["error"] != c.body { + t.Fatalf("body %s, want only error %q", rec.Body.String(), c.body) + } + } + }) + } +} + +func TestRequireRoleAdmitsARecoverySessionWhenAllowed(t *testing.T) { + api := newFakeAPI(t) + a := newTestAdapter(t, api, func(o *Options) { o.AllowRecoverySessions = true }) + token := signRS256(t, map[string]any{"sub": "u1", "typ": "access", "roles": []string{"admin"}, "recovery": true, "iss": api.server.URL, "aud": api.server.URL}) + h := a.RequireRole("admin")(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, 200, map[string]any{}) + })) + if rec := call(h, withHeader("Authorization", "Bearer "+token)); rec.Code != 200 { + t.Fatalf("status %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestRequireRolePanicsWithoutARole(t *testing.T) { + a := newTestAdapter(t, newFakeAPI(t)) + for name, roles := range map[string][]string{"none": nil, "empty": {""}, "blank": {"admin", " "}} { + t.Run(name, func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + a.RequireRole(roles...) + }) + } +} + +func TestTheOrganizationComesFromTheVerifiedToken(t *testing.T) { + api := newFakeAPI(t) + a := newTestAdapter(t, api) + token := func(claims map[string]any) string { + body := map[string]any{"sub": "u1", "typ": "access", "iss": api.server.URL, "aud": api.server.URL} + for k, v := range claims { + body[k] = v + } + return signRS256(t, body) + } + + cases := []struct { + name string + setup func(*http.Request) + org string + }{ + {"cookie", withCookie(signedCookie(t, "seamless-access", map[string]any{ + "sub": "u1", "token": token(map[string]any{"org_id": "org-1"}), "organizationId": "org-cookie", + })), "org-1"}, + {"cookie without org_id", withCookie(signedCookie(t, "seamless-access", map[string]any{ + "sub": "u1", "token": token(nil), "organizationId": "org-cookie", + })), ""}, + {"bearer", withHeader("Authorization", "Bearer "+token(map[string]any{"org_id": "org-1"})), "org-1"}, + {"bearer without org_id", withHeader("Authorization", "Bearer "+token(nil)), ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/me", nil) + c.setup(req) + user, err := a.Authenticate(req) + if err != nil { + t.Fatal(err) + } + if user.OrganizationID != c.org { + t.Fatalf("organization %q, want %q", user.OrganizationID, c.org) + } + }) + } +}