From c39e4c338a237e5a129aadddd1efb0a59b17f706 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 09:07:07 +0100 Subject: [PATCH 01/43] feat(auth): isolate provider session keys --- authsession.go | 18 ++++++++-------- config.go | 2 ++ namespace_test.go | 53 +++++++++++++++++++++++++++++++++++++++++++++++ oauth2.go | 30 +++++++++++++-------------- server.go | 17 +++++++++++++++ 5 files changed, 96 insertions(+), 24 deletions(-) create mode 100644 namespace_test.go diff --git a/authsession.go b/authsession.go index 4ac39f3..ed9fcc9 100644 --- a/authsession.go +++ b/authsession.go @@ -83,7 +83,7 @@ func (srv *Server) oauth2Context(ctx context.Context) (authctx context.Context) func (srv *Server) sessionAuthStatus(sess *jaws.Session, now func() time.Time) (current, present bool) { if srv != nil && sess != nil { - expiryValue := sess.Get(oauth2IDTokenExpiryKey) + expiryValue := sess.Get(srv.sessionKey(oauth2IDTokenExpiryKey)) authValue := sess.Get(srv.SessionKey) present = authValue != nil || expiryValue != nil if authValue != nil && now != nil { @@ -127,7 +127,7 @@ func (srv *Server) storeSessionAuthClaims(sess *jaws.Session, claims map[string] } sess.Set(srv.SessionKey, claims) sess.Set(srv.SessionTokenKey, tokenSource) - sess.Set(oauth2IDTokenExpiryKey, expiry) + sess.Set(srv.sessionKey(oauth2IDTokenExpiryKey), expiry) sess.Set(srv.SessionEmailKey, email) sess.Set(srv.SessionEmailVerifiedKey, verified) delay, replaced := srv.scheduleSessionAuthTimerLocked(sess, expiry) @@ -335,11 +335,11 @@ func (srv *Server) handleSessionAuthTimer(sess *jaws.Session, entry *authTimerSt } } -func clearSessionOAuthFlow(sess *jaws.Session) { - sess.Set(oauth2StateKey, nil) - sess.Set(oauth2PKCEVerifierKey, nil) - sess.Set(oauth2NonceKey, nil) - sess.Set(oauth2ReferrerKey, nil) +func (srv *Server) clearSessionOAuthFlow(sess *jaws.Session) { + sess.Set(srv.sessionKey(oauth2StateKey), nil) + sess.Set(srv.sessionKey(oauth2PKCEVerifierKey), nil) + sess.Set(srv.sessionKey(oauth2NonceKey), nil) + sess.Set(srv.sessionKey(oauth2ReferrerKey), nil) } // Logout clears all authentication state for sess. @@ -364,10 +364,10 @@ func (srv *Server) clearSessionAuth(sess *jaws.Session, hr *http.Request, entry cleared = srv.stopSessionAuthTimerLocked(sess, entry) var requests []*jaws.Request if cleared { - clearSessionOAuthFlow(sess) + srv.clearSessionOAuthFlow(sess) sess.Set(srv.SessionKey, nil) sess.Set(srv.SessionTokenKey, nil) - sess.Set(oauth2IDTokenExpiryKey, nil) + sess.Set(srv.sessionKey(oauth2IDTokenExpiryKey), nil) sess.Set(srv.SessionEmailKey, nil) sess.Set(srv.SessionEmailVerifiedKey, nil) requests = sess.Requests() diff --git a/config.go b/config.go index 6d5f4d9..5295e82 100644 --- a/config.go +++ b/config.go @@ -21,6 +21,8 @@ import ( type Config struct { RedirectURL string // required. e.g. "https://application.example.com/oauth2/callback" Issuer string // required. e.g. "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0" + // Namespace isolates this provider's session values when multiple Servers share one JaWS instance. + Namespace string AuthURL string // optional override for discovered authorization_endpoint TokenURL string // optional override for discovered token_endpoint UserInfoURL string // optional override for discovered userinfo_endpoint diff --git a/namespace_test.go b/namespace_test.go new file mode 100644 index 0000000..df7bd6e --- /dev/null +++ b/namespace_test.go @@ -0,0 +1,53 @@ +package jawsauth + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/linkdata/jaws" + "golang.org/x/oauth2" +) + +func TestOAuthFlowNamespacesShareSessionWithoutSharingState(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + newServer := func(namespace string) *Server { + srv, err := New(jw, &Config{Namespace: namespace}, nil) + if err != nil { + t.Fatal(err) + } + srv.oauth2cfg = &oauth2.Config{ClientID: namespace, RedirectURL: "https://app.example/auth/callback", + Endpoint: oauth2.Endpoint{AuthURL: "https://provider.example/authorize", TokenURL: "https://provider.example/token"}} + return srv + } + github, gitlab := newServer("github"), newServer("gitlab") + if github.SessionKey == gitlab.SessionKey || github.SessionTokenKey == gitlab.SessionTokenKey || + github.SessionEmailKey == gitlab.SessionEmailKey || github.SessionEmailVerifiedKey == gitlab.SessionEmailVerifiedKey { + t.Fatal("provider auth keys overlap") + } + r := httptest.NewRequest(http.MethodGet, "https://app.example/auth/login", nil) + for _, srv := range []*Server{github, gitlab} { + response := httptest.NewRecorder() + srv.HandleLogin(response, r) + if response.Code != http.StatusFound { + t.Fatalf("%s start: %d", srv.namespace, response.Code) + } + } + sess := jw.GetSession(r) + if sess == nil { + t.Fatal("shared session missing") + } + first, _ := sess.Get(github.sessionKey(oauth2StateKey)).(string) + second, _ := sess.Get(gitlab.sessionKey(oauth2StateKey)).(string) + if first == "" || second == "" || first == second { + t.Fatalf("provider states overlap: first=%q second=%q", first, second) + } + github.clearSessionOAuthFlow(sess) + if sess.Get(github.sessionKey(oauth2StateKey)) != nil || sess.Get(gitlab.sessionKey(oauth2StateKey)) != second { + t.Fatal("clearing one provider's flow cleared the other") + } +} diff --git a/oauth2.go b/oauth2.go index 7865ef2..3bd658f 100644 --- a/oauth2.go +++ b/oauth2.go @@ -100,14 +100,14 @@ func (srv *Server) HandleLogin(hw http.ResponseWriter, hr *http.Request) { } authOptions := append([]oauth2.AuthCodeOption{}, srv.Options...) state := randomHexString() - sess.Set(oauth2StateKey, state) + sess.Set(srv.sessionKey(oauth2StateKey), state) nonce := randomHexString() - sess.Set(oauth2NonceKey, nonce) + sess.Set(srv.sessionKey(oauth2NonceKey), nonce) authOptions = append(authOptions, oidc.Nonce(nonce)) verifier := oauth2.GenerateVerifier() - sess.Set(oauth2PKCEVerifierKey, verifier) + sess.Set(srv.sessionKey(oauth2PKCEVerifierKey), verifier) authOptions = append(authOptions, oauth2.S256ChallengeOption(verifier)) - sess.Set(oauth2ReferrerKey, location) + sess.Set(srv.sessionKey(oauth2ReferrerKey), location) location = oauth2cfg.AuthCodeURL(state, authOptions...) } hw.Header().Set("Location", location) @@ -306,12 +306,12 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) statusCode = http.StatusBadRequest if sess != nil { gotState := hr.FormValue("state") // #nosec G120 - wantState, _ := sess.Get(oauth2StateKey).(string) - verifier, _ := sess.Get(oauth2PKCEVerifierKey).(string) - wantNonce, _ := sess.Get(oauth2NonceKey).(string) - sess.Set(oauth2StateKey, nil) - sess.Set(oauth2PKCEVerifierKey, nil) - sess.Set(oauth2NonceKey, nil) + wantState, _ := sess.Get(srv.sessionKey(oauth2StateKey)).(string) + verifier, _ := sess.Get(srv.sessionKey(oauth2PKCEVerifierKey)).(string) + wantNonce, _ := sess.Get(srv.sessionKey(oauth2NonceKey)).(string) + sess.Set(srv.sessionKey(oauth2StateKey), nil) + sess.Set(srv.sessionKey(oauth2PKCEVerifierKey), nil) + sess.Set(srv.sessionKey(oauth2NonceKey), nil) err = ErrOAuth2MissingState if wantState != "" { err = ErrOAuth2WrongState @@ -343,7 +343,7 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) tokenSource := oauth2Config.TokenSource(srv.oauth2Context(context.Background()), token) // Finish network work before starting the new session's idle lifetime. srv.addUserInfoClaims(authctx, claims, tokenSource) - referrer := sess.Get(oauth2ReferrerKey) + referrer := sess.Get(srv.sessionKey(oauth2ReferrerKey)) requests := sess.Requests() _, hadAuth := srv.sessionAuthStatus(sess, nil) err = ErrOAuth2MissingSession @@ -358,7 +358,7 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) srv.LogoutEvent(sess, hr) } sess = rotated - sess.Set(oauth2ReferrerKey, referrer) + sess.Set(srv.sessionKey(oauth2ReferrerKey), referrer) err = srv.storeSessionAuthClaims(sess, claims, tokenSource, idToken.Expiry, nil) } else { hw.Header().Set("Retry-After", "60") @@ -366,10 +366,10 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) if err == nil { sessValue = claims sessEmail, _ = sess.Get(srv.SessionEmailKey).(string) - if s, ok := sess.Get(oauth2ReferrerKey).(string); ok { + if s, ok := sess.Get(srv.sessionKey(oauth2ReferrerKey)).(string); ok { location = sanitizeRedirectTarget(hr.Host, s) } - sess.Set(oauth2ReferrerKey, nil) + sess.Set(srv.sessionKey(oauth2ReferrerKey), nil) hw.Header().Set("Location", location) statusCode = http.StatusFound } @@ -388,7 +388,7 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) } if sess != nil { if sessValue == nil { - clearSessionOAuthFlow(sess) + srv.clearSessionOAuthFlow(sess) srv.Jaws.Dirty(sess) } else if srv.LoginEvent != nil { srv.LoginEvent(sess, hr) diff --git a/server.go b/server.go index 874d8ea..728f6a3 100644 --- a/server.go +++ b/server.go @@ -104,6 +104,7 @@ type Server struct { handle403 http.Handler // handler for 403 Forbidden authTimers map[uint64]*authTimerState authTimerAfterFunc authTimerAfterFunc + namespace string } // NewDebug behaves like New but can override the scheme and host of cfg.RedirectURL. @@ -133,6 +134,15 @@ func NewDebug(jw *jaws.Jaws, cfg *Config, handleFn HandleFunc, overrideUrl strin authTimers: make(map[uint64]*authTimerState), authTimerAfterFunc: realAuthTimerAfterFunc, } // #nosec G101 + if cfg != nil { + srv.namespace = cfg.Namespace + if cfg.Namespace != "" { + srv.SessionKey = srv.sessionKey(srv.SessionKey) + srv.SessionTokenKey = srv.sessionKey(srv.SessionTokenKey) + srv.SessionEmailKey = srv.sessionKey(srv.SessionEmailKey) + srv.SessionEmailVerifiedKey = srv.sessionKey(srv.SessionEmailVerifiedKey) + } + } if cfg != nil && handleFn != nil && cfg.RedirectURL != "" { if srv.oauth2cfg, srv.userinfoUrl, srv.idTokenVerifier, err = cfg.buildContext(context.Background(), overrideUrl); err == nil { srv.httpClient = cfg.HTTPClient @@ -164,6 +174,13 @@ func (srv *Server) makeAuth(rq *jaws.Request) jaws.Auth { return &JawsAuth{server: srv, sess: srv.Jaws.GetSession(rq.Initial())} } +func (srv *Server) sessionKey(key string) string { + if srv.namespace != "" { + return key + "." + srv.namespace + } + return key +} + func (srv *Server) handlePath(p string, handleFn HandleFunc, h http.Handler) { if _, ok := srv.HandledPaths[p]; !ok { srv.HandledPaths[p] = struct{}{} From db7080cfb00ed2dda4d0c386b9cb143bdc3262b5 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 09:44:44 +0100 Subject: [PATCH 02/43] feat(auth): support OAuth profile linking and bounded flows Use provider-scoped JaWS session keys, lossless profile IDs, and a pre-rotation link callback. Extend only pending OAuth sessions through the provider visit and bound profile auth lifetime. --- README.md | 22 +- authsession.go | 11 +- config.go | 64 +++++- doc.go | 12 +- errconfig.go | 3 + erroauth2callback_test.go | 3 + go.mod | 3 +- go.sum | 6 +- jawsauth.go | 2 +- oauth2.go | 337 ++++++++++++++++++++-------- oauth2_test.go | 37 +-- oauth_flow_grace_test.go | 221 ++++++++++++++++++ profile_link_test.go | 459 ++++++++++++++++++++++++++++++++++++++ server.go | 49 ++-- sessionlifecycle_test.go | 2 + 15 files changed, 1081 insertions(+), 150 deletions(-) create mode 100644 oauth_flow_grace_test.go create mode 100644 profile_link_test.go diff --git a/README.md b/README.md index ed16c57..07c7d7b 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,29 @@ # jawsauth -OIDC-verified authentication for [JaWS](https://github.com/linkdata/jaws) sessions. +Verified OIDC or OAuth2 profile authentication for [JaWS](https://github.com/linkdata/jaws) sessions. -- Requires an OIDC-compliant provider. -- Uses OIDC discovery from the configured issuer. -- Verifies `id_token` and stores identity claims in session data. -- Adds PKCE (S256) and OIDC nonce verification to the authorization-code flow. -- Automatically refreshes the `id_token` in the background before it expires. +- OIDC mode discovers the issuer, verifies `id_token` and nonce, and refreshes before expiry. +- Profile mode accepts explicit HTTPS endpoints and a `Config.ProfileSubject` verifier for providers without ID tokens. Its authenticated profile response must contain a stable subject; email and names are not identity proof. JSON numbers arrive as `json.Number` so large numeric IDs remain exact. +- Both modes use authorization-code state and PKCE (S256). Pending flows expire after ten minutes; jawsauth extends only that JaWS session's grace while the user visits the provider. +- Profile authentication expires after 24 hours or the provider token's earlier expiry; it does not attempt OIDC refresh. - Supports admin-only handlers: `Wrap`/`Handler` for any authenticated user, `WrapAdmin`/`HandlerAdmin` gated by `SetAdmins`. Protected routes create JaWS sessions for unauthenticated visitors. Set `Jaws.MaxSessions` and `Jaws.MaxSessionsPerIP` to bound session counts. When a session cannot be created, login returns 503 with `Retry-After: 60`. Post-login return targets longer than 8192 bytes fall back to `/`. +Login callback token/profile calls and startup discovery have a 15-second deadline; +profile responses are capped at 32 KiB. + +For multiple providers on one JaWS instance, give each `Config` a distinct +`Namespace` and callback path. `New` sets `Jaws.MakeAuth` for its own Server, so +install a composite `MakeAuth` after creating all provider Servers if pages need +to recognize more than one provider. `HandleLink` is an opt-in POST handler: +authorize the existing session before calling it, then set `Server.LinkEvent` to +recheck the original identity and accept the newly verified subject. A successful +link keeps the original session; ordinary login still rotates it. Link callbacks +run only while the original JaWS session and one-use flow state remain valid. Admin allowlists match parsed addresses with ASCII case ignored; non-ASCII characters are matched exactly. Session email values use the same case rules. diff --git a/authsession.go b/authsession.go index ed9fcc9..36cfd09 100644 --- a/authsession.go +++ b/authsession.go @@ -232,6 +232,9 @@ func (srv *Server) refreshSessionAuth(ctx context.Context, sess *jaws.Session, m // Caller holds srv.mu across the auth writes and timer replacement. func (srv *Server) scheduleSessionAuthTimerLocked(sess *jaws.Session, expiry time.Time) (delay time.Duration, replaced bool) { delay = max(time.Until(expiry.Add(-authRefreshSkew)), 0) + if srv.profileSubject != nil { + delay = max(time.Until(expiry), 0) + } entry := &authTimerState{expiry: expiry} if srv.authTimers == nil { srv.authTimers = make(map[uint64]*authTimerState) @@ -273,6 +276,10 @@ func (srv *Server) stopSessionAuthTimerLocked(sess *jaws.Session, entry *authTim func (srv *Server) handleSessionAuthTimer(sess *jaws.Session, entry *authTimerState) { if srv.sessionAuthTimerCurrent(sess, entry) { + if srv.profileSubject != nil { + srv.clearSessionAuth(sess, nil, entry) + return + } if !sessionAlive(sess) { srv.clearSessionAuth(sess, nil, entry) return @@ -337,6 +344,8 @@ func (srv *Server) handleSessionAuthTimer(sess *jaws.Session, entry *authTimerSt func (srv *Server) clearSessionOAuthFlow(sess *jaws.Session) { sess.Set(srv.sessionKey(oauth2StateKey), nil) + sess.Set(srv.sessionKey(oauth2FlowExpiryKey), nil) + sess.Set(srv.sessionKey(oauth2LinkKey), nil) sess.Set(srv.sessionKey(oauth2PKCEVerifierKey), nil) sess.Set(srv.sessionKey(oauth2NonceKey), nil) sess.Set(srv.sessionKey(oauth2ReferrerKey), nil) @@ -344,7 +353,7 @@ func (srv *Server) clearSessionOAuthFlow(sess *jaws.Session) { // Logout clears all authentication state for sess. // -// It stops the auth-refresh timer, clears the OIDC claims, token source, email, expiry +// It stops the auth timer, clears verified claims, token source, email, expiry // and any in-flight OAuth flow keys, then reloads its pages with [jaws.Request.Reload], // calls [Server.LogoutEvent] (if set), and marks the session dirty. It returns false for a // nil receiver or nil session, and true otherwise. The hr argument may be nil. diff --git a/config.go b/config.go index 5295e82..da75283 100644 --- a/config.go +++ b/config.go @@ -15,26 +15,30 @@ import ( // Config holds the OIDC/OAuth2 settings used by New and NewDebug to construct a Server. // -// RedirectURL, Issuer and ClientID are required; AuthURL, TokenURL and UserInfoURL -// override values otherwise obtained via OIDC discovery, and the remaining fields -// are optional. +// RedirectURL, Issuer and ClientID are required. OIDC mode discovers endpoints; +// URL fields may override them. Profile mode requires explicit AuthURL, TokenURL, +// UserInfoURL and ProfileSubject. type Config struct { RedirectURL string // required. e.g. "https://application.example.com/oauth2/callback" Issuer string // required. e.g. "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0" // Namespace isolates this provider's session values when multiple Servers share one JaWS instance. Namespace string - AuthURL string // optional override for discovered authorization_endpoint - TokenURL string // optional override for discovered token_endpoint - UserInfoURL string // optional override for discovered userinfo_endpoint - // AllowInsecureIssuer permits "http://" Issuer URLs and should only be used for tests/dev. + AuthURL string // OIDC override; required in profile mode + TokenURL string // OIDC override; required in profile mode + UserInfoURL string // OIDC override; required in profile mode + // AllowInsecureIssuer permits HTTP issuer/profile URLs and is only for tests/dev. AllowInsecureIssuer bool // HTTPClient is used for OIDC discovery at startup and, unless a per-request // oauth2.HTTPClient is supplied via the request context, as the default client // for token exchange/refresh and UserInfo requests. HTTPClient *http.Client - Scopes []string // optional additional scopes, "openid" and "email" are always ensured + Scopes []string // optional; OIDC adds "openid" and "email", profile mode uses only these scopes ClientID string ClientSecret string + // ProfileSubject enables OAuth2 profile authentication without an OIDC ID token. + // AuthURL, TokenURL and UserInfoURL are required in this mode. It must return + // a stable, non-empty provider subject from the authenticated profile response. + ProfileSubject func(map[string]any) (string, error) } func requireLen(k string, n int) (err error) { @@ -79,9 +83,9 @@ func validateUrl(k, u, defaultURL string, optional bool) (value string, err erro // Validate checks whether cfg contains usable OIDC/OAuth2 settings. // // RedirectURL, Issuer and ClientID must be present. URL fields must be absolute -// and include a host; AuthURL, TokenURL and UserInfoURL are optional and -// validated only when set. Issuer must use https unless AllowInsecureIssuer is -// true. Returned validation failures match [ErrConfig]. +// and include a host. OIDC endpoint overrides are optional; profile endpoints +// are required and must use HTTPS. Issuer must use HTTPS unless +// AllowInsecureIssuer is true. Returned failures match [ErrConfig]. func (cfg *Config) Validate() (err error) { if _, err = validateUrl("RedirectURL", cfg.RedirectURL, "", false); err == nil { if _, err = validateUrl("Issuer", cfg.Issuer, "", false); err == nil { @@ -94,6 +98,22 @@ func (cfg *Config) Validate() (err error) { } } if err == nil { + if cfg.ProfileSubject != nil { + for _, field := range []struct{ name, value string }{ + {"AuthURL", cfg.AuthURL}, {"TokenURL", cfg.TokenURL}, {"UserInfoURL", cfg.UserInfoURL}, + } { + if _, err = validateUrl(field.name, field.value, "", false); err != nil { + return err + } + if !cfg.AllowInsecureIssuer { + parsed, _ := url.Parse(field.value) + if parsed.Scheme != "https" { + return errConfig{field: field.name, cause: ErrConfigProfileEndpointMustBeHTTPS} + } + } + } + return requireStr("ClientID", cfg.ClientID) + } if _, err = validateUrl("AuthURL", cfg.AuthURL, "", true); err == nil { if _, err = validateUrl("TokenURL", cfg.TokenURL, "", true); err == nil { if _, err = validateUrl("UserInfoURL", cfg.UserInfoURL, "", true); err == nil { @@ -128,6 +148,28 @@ func ensureScopes(rawScopes []string) (scopes []string) { func (cfg *Config) buildContext(ctx context.Context, overrideUrl string) (oauth2cfg *oauth2.Config, userInfoURL string, verifier *oidc.IDTokenVerifier, err error) { if err = cfg.Validate(); err == nil { + if cfg.ProfileSubject != nil { + var scopes []string + for _, set := range cfg.Scopes { + scopes = append(scopes, strings.Fields(set)...) + } + slices.Sort(scopes) + scopes = slices.Compact(scopes) + var redir *url.URL + if redir, err = url.Parse(cfg.RedirectURL); err != nil { + return + } + if u, parseErr := url.Parse(overrideUrl); parseErr == nil { + overrideStr(&redir.Scheme, u.Scheme) + overrideStr(&redir.Host, u.Host) + } + oauth2cfg = &oauth2.Config{ + ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret, + Endpoint: oauth2.Endpoint{AuthURL: cfg.AuthURL, TokenURL: cfg.TokenURL}, + RedirectURL: redir.String(), Scopes: scopes, + } + return oauth2cfg, cfg.UserInfoURL, nil, nil + } if cfg.HTTPClient != nil { ctx = context.WithValue(ctx, oauth2.HTTPClient, cfg.HTTPClient) } diff --git a/doc.go b/doc.go index 174e0f0..d4df707 100644 --- a/doc.go +++ b/doc.go @@ -1,10 +1,10 @@ -// Package jawsauth provides OIDC-verified authentication for JaWS HTTP sessions. +// Package jawsauth provides verified OIDC and OAuth2 profile authentication for JaWS sessions. // -// It uses OIDC discovery from a configured issuer to run the OAuth2 -// authorization-code flow with PKCE and nonce verification, verifies the -// returned id_token, and stores the identity claims in the JaWS session. It -// also refreshes the id_token in the background before it expires and supports -// gating handlers to administrator emails. +// OIDC mode uses discovery, PKCE, nonce and ID token verification. Profile mode +// uses explicit provider endpoints, PKCE and an authenticated profile response +// with an application-supplied stable-subject verifier. The verified claims are +// stored in a JaWS session. OIDC ID tokens refresh before expiry; profile auth +// expires after a bounded lifetime. Both modes support admin email gates. // // Create a [Server] with [New] (or [NewDebug]) and protect handlers with // [Server.Wrap], [Server.WrapAdmin], [Server.Handler] or [Server.HandlerAdmin]. diff --git a/errconfig.go b/errconfig.go index 92b46cc..79f0005 100644 --- a/errconfig.go +++ b/errconfig.go @@ -19,6 +19,9 @@ var ErrConfigURLMissingHost = errors.New("url host is missing") // ErrConfigIssuerMustBeHTTPS means Issuer must use the https scheme unless AllowInsecureIssuer is enabled. var ErrConfigIssuerMustBeHTTPS = errors.New("issuer url must use https") +// ErrConfigProfileEndpointMustBeHTTPS means a profile-flow endpoint must use HTTPS. +var ErrConfigProfileEndpointMustBeHTTPS = errors.New("profile endpoint must use https") + type errConfig struct { field string cause error diff --git a/erroauth2callback_test.go b/erroauth2callback_test.go index f9b4baa..0ebbd3e 100644 --- a/erroauth2callback_test.go +++ b/erroauth2callback_test.go @@ -8,6 +8,7 @@ import ( "net/url" "strings" "testing" + "time" "github.com/linkdata/jaws" "golang.org/x/oauth2" @@ -128,6 +129,7 @@ func Test_handleAuthResponseOAuthErrorCallback(t *testing.T) { ) sess := jw.NewSession(rec, req) sess.Set(oauth2StateKey, "state123") + sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) srv.HandleAuthResponse(rec, req) if callbackStatus != http.StatusForbidden { t.Fatal(callbackStatus) @@ -203,6 +205,7 @@ func Test_handleAuthResponseOAuthErrorCallbackReceivesSessionEmail(t *testing.T) ) sess := jw.NewSession(rec, req) sess.Set(oauth2StateKey, "state123") + sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) sess.Set(srv.SessionKey, map[string]any{"email": "user@example.com"}) sess.Set(srv.SessionEmailKey, "user@example.com") diff --git a/go.mod b/go.mod index b82500d..b0a0c67 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.27.0 require ( github.com/coder/websocket v1.8.15 github.com/coreos/go-oidc/v3 v3.20.0 - github.com/linkdata/jaws v0.808.0 + github.com/linkdata/jaws v0.901.2-0.20261104081709-b5ee7ec23e15 github.com/linkdata/secureheaders v1.5.0 golang.org/x/oauth2 v0.36.0 ) @@ -16,6 +16,7 @@ require ( github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/linkdata/deadlock v0.5.5 // indirect github.com/linkdata/jq v0.6.0 // indirect + github.com/linkdata/rate v1.17.1 // indirect github.com/linkdata/staticserve v1.1.8 // indirect github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect ) diff --git a/go.sum b/go.sum index 6a0be3b..5fb5208 100644 --- a/go.sum +++ b/go.sum @@ -6,10 +6,12 @@ github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/linkdata/deadlock v0.5.5 h1:d6O+rzEqasSfamGDA8u7bjtaq7hOX8Ha4Zn36Wxrkvo= github.com/linkdata/deadlock v0.5.5/go.mod h1:tXb28stzAD3trzEEK0UJWC+rZKuobCoPktPYzebb1u0= -github.com/linkdata/jaws v0.808.0 h1:63JUxe/dARqNIb6FsPKyWO/Aiso3cSgG4T5An+QywnY= -github.com/linkdata/jaws v0.808.0/go.mod h1:WAjBu+Fgwzf8ydzQJDP6x+WL/5/w5O3bYyFrjtvazAc= +github.com/linkdata/jaws v0.901.2-0.20261104081709-b5ee7ec23e15 h1:lhEtb6Ok6GnUNHl65ADniyyY1kkrUH1b8ueg3hmvvyc= +github.com/linkdata/jaws v0.901.2-0.20261104081709-b5ee7ec23e15/go.mod h1:4l/hXl4qhhEzBMyej3tXT3tLlkEyz1iAAEwWS6Yxpqg= github.com/linkdata/jq v0.6.0 h1:yxAeWsknIWmt7Wbr5CIEkj2i1WwC2DnJDZlOrPxJkr0= github.com/linkdata/jq v0.6.0/go.mod h1:b76MMuWybyXrVEKSHJkv+r5IEattnghPf1bNRJ5PNlE= +github.com/linkdata/rate v1.17.1 h1:b3ld6CaQQzDxk/ChR7x1iDGMQIFyWIZAKQknZWAmiuc= +github.com/linkdata/rate v1.17.1/go.mod h1:VMPlHbG1mu28l0QsvRF3cMN0/pmOmEW+7vq622fx54Y= github.com/linkdata/secureheaders v1.5.0 h1:4kI290hrVUYqluxF8pQfRYpbZuorSd9+8GlMgrLcrK4= github.com/linkdata/secureheaders v1.5.0/go.mod h1:50TiKmPaWki8gIZXmBxrLUinyJst12YuN3EbsXZMO+4= github.com/linkdata/staticserve v1.1.8 h1:qpm0dAaAsik1YrETSiMoS7ciBL9qPVNUFcfdio/ol84= diff --git a/jawsauth.go b/jawsauth.go index a52f7eb..8876265 100644 --- a/jawsauth.go +++ b/jawsauth.go @@ -9,7 +9,7 @@ type JawsAuth struct { sess *jaws.Session } -// Data returns the verified OIDC claims stored in the session, or nil. +// Data returns the verified OIDC or OAuth2 profile claims stored in the session, or nil. // It is safe to call on a nil or zero-value JawsAuth. func (a *JawsAuth) Data() (x map[string]any) { if a != nil && a.server != nil && a.sess != nil { diff --git a/oauth2.go b/oauth2.go index 3bd658f..2529dc2 100644 --- a/oauth2.go +++ b/oauth2.go @@ -1,6 +1,7 @@ package jawsauth import ( + "bytes" "context" "crypto/rand" "encoding/hex" @@ -15,8 +16,10 @@ import ( "net/url" "strconv" "strings" + "time" "github.com/coreos/go-oidc/v3/oidc" + "github.com/linkdata/jaws" "github.com/linkdata/secureheaders" "golang.org/x/oauth2" ) @@ -26,6 +29,40 @@ const oauth2StateKey = "oauth2state" const oauth2PKCEVerifierKey = "oauth2pkceverifier" const oauth2NonceKey = "oauth2nonce" const oauth2IDTokenExpiryKey = "oauth2idtokenexpiry" // #nosec G101 +const oauth2FlowExpiryKey = "oauth2flowexpiry" +const oauth2LinkKey = "oauth2link" + +const oauth2FlowLifetime = 10 * time.Minute +const oauth2ProviderTimeout = 15 * time.Second + +type pendingOAuthFlow struct { + verifier string + nonce string + referrer string + link bool +} + +func (srv *Server) takeOAuthFlow(sess *jaws.Session, gotState string) (flow pendingOAuthFlow, err error) { + srv.mu.Lock() + defer srv.mu.Unlock() + wantState, _ := sess.Get(srv.sessionKey(oauth2StateKey)).(string) + if wantState == "" { + return flow, ErrOAuth2MissingState + } + if gotState != wantState { + return flow, ErrOAuth2WrongState + } + flow.verifier, _ = sess.Get(srv.sessionKey(oauth2PKCEVerifierKey)).(string) + flow.nonce, _ = sess.Get(srv.sessionKey(oauth2NonceKey)).(string) + flow.referrer, _ = sess.Get(srv.sessionKey(oauth2ReferrerKey)).(string) + flow.link, _ = sess.Get(srv.sessionKey(oauth2LinkKey)).(bool) + expiry, _ := sess.Get(srv.sessionKey(oauth2FlowExpiryKey)).(time.Time) + srv.clearSessionOAuthFlow(sess) + if !time.Now().Before(expiry) { + return flow, ErrOAuth2ExpiredState + } + return flow, nil +} // maxRedirectTargetLen bounds return targets retained in pre-auth sessions. const maxRedirectTargetLen = 8192 @@ -78,36 +115,74 @@ func (srv *Server) begin(hr *http.Request) (oauth2cfg *oauth2.Config, location s return } -// HandleLogin begins the OIDC login flow. +// HandleLogin begins the configured OIDC or OAuth2 profile login flow. // // For GET requests it generates and stores the state, nonce and PKCE verifier in the // session, then responds with a 302 redirect to the provider's authorization URL. // If no session can be created, it returns 503 with Retry-After instead. // Non-GET requests receive 405. func (srv *Server) HandleLogin(hw http.ResponseWriter, hr *http.Request) { + srv.startOAuth(hw, hr, false) +} + +// HandleLink begins a provider flow for linking to the caller's existing +// identity. The application must authorize this POST before calling it and +// recheck that identity in LinkEvent when the provider returns. +func (srv *Server) HandleLink(hw http.ResponseWriter, hr *http.Request) { + srv.startOAuth(hw, hr, true) +} + +func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link bool) { statusCode := http.StatusMethodNotAllowed - if hr.Method == http.MethodGet { + if (!link && hr.Method == http.MethodGet) || (link && hr.Method == http.MethodPost) { + if link && http.NewCrossOriginProtection().Check(hr) != nil { + srv.writeResult(hw, http.StatusForbidden, ErrOAuth2LinkRejected, nil) + return + } oauth2cfg, location := srv.begin(hr) if oauth2cfg != nil { sess := srv.Jaws.GetSession(hr) - if sess == nil { + if sess == nil && !link { sess = srv.Jaws.NewSession(hw, hr) } + if link && sess == nil { + srv.writeResult(hw, http.StatusForbidden, ErrOAuth2MissingSession, nil) + return + } + if link && srv.LinkEvent == nil { + srv.writeResult(hw, http.StatusInternalServerError, ErrOAuth2NotConfigured, nil) + return + } if sess == nil { hw.Header().Set("Retry-After", "60") srv.writeResult(hw, http.StatusServiceUnavailable, ErrOAuth2MissingSession, nil) return } + flowExpiry := time.Now().Add(oauth2FlowLifetime) + if !sess.ExtendGrace(oauth2FlowLifetime + oauth2ProviderTimeout + time.Second) { + srv.writeResult(hw, http.StatusServiceUnavailable, ErrOAuth2MissingSession, nil) + return + } authOptions := append([]oauth2.AuthCodeOption{}, srv.Options...) state := randomHexString() + var nonce string + if srv.profileSubject == nil { + nonce = randomHexString() + authOptions = append(authOptions, oidc.Nonce(nonce)) + } + verifier := oauth2.GenerateVerifier() + authOptions = append(authOptions, oauth2.S256ChallengeOption(verifier)) + if link && location == hr.RequestURI { + location = "/" + } + srv.mu.Lock() sess.Set(srv.sessionKey(oauth2StateKey), state) - nonce := randomHexString() + sess.Set(srv.sessionKey(oauth2FlowExpiryKey), flowExpiry) + sess.Set(srv.sessionKey(oauth2LinkKey), link) sess.Set(srv.sessionKey(oauth2NonceKey), nonce) - authOptions = append(authOptions, oidc.Nonce(nonce)) - verifier := oauth2.GenerateVerifier() sess.Set(srv.sessionKey(oauth2PKCEVerifierKey), verifier) - authOptions = append(authOptions, oauth2.S256ChallengeOption(verifier)) sess.Set(srv.sessionKey(oauth2ReferrerKey), location) + srv.mu.Unlock() location = oauth2cfg.AuthCodeURL(state, authOptions...) } hw.Header().Set("Location", location) @@ -175,12 +250,24 @@ var ErrOAuth2MissingState = errors.New("oauth2 missing state") // ErrOAuth2WrongState means the callback state value did not match the stored session state. var ErrOAuth2WrongState = errors.New("oauth2 wrong state") +// ErrOAuth2ExpiredState means the provider returned after the flow lifetime. +var ErrOAuth2ExpiredState = errors.New("oauth2 expired state") + // ErrOAuth2MissingPKCEVerifier means the callback session did not contain the required PKCE verifier. var ErrOAuth2MissingPKCEVerifier = errors.New("oauth2 missing pkce verifier") // ErrUserInfoStatus means the UserInfo endpoint returned a non-200 HTTP status. var ErrUserInfoStatus = errors.New("userinfo status") +// ErrUserInfoTooLarge means the authenticated profile exceeded the response limit. +var ErrUserInfoTooLarge = errors.New("userinfo response too large") + +// ErrOAuth2InvalidProfile means an authenticated profile lacked a stable subject. +var ErrOAuth2InvalidProfile = errors.New("oauth2 invalid profile") + +// ErrOAuth2LinkRejected means the application did not accept a proven link. +var ErrOAuth2LinkRejected = errors.New("oauth2 link rejected") + func randomHexString() string { b := [32]byte{} _, _ = rand.Read(b[:]) // never returns an error, always fills all of b @@ -227,7 +314,7 @@ func (srv *Server) extractEmail(claims map[string]any) (sessEmailValue any, veri } } if l := srv.Jaws.Logger; l != nil { - l.Warn("jawsauth: no email found", "userinfo", claims) + l.Warn("jawsauth: no email found") } return } @@ -241,6 +328,10 @@ func extractEmailVerified(claims map[string]any) (verified bool) { verified, _ = strconv.ParseBool(value) case float64: verified = value != 0 + case json.Number: + if number, err := value.Float64(); err == nil { + verified = number != 0 + } } } return @@ -257,9 +348,21 @@ func (srv *Server) fetchUserInfo(ctx context.Context, userinfoURL string, tokenS } }() var body []byte - if body, err = io.ReadAll(io.LimitReader(resp.Body, 32768)); err == nil { - if resp.StatusCode == http.StatusOK { - err = json.Unmarshal(body, &userinfo) + if body, err = io.ReadAll(io.LimitReader(resp.Body, 32769)); err == nil { + if len(body) > 32768 { + err = ErrUserInfoTooLarge + } else if resp.StatusCode == http.StatusOK { + decoder := json.NewDecoder(bytes.NewReader(body)) + if srv.profileSubject != nil { + decoder.UseNumber() + } + err = decoder.Decode(&userinfo) + if err == nil { + var extra any + if trailingErr := decoder.Decode(&extra); trailingErr != io.EOF { + err = ErrOAuth2InvalidProfile + } + } } else { err = fmt.Errorf("%w %s", ErrUserInfoStatus, resp.Status) } @@ -275,12 +378,68 @@ func (srv *Server) addUserInfoClaims(ctx context.Context, claims map[string]any, } } -// HandleAuthResponse handles the OIDC redirect/callback endpoint. +func (srv *Server) verifyOAuthIdentity(ctx context.Context, token *oauth2.Token, nonce string) (claims map[string]any, tokenSource oauth2.TokenSource, expiry time.Time, status int, err error) { + status = http.StatusUnauthorized + if srv.idTokenVerifier != nil { + err = ErrOIDCMissingIDToken + raw, _ := token.Extra("id_token").(string) + if raw == "" { + return + } + var idToken *oidc.IDToken + idToken, err = srv.idTokenVerifier.Verify(ctx, raw) + if wrapOIDC(ErrOIDCInvalidIDToken, &err) != nil { + return + } + err = ErrOIDCMissingNonce + if nonce == "" { + return + } + err = ErrOIDCNonceMismatch + if idToken.Nonce != nonce { + return + } + err = idToken.Claims(&claims) + if wrapOIDC(ErrOIDCInvalidIDToken, &err) != nil { + return + } + tokenSource = srv.oauth2cfg.TokenSource(srv.oauth2Context(context.Background()), token) + srv.addUserInfoClaims(ctx, claims, tokenSource) + return claims, tokenSource, idToken.Expiry, status, nil + } + if srv.profileSubject == nil || token.AccessToken == "" || !token.Valid() { + return nil, nil, time.Time{}, status, ErrOAuth2InvalidProfile + } + status = http.StatusBadGateway + claims, err = srv.fetchUserInfo(ctx, srv.userinfoUrl, oauth2.StaticTokenSource(token)) + if err != nil { + return + } + status = http.StatusUnauthorized + if claims == nil { + return nil, nil, time.Time{}, status, ErrOAuth2InvalidProfile + } + var subject string + subject, err = srv.profileSubject(claims) + if err != nil || subject == "" { + return nil, nil, time.Time{}, status, ErrOAuth2InvalidProfile + } + claims["iss"] = srv.issuer + claims["sub"] = subject + expiry = time.Now().Add(24 * time.Hour) + if !token.Expiry.IsZero() && token.Expiry.Before(expiry) { + expiry = token.Expiry + } + return claims, nil, expiry, status, nil +} + +// HandleAuthResponse handles the OIDC or OAuth2 profile callback endpoint. // // For GET requests it validates the state, exchanges the authorization code using the -// stored PKCE verifier, verifies the id_token and its nonce, fetches UserInfo, and stores -// the claims in a new session. It invokes [Server.LoginEvent] on success or -// [Server.LoginFailed] on failure. Non-GET requests receive 405. +// stored PKCE verifier, verifies the ID token or authenticated profile, and stores +// claims in a new session. A link flow instead invokes [Server.LinkEvent] before +// rotation and keeps the original session. It invokes [Server.LoginEvent] after a +// new sign-in or [Server.LoginFailed] on failure. Non-GET requests receive 405. // // Rotation discards pre-login application data and requires a free JaWS session slot. // If no slot is available, existing authentication is preserved and the default @@ -293,7 +452,10 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) oauth2Config, location := srv.begin(hr) var sessValue any var sessEmail string - authctx := srv.oauth2Context(hr.Context()) + var linked bool + providerCtx, cancel := context.WithTimeout(hr.Context(), oauth2ProviderTimeout) + defer cancel() + authctx := srv.oauth2Context(providerCtx) sess := srv.Jaws.GetSession(hr) if sess != nil { sessEmail, _ = sess.Get(srv.SessionEmailKey).(string) @@ -306,77 +468,77 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) statusCode = http.StatusBadRequest if sess != nil { gotState := hr.FormValue("state") // #nosec G120 - wantState, _ := sess.Get(srv.sessionKey(oauth2StateKey)).(string) - verifier, _ := sess.Get(srv.sessionKey(oauth2PKCEVerifierKey)).(string) - wantNonce, _ := sess.Get(srv.sessionKey(oauth2NonceKey)).(string) - sess.Set(srv.sessionKey(oauth2StateKey), nil) - sess.Set(srv.sessionKey(oauth2PKCEVerifierKey), nil) - sess.Set(srv.sessionKey(oauth2NonceKey), nil) - err = ErrOAuth2MissingState - if wantState != "" { - err = ErrOAuth2WrongState - if wantState == gotState { - if statusCode, err = oauth2CallbackError(statusCode, hr); err == nil { - err = ErrOAuth2MissingPKCEVerifier - if verifier != "" { - var token *oauth2.Token - exchangeOptions := []oauth2.AuthCodeOption{ - oauth2.AccessTypeOffline, - oauth2.VerifierOption(verifier), - } - if token, err = oauth2Config.Exchange(authctx, hr.FormValue("code") /* #nosec G120 */, exchangeOptions...); srv.Jaws.Log(err) == nil { - err = ErrOAuth2NotConfigured - statusCode = http.StatusInternalServerError - if srv.idTokenVerifier != nil { - rawIDToken, _ := token.Extra("id_token").(string) - statusCode = http.StatusUnauthorized - err = ErrOIDCMissingIDToken - if rawIDToken != "" { - var idToken *oidc.IDToken - if idToken, err = srv.idTokenVerifier.Verify(authctx, rawIDToken); wrapOIDC(ErrOIDCInvalidIDToken, &err) == nil { - err = ErrOIDCMissingNonce - if wantNonce != "" { - err = ErrOIDCNonceMismatch - if idToken.Nonce == wantNonce { - var claims map[string]any - if err = idToken.Claims(&claims); wrapOIDC(ErrOIDCInvalidIDToken, &err) == nil { - tokenSource := oauth2Config.TokenSource(srv.oauth2Context(context.Background()), token) - // Finish network work before starting the new session's idle lifetime. - srv.addUserInfoClaims(authctx, claims, tokenSource) - referrer := sess.Get(srv.sessionKey(oauth2ReferrerKey)) - requests := sess.Requests() - _, hadAuth := srv.sessionAuthStatus(sess, nil) - err = ErrOAuth2MissingSession - statusCode = http.StatusServiceUnavailable - if rotated := srv.Jaws.NewSession(hw, hr); rotated != nil { - srv.mu.Lock() - hadTimer := srv.authTimers[sess.ID()] != nil - srv.stopSessionAuthTimerLocked(sess, nil) - srv.mu.Unlock() - reloadAuthRequests(requests) - if hadAuth && hadTimer && srv.LogoutEvent != nil { - srv.LogoutEvent(sess, hr) - } - sess = rotated - sess.Set(srv.sessionKey(oauth2ReferrerKey), referrer) - err = srv.storeSessionAuthClaims(sess, claims, tokenSource, idToken.Expiry, nil) - } else { - hw.Header().Set("Retry-After", "60") - } - if err == nil { - sessValue = claims - sessEmail, _ = sess.Get(srv.SessionEmailKey).(string) - if s, ok := sess.Get(srv.sessionKey(oauth2ReferrerKey)).(string); ok { - location = sanitizeRedirectTarget(hr.Host, s) - } - sess.Set(srv.sessionKey(oauth2ReferrerKey), nil) - hw.Header().Set("Location", location) - statusCode = http.StatusFound - } - } - } - } + flow, flowErr := srv.takeOAuthFlow(sess, gotState) + err = flowErr + if err == nil { + if statusCode, err = oauth2CallbackError(statusCode, hr); err == nil { + err = ErrOAuth2MissingPKCEVerifier + if flow.verifier != "" { + var token *oauth2.Token + exchangeOptions := []oauth2.AuthCodeOption{ + oauth2.VerifierOption(flow.verifier), + } + if srv.profileSubject == nil { + exchangeOptions = append(exchangeOptions, oauth2.AccessTypeOffline) + } + if token, err = oauth2Config.Exchange(authctx, hr.FormValue("code") /* #nosec G120 */, exchangeOptions...); srv.Jaws.Log(err) == nil { + var claims map[string]any + var tokenSource oauth2.TokenSource + var expiry time.Time + claims, tokenSource, expiry, statusCode, err = srv.verifyOAuthIdentity(authctx, token, flow.nonce) + if err == nil { + if flow.link { + statusCode = http.StatusForbidden + err = ErrOAuth2MissingSession + if srv.LinkEvent != nil && srv.Jaws.GetSession(hr) == sess { + var linkErr error + statusCode, linkErr = srv.LinkEvent(sess, hr, claims) + if linkErr == nil && statusCode == 0 { + err = nil + sessValue = claims + linked = true + } else { + _ = srv.Jaws.Log(linkErr) + err = ErrOAuth2LinkRejected + } + } + if err != nil && (statusCode < 400 || statusCode > 599) { + statusCode = http.StatusForbidden + } + if err == nil { + if flow.referrer != "" { + location = sanitizeRedirectTarget(hr.Host, flow.referrer) + } + hw.Header().Set("Location", location) + statusCode = http.StatusFound + } + } else { + requests := sess.Requests() + _, hadAuth := srv.sessionAuthStatus(sess, nil) + err = ErrOAuth2MissingSession + statusCode = http.StatusServiceUnavailable + if rotated := srv.Jaws.NewSession(hw, hr); rotated != nil { + srv.mu.Lock() + hadTimer := srv.authTimers[sess.ID()] != nil + srv.stopSessionAuthTimerLocked(sess, nil) + srv.mu.Unlock() + reloadAuthRequests(requests) + if hadAuth && hadTimer && srv.LogoutEvent != nil { + srv.LogoutEvent(sess, hr) + } + sess = rotated + err = srv.storeSessionAuthClaims(sess, claims, tokenSource, expiry, nil) + } else { + hw.Header().Set("Retry-After", "60") + } + if err == nil { + sessValue = claims + sessEmail, _ = sess.Get(srv.SessionEmailKey).(string) + if flow.referrer != "" { + location = sanitizeRedirectTarget(hr.Host, flow.referrer) } + hw.Header().Set("Location", location) + statusCode = http.StatusFound } } } @@ -388,9 +550,8 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) } if sess != nil { if sessValue == nil { - srv.clearSessionOAuthFlow(sess) srv.Jaws.Dirty(sess) - } else if srv.LoginEvent != nil { + } else if !linked && srv.LoginEvent != nil { srv.LoginEvent(sess, hr) } } diff --git a/oauth2_test.go b/oauth2_test.go index 61708cc..a93d3b8 100644 --- a/oauth2_test.go +++ b/oauth2_test.go @@ -296,6 +296,7 @@ func Test_handleLogoutClearsPendingOAuthState(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/logout", nil) sess := jw.NewSession(httptest.NewRecorder(), req) sess.Set(oauth2StateKey, "state123") + sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) sess.Set(oauth2PKCEVerifierKey, "verifier123") sess.Set(oauth2NonceKey, "nonce123") sess.Set(oauth2ReferrerKey, "/protected") @@ -426,6 +427,7 @@ func Test_handleAuthResponseUsesPKCEVerifier(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state="+wantState+"&code="+wantCode, nil) sess := jw.NewSession(rec, req) sess.Set(oauth2StateKey, wantState) + sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) sess.Set(oauth2PKCEVerifierKey, verifier) sess.Set(oauth2NonceKey, wantNonce) sess.Set(oauth2ReferrerKey, "/secure") @@ -556,6 +558,7 @@ func Test_handleAuthResponseStoredTokenSourceSurvivesRequestCancel(t *testing.T) req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state&code=code", nil).WithContext(ctx) sess := jw.NewSession(rec, req) sess.Set(oauth2StateKey, "state") + sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) sess.Set(oauth2PKCEVerifierKey, oauth2.GenerateVerifier()) sess.Set(oauth2NonceKey, wantNonce) @@ -623,6 +626,7 @@ func Test_handleAuthResponseMissingPKCEVerifier(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state123&code=authcode123", nil) sess := jw.NewSession(rec, req) sess.Set(oauth2StateKey, "state123") + sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) sess.Set(oauth2NonceKey, "nonce123") srv.HandleAuthResponse(rec, req) @@ -666,6 +670,7 @@ func Test_handleAuthResponseWrongStatePreservesCurrentAuth(t *testing.T) { sess.Set(srv.SessionEmailKey, "user@example.com") sess.Set(srv.SessionEmailVerifiedKey, true) sess.Set(oauth2StateKey, "legit") + sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) sess.Set(oauth2PKCEVerifierKey, oauth2.GenerateVerifier()) sess.Set(oauth2NonceKey, "nonce") sess.Set(oauth2ReferrerKey, "/secure") @@ -699,16 +704,16 @@ func Test_handleAuthResponseWrongStatePreservesCurrentAuth(t *testing.T) { if got, _ := sess.Get(srv.SessionEmailVerifiedKey).(bool); !got { t.Fatal(got) } - if got := sess.Get(oauth2StateKey); got != nil { + if got := sess.Get(oauth2StateKey); got != "legit" { t.Fatal(got) } - if got := sess.Get(oauth2PKCEVerifierKey); got != nil { + if got := sess.Get(oauth2PKCEVerifierKey); got == nil { t.Fatal(got) } - if got := sess.Get(oauth2NonceKey); got != nil { + if got := sess.Get(oauth2NonceKey); got != "nonce" { t.Fatal(got) } - if got := sess.Get(oauth2ReferrerKey); got != nil { + if got := sess.Get(oauth2ReferrerKey); got != "/secure" { t.Fatal(got) } } @@ -729,6 +734,11 @@ func Test_extractEmailVerified(t *testing.T) { claims: map[string]any{"email_verified": "true"}, want: true, }, + { + name: "profile numeric", + claims: map[string]any{"email_verified": json.Number("1.0")}, + want: true, + }, { name: "float", claims: map[string]any{"email_verified": float64(1)}, @@ -833,6 +843,7 @@ func Test_handleAuthResponseMissingIDToken(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state123&code=authcode123", nil) sess := jw.NewSession(rec, req) sess.Set(oauth2StateKey, "state123") + sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) sess.Set(oauth2PKCEVerifierKey, oauth2.GenerateVerifier()) sess.Set(oauth2NonceKey, "nonce123") @@ -899,6 +910,7 @@ func Test_handleAuthResponseInvalidIDToken(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state123&code=authcode123", nil) sess := jw.NewSession(rec, req) sess.Set(oauth2StateKey, "state123") + sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) sess.Set(oauth2PKCEVerifierKey, oauth2.GenerateVerifier()) sess.Set(oauth2NonceKey, "nonce123") @@ -965,6 +977,7 @@ func Test_handleAuthResponseAudienceMismatch(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state123&code=authcode123", nil) sess := jw.NewSession(rec, req) sess.Set(oauth2StateKey, "state123") + sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) sess.Set(oauth2PKCEVerifierKey, oauth2.GenerateVerifier()) sess.Set(oauth2NonceKey, "nonce123") @@ -1031,6 +1044,7 @@ func Test_handleAuthResponseNonceMismatch(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state123&code=authcode123", nil) sess := jw.NewSession(rec, req) sess.Set(oauth2StateKey, "state123") + sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) sess.Set(oauth2PKCEVerifierKey, oauth2.GenerateVerifier()) sess.Set(oauth2NonceKey, "nonce123") @@ -1265,17 +1279,8 @@ func TestServerExtractEmail(t *testing.T) { if handler.message != "jawsauth: no email found" { t.Fatalf("unexpected log message: %s", handler.message) } - var attrFound bool - for _, attr := range handler.attrs { - if attr.Key == "userinfo" { - attrFound = true - if !reflect.DeepEqual(attr.Value.Any(), tc.userinfo) { - t.Fatalf("logged userinfo mismatch: %#v", attr.Value.Any()) - } - } - } - if !attrFound { - t.Fatal("userinfo attribute missing from log entry") + if len(handler.attrs) != 0 { + t.Fatalf("profile claims leaked to log: %#v", handler.attrs) } } else { if handler.called { @@ -1400,6 +1405,7 @@ func Test_handleAuthResponseMissingNonce(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state123&code=authcode123", nil) sess := jw.NewSession(rec, req) sess.Set(oauth2StateKey, "state123") + sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) sess.Set(oauth2PKCEVerifierKey, oauth2.GenerateVerifier()) // deliberately leave oauth2NonceKey unset to exercise the missing-nonce branch @@ -1455,6 +1461,7 @@ func Test_handlersRejectNonGet(t *testing.T) { req := httptest.NewRequest(method, "http://example.com/oauth2/callback", nil) sess := jw.NewSession(rec, req) sess.Set(oauth2StateKey, "state123") + sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) sess.Set(oauth2PKCEVerifierKey, "verifier123") sess.Set(oauth2NonceKey, "nonce123") diff --git a/oauth_flow_grace_test.go b/oauth_flow_grace_test.go new file mode 100644 index 0000000..b9cea30 --- /dev/null +++ b/oauth_flow_grace_test.go @@ -0,0 +1,221 @@ +package jawsauth + +import ( + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/synctest" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/linkdata/jaws" + "golang.org/x/oauth2" +) + +func TestOAuthProviderVisitKeepsOnlyPendingSessionAlive(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + srv := &Server{Jaws: jw, oauth2cfg: &oauth2.Config{ + ClientID: "test", RedirectURL: "https://app.example/auth/callback", + Endpoint: oauth2.Endpoint{AuthURL: "https://provider.example/auth", TokenURL: "https://provider.example/token"}, + }} + start := httptest.NewRequest(http.MethodGet, "https://app.example/auth/login", nil) + start.RemoteAddr = "192.0.2.1:1234" + response := httptest.NewRecorder() + srv.HandleLogin(response, start) + if response.Code != http.StatusFound { + t.Fatalf("start: %d", response.Code) + } + cookies := response.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("cookies: %d", len(cookies)) + } + callback := httptest.NewRequest(http.MethodGet, "https://app.example/auth/callback?state=wrong&code=unused", nil) + callback.RemoteAddr = start.RemoteAddr + callback.AddCookie(cookies[0]) + unrelated := httptest.NewRequest(http.MethodGet, "https://app.example/other", nil) + unrelated.RemoteAddr = "192.0.2.2:1234" + if jw.NewSession(httptest.NewRecorder(), unrelated) == nil { + t.Fatal("unrelated session missing") + } + time.Sleep(61 * time.Second) + if jw.GetSession(callback) == nil { + t.Fatal("pending preauth session expired during provider visit") + } + if jw.GetSession(unrelated) != nil { + t.Fatal("unrelated idle session gained OAuth grace") + } + time.Sleep(oauth2FlowLifetime) + if jw.GetSession(callback) != nil { + t.Fatal("pending preauth session survived its bounded grace") + } + }) +} + +func TestOAuthFlowExpiresEvenWithLiveSession(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + providerCalls := 0 + srv := &Server{Jaws: jw, oauth2cfg: &oauth2.Config{ + ClientID: "test", RedirectURL: "https://app.example/auth/callback", + Endpoint: oauth2.Endpoint{AuthURL: "https://provider.example/auth", TokenURL: "https://provider.example/token"}, + }, httpClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + providerCalls++ + return nil, errors.New("provider should not be called") + })}} + start := httptest.NewRequest(http.MethodGet, "https://app.example/auth/login", nil) + start.RemoteAddr = "192.0.2.1:1234" + response := httptest.NewRecorder() + srv.HandleLogin(response, start) + if response.Code != http.StatusFound { + t.Fatalf("start: %d", response.Code) + } + cookies := response.Result().Cookies() + callback := httptest.NewRequest(http.MethodGet, "https://app.example/auth/callback?state=wrong&code=unused", nil) + callback.RemoteAddr = start.RemoteAddr + callback.AddCookie(cookies[0]) + sess := jw.GetSession(callback) + if sess == nil { + t.Fatal("session missing") + } + state, _ := sess.Get(oauth2StateKey).(string) + callback.URL.RawQuery = "state=" + state + "&code=unused" + page := jw.NewRequest(httptest.NewRecorder(), callback) + if page.Session() != sess { + t.Fatal("page did not attach") + } + time.Sleep(oauth2FlowLifetime + time.Second) + if jw.GetSession(callback) == nil { + t.Fatal("live page should hold session") + } + result := httptest.NewRecorder() + srv.HandleAuthResponse(result, callback) + if result.Code != http.StatusBadRequest || !strings.Contains(result.Body.String(), ErrOAuth2ExpiredState.Error()) || sess.Get(oauth2StateKey) != nil || providerCalls != 0 { + t.Fatalf("expired flow: status=%d state=%v provider_calls=%d body=%q", result.Code, sess.Get(oauth2StateKey), providerCalls, result.Body.String()) + } + }) +} + +func TestOIDCInitialCallbackAfterProviderVisit(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + var idToken string + exchanges := 0 + srv := &Server{Jaws: jw, + SessionKey: "claims", SessionTokenKey: "token", SessionEmailKey: "email", SessionEmailVerifiedKey: "email_verified", + oauth2cfg: &oauth2.Config{ClientID: "app", RedirectURL: "https://app.example/auth/callback", + Endpoint: oauth2.Endpoint{AuthURL: "https://issuer.example/authorize", TokenURL: "https://issuer.example/token"}}, + idTokenVerifier: oidc.NewVerifier("https://issuer.example", passthroughKeySet{}, &oidc.Config{ClientID: "app"}), + httpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + exchanges++ + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"access_token":"access","token_type":"Bearer","id_token":"` + idToken + `"}`))}, nil + })}, + } + start := httptest.NewRequest(http.MethodGet, "https://app.example/auth/login", nil) + start.RemoteAddr = "192.0.2.1:1234" + response := httptest.NewRecorder() + srv.HandleLogin(response, start) + if response.Code != http.StatusFound { + t.Fatalf("start: %d", response.Code) + } + cookie := response.Result().Cookies()[0] + preauth := jw.GetSession(start) + state, _ := preauth.Get(oauth2StateKey).(string) + nonce, _ := preauth.Get(oauth2NonceKey).(string) + idToken = makeIDToken(t, map[string]any{ + "iss": "https://issuer.example", "aud": "app", "sub": "subject-1", "nonce": nonce, + "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(), + }) + time.Sleep(61 * time.Second) + callback := httptest.NewRequest(http.MethodGet, "https://app.example/auth/callback?state="+state+"&code=code", nil) + callback.RemoteAddr = start.RemoteAddr + callback.AddCookie(cookie) + result := httptest.NewRecorder() + srv.HandleAuthResponse(result, callback) + if result.Code != http.StatusFound || exchanges != 1 || jw.GetSession(callback) == preauth { + t.Fatalf("delayed OIDC callback: status=%d exchanges=%d body=%q", result.Code, exchanges, result.Body.String()) + } + rotated := result.Result().Cookies() + if len(rotated) != 1 { + t.Fatalf("rotated cookies: %d", len(rotated)) + } + authorized := httptest.NewRequest(http.MethodGet, "https://app.example/", nil) + authorized.RemoteAddr = start.RemoteAddr + authorized.AddCookie(rotated[0]) + sess := jw.GetSession(authorized) + if sess == nil || sess.Get(srv.SessionKey).(map[string]any)["sub"] != "subject-1" { + t.Fatal("delayed OIDC sign-in missing verified subject") + } + }) +} + +func TestOAuthLinkCallbackNearDeadlineCanFinishProviderExchange(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + linked := false + srv := &Server{Jaws: jw, SessionKey: "claims", issuer: "https://provider.example", + profileSubject: func(map[string]any) (string, error) { return "42", nil }, + userinfoUrl: "https://provider.example/user", + oauth2cfg: &oauth2.Config{ClientID: "app", RedirectURL: "https://app.example/auth/callback", + Endpoint: oauth2.Endpoint{AuthURL: "https://provider.example/auth", TokenURL: "https://provider.example/token"}}, + httpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/token" { + time.Sleep(10 * time.Second) + return profileTestResponse(`{"access_token":"access","token_type":"Bearer"}`), nil + } + return profileTestResponse(`{"id":42}`), nil + })}, + } + start := httptest.NewRequest(http.MethodPost, "https://app.example/auth/link", nil) + start.RemoteAddr = "192.0.2.1:1234" + created := httptest.NewRecorder() + sess := jw.NewSession(created, start) + if sess == nil { + t.Fatal("original session missing") + } + sess.Set(srv.SessionKey, map[string]any{"sub": "original"}) + srv.LinkEvent = func(current *jaws.Session, _ *http.Request, claims map[string]any) (int, error) { + if current != sess || current.Get(srv.SessionKey).(map[string]any)["sub"] != "original" || claims["sub"] != "42" { + return http.StatusForbidden, ErrOAuth2LinkRejected + } + linked = true + return 0, nil + } + startResult := httptest.NewRecorder() + srv.HandleLink(startResult, start) + if startResult.Code != http.StatusFound { + t.Fatalf("link start: %d", startResult.Code) + } + state, _ := sess.Get(oauth2StateKey).(string) + cookie := created.Result().Cookies()[0] + time.Sleep(oauth2FlowLifetime - time.Second) + callback := httptest.NewRequest(http.MethodGet, "https://app.example/auth/callback?state="+state+"&code=code", nil) + callback.RemoteAddr = start.RemoteAddr + callback.AddCookie(cookie) + result := httptest.NewRecorder() + srv.HandleAuthResponse(result, callback) + if result.Code != http.StatusFound || !linked || jw.GetSession(callback) != sess { + t.Fatalf("near-deadline link: status=%d linked=%t body=%q", result.Code, linked, result.Body.String()) + } + }) +} diff --git a/profile_link_test.go b/profile_link_test.go new file mode 100644 index 0000000..f09be56 --- /dev/null +++ b/profile_link_test.go @@ -0,0 +1,459 @@ +package jawsauth + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + "github.com/linkdata/jaws" + "golang.org/x/oauth2" +) + +func profileTestResponse(body string) *http.Response { + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(body))} +} + +func TestOAuthProfileConfigRequiresExplicitSecureEndpoints(t *testing.T) { + cfg := &Config{RedirectURL: "https://app.example/auth/callback", Issuer: "https://provider.example", + AuthURL: "https://provider.example/authorize", TokenURL: "https://provider.example/token", + UserInfoURL: "https://provider.example/user", ClientID: "app", + ProfileSubject: func(map[string]any) (string, error) { return "42", nil }} + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + cfg.UserInfoURL = "" + if err := cfg.Validate(); !errors.Is(err, ErrConfigMissingValue) { + t.Fatalf("missing profile URL: %v", err) + } + cfg.UserInfoURL = "http://provider.example/user" + if err := cfg.Validate(); !errors.Is(err, ErrConfigProfileEndpointMustBeHTTPS) { + t.Fatalf("insecure profile URL: %v", err) + } +} + +func TestOAuthProfileAuthLifetimeCapsLongProviderToken(t *testing.T) { + srv := &Server{issuer: "https://provider.example", userinfoUrl: "https://provider.example/user", + profileSubject: func(map[string]any) (string, error) { return "42", nil }} + ctx := context.WithValue(t.Context(), oauth2.HTTPClient, &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return profileTestResponse(`{"id":42}`), nil + })}) + for _, duration := range []time.Duration{30 * time.Minute, 72 * time.Hour} { + token := &oauth2.Token{AccessToken: "secret", TokenType: "Bearer", Expiry: time.Now().Add(duration)} + _, _, expiry, status, err := srv.verifyOAuthIdentity(ctx, token, "") + if err != nil || status != http.StatusUnauthorized { + t.Fatalf("duration %s: status=%d error=%v", duration, status, err) + } + want := min(duration, 24*time.Hour) + if diff := time.Until(expiry) - want; diff < -time.Second || diff > time.Second { + t.Fatalf("duration %s: expiry offset %s", duration, diff) + } + } +} + +func TestOAuthProfileCallbackStateIsSingleUseConcurrently(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + var calls atomic.Int32 + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + calls.Add(1) + if r.URL.Path == "/token" { + return profileTestResponse(`{"access_token":"secret","token_type":"Bearer"}`), nil + } + return profileTestResponse(`{"id":42}`), nil + })} + srv, err := New(jw, &Config{Namespace: "one", RedirectURL: "https://app.example/auth/callback", + Issuer: "https://provider.example", AuthURL: "https://provider.example/authorize", + TokenURL: "https://provider.example/token", UserInfoURL: "https://provider.example/user", + ClientID: "app", HTTPClient: client, + ProfileSubject: func(map[string]any) (string, error) { return "42", nil }}, func(string, http.Handler) {}) + if err != nil { + t.Fatal(err) + } + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + responses := make(chan int, 2) + var workers sync.WaitGroup + for range 2 { + workers.Add(1) + go func() { + defer workers.Done() + responses <- profileCallback(srv, cookie, state, "first").Code + }() + } + workers.Wait() + close(responses) + seen := map[int]int{} + for code := range responses { + seen[code]++ + } + if seen[http.StatusFound] != 1 || seen[http.StatusBadRequest] != 1 || calls.Load() != 2 { + t.Fatalf("concurrent replay: statuses=%v provider_calls=%d", seen, calls.Load()) + } +} + +func TestOAuthFailedCallbackDoesNotEraseNewFlow(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + entered := make(chan struct{}) + release := make(chan struct{}) + var enterOnce sync.Once + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/token" { + if err := r.ParseForm(); err != nil { + return nil, err + } + if r.PostForm.Get("code") == "first" { + enterOnce.Do(func() { close(entered) }) + <-release + return nil, errors.New("provider unavailable") + } + return profileTestResponse(`{"access_token":"secret","token_type":"Bearer"}`), nil + } + return profileTestResponse(`{"id":42}`), nil + })} + srv, err := New(jw, &Config{Namespace: "one", RedirectURL: "https://app.example/auth/callback", + Issuer: "https://provider.example", AuthURL: "https://provider.example/authorize", + TokenURL: "https://provider.example/token", UserInfoURL: "https://provider.example/user", + ClientID: "app", HTTPClient: client, + ProfileSubject: func(map[string]any) (string, error) { return "42", nil }}, func(string, http.Handler) {}) + if err != nil { + t.Fatal(err) + } + cookie, firstState := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + firstResult := make(chan int, 1) + go func() { firstResult <- profileCallback(srv, cookie, firstState, "first").Code }() + <-entered + r := httptest.NewRequest(http.MethodGet, "https://app.example/auth/login", nil) + r.RemoteAddr = "192.0.2.1:1234" + r.Header.Set("Referer", "https://app.example/second") + r.AddCookie(cookie) + w := httptest.NewRecorder() + srv.HandleLogin(w, r) + if w.Code != http.StatusFound { + t.Fatalf("second start: %d", w.Code) + } + location, err := url.Parse(w.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + secondState := location.Query().Get("state") + close(release) + if code := <-firstResult; code == http.StatusFound { + t.Fatal("failed callback signed in") + } + sess := jw.GetSession(r) + if sess == nil || sess.Get(srv.sessionKey(oauth2StateKey)) != secondState || + sess.Get(srv.sessionKey(oauth2ReferrerKey)) != "/second" { + t.Fatal("failed callback erased the newer flow") + } + second := profileCallback(srv, cookie, secondState, "second") + if second.Code != http.StatusFound || second.Header().Get("Location") != "/second" { + t.Fatalf("second callback: status=%d location=%q", second.Code, second.Header().Get("Location")) + } +} + +func newProfileTestServer(t *testing.T, jw *jaws.Jaws, calls *int) *Server { + t.Helper() + client := &http.Client{Timeout: time.Second, Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + *calls++ + switch r.URL.Path { + case "/token": + if err := r.ParseForm(); err != nil || r.PostForm.Get("code_verifier") == "" { + return nil, errors.New("missing PKCE verifier") + } + if r.PostForm.Get("code") == "second" { + return profileTestResponse(`{"access_token":"second-token","token_type":"Bearer"}`), nil + } + return profileTestResponse(`{"access_token":"first-token","token_type":"Bearer"}`), nil + case "/user": + switch r.Header.Get("Authorization") { + case "Bearer first-token": + return profileTestResponse(`{"id":9007199254740993,"login":"first","email":"first@example.com"}`), nil + case "Bearer second-token": + return profileTestResponse(`{"id":99,"login":"second"}`), nil + } + } + return nil, errors.New("unexpected provider request") + })} + srv, err := New(jw, &Config{ + Namespace: "github", RedirectURL: "https://app.example/auth/callback", Issuer: "https://provider.example", + AuthURL: "https://provider.example/authorize", TokenURL: "https://provider.example/token", + UserInfoURL: "https://provider.example/user", ClientID: "app", ClientSecret: "secret", + Scopes: []string{"read:user"}, HTTPClient: client, + ProfileSubject: func(claims map[string]any) (string, error) { + id, ok := claims["id"].(json.Number) + if !ok { + return "", ErrOAuth2InvalidProfile + } + value, err := strconv.ParseUint(string(id), 10, 64) + if err != nil || value == 0 { + return "", ErrOAuth2InvalidProfile + } + return string(id), nil + }, + }, func(string, http.Handler) {}) + if err != nil || !srv.Valid() { + t.Fatalf("profile server: %v", err) + } + return srv +} + +func profileStart(t *testing.T, srv *Server, method, path string, cookie *http.Cookie) (*http.Cookie, string) { + t.Helper() + r := httptest.NewRequest(method, "https://app.example"+path, nil) + r.RemoteAddr = "192.0.2.1:1234" + if cookie != nil { + r.AddCookie(cookie) + } + w := httptest.NewRecorder() + if method == http.MethodPost { + srv.HandleLink(w, r) + } else { + srv.HandleLogin(w, r) + } + if w.Code != http.StatusFound { + t.Fatalf("start %s: %d %s", path, w.Code, w.Body.String()) + } + location, err := url.Parse(w.Header().Get("Location")) + if err != nil || location.Query().Get("state") == "" { + t.Fatalf("provider redirect: %v %q", err, w.Header().Get("Location")) + } + if method == http.MethodGet { + if strings.Contains(location.RawQuery, "openid") || location.Query().Has("nonce") { + t.Fatalf("profile flow sent OIDC-only parameters: %s", location) + } + cookies := w.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("session cookies: %d", len(cookies)) + } + cookie = cookies[0] + } + return cookie, location.Query().Get("state") +} + +func profileCallback(srv *Server, cookie *http.Cookie, state, code string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, "https://app.example/auth/callback?state="+url.QueryEscape(state)+"&code="+code, nil) + r.RemoteAddr = "192.0.2.1:1234" + r.AddCookie(cookie) + w := httptest.NewRecorder() + srv.HandleAuthResponse(w, r) + return w +} + +func TestOAuthProfileSignInAndDualProofLink(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + logins, links := 0, 0 + srv.LoginEvent = func(*jaws.Session, *http.Request) { logins++ } + oldCookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + wrong := profileCallback(srv, oldCookie, "wrong", "first") + if wrong.Code != http.StatusBadRequest || calls != 0 { + t.Fatalf("wrong state consumed login: status=%d calls=%d", wrong.Code, calls) + } + time.Sleep(61 * time.Second) + first := profileCallback(srv, oldCookie, state, "first") + if first.Code != http.StatusFound || logins != 1 || calls != 2 { + t.Fatalf("first sign-in: status=%d logins=%d calls=%d", first.Code, logins, calls) + } + newCookies := first.Result().Cookies() + if len(newCookies) != 1 { + t.Fatalf("rotated cookies: %d", len(newCookies)) + } + cookie := newCookies[0] + request := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + request.RemoteAddr = "192.0.2.1:1234" + request.AddCookie(cookie) + sess := jw.GetSession(request) + claims, _ := sess.Get(srv.SessionKey).(map[string]any) + if claims["sub"] != "9007199254740993" || claims["iss"] != "https://provider.example" || claims["email"] != "first@example.com" { + t.Fatalf("verified profile: %#v", claims) + } + srv.LinkEvent = func(current *jaws.Session, _ *http.Request, newClaims map[string]any) (int, error) { + links++ + if current != sess || current.Get(srv.SessionKey).(map[string]any)["sub"] != "9007199254740993" || newClaims["sub"] != "99" { + return http.StatusForbidden, errors.New("proof mismatch") + } + return 0, nil + } + _, linkState := profileStart(t, srv, http.MethodPost, "/auth/link/github", cookie) + time.Sleep(61 * time.Second) + linked := profileCallback(srv, cookie, linkState, "second") + if linked.Code != http.StatusFound || links != 1 || logins != 1 || calls != 4 || len(linked.Result().Cookies()) != 0 { + t.Fatalf("link: status=%d links=%d logins=%d calls=%d", linked.Code, links, logins, calls) + } + if jw.GetSession(request) != sess || sess.Get(srv.SessionKey).(map[string]any)["sub"] != "9007199254740993" { + t.Fatal("link replaced the original proof") + } + replayed := profileCallback(srv, cookie, linkState, "second") + if replayed.Code != http.StatusBadRequest || calls != 4 || links != 1 { + t.Fatalf("replay: status=%d calls=%d links=%d", replayed.Code, calls, links) + } + }) +} + +func TestOAuthProfileExpiredLinkStateNeverCallsProvider(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + oldCookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + first := profileCallback(srv, oldCookie, state, "first") + cookie := first.Result().Cookies()[0] + srv.LinkEvent = func(*jaws.Session, *http.Request, map[string]any) (int, error) { + t.Fatal("expired link reached link event") + return 0, nil + } + _, linkState := profileStart(t, srv, http.MethodPost, "/auth/link/github", cookie) + r := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + r.RemoteAddr = "192.0.2.1:1234" + r.AddCookie(cookie) + sess := jw.GetSession(r) + sess.Set(srv.sessionKey(oauth2FlowExpiryKey), time.Now().Add(-time.Second)) + before := calls + response := profileCallback(srv, cookie, linkState, "second") + if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), ErrOAuth2ExpiredState.Error()) || calls != before { + t.Fatalf("expired: status=%d calls=%d body=%q", response.Code, calls, response.Body.String()) + } +} + +func TestOAuthLinkRequiresCurrentSameOriginProofAndRejectsDeclinedLink(t *testing.T) { + for _, reject := range []struct { + name string + err error + }{ + {name: "explicit error", err: errors.New("secret persistence detail")}, + {name: "status alone"}, + } { + t.Run(reject.name, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + missing := httptest.NewRequest(http.MethodPost, "https://app.example/auth/link/github", nil) + missing.RemoteAddr = "192.0.2.1:1234" + w := httptest.NewRecorder() + srv.LinkEvent = func(*jaws.Session, *http.Request, map[string]any) (int, error) { + return http.StatusForbidden, reject.err + } + srv.HandleLink(w, missing) + if w.Code != http.StatusForbidden || calls != 0 { + t.Fatalf("anonymous link: status=%d calls=%d", w.Code, calls) + } + oldCookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + first := profileCallback(srv, oldCookie, state, "first") + if first.Code != http.StatusFound { + t.Fatalf("first sign-in: %d", first.Code) + } + cookie := first.Result().Cookies()[0] + cross := httptest.NewRequest(http.MethodPost, "https://app.example/auth/link/github", nil) + cross.RemoteAddr = "192.0.2.1:1234" + cross.Header.Set("Origin", "https://evil.example") + cross.AddCookie(cookie) + w = httptest.NewRecorder() + srv.HandleLink(w, cross) + if w.Code != http.StatusForbidden || calls != 2 { + t.Fatalf("cross-origin link: status=%d calls=%d", w.Code, calls) + } + _, linkState := profileStart(t, srv, http.MethodPost, "/auth/link/github", cookie) + declined := profileCallback(srv, cookie, linkState, "second") + if declined.Code != http.StatusForbidden || declined.Header().Get("Location") != "" || + !strings.Contains(declined.Body.String(), ErrOAuth2LinkRejected.Error()) || + strings.Contains(declined.Body.String(), "secret persistence detail") || len(declined.Result().Cookies()) != 0 { + t.Fatalf("declined link: status=%d body=%q", declined.Code, declined.Body.String()) + } + r := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + r.RemoteAddr = "192.0.2.1:1234" + r.AddCookie(cookie) + sess := jw.GetSession(r) + if sess == nil || sess.Get(srv.SessionKey).(map[string]any)["sub"] != "9007199254740993" { + t.Fatal("declined link changed original proof") + } + }) + } +} + +func TestOAuthProfileRejectsInvalidSubjectAndOversizedResponse(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + srv.profileSubject = func(map[string]any) (string, error) { return "", ErrOAuth2InvalidProfile } + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + response := profileCallback(srv, cookie, state, "first") + if response.Code != http.StatusUnauthorized || calls != 2 || len(response.Result().Cookies()) != 0 { + t.Fatalf("invalid subject: status=%d calls=%d", response.Code, calls) + } + request := httptest.NewRequest(http.MethodGet, "https://app.example/", nil) + request.RemoteAddr = "192.0.2.1:1234" + request.AddCookie(cookie) + if sess := jw.GetSession(request); sess == nil || sess.Get(srv.SessionKey) != nil { + t.Fatal("invalid profile authenticated the preauth session") + } + oversized := &Server{profileSubject: srv.profileSubject} + ctx := context.WithValue(t.Context(), oauth2.HTTPClient, &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return profileTestResponse(`{"id":42}` + strings.Repeat(" ", 32769)), nil + })}) + _, err = oversized.fetchUserInfo(ctx, "https://provider.example/user", oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "secret"})) + if !errors.Is(err, ErrUserInfoTooLarge) { + t.Fatalf("oversized profile: %v", err) + } +} + +func TestOAuthProfileExpiresWithoutRefresh(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + factory := &testAuthTimerFactory{} + srv.authTimerAfterFunc = factory.after + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + response := profileCallback(srv, cookie, state, "first") + if response.Code != http.StatusFound || factory.len() != 1 { + t.Fatalf("profile sign-in: status=%d timers=%d", response.Code, factory.len()) + } + if delay := factory.timer(0).delay; delay < 23*time.Hour || delay > 24*time.Hour { + t.Fatalf("profile auth lifetime: %s", delay) + } + r := httptest.NewRequest(http.MethodGet, "https://app.example/", nil) + r.RemoteAddr = "192.0.2.1:1234" + r.AddCookie(response.Result().Cookies()[0]) + sess := jw.GetSession(r) + factory.timer(0).fire() + if sess.Get(srv.SessionKey) != nil || !factory.timer(0).isStopped() { + t.Fatal("profile auth remained after expiry timer") + } +} diff --git a/server.go b/server.go index 728f6a3..dce94eb 100644 --- a/server.go +++ b/server.go @@ -71,7 +71,11 @@ type EventFunc func(sess *jaws.Session, hr *http.Request) // when logging them and escape them for the output format when rendering them. type FailedFunc func(hw http.ResponseWriter, hr *http.Request, httpCode int, err error, email string) (wroteresponse bool) -// Server provides OIDC-verified authentication for JaWS sessions. +// LinkFunc receives the current session and a newly verified identity before +// any session rotation. Return zero and nil on success, or an HTTP error status. +type LinkFunc func(sess *jaws.Session, hr *http.Request, claims map[string]any) (statusCode int, err error) + +// Server provides OIDC or verified OAuth2 profile authentication for JaWS sessions. // // Create one with New or NewDebug. Its methods are safe for concurrent use; the // exported configuration fields and callbacks should be set before serving requests. @@ -85,6 +89,7 @@ type Server struct { LoginEvent EventFunc // if not nil, called after a successful login LogoutEvent EventFunc // if not nil, called after auth is cleared; hr may be nil LoginFailed FailedFunc // if not nil, called on failed login + LinkEvent LinkFunc // if not nil, enables HandleLink Options []oauth2.AuthCodeOption // options to use, see https://pkg.go.dev/golang.org/x/oauth2#AuthCodeOption // RequireVerifiedAdminEmail requires email_verified for a non-empty admin list. @@ -97,6 +102,8 @@ type Server struct { oauth2cfg *oauth2.Config idTokenVerifier *oidc.IDTokenVerifier userinfoUrl string + profileSubject func(map[string]any) (string, error) + issuer string httpClient *http.Client ishttps bool mu sync.Mutex // protects following fields and session email/verification pairs @@ -114,9 +121,9 @@ type Server struct { // development. // // A nil jw returns ErrServerNilJaws and a nil Server. Otherwise a non-nil Server is -// always returned. OIDC is configured, and the login, logout and callback handlers -// registered via handleFn, only when cfg, handleFn and cfg.RedirectURL are all provided; -// any error from OIDC discovery is returned alongside the not-yet-Valid Server. +// always returned. Authentication is configured, and the login, logout and callback +// handlers registered via handleFn, only when cfg, handleFn and cfg.RedirectURL are +// all provided; setup errors are returned alongside the not-yet-Valid Server. func NewDebug(jw *jaws.Jaws, cfg *Config, handleFn HandleFunc, overrideUrl string) (srv *Server, err error) { if jw == nil { err = ErrServerNilJaws @@ -136,6 +143,7 @@ func NewDebug(jw *jaws.Jaws, cfg *Config, handleFn HandleFunc, overrideUrl strin } // #nosec G101 if cfg != nil { srv.namespace = cfg.Namespace + srv.issuer = cfg.Issuer if cfg.Namespace != "" { srv.SessionKey = srv.sessionKey(srv.SessionKey) srv.SessionTokenKey = srv.sessionKey(srv.SessionTokenKey) @@ -144,8 +152,11 @@ func NewDebug(jw *jaws.Jaws, cfg *Config, handleFn HandleFunc, overrideUrl strin } } if cfg != nil && handleFn != nil && cfg.RedirectURL != "" { - if srv.oauth2cfg, srv.userinfoUrl, srv.idTokenVerifier, err = cfg.buildContext(context.Background(), overrideUrl); err == nil { + setupCtx, cancel := context.WithTimeout(context.Background(), oauth2ProviderTimeout) + defer cancel() + if srv.oauth2cfg, srv.userinfoUrl, srv.idTokenVerifier, err = cfg.buildContext(setupCtx, overrideUrl); err == nil { srv.httpClient = cfg.HTTPClient + srv.profileSubject = cfg.ProfileSubject var u *url.URL if u, err = url.Parse(srv.oauth2cfg.RedirectURL); err == nil { srv.ishttps = (u.Scheme == "https") @@ -161,11 +172,11 @@ func NewDebug(jw *jaws.Jaws, cfg *Config, handleFn HandleFunc, overrideUrl strin return } -// New creates a Server providing OIDC-verified authentication for JaWS sessions. +// New creates a Server providing OIDC or OAuth2 profile authentication for JaWS sessions. // // It configures the Server from cfg and registers the login, logout and OAuth2 // callback endpoints via handleFn. A nil jw returns ErrServerNilJaws. Use Valid to -// test whether OIDC authentication was successfully configured. +// test whether authentication was successfully configured. func New(jw *jaws.Jaws, cfg *Config, handleFn HandleFunc) (srv *Server, err error) { return NewDebug(jw, cfg, handleFn, "") } @@ -301,17 +312,17 @@ func (srv *Server) get403Handler() (h http.Handler) { return } -// Valid returns true if OIDC authentication is configured. +// Valid returns true if OIDC or OAuth2 profile authentication is configured. func (srv *Server) Valid() bool { - return srv != nil && srv.oauth2cfg != nil && srv.idTokenVerifier != nil + return srv != nil && srv.oauth2cfg != nil && (srv.idTokenVerifier != nil || srv.profileSubject != nil) } // wrap returns a http.Handler that requires an authenticated user before invoking h. // // When admin is true the user must additionally be an administrator (see SetAdmins), // otherwise the 403 handler is served. Unauthenticated requests are redirected into the -// OIDC login flow (HandleLogin), which verifies the id_token and stores the claims in -// srv.SessionKey (with optional UserInfo fallback) before the user returns. If the +// configured login flow (HandleLogin), which verifies the provider identity and stores +// claims in srv.SessionKey before the user returns. If the // Server is not Valid, returns h. func (srv *Server) wrap(h http.Handler, admin bool) (rh http.Handler) { rh = h @@ -324,7 +335,7 @@ func (srv *Server) wrap(h http.Handler, admin bool) (rh http.Handler) { // WrapAdmin returns a http.Handler that requires an authenticated administrator // before invoking h. // -// Unauthenticated requests are redirected into the OIDC login flow (HandleLogin); +// Unauthenticated requests are redirected into the configured login flow (HandleLogin); // authenticated users whose email is not an admin (see SetAdmins and IsAdmin) are // served the 403 handler instead of h. [Server.RequireVerifiedAdminEmail] also // applies. If the Server is not Valid, returns h. @@ -334,9 +345,9 @@ func (srv *Server) WrapAdmin(h http.Handler) (rh http.Handler) { // Wrap returns a http.Handler that requires an authenticated user before invoking h. // -// Unauthenticated requests are redirected into the OIDC login flow (HandleLogin), which -// verifies the id_token and stores the claims in srv.SessionKey (with optional UserInfo -// fallback) before the user returns. If the Server is not Valid, returns h. +// Unauthenticated requests are redirected into the configured login flow +// (HandleLogin), which stores verified claims in srv.SessionKey before the user +// returns. If the Server is not Valid, returns h. func (srv *Server) Wrap(h http.Handler) (rh http.Handler) { return srv.wrap(h, false) } @@ -344,7 +355,7 @@ func (srv *Server) Wrap(h http.Handler) (rh http.Handler) { // HandlerAdmin returns a http.Handler that renders the named jaws.Template with dot // and requires an authenticated administrator. // -// Unauthenticated requests are redirected into the OIDC login flow (HandleLogin); +// Unauthenticated requests are redirected into the configured login flow (HandleLogin); // authenticated non-admins (see SetAdmins and IsAdmin) are served the 403 handler. // [Server.RequireVerifiedAdminEmail] also applies. // If the Server is not Valid, the template handler is returned without the @@ -356,9 +367,9 @@ func (srv *Server) HandlerAdmin(name string, dot any) http.Handler { // Handler returns a http.Handler that renders the named jaws.Template with dot // and requires an authenticated user. // -// Unauthenticated requests are redirected into the OIDC login flow (HandleLogin), -// which verifies the id_token and stores the claims in srv.SessionKey (with optional -// UserInfo fallback) before the user returns. If the Server is not Valid, the template +// Unauthenticated requests are redirected into the configured login flow +// (HandleLogin), which stores verified claims in srv.SessionKey before the user +// returns. If the Server is not Valid, the template // handler is returned without the authentication requirement. func (srv *Server) Handler(name string, dot any) http.Handler { return srv.wrap(ui.Handler(srv.Jaws, name, dot), false) diff --git a/sessionlifecycle_test.go b/sessionlifecycle_test.go index fb3c39f..3df4454 100644 --- a/sessionlifecycle_test.go +++ b/sessionlifecycle_test.go @@ -173,6 +173,7 @@ func TestLoginRotationExistingAuth(t *testing.T) { } } sess.Set(oauth2StateKey, "state") + sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) sess.Set(oauth2NonceKey, "nonce") sess.Set(oauth2PKCEVerifierKey, oauth2.GenerateVerifier()) rec := httptest.NewRecorder() @@ -338,6 +339,7 @@ func TestLoginPreparesUserInfoBeforeRotation(t *testing.T) { srv.LoginEvent = func(*jaws.Session, *http.Request) { logins++ } srv.LogoutEvent = func(*jaws.Session, *http.Request) { logouts++ } sess.Set(oauth2StateKey, "state") + sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) sess.Set(oauth2NonceKey, "nonce") sess.Set(oauth2PKCEVerifierKey, oauth2.GenerateVerifier()) hw := httptest.NewRecorder() From 1f26ab60e8fea5175a5d1a2fe27ebc6a94b162a7 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 10:12:10 +0100 Subject: [PATCH 03/43] fix(auth): bound provider callbacks and session retention --- README.md | 6 ++- authsession.go | 15 +++++++- cmd/go.mod | 3 +- cmd/go.sum | 6 ++- config.go | 22 ++++------- currentclaims_test.go | 52 ++++++++++++++++++++++++++ jawsauth.go | 26 ++++++++++++- oauth2.go | 6 ++- oauth2_test.go | 55 +++++++++++++++++++++++++++ profile_link_test.go | 87 +++++++++++++++++++++++++++++++++++++++++-- 10 files changed, 252 insertions(+), 26 deletions(-) create mode 100644 currentclaims_test.go diff --git a/README.md b/README.md index 07c7d7b..6fc1255 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,11 @@ Login callback token/profile calls and startup discovery have a 15-second deadli profile responses are capped at 32 KiB. For multiple providers on one JaWS instance, give each `Config` a distinct -`Namespace` and callback path. `New` sets `Jaws.MakeAuth` for its own Server, so +`Namespace` and callback directory (for example, `/oauth/github/callback` and +`/oauth/gitlab/callback`). `New` sets `Jaws.MakeAuth` for its own Server, so install a composite `MakeAuth` after creating all provider Servers if pages need -to recognize more than one provider. `HandleLink` is an opt-in POST handler: +to recognize more than one provider. Use `Server.CurrentClaims` to check each +provider's live session auth there. `HandleLink` is an opt-in POST handler: authorize the existing session before calling it, then set `Server.LinkEvent` to recheck the original identity and accept the newly verified subject. A successful link keeps the original session; ordinary login still rotates it. Link callbacks diff --git a/authsession.go b/authsession.go index 36cfd09..686025f 100644 --- a/authsession.go +++ b/authsession.go @@ -12,6 +12,7 @@ import ( ) const authRefreshSkew = 10 * time.Second +const profileAuthCheckInterval = time.Minute var errOIDCStaleIDToken = errors.New("oidc stale id_token") var errAuthTimerStale = errors.New("auth timer stale") @@ -233,7 +234,7 @@ func (srv *Server) refreshSessionAuth(ctx context.Context, sess *jaws.Session, m func (srv *Server) scheduleSessionAuthTimerLocked(sess *jaws.Session, expiry time.Time) (delay time.Duration, replaced bool) { delay = max(time.Until(expiry.Add(-authRefreshSkew)), 0) if srv.profileSubject != nil { - delay = max(time.Until(expiry), 0) + delay = min(max(time.Until(expiry), 0), profileAuthCheckInterval) } entry := &authTimerState{expiry: expiry} if srv.authTimers == nil { @@ -277,7 +278,17 @@ func (srv *Server) stopSessionAuthTimerLocked(sess *jaws.Session, entry *authTim func (srv *Server) handleSessionAuthTimer(sess *jaws.Session, entry *authTimerState) { if srv.sessionAuthTimerCurrent(sess, entry) { if srv.profileSubject != nil { - srv.clearSessionAuth(sess, nil, entry) + if !sessionAlive(sess) || !time.Now().Before(entry.expiry) { + srv.clearSessionAuth(sess, nil, entry) + return + } + srv.mu.Lock() + if srv.authTimers[sess.ID()] == entry { + entry.timer = srv.authTimerAfterFunc(min(time.Until(entry.expiry), profileAuthCheckInterval), func() { + srv.handleSessionAuthTimer(sess, entry) + }) + } + srv.mu.Unlock() return } if !sessionAlive(sess) { diff --git a/cmd/go.mod b/cmd/go.mod index 40baa1d..7a66c9f 100644 --- a/cmd/go.mod +++ b/cmd/go.mod @@ -4,7 +4,7 @@ go 1.27.0 require ( github.com/linkdata/deadlock v0.5.5 - github.com/linkdata/jaws v0.808.0 + github.com/linkdata/jaws v0.901.2-0.20261104081709-b5ee7ec23e15 github.com/linkdata/jawsauth v0.0.0 github.com/linkdata/webserv v1.4.2 github.com/moby/moby/api v1.55.0 @@ -37,6 +37,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/klauspost/compress v1.19.2 // indirect github.com/linkdata/jq v0.6.0 // indirect + github.com/linkdata/rate v1.17.1 // indirect github.com/linkdata/secureheaders v1.5.0 // indirect github.com/linkdata/staticserve v1.1.8 // indirect github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect diff --git a/cmd/go.sum b/cmd/go.sum index 5ecccc5..6c339ae 100644 --- a/cmd/go.sum +++ b/cmd/go.sum @@ -54,10 +54,12 @@ github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/linkdata/deadlock v0.5.5 h1:d6O+rzEqasSfamGDA8u7bjtaq7hOX8Ha4Zn36Wxrkvo= github.com/linkdata/deadlock v0.5.5/go.mod h1:tXb28stzAD3trzEEK0UJWC+rZKuobCoPktPYzebb1u0= -github.com/linkdata/jaws v0.808.0 h1:63JUxe/dARqNIb6FsPKyWO/Aiso3cSgG4T5An+QywnY= -github.com/linkdata/jaws v0.808.0/go.mod h1:WAjBu+Fgwzf8ydzQJDP6x+WL/5/w5O3bYyFrjtvazAc= +github.com/linkdata/jaws v0.901.2-0.20261104081709-b5ee7ec23e15 h1:lhEtb6Ok6GnUNHl65ADniyyY1kkrUH1b8ueg3hmvvyc= +github.com/linkdata/jaws v0.901.2-0.20261104081709-b5ee7ec23e15/go.mod h1:4l/hXl4qhhEzBMyej3tXT3tLlkEyz1iAAEwWS6Yxpqg= github.com/linkdata/jq v0.6.0 h1:yxAeWsknIWmt7Wbr5CIEkj2i1WwC2DnJDZlOrPxJkr0= github.com/linkdata/jq v0.6.0/go.mod h1:b76MMuWybyXrVEKSHJkv+r5IEattnghPf1bNRJ5PNlE= +github.com/linkdata/rate v1.17.1 h1:b3ld6CaQQzDxk/ChR7x1iDGMQIFyWIZAKQknZWAmiuc= +github.com/linkdata/rate v1.17.1/go.mod h1:VMPlHbG1mu28l0QsvRF3cMN0/pmOmEW+7vq622fx54Y= github.com/linkdata/secureheaders v1.5.0 h1:4kI290hrVUYqluxF8pQfRYpbZuorSd9+8GlMgrLcrK4= github.com/linkdata/secureheaders v1.5.0/go.mod h1:50TiKmPaWki8gIZXmBxrLUinyJst12YuN3EbsXZMO+4= github.com/linkdata/staticserve v1.1.8 h1:qpm0dAaAsik1YrETSiMoS7ciBL9qPVNUFcfdio/ol84= diff --git a/config.go b/config.go index da75283..a5653ba 100644 --- a/config.go +++ b/config.go @@ -84,18 +84,14 @@ func validateUrl(k, u, defaultURL string, optional bool) (value string, err erro // // RedirectURL, Issuer and ClientID must be present. URL fields must be absolute // and include a host. OIDC endpoint overrides are optional; profile endpoints -// are required and must use HTTPS. Issuer must use HTTPS unless -// AllowInsecureIssuer is true. Returned failures match [ErrConfig]. +// are required and must use HTTPS, or HTTP when AllowInsecureIssuer is true. +// The same scheme rule applies to Issuer. Returned failures match [ErrConfig]. func (cfg *Config) Validate() (err error) { if _, err = validateUrl("RedirectURL", cfg.RedirectURL, "", false); err == nil { if _, err = validateUrl("Issuer", cfg.Issuer, "", false); err == nil { - if !cfg.AllowInsecureIssuer { - var issuer *url.URL - if issuer, err = url.Parse(cfg.Issuer); err == nil { - if issuer.Scheme != "https" { - err = errConfig{field: "Issuer", cause: ErrConfigIssuerMustBeHTTPS} - } - } + issuer, _ := url.Parse(cfg.Issuer) + if issuer.Scheme != "https" && (!cfg.AllowInsecureIssuer || issuer.Scheme != "http") { + err = errConfig{field: "Issuer", cause: ErrConfigIssuerMustBeHTTPS} } if err == nil { if cfg.ProfileSubject != nil { @@ -105,11 +101,9 @@ func (cfg *Config) Validate() (err error) { if _, err = validateUrl(field.name, field.value, "", false); err != nil { return err } - if !cfg.AllowInsecureIssuer { - parsed, _ := url.Parse(field.value) - if parsed.Scheme != "https" { - return errConfig{field: field.name, cause: ErrConfigProfileEndpointMustBeHTTPS} - } + parsed, _ := url.Parse(field.value) + if parsed.Scheme != "https" && (!cfg.AllowInsecureIssuer || parsed.Scheme != "http") { + return errConfig{field: field.name, cause: ErrConfigProfileEndpointMustBeHTTPS} } } return requireStr("ClientID", cfg.ClientID) diff --git a/currentclaims_test.go b/currentclaims_test.go new file mode 100644 index 0000000..0ded57e --- /dev/null +++ b/currentclaims_test.go @@ -0,0 +1,52 @@ +package jawsauth + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/linkdata/jaws" +) + +func TestServerCurrentClaimsChecksExpiryAndJaWSSession(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + srv := &Server{Jaws: jw, SessionKey: "claims", namespace: "github"} + sess := jw.NewSession(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "https://app.example/", nil)) + if sess == nil { + t.Fatal("missing session") + } + if (*Server)(nil).CurrentClaims(sess) != nil || srv.CurrentClaims(nil) != nil || srv.CurrentClaims(sess) != nil { + t.Fatal("nil or unauthenticated session returned claims") + } + claims := map[string]any{"iss": "https://github.com", "sub": "42"} + sess.Set(srv.SessionKey, claims) + if srv.CurrentClaims(sess) != nil { + t.Fatal("claims without verified expiry were accepted") + } + sess.Set(srv.sessionKey(oauth2IDTokenExpiryKey), time.Now().Add(time.Hour)) + if got := srv.CurrentClaims(sess); got["sub"] != "42" { + t.Fatalf("live claims: %v", got) + } + other, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer other.Close() + if (&Server{Jaws: other, SessionKey: srv.SessionKey, namespace: srv.namespace}).CurrentClaims(sess) != nil { + t.Fatal("another JaWS instance accepted session") + } + sess.Set(srv.sessionKey(oauth2IDTokenExpiryKey), time.Now().Add(-time.Second)) + if srv.CurrentClaims(sess) != nil { + t.Fatal("expired authentication returned claims") + } + sess.Set(srv.sessionKey(oauth2IDTokenExpiryKey), time.Now().Add(time.Hour)) + sess.Close() + if srv.CurrentClaims(sess) != nil { + t.Fatal("closed session returned claims") + } +} diff --git a/jawsauth.go b/jawsauth.go index 8876265..d1af44c 100644 --- a/jawsauth.go +++ b/jawsauth.go @@ -1,6 +1,30 @@ package jawsauth -import "github.com/linkdata/jaws" +import ( + "time" + + "github.com/linkdata/jaws" +) + +// CurrentClaims returns this provider's claims only while authentication and +// the JaWS session are live. It is useful when several providers share JaWS. +// Treat the returned session map as read-only, and recheck before protected writes +// because the session may close after this call. +func (srv *Server) CurrentClaims(sess *jaws.Session) map[string]any { + if srv == nil || srv.Jaws == nil || srv.SessionKey == "" || sess == nil || sess.Jaws() != srv.Jaws { + return nil + } + srv.mu.Lock() + defer srv.mu.Unlock() + if current, _ := srv.sessionAuthStatus(sess, time.Now); !current { + return nil + } + claims, _ := sess.Get(srv.SessionKey).(map[string]any) + if !sessionAlive(sess) { + return nil + } + return claims +} // JawsAuth exposes authenticated session data to JaWS templates. // Its zero value is safe to use and reports no user data. diff --git a/oauth2.go b/oauth2.go index 2529dc2..d261f7d 100644 --- a/oauth2.go +++ b/oauth2.go @@ -340,6 +340,7 @@ func extractEmailVerified(claims map[string]any) (verified bool) { func (srv *Server) fetchUserInfo(ctx context.Context, userinfoURL string, tokenSource oauth2.TokenSource) (userinfo map[string]any, err error) { if userinfoURL != "" && tokenSource != nil { client := oauth2.NewClient(ctx, tokenSource) + client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } var resp *http.Response if resp, err = client.Get(userinfoURL); /*#nosec G704*/ err == nil { defer func() { @@ -404,7 +405,7 @@ func (srv *Server) verifyOAuthIdentity(ctx context.Context, token *oauth2.Token, return } tokenSource = srv.oauth2cfg.TokenSource(srv.oauth2Context(context.Background()), token) - srv.addUserInfoClaims(ctx, claims, tokenSource) + srv.addUserInfoClaims(ctx, claims, oauth2.StaticTokenSource(token)) return claims, tokenSource, idToken.Expiry, status, nil } if srv.profileSubject == nil || token.AccessToken == "" || !token.Valid() { @@ -415,6 +416,9 @@ func (srv *Server) verifyOAuthIdentity(ctx context.Context, token *oauth2.Token, if err != nil { return } + if !token.Valid() { + return nil, nil, time.Time{}, http.StatusUnauthorized, ErrOAuth2InvalidProfile + } status = http.StatusUnauthorized if claims == nil { return nil, nil, time.Time{}, status, ErrOAuth2InvalidProfile diff --git a/oauth2_test.go b/oauth2_test.go index a93d3b8..8255717 100644 --- a/oauth2_test.go +++ b/oauth2_test.go @@ -780,6 +780,61 @@ func TestServer_fetchUserInfoStatusError(t *testing.T) { } } +func TestServer_fetchUserInfoDoesNotFollowBearerRedirect(t *testing.T) { + target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Error("UserInfo redirect target received the bearer token request") + })) + defer target.Close() + provider := httptest.NewServer(http.HandlerFunc(func(hw http.ResponseWriter, hr *http.Request) { + if hr.Header.Get("Authorization") != "Bearer secret" { + t.Errorf("original UserInfo authorization = %q", hr.Header.Get("Authorization")) + } + http.Redirect(hw, hr, target.URL+"/profile", http.StatusFound) + })) + defer provider.Close() + + _, err := (&Server{}).fetchUserInfo(t.Context(), provider.URL+"/userinfo", + oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "secret", TokenType: "Bearer"})) + if !errors.Is(err, ErrUserInfoStatus) || !strings.Contains(err.Error(), "302 Found") { + t.Fatalf("redirect response = %v, want 302 without following", err) + } +} + +func TestVerifyOIDCUserInfoDoesNotRefreshDuringCallback(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + srv := newWrapperTestServer(jw, "https://issuer.example") + srv.userinfoUrl = "https://provider.example/userinfo" + userinfoCalls, refreshCalls := 0, 0 + client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch req.URL.Path { + case "/userinfo": + userinfoCalls++ + return profileTestResponse(`{"sub":"one","email":"user@example.com"}`), nil + case "/token": + refreshCalls++ + return profileTestResponse(`{"access_token":"refreshed","token_type":"Bearer"}`), nil + default: + t.Fatalf("unexpected provider request %s", req.URL) + return nil, nil + } + })} + ctx := context.WithValue(t.Context(), oauth2.HTTPClient, client) + raw := makeIDToken(t, map[string]any{ + "iss": "https://issuer.example", "aud": "client", "sub": "one", "nonce": "nonce", + "exp": time.Now().Add(time.Hour).Unix(), + }) + token := makeOAuth2Token("access", raw, "refresh") + token.Expiry = time.Now().Add(time.Second) // Inside oauth2's refresh skew. + claims, source, _, _, err := srv.verifyOAuthIdentity(ctx, token, "nonce") + if err != nil || source == nil || claims["email"] != "user@example.com" || userinfoCalls != 1 || refreshCalls != 0 { + t.Fatalf("claims=%v source=%t userinfo=%d refresh=%d err=%v", claims, source != nil, userinfoCalls, refreshCalls, err) + } +} + func TestServer_fetchUserInfoCloseError(t *testing.T) { wantErr := errors.New("close response body") client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { diff --git a/profile_link_test.go b/profile_link_test.go index f09be56..0bdabb1 100644 --- a/profile_link_test.go +++ b/profile_link_test.go @@ -42,6 +42,38 @@ func TestOAuthProfileConfigRequiresExplicitSecureEndpoints(t *testing.T) { } } +func TestOAuthProfileConfigInsecureModeAllowsHTTPOnly(t *testing.T) { + base := Config{RedirectURL: "https://app.example/auth/callback", Issuer: "https://provider.example", + AuthURL: "https://provider.example/authorize", TokenURL: "https://provider.example/token", + UserInfoURL: "https://provider.example/user", ClientID: "app", AllowInsecureIssuer: true, + ProfileSubject: func(map[string]any) (string, error) { return "42", nil }} + for _, tc := range []struct { + name string + set func(*Config) + want error + }{ + {"issuer", func(c *Config) { c.Issuer = "ftp://provider.example" }, ErrConfigIssuerMustBeHTTPS}, + {"authorize", func(c *Config) { c.AuthURL = "ftp://provider.example/authorize" }, ErrConfigProfileEndpointMustBeHTTPS}, + {"token", func(c *Config) { c.TokenURL = "ftp://provider.example/token" }, ErrConfigProfileEndpointMustBeHTTPS}, + {"userinfo", func(c *Config) { c.UserInfoURL = "ftp://provider.example/user" }, ErrConfigProfileEndpointMustBeHTTPS}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := base + tc.set(&cfg) + if err := cfg.Validate(); !errors.Is(err, tc.want) { + t.Fatalf("scheme validation: %v", err) + } + }) + } + base.Issuer = "http://provider.example" + base.AuthURL = "http://provider.example/authorize" + base.TokenURL = "http://provider.example/token" + base.UserInfoURL = "http://provider.example/user" + if err := base.Validate(); err != nil { + t.Fatalf("HTTP dev endpoints: %v", err) + } +} + func TestOAuthProfileAuthLifetimeCapsLongProviderToken(t *testing.T) { srv := &Server{issuer: "https://provider.example", userinfoUrl: "https://provider.example/user", profileSubject: func(map[string]any) (string, error) { return "42", nil }} @@ -430,6 +462,22 @@ func TestOAuthProfileRejectsInvalidSubjectAndOversizedResponse(t *testing.T) { } } +func TestOAuthProfileRejectsTokenExpiredDuringUserInfo(t *testing.T) { + token := &oauth2.Token{AccessToken: "access", TokenType: "Bearer", Expiry: time.Now().Add(11 * time.Second)} + srv := &Server{ + userinfoUrl: "https://provider.example/user", + profileSubject: func(map[string]any) (string, error) { return "42", nil }, + } + ctx := context.WithValue(t.Context(), oauth2.HTTPClient, &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + token.Expiry = time.Now().Add(-time.Second) + return profileTestResponse(`{"id":42}`), nil + })}) + claims, source, expiry, status, err := srv.verifyOAuthIdentity(ctx, token, "") + if !errors.Is(err, ErrOAuth2InvalidProfile) || claims != nil || source != nil || !expiry.IsZero() || status != http.StatusUnauthorized { + t.Fatalf("expired profile: claims=%v source=%t expiry=%v status=%d err=%v", claims, source != nil, expiry, status, err) + } +} + func TestOAuthProfileExpiresWithoutRefresh(t *testing.T) { jw, err := jaws.New() if err != nil { @@ -445,15 +493,48 @@ func TestOAuthProfileExpiresWithoutRefresh(t *testing.T) { if response.Code != http.StatusFound || factory.len() != 1 { t.Fatalf("profile sign-in: status=%d timers=%d", response.Code, factory.len()) } - if delay := factory.timer(0).delay; delay < 23*time.Hour || delay > 24*time.Hour { - t.Fatalf("profile auth lifetime: %s", delay) + if delay := factory.timer(0).delay; delay <= 0 || delay > profileAuthCheckInterval { + t.Fatalf("profile auth cleanup interval: %s", delay) } r := httptest.NewRequest(http.MethodGet, "https://app.example/", nil) r.RemoteAddr = "192.0.2.1:1234" r.AddCookie(response.Result().Cookies()[0]) sess := jw.GetSession(r) factory.timer(0).fire() - if sess.Get(srv.SessionKey) != nil || !factory.timer(0).isStopped() { + if sess.Get(srv.SessionKey) == nil || factory.len() != 2 { + t.Fatal("profile auth cleared before its expiry") + } + srv.mu.Lock() + srv.authTimers[sess.ID()].expiry = time.Now().Add(-time.Second) + srv.mu.Unlock() + factory.timer(1).fire() + if sess.Get(srv.SessionKey) != nil || !factory.timer(1).isStopped() { t.Fatal("profile auth remained after expiry timer") } } + +func TestOAuthProfileTimerReleasesClosedSession(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + factory := &testAuthTimerFactory{} + srv.authTimerAfterFunc = factory.after + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + response := profileCallback(srv, cookie, state, "first") + if response.Code != http.StatusFound || factory.len() != 1 { + t.Fatalf("profile sign-in: status=%d timers=%d", response.Code, factory.len()) + } + r := httptest.NewRequest(http.MethodGet, "https://app.example/", nil) + r.RemoteAddr = "192.0.2.1:1234" + r.AddCookie(response.Result().Cookies()[0]) + sess := jw.GetSession(r) + sess.Close() + factory.timer(0).fire() + if sess.Get(srv.SessionKey) != nil || len(srv.authTimers) != 0 || !factory.timer(0).isStopped() { + t.Fatal("closed profile session remained pinned by auth timer") + } +} From ea903d1cf3b1549ebd4a60e443378eaac8752126 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 10:24:03 +0100 Subject: [PATCH 04/43] test(auth): isolate overlapping provider flows --- namespace_test.go | 73 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 57 insertions(+), 16 deletions(-) diff --git a/namespace_test.go b/namespace_test.go index df7bd6e..550f260 100644 --- a/namespace_test.go +++ b/namespace_test.go @@ -1,6 +1,7 @@ package jawsauth import ( + "errors" "net/http" "net/http/httptest" "testing" @@ -20,7 +21,7 @@ func TestOAuthFlowNamespacesShareSessionWithoutSharingState(t *testing.T) { if err != nil { t.Fatal(err) } - srv.oauth2cfg = &oauth2.Config{ClientID: namespace, RedirectURL: "https://app.example/auth/callback", + srv.oauth2cfg = &oauth2.Config{ClientID: namespace, RedirectURL: "https://app.example/auth/" + namespace + "/callback", Endpoint: oauth2.Endpoint{AuthURL: "https://provider.example/authorize", TokenURL: "https://provider.example/token"}} return srv } @@ -29,25 +30,65 @@ func TestOAuthFlowNamespacesShareSessionWithoutSharingState(t *testing.T) { github.SessionEmailKey == gitlab.SessionEmailKey || github.SessionEmailVerifiedKey == gitlab.SessionEmailVerifiedKey { t.Fatal("provider auth keys overlap") } - r := httptest.NewRequest(http.MethodGet, "https://app.example/auth/login", nil) - for _, srv := range []*Server{github, gitlab} { - response := httptest.NewRecorder() - srv.HandleLogin(response, r) - if response.Code != http.StatusFound { - t.Fatalf("%s start: %d", srv.namespace, response.Code) - } + r := httptest.NewRequest(http.MethodGet, "https://app.example/auth/github/login", nil) + login := httptest.NewRecorder() + github.HandleLogin(login, r) + if login.Code != http.StatusFound { + t.Fatalf("github start: %d", login.Code) + } + gitlab.LinkEvent = func(*jaws.Session, *http.Request, map[string]any) (int, error) { return 0, nil } + linkRequest := httptest.NewRequest(http.MethodPost, "https://app.example/auth/gitlab/link", nil) + for _, cookie := range login.Result().Cookies() { + linkRequest.AddCookie(cookie) + } + link := httptest.NewRecorder() + gitlab.HandleLink(link, linkRequest) + if link.Code != http.StatusFound { + t.Fatalf("gitlab link start: %d", link.Code) } sess := jw.GetSession(r) - if sess == nil { + if sess == nil || jw.GetSession(linkRequest) != sess { t.Fatal("shared session missing") } - first, _ := sess.Get(github.sessionKey(oauth2StateKey)).(string) - second, _ := sess.Get(gitlab.sessionKey(oauth2StateKey)).(string) - if first == "" || second == "" || first == second { - t.Fatalf("provider states overlap: first=%q second=%q", first, second) + type flowState struct { + state, verifier, nonce string + link bool + } + read := func(srv *Server) flowState { + state, _ := sess.Get(srv.sessionKey(oauth2StateKey)).(string) + verifier, _ := sess.Get(srv.sessionKey(oauth2PKCEVerifierKey)).(string) + nonce, _ := sess.Get(srv.sessionKey(oauth2NonceKey)).(string) + link, _ := sess.Get(srv.sessionKey(oauth2LinkKey)).(bool) + return flowState{state, verifier, nonce, link} + } + githubState, gitlabState := read(github), read(gitlab) + if githubState.state == "" || githubState.verifier == "" || githubState.nonce == "" || + gitlabState.state == "" || gitlabState.verifier == "" || gitlabState.nonce == "" || + githubState.state == gitlabState.state || githubState.verifier == gitlabState.verifier || githubState.nonce == gitlabState.nonce || + githubState.link || !gitlabState.link { + t.Fatal("provider flows overlap or lack proof") + } + if _, err := github.takeOAuthFlow(sess, gitlabState.state); !errors.Is(err, ErrOAuth2WrongState) { + t.Fatalf("github accepted gitlab state: %v", err) + } + if _, err := gitlab.takeOAuthFlow(sess, githubState.state); !errors.Is(err, ErrOAuth2WrongState) { + t.Fatalf("gitlab accepted github state: %v", err) + } + if read(github) != githubState || read(gitlab) != gitlabState { + t.Fatal("wrong-provider state changed a pending flow") + } + gotGitlab, err := gitlab.takeOAuthFlow(sess, gitlabState.state) + if err != nil || gotGitlab.verifier != gitlabState.verifier || gotGitlab.nonce != gitlabState.nonce || !gotGitlab.link { + t.Fatalf("gitlab link flow mismatch: %v", err) + } + if read(github) != githubState || read(gitlab).state != "" { + t.Fatal("consuming gitlab flow changed github flow") + } + gotGithub, err := github.takeOAuthFlow(sess, githubState.state) + if err != nil || gotGithub.verifier != githubState.verifier || gotGithub.nonce != githubState.nonce || gotGithub.link { + t.Fatalf("github login flow mismatch: %v", err) } - github.clearSessionOAuthFlow(sess) - if sess.Get(github.sessionKey(oauth2StateKey)) != nil || sess.Get(gitlab.sessionKey(oauth2StateKey)) != second { - t.Fatal("clearing one provider's flow cleared the other") + if read(github).state != "" { + t.Fatal("github flow remained after consumption") } } From 4726e13e67ec2a24ef00953c16fd2bdab93b6c83 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 10:47:52 +0100 Subject: [PATCH 05/43] fix: cancel stale OAuth callbacks on logout --- authsession.go | 14 +++++ oauth2.go | 70 ++++++++++++++++++----- oauth2_test.go | 15 +++++ profile_link_test.go | 130 +++++++++++++++++++++++++++++++++++++++++++ server.go | 1 + 5 files changed, 216 insertions(+), 14 deletions(-) diff --git a/authsession.go b/authsession.go index 686025f..34ca5be 100644 --- a/authsession.go +++ b/authsession.go @@ -102,6 +102,10 @@ func sessionAlive(sess *jaws.Session) bool { } func (srv *Server) storeSessionAuthClaims(sess *jaws.Session, claims map[string]any, tokenSource oauth2.TokenSource, expiry time.Time, entry *authTimerState) error { + return srv.storeSessionAuthClaimsIfActive(sess, claims, tokenSource, expiry, entry, nil) +} + +func (srv *Server) storeSessionAuthClaimsIfActive(sess *jaws.Session, claims map[string]any, tokenSource oauth2.TokenSource, expiry time.Time, entry *authTimerState, canceled <-chan struct{}) error { if srv == nil { return ErrOAuth2NotConfigured } @@ -114,6 +118,12 @@ func (srv *Server) storeSessionAuthClaims(sess *jaws.Session, claims map[string] email, verified := srv.extractEmail(claims) // Logger callbacks must run outside srv.mu. claims["email_verified"] = verified srv.mu.Lock() + select { + case <-canceled: + srv.mu.Unlock() + return ErrOAuth2MissingSession + default: + } if entry != nil && srv.authTimers[sess.ID()] != entry { srv.mu.Unlock() return errAuthTimerStale @@ -354,6 +364,10 @@ func (srv *Server) handleSessionAuthTimer(sess *jaws.Session, entry *authTimerSt } func (srv *Server) clearSessionOAuthFlow(sess *jaws.Session) { + if canceled := srv.oauthCallbacks[sess]; canceled != nil { + close(canceled) + delete(srv.oauthCallbacks, sess) + } sess.Set(srv.sessionKey(oauth2StateKey), nil) sess.Set(srv.sessionKey(oauth2FlowExpiryKey), nil) sess.Set(srv.sessionKey(oauth2LinkKey), nil) diff --git a/oauth2.go b/oauth2.go index d261f7d..dab51f4 100644 --- a/oauth2.go +++ b/oauth2.go @@ -40,6 +40,16 @@ type pendingOAuthFlow struct { nonce string referrer string link bool + canceled chan struct{} +} + +func (flow pendingOAuthFlow) revoked() bool { + select { + case <-flow.canceled: + return true + default: + return false + } } func (srv *Server) takeOAuthFlow(sess *jaws.Session, gotState string) (flow pendingOAuthFlow, err error) { @@ -61,9 +71,25 @@ func (srv *Server) takeOAuthFlow(sess *jaws.Session, gotState string) (flow pend if !time.Now().Before(expiry) { return flow, ErrOAuth2ExpiredState } + flow.canceled = make(chan struct{}) + if srv.oauthCallbacks == nil { + srv.oauthCallbacks = make(map[*jaws.Session]chan struct{}) + } + srv.oauthCallbacks[sess] = flow.canceled return flow, nil } +func (srv *Server) finishOAuthFlow(sess *jaws.Session, flow pendingOAuthFlow) { + if flow.canceled == nil { + return + } + srv.mu.Lock() + if srv.oauthCallbacks[sess] == flow.canceled { + delete(srv.oauthCallbacks, sess) + } + srv.mu.Unlock() +} + // maxRedirectTargetLen bounds return targets retained in pre-auth sessions. const maxRedirectTargetLen = 8192 @@ -176,6 +202,7 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link boo location = "/" } srv.mu.Lock() + srv.clearSessionOAuthFlow(sess) sess.Set(srv.sessionKey(oauth2StateKey), state) sess.Set(srv.sessionKey(oauth2FlowExpiryKey), flowExpiry) sess.Set(srv.sessionKey(oauth2LinkKey), link) @@ -348,11 +375,14 @@ func (srv *Server) fetchUserInfo(ctx context.Context, userinfoURL string, tokenS err = closeErr } }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w %s", ErrUserInfoStatus, resp.Status) + } var body []byte if body, err = io.ReadAll(io.LimitReader(resp.Body, 32769)); err == nil { if len(body) > 32768 { err = ErrUserInfoTooLarge - } else if resp.StatusCode == http.StatusOK { + } else { decoder := json.NewDecoder(bytes.NewReader(body)) if srv.profileSubject != nil { decoder.UseNumber() @@ -364,8 +394,6 @@ func (srv *Server) fetchUserInfo(ctx context.Context, userinfoURL string, tokenS err = ErrOAuth2InvalidProfile } } - } else { - err = fmt.Errorf("%w %s", ErrUserInfoStatus, resp.Status) } } } @@ -475,6 +503,7 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) flow, flowErr := srv.takeOAuthFlow(sess, gotState) err = flowErr if err == nil { + defer srv.finishOAuthFlow(sess, flow) if statusCode, err = oauth2CallbackError(statusCode, hr); err == nil { err = ErrOAuth2MissingPKCEVerifier if flow.verifier != "" { @@ -491,10 +520,13 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) var expiry time.Time claims, tokenSource, expiry, statusCode, err = srv.verifyOAuthIdentity(authctx, token, flow.nonce) if err == nil { - if flow.link { + if flow.revoked() || srv.Jaws.GetSession(hr) != sess { + err = ErrOAuth2MissingSession + statusCode = http.StatusForbidden + } else if flow.link { statusCode = http.StatusForbidden err = ErrOAuth2MissingSession - if srv.LinkEvent != nil && srv.Jaws.GetSession(hr) == sess { + if srv.LinkEvent != nil && !flow.revoked() && srv.Jaws.GetSession(hr) == sess { var linkErr error statusCode, linkErr = srv.LinkEvent(sess, hr, claims) if linkErr == nil && statusCode == 0 { @@ -522,16 +554,26 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) err = ErrOAuth2MissingSession statusCode = http.StatusServiceUnavailable if rotated := srv.Jaws.NewSession(hw, hr); rotated != nil { - srv.mu.Lock() - hadTimer := srv.authTimers[sess.ID()] != nil - srv.stopSessionAuthTimerLocked(sess, nil) - srv.mu.Unlock() - reloadAuthRequests(requests) - if hadAuth && hadTimer && srv.LogoutEvent != nil { - srv.LogoutEvent(sess, hr) + if flow.revoked() { + http.SetCookie(hw, rotated.Close()) + err = ErrOAuth2MissingSession + statusCode = http.StatusForbidden + } else { + srv.mu.Lock() + hadTimer := srv.authTimers[sess.ID()] != nil + srv.stopSessionAuthTimerLocked(sess, nil) + srv.mu.Unlock() + reloadAuthRequests(requests) + if hadAuth && hadTimer && srv.LogoutEvent != nil { + srv.LogoutEvent(sess, hr) + } + sess = rotated + err = srv.storeSessionAuthClaimsIfActive(sess, claims, tokenSource, expiry, nil, flow.canceled) + if errors.Is(err, ErrOAuth2MissingSession) { + http.SetCookie(hw, rotated.Close()) + statusCode = http.StatusForbidden + } } - sess = rotated - err = srv.storeSessionAuthClaims(sess, claims, tokenSource, expiry, nil) } else { hw.Header().Set("Retry-After", "60") } diff --git a/oauth2_test.go b/oauth2_test.go index 8255717..3a780ea 100644 --- a/oauth2_test.go +++ b/oauth2_test.go @@ -780,6 +780,21 @@ func TestServer_fetchUserInfoStatusError(t *testing.T) { } } +func TestServer_fetchUserInfoOversizedStatusError(t *testing.T) { + provider := httptest.NewServer(http.HandlerFunc(func(hw http.ResponseWriter, hr *http.Request) { + _ = hr + hw.WriteHeader(http.StatusServiceUnavailable) + _, _ = io.WriteString(hw, strings.Repeat("x", 32769)) + })) + defer provider.Close() + + _, err := (&Server{}).fetchUserInfo(t.Context(), provider.URL, + oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "token", TokenType: "Bearer"})) + if !errors.Is(err, ErrUserInfoStatus) || errors.Is(err, ErrUserInfoTooLarge) || !strings.Contains(err.Error(), "503 Service Unavailable") { + t.Fatalf("oversized error response = %v, want status error", err) + } +} + func TestServer_fetchUserInfoDoesNotFollowBearerRedirect(t *testing.T) { target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Error("UserInfo redirect target received the bearer token request") diff --git a/profile_link_test.go b/profile_link_test.go index 0bdabb1..71ef651 100644 --- a/profile_link_test.go +++ b/profile_link_test.go @@ -200,6 +200,136 @@ func TestOAuthFailedCallbackDoesNotEraseNewFlow(t *testing.T) { } } +func TestOAuthProfileCallbackCanceledBeforeRotation(t *testing.T) { + for _, action := range []string{"logout", "new flow"} { + t.Run(action, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + entered := make(chan struct{}) + release := make(chan struct{}) + var releaseOnce sync.Once + defer releaseOnce.Do(func() { close(release) }) + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + srv.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/token" { + close(entered) + <-release + return profileTestResponse(`{"access_token":"first-token","token_type":"Bearer"}`), nil + } + return profileTestResponse(`{"id":42,"email":"first@example.com"}`), nil + })} + logins := 0 + srv.LoginEvent = func(*jaws.Session, *http.Request) { logins++ } + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + request := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + request.RemoteAddr = "192.0.2.1:1234" + request.AddCookie(cookie) + sess := jw.GetSession(request) + if sess == nil { + t.Fatal("missing pre-auth session") + } + result := make(chan *httptest.ResponseRecorder, 1) + go func() { result <- profileCallback(srv, cookie, state, "first") }() + <-entered + + var nextState string + if action == "logout" { + if !srv.Logout(sess, nil) { + t.Fatal("logout did not clear session") + } + } else { + start := httptest.NewRequest(http.MethodGet, "https://app.example/auth/login", nil) + start.RemoteAddr = "192.0.2.1:1234" + start.AddCookie(cookie) + w := httptest.NewRecorder() + srv.HandleLogin(w, start) + if w.Code != http.StatusFound { + t.Fatalf("new flow start: %d", w.Code) + } + location, err := url.Parse(w.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + nextState = location.Query().Get("state") + if nextState == "" || nextState == state { + t.Fatalf("new flow state = %q", nextState) + } + } + + releaseOnce.Do(func() { close(release) }) + w := <-result + if w.Code != http.StatusForbidden || len(w.Result().Cookies()) != 0 || logins != 0 { + t.Fatalf("canceled callback: status=%d cookies=%v logins=%d", w.Code, w.Result().Cookies(), logins) + } + storedState, _ := sess.Get(srv.sessionKey(oauth2StateKey)).(string) + if sess.Get(srv.SessionKey) != nil || storedState != nextState { + t.Fatalf("canceled callback changed session or new flow: claims=%v state=%v", sess.Get(srv.SessionKey), sess.Get(srv.sessionKey(oauth2StateKey))) + } + }) + } +} + +func TestOAuthProfileLinkCanceledByLogout(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + entered := make(chan struct{}) + release := make(chan struct{}) + var releaseOnce sync.Once + defer releaseOnce.Do(func() { close(release) }) + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + srv.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/token" { + if err := r.ParseForm(); err != nil { + return nil, err + } + if r.PostForm.Get("code") == "second" { + close(entered) + <-release + } + return profileTestResponse(`{"access_token":"first-token","token_type":"Bearer"}`), nil + } + return profileTestResponse(`{"id":42,"email":"first@example.com"}`), nil + })} + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + login := profileCallback(srv, cookie, state, "first") + if login.Code != http.StatusFound || len(login.Result().Cookies()) != 1 { + t.Fatalf("initial login: status=%d cookies=%v", login.Code, login.Result().Cookies()) + } + cookie = login.Result().Cookies()[0] + request := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + request.RemoteAddr = "192.0.2.1:1234" + request.AddCookie(cookie) + sess := jw.GetSession(request) + if sess == nil || sess.Get(srv.SessionKey) == nil { + t.Fatal("missing authenticated session") + } + links := 0 + srv.LinkEvent = func(*jaws.Session, *http.Request, map[string]any) (int, error) { + links++ + return 0, nil + } + _, linkState := profileStart(t, srv, http.MethodPost, "/auth/link/github", cookie) + result := make(chan *httptest.ResponseRecorder, 1) + go func() { result <- profileCallback(srv, cookie, linkState, "second") }() + <-entered + if !srv.Logout(sess, nil) { + t.Fatal("logout did not clear linked session") + } + releaseOnce.Do(func() { close(release) }) + w := <-result + if w.Code != http.StatusForbidden || len(w.Result().Cookies()) != 0 || links != 0 || sess.Get(srv.SessionKey) != nil { + t.Fatalf("canceled link: status=%d cookies=%v links=%d claims=%v", w.Code, w.Result().Cookies(), links, sess.Get(srv.SessionKey)) + } +} + func newProfileTestServer(t *testing.T, jw *jaws.Jaws, calls *int) *Server { t.Helper() client := &http.Client{Timeout: time.Second, Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { diff --git a/server.go b/server.go index dce94eb..d749eb3 100644 --- a/server.go +++ b/server.go @@ -110,6 +110,7 @@ type Server struct { admins map[string]struct{} // if not empty, emails of admins handle403 http.Handler // handler for 403 Forbidden authTimers map[uint64]*authTimerState + oauthCallbacks map[*jaws.Session]chan struct{} authTimerAfterFunc authTimerAfterFunc namespace string } From ee259301dccb4030ebd80f67aff11836e55e6f22 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 11:14:45 +0100 Subject: [PATCH 06/43] fix: reject invalid OAuth identities at callback completion --- authsession.go | 24 +++- debug.go | 1 + oauth2.go | 41 +++++-- oauth2_callback_edge_test.go | 213 +++++++++++++++++++++++++++++++++++ 4 files changed, 271 insertions(+), 8 deletions(-) create mode 100644 oauth2_callback_edge_test.go diff --git a/authsession.go b/authsession.go index 34ca5be..ea61b22 100644 --- a/authsession.go +++ b/authsession.go @@ -17,6 +17,7 @@ const profileAuthCheckInterval = time.Minute var errOIDCStaleIDToken = errors.New("oidc stale id_token") var errAuthTimerStale = errors.New("auth timer stale") var errOIDCInvalidExpiry = errors.New("oidc invalid exp") +var errOIDCMissingSubject = errors.New("oidc missing sub") type authTimer interface { Stop() bool @@ -101,6 +102,16 @@ func sessionAlive(sess *jaws.Session) bool { return cookie != nil && cookie.MaxAge >= 0 } +func (srv *Server) identityExpiryError(expiry time.Time) error { + if expiry.After(time.Now()) { + return nil + } + if srv.idTokenVerifier == nil { + return ErrOAuth2InvalidProfile + } + return errOIDC{kind: ErrOIDCInvalidIDToken, cause: errOIDCInvalidExpiry} +} + func (srv *Server) storeSessionAuthClaims(sess *jaws.Session, claims map[string]any, tokenSource oauth2.TokenSource, expiry time.Time, entry *authTimerState) error { return srv.storeSessionAuthClaimsIfActive(sess, claims, tokenSource, expiry, entry, nil) } @@ -124,6 +135,12 @@ func (srv *Server) storeSessionAuthClaimsIfActive(sess *jaws.Session, claims map return ErrOAuth2MissingSession default: } + if canceled != nil { + if err := srv.identityExpiryError(expiry); err != nil { + srv.mu.Unlock() + return err + } + } if entry != nil && srv.authTimers[sess.ID()] != entry { srv.mu.Unlock() return errAuthTimerStale @@ -163,6 +180,9 @@ func (srv *Server) setSessionAuthFromToken(ctx context.Context, sess *jaws.Sessi if rawIDToken != "" { var idToken *oidc.IDToken if idToken, err = srv.idTokenVerifier.Verify(ctx, rawIDToken); wrapOIDC(ErrOIDCInvalidIDToken, &err) == nil { + if idToken.Subject == "" { + return errOIDC{kind: ErrOIDCInvalidIDToken, cause: errOIDCMissingSubject} + } var claims map[string]any if err = idToken.Claims(&claims); wrapOIDC(ErrOIDCInvalidIDToken, &err) == nil { if idToken.Expiry.IsZero() { @@ -171,7 +191,9 @@ func (srv *Server) setSessionAuthFromToken(ctx context.Context, sess *jaws.Sessi err = errOIDC{kind: ErrOIDCInvalidIDToken, cause: errOIDCStaleIDToken} } else { srv.addUserInfoClaims(ctx, claims, tokenSource) - err = srv.storeSessionAuthClaims(sess, claims, tokenSource, idToken.Expiry, entry) + if err = srv.identityExpiryError(idToken.Expiry); err == nil { + err = srv.storeSessionAuthClaims(sess, claims, tokenSource, idToken.Expiry, entry) + } } } } diff --git a/debug.go b/debug.go index 3a32980..07e685b 100644 --- a/debug.go +++ b/debug.go @@ -286,6 +286,7 @@ func errorDebugClasses(err error) (classes []string) { classes = appendErrorDebugClass(classes, err, ErrOIDCNonceMismatch, "oidc_nonce_mismatch") classes = appendErrorDebugClass(classes, err, errOIDCStaleIDToken, "oidc_stale_id_token") classes = appendErrorDebugClass(classes, err, errOIDCInvalidExpiry, "oidc_invalid_expiry") + classes = appendErrorDebugClass(classes, err, errOIDCMissingSubject, "oidc_missing_subject") classes = appendErrorDebugClass(classes, err, errAuthTimerStale, "auth_timer_stale") return } diff --git a/oauth2.go b/oauth2.go index dab51f4..b35133f 100644 --- a/oauth2.go +++ b/oauth2.go @@ -420,6 +420,9 @@ func (srv *Server) verifyOAuthIdentity(ctx context.Context, token *oauth2.Token, if wrapOIDC(ErrOIDCInvalidIDToken, &err) != nil { return } + if idToken.Subject == "" { + return nil, nil, time.Time{}, status, errOIDC{kind: ErrOIDCInvalidIDToken, cause: errOIDCMissingSubject} + } err = ErrOIDCMissingNonce if nonce == "" { return @@ -434,6 +437,9 @@ func (srv *Server) verifyOAuthIdentity(ctx context.Context, token *oauth2.Token, } tokenSource = srv.oauth2cfg.TokenSource(srv.oauth2Context(context.Background()), token) srv.addUserInfoClaims(ctx, claims, oauth2.StaticTokenSource(token)) + if err = srv.identityExpiryError(idToken.Expiry); err != nil { + return nil, nil, time.Time{}, status, err + } return claims, tokenSource, idToken.Expiry, status, nil } if srv.profileSubject == nil || token.AccessToken == "" || !token.Valid() { @@ -462,6 +468,9 @@ func (srv *Server) verifyOAuthIdentity(ctx context.Context, token *oauth2.Token, if !token.Expiry.IsZero() && token.Expiry.Before(expiry) { expiry = token.Expiry } + if err = srv.identityExpiryError(expiry); err != nil { + return nil, nil, time.Time{}, status, err + } return claims, nil, expiry, status, nil } @@ -485,6 +494,7 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) var sessValue any var sessEmail string var linked bool + var flow pendingOAuthFlow providerCtx, cancel := context.WithTimeout(hr.Context(), oauth2ProviderTimeout) defer cancel() authctx := srv.oauth2Context(providerCtx) @@ -500,8 +510,7 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) statusCode = http.StatusBadRequest if sess != nil { gotState := hr.FormValue("state") // #nosec G120 - flow, flowErr := srv.takeOAuthFlow(sess, gotState) - err = flowErr + flow, err = srv.takeOAuthFlow(sess, gotState) if err == nil { defer srv.finishOAuthFlow(sess, flow) if statusCode, err = oauth2CallbackError(statusCode, hr); err == nil { @@ -520,7 +529,9 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) var expiry time.Time claims, tokenSource, expiry, statusCode, err = srv.verifyOAuthIdentity(authctx, token, flow.nonce) if err == nil { - if flow.revoked() || srv.Jaws.GetSession(hr) != sess { + if err = srv.identityExpiryError(expiry); err != nil { + statusCode = http.StatusUnauthorized + } else if flow.revoked() || srv.Jaws.GetSession(hr) != sess { err = ErrOAuth2MissingSession statusCode = http.StatusForbidden } else if flow.link { @@ -569,9 +580,13 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) } sess = rotated err = srv.storeSessionAuthClaimsIfActive(sess, claims, tokenSource, expiry, nil, flow.canceled) - if errors.Is(err, ErrOAuth2MissingSession) { + if err != nil { http.SetCookie(hw, rotated.Close()) - statusCode = http.StatusForbidden + if errors.Is(err, ErrOAuth2MissingSession) { + statusCode = http.StatusForbidden + } else { + statusCode = http.StatusUnauthorized + } } } } else { @@ -597,8 +612,20 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) if sess != nil { if sessValue == nil { srv.Jaws.Dirty(sess) - } else if !linked && srv.LoginEvent != nil { - srv.LoginEvent(sess, hr) + } else if !linked { + if srv.LoginEvent != nil && !flow.revoked() { + srv.LoginEvent(sess, hr) + } + current, present := srv.sessionAuthStatus(sess, time.Now) + if flow.revoked() || !current || !sessionAlive(sess) { + if present { + srv.Logout(sess, hr) + } + http.SetCookie(hw, sess.Close()) + hw.Header().Del("Location") + err = ErrOAuth2MissingSession + statusCode = http.StatusForbidden + } } } if err != nil && srv.LoginFailed != nil { diff --git a/oauth2_callback_edge_test.go b/oauth2_callback_edge_test.go new file mode 100644 index 0000000..d599510 --- /dev/null +++ b/oauth2_callback_edge_test.go @@ -0,0 +1,213 @@ +package jawsauth + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "testing/synctest" + "time" + + "github.com/linkdata/jaws" + "golang.org/x/oauth2" +) + +func TestOIDCCallbackRejectsEmptySubject(t *testing.T) { + for _, link := range []bool{false, true} { + name := "sign-in" + if link { + name = "link" + } + t.Run(name, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + srv := newWrapperTestServer(jw, "https://issuer.example") + idToken := makeIDToken(t, map[string]any{ + "iss": "https://issuer.example", "aud": "client", "sub": "", "nonce": "nonce", + "exp": time.Now().Add(time.Hour).Unix(), + }) + srv.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return profileTestResponse(`{"access_token":"access","token_type":"Bearer","id_token":"` + idToken + `"}`), nil + })} + logins, links := 0, 0 + srv.LoginEvent = func(*jaws.Session, *http.Request) { logins++ } + srv.LinkEvent = func(*jaws.Session, *http.Request, map[string]any) (int, error) { + links++ + return 0, nil + } + req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state&code=code", nil) + w := httptest.NewRecorder() + sess := jw.NewSession(w, req) + sess.Set(oauth2StateKey, "state") + sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) + sess.Set(oauth2PKCEVerifierKey, oauth2.GenerateVerifier()) + sess.Set(oauth2NonceKey, "nonce") + sess.Set(oauth2LinkKey, link) + if link { + sess.Set(srv.SessionKey, map[string]any{"sub": "existing"}) + } + + srv.HandleAuthResponse(w, req) + if w.Code != http.StatusUnauthorized || !strings.Contains(w.Body.String(), ErrOIDCInvalidIDToken.Error()) || + jw.GetSession(req) != sess || logins != 0 || links != 0 || len(w.Result().Cookies()) != 1 { + t.Fatalf("empty subject: status=%d body=%q logins=%d links=%d cookies=%v", w.Code, w.Body.String(), logins, links, w.Result().Cookies()) + } + if link && sess.Get(srv.SessionKey).(map[string]any)["sub"] != "existing" { + t.Fatal("link callback changed original authentication") + } + if !link && sess.Get(srv.SessionKey) != nil { + t.Fatal("empty subject authenticated pre-auth session") + } + if !link { + refreshReq := httptest.NewRequest(http.MethodGet, "http://example.com/refresh", nil) + refreshSess := jw.NewSession(httptest.NewRecorder(), refreshReq) + err := srv.setSessionAuthFromToken(t.Context(), refreshSess, nil, makeOAuth2Token("access", idToken, ""), time.Time{}, nil) + if !errors.Is(err, ErrOIDCInvalidIDToken) || refreshSess.Get(srv.SessionKey) != nil { + t.Fatalf("empty subject refresh: claims=%v err=%v", refreshSess.Get(srv.SessionKey), err) + } + } + }) + } +} + +func TestOIDCExpiryDuringUserInfoRejected(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + srv := newWrapperTestServer(jw, "https://issuer.example") + srv.userinfoUrl = "https://provider.example/userinfo" + idToken := makeIDToken(t, map[string]any{ + "iss": "https://issuer.example", "aud": "client", "sub": "person", "nonce": "nonce", + "exp": time.Now().Add(5 * time.Second).Unix(), + }) + srv.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/userinfo" { + time.Sleep(6 * time.Second) + return profileTestResponse(`{"sub":"person","email":"user@example.com"}`), nil + } + return profileTestResponse(`{"access_token":"access","token_type":"Bearer","id_token":"` + idToken + `"}`), nil + })} + req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state&code=code", nil) + w := httptest.NewRecorder() + sess := jw.NewSession(w, req) + sess.Set(oauth2StateKey, "state") + sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) + sess.Set(oauth2PKCEVerifierKey, oauth2.GenerateVerifier()) + sess.Set(oauth2NonceKey, "nonce") + + srv.HandleAuthResponse(w, req) + if w.Code != http.StatusUnauthorized || !strings.Contains(w.Body.String(), ErrOIDCInvalidIDToken.Error()) || + jw.GetSession(req) != sess || sess.Get(srv.SessionKey) != nil || len(w.Result().Cookies()) != 1 { + t.Fatalf("expired during UserInfo: status=%d body=%q cookies=%v", w.Code, w.Body.String(), w.Result().Cookies()) + } + idToken = makeIDToken(t, map[string]any{ + "iss": "https://issuer.example", "aud": "client", "sub": "person", "nonce": "nonce", + "exp": time.Now().Add(5 * time.Second).Unix(), + }) + refreshReq := httptest.NewRequest(http.MethodGet, "http://example.com/refresh", nil) + refreshSess := jw.NewSession(httptest.NewRecorder(), refreshReq) + refreshToken := makeOAuth2Token("access", idToken, "") + err = srv.setSessionAuthFromToken(srv.oauth2Context(t.Context()), refreshSess, + oauth2.StaticTokenSource(refreshToken), refreshToken, time.Time{}, nil) + if !errors.Is(err, ErrOIDCInvalidIDToken) || refreshSess.Get(srv.SessionKey) != nil { + t.Fatalf("expired refresh during UserInfo: claims=%v err=%v", refreshSess.Get(srv.SessionKey), err) + } + }) +} + +func TestProfileExpiryDuringSubjectAndFinalStoreRejected(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + srv := &Server{Jaws: jw, userinfoUrl: "https://provider.example/user", + profileSubject: func(map[string]any) (string, error) { + time.Sleep(12 * time.Second) + return "person", nil + }} + ctx := context.WithValue(t.Context(), oauth2.HTTPClient, &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return profileTestResponse(`{"id":42}`), nil + })}) + token := &oauth2.Token{AccessToken: "access", TokenType: "Bearer", Expiry: time.Now().Add(11 * time.Second)} + claims, _, _, status, err := srv.verifyOAuthIdentity(ctx, token, "") + if !errors.Is(err, ErrOAuth2InvalidProfile) || claims != nil || status != http.StatusUnauthorized { + t.Fatalf("expiry during subject: claims=%v status=%d err=%v", claims, status, err) + } + + req := httptest.NewRequest(http.MethodGet, "https://app.example/", nil) + sess := jw.NewSession(httptest.NewRecorder(), req) + srv.SessionKey = "claims" + srv.SessionTokenKey = "token" + srv.SessionEmailKey = "email" + srv.SessionEmailVerifiedKey = "verified" + canceled := make(chan struct{}) + err = srv.storeSessionAuthClaimsIfActive(sess, map[string]any{"sub": "person"}, nil, time.Now().Add(-time.Second), nil, canceled) + if !errors.Is(err, ErrOAuth2InvalidProfile) || sess.Get(srv.SessionKey) != nil { + t.Fatalf("final store of expired profile: claims=%v err=%v", sess.Get(srv.SessionKey), err) + } + }) +} + +func TestOAuthLogoutDuringLoginEventClosesRotatedSession(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + entered := make(chan *jaws.Session, 1) + release := make(chan struct{}) + var releaseOnce sync.Once + defer releaseOnce.Do(func() { close(release) }) + srv.LoginEvent = func(sess *jaws.Session, _ *http.Request) { + entered <- sess + <-release + } + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + req := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + req.RemoteAddr = "192.0.2.1:1234" + req.AddCookie(cookie) + preauth := jw.GetSession(req) + if preauth == nil { + t.Fatal("missing pre-auth session") + } + result := make(chan *httptest.ResponseRecorder, 1) + go func() { result <- profileCallback(srv, cookie, state, "first") }() + var rotated *jaws.Session + select { + case rotated = <-entered: + case <-time.After(5 * time.Second): + t.Fatal("LoginEvent was not reached") + } + if rotated == preauth || rotated.Get(srv.SessionKey) == nil { + t.Fatal("LoginEvent did not receive an authenticated rotated session") + } + if !srv.Logout(preauth, nil) { + t.Fatal("logout failed") + } + releaseOnce.Do(func() { close(release) }) + var w *httptest.ResponseRecorder + select { + case w = <-result: + case <-time.After(5 * time.Second): + t.Fatal("canceled callback did not finish") + } + cookies := w.Result().Cookies() + if w.Code != http.StatusForbidden || w.Header().Get("Location") != "" || len(cookies) != 2 || cookies[1].MaxAge >= 0 || + cookies[1].Name != cookies[0].Name || cookies[1].Value != cookies[0].Value || + rotated.Cookie().MaxAge >= 0 || rotated.Get(srv.SessionKey) != nil || srv.authTimers[rotated.ID()] != nil { + t.Fatalf("superseded login: status=%d location=%q cookies=%v claims=%v", w.Code, w.Header().Get("Location"), cookies, rotated.Get(srv.SessionKey)) + } +} From c3ee58e216882ee37be8caab71c6d5a59ceee5a2 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 11:35:03 +0100 Subject: [PATCH 07/43] fix: handle short profile tokens and offline consent --- README.md | 5 +++++ oauth2.go | 18 +++++++--------- oauth2_test.go | 49 ++++++++++++++++++++++++++++++++++++++++++++ profile_link_test.go | 29 ++++++++++++++++++++++++++ server.go | 2 +- 5 files changed, 91 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 6fc1255..0099067 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Verified OIDC or OAuth2 profile authentication for [JaWS](https://github.com/lin - OIDC mode discovers the issuer, verifies `id_token` and nonce, and refreshes before expiry. - Profile mode accepts explicit HTTPS endpoints and a `Config.ProfileSubject` verifier for providers without ID tokens. Its authenticated profile response must contain a stable subject; email and names are not identity proof. JSON numbers arrive as `json.Number` so large numeric IDs remain exact. - Both modes use authorization-code state and PKCE (S256). Pending flows expire after ten minutes; jawsauth extends only that JaWS session's grace while the user visits the provider. +- For providers that require an offline-access request to issue refresh tokens, set `Server.Options = []oauth2.AuthCodeOption{oauth2.AccessTypeOffline}` before serving. This adds `access_type=offline` to the authorization redirect, not the token request. - Profile authentication expires after 24 hours or the provider token's earlier expiry; it does not attempt OIDC refresh. - Supports admin-only handlers: `Wrap`/`Handler` for any authenticated user, `WrapAdmin`/`HandlerAdmin` gated by `SetAdmins`. @@ -29,6 +30,10 @@ authorize the existing session before calling it, then set `Server.LinkEvent` to recheck the original identity and accept the newly verified subject. A successful link keeps the original session; ordinary login still rotates it. Link callbacks run only while the original JaWS session and one-use flow state remain valid. +Successful linking keeps the session whose idle grace was extended for the +provider visit. With no connected pages, that authenticated session can retain +that grace until the original flow's grace deadline, about ten minutes plus +the callback allowance after link start. Admin allowlists match parsed addresses with ASCII case ignored; non-ASCII characters are matched exactly. Session email values use the same case rules. diff --git a/oauth2.go b/oauth2.go index b35133f..6660e2b 100644 --- a/oauth2.go +++ b/oauth2.go @@ -442,7 +442,7 @@ func (srv *Server) verifyOAuthIdentity(ctx context.Context, token *oauth2.Token, } return claims, tokenSource, idToken.Expiry, status, nil } - if srv.profileSubject == nil || token.AccessToken == "" || !token.Valid() { + if srv.profileSubject == nil || !profileTokenUnexpired(token) { return nil, nil, time.Time{}, status, ErrOAuth2InvalidProfile } status = http.StatusBadGateway @@ -450,7 +450,7 @@ func (srv *Server) verifyOAuthIdentity(ctx context.Context, token *oauth2.Token, if err != nil { return } - if !token.Valid() { + if !profileTokenUnexpired(token) { return nil, nil, time.Time{}, http.StatusUnauthorized, ErrOAuth2InvalidProfile } status = http.StatusUnauthorized @@ -474,6 +474,10 @@ func (srv *Server) verifyOAuthIdentity(ctx context.Context, token *oauth2.Token, return claims, nil, expiry, status, nil } +func profileTokenUnexpired(token *oauth2.Token) bool { + return token != nil && token.AccessToken != "" && (token.Expiry.IsZero() || token.Expiry.After(time.Now())) +} + // HandleAuthResponse handles the OIDC or OAuth2 profile callback endpoint. // // For GET requests it validates the state, exchanges the authorization code using the @@ -517,13 +521,7 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) err = ErrOAuth2MissingPKCEVerifier if flow.verifier != "" { var token *oauth2.Token - exchangeOptions := []oauth2.AuthCodeOption{ - oauth2.VerifierOption(flow.verifier), - } - if srv.profileSubject == nil { - exchangeOptions = append(exchangeOptions, oauth2.AccessTypeOffline) - } - if token, err = oauth2Config.Exchange(authctx, hr.FormValue("code") /* #nosec G120 */, exchangeOptions...); srv.Jaws.Log(err) == nil { + if token, err = oauth2Config.Exchange(authctx, hr.FormValue("code") /* #nosec G120 */, oauth2.VerifierOption(flow.verifier)); srv.Jaws.Log(err) == nil { var claims map[string]any var tokenSource oauth2.TokenSource var expiry time.Time @@ -560,7 +558,6 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) statusCode = http.StatusFound } } else { - requests := sess.Requests() _, hadAuth := srv.sessionAuthStatus(sess, nil) err = ErrOAuth2MissingSession statusCode = http.StatusServiceUnavailable @@ -574,7 +571,6 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) hadTimer := srv.authTimers[sess.ID()] != nil srv.stopSessionAuthTimerLocked(sess, nil) srv.mu.Unlock() - reloadAuthRequests(requests) if hadAuth && hadTimer && srv.LogoutEvent != nil { srv.LogoutEvent(sess, hr) } diff --git a/oauth2_test.go b/oauth2_test.go index 3a780ea..b81b4e2 100644 --- a/oauth2_test.go +++ b/oauth2_test.go @@ -815,6 +815,55 @@ func TestServer_fetchUserInfoDoesNotFollowBearerRedirect(t *testing.T) { } } +func TestOAuthOfflineOptionUsesAuthorizationOnly(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + srv := newWrapperTestServer(jw, "https://issuer.example") + srv.Options = []oauth2.AuthCodeOption{oauth2.AccessTypeOffline} + var idToken string + var tokenAccessType, tokenVerifier, tokenCode string + srv.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/token" { + t.Fatalf("unexpected provider request %s", r.URL) + } + if err := r.ParseForm(); err != nil { + return nil, err + } + tokenAccessType = r.PostForm.Get("access_type") + tokenVerifier = r.PostForm.Get("code_verifier") + tokenCode = r.PostForm.Get("code") + return profileTestResponse(`{"access_token":"access","token_type":"Bearer","id_token":"` + idToken + `"}`), nil + })} + start := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/login", nil) + startResult := httptest.NewRecorder() + srv.HandleLogin(startResult, start) + if startResult.Code != http.StatusFound || len(startResult.Result().Cookies()) != 1 { + t.Fatalf("login start: status=%d cookies=%v", startResult.Code, startResult.Result().Cookies()) + } + location, err := url.Parse(startResult.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + if location.Query().Get("access_type") != "offline" || location.Query().Get("state") == "" || location.Query().Get("nonce") == "" { + t.Fatalf("authorization location = %s", location) + } + idToken = makeIDToken(t, map[string]any{ + "iss": "https://issuer.example", "aud": "client", "sub": "person", "nonce": location.Query().Get("nonce"), + "exp": time.Now().Add(time.Hour).Unix(), + }) + callback := httptest.NewRequest(http.MethodGet, + "http://example.com/oauth2/callback?state="+url.QueryEscape(location.Query().Get("state"))+"&code=example-code", nil) + callback.AddCookie(startResult.Result().Cookies()[0]) + result := httptest.NewRecorder() + srv.HandleAuthResponse(result, callback) + if result.Code != http.StatusFound || tokenAccessType != "" || tokenVerifier == "" || tokenCode != "example-code" { + t.Fatalf("exchange: status=%d access_type=%q verifier=%q code=%q body=%q", result.Code, tokenAccessType, tokenVerifier, tokenCode, result.Body.String()) + } +} + func TestVerifyOIDCUserInfoDoesNotRefreshDuringCallback(t *testing.T) { jw, err := jaws.New() if err != nil { diff --git a/profile_link_test.go b/profile_link_test.go index 71ef651..957f4ae 100644 --- a/profile_link_test.go +++ b/profile_link_test.go @@ -608,6 +608,35 @@ func TestOAuthProfileRejectsTokenExpiredDuringUserInfo(t *testing.T) { } } +func TestOAuthProfileAcceptsShortUnexpiredToken(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + srv := &Server{Jaws: jw, issuer: "https://provider.example", userinfoUrl: "https://provider.example/user", + profileSubject: func(map[string]any) (string, error) { return "42", nil }} + calls := 0 + ctx := context.WithValue(t.Context(), oauth2.HTTPClient, &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + calls++ + if got := r.Header.Get("Authorization"); got != "Bearer access" { + t.Fatalf("userinfo authorization = %q", got) + } + return profileTestResponse(`{"id":42}`), nil + })}) + token := &oauth2.Token{AccessToken: "access", TokenType: "Bearer", Expiry: time.Now().Add(5 * time.Second)} + if token.Valid() { + t.Fatal("test token should be inside oauth2's refresh skew") + } + claims, source, expiry, status, err := srv.verifyOAuthIdentity(ctx, token, "") + if err != nil || status != http.StatusUnauthorized || calls != 1 || source != nil || claims["sub"] != "42" || + !expiry.Equal(token.Expiry) { + t.Fatalf("short token: claims=%v source=%v expiry=%v status=%d calls=%d err=%v", claims, source, expiry, status, calls, err) + } + }) +} + func TestOAuthProfileExpiresWithoutRefresh(t *testing.T) { jw, err := jaws.New() if err != nil { diff --git a/server.go b/server.go index d749eb3..2711ede 100644 --- a/server.go +++ b/server.go @@ -90,7 +90,7 @@ type Server struct { LogoutEvent EventFunc // if not nil, called after auth is cleared; hr may be nil LoginFailed FailedFunc // if not nil, called on failed login LinkEvent LinkFunc // if not nil, enables HandleLink - Options []oauth2.AuthCodeOption // options to use, see https://pkg.go.dev/golang.org/x/oauth2#AuthCodeOption + Options []oauth2.AuthCodeOption // authorization URL options, e.g. oauth2.AccessTypeOffline; never sent to token exchange // RequireVerifiedAdminEmail requires email_verified for a non-empty admin list. // From 17cad0475286e0d0ac5de48b5ac02eb368f83aaa Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 11:57:16 +0100 Subject: [PATCH 08/43] fix(oauth): honor UserInfo deadlines and token expiry Bind UserInfo requests to callback context so slow profile requests stop at the provider deadline. Reject explicit zero token expiry while retaining the cap for omitted expiry, and return an error for unconfigured link starts. --- oauth2.go | 25 +++++++++- oauth2_test.go | 56 ++++++++++++++++++++++ profile_link_test.go | 100 +++++++++++++++++++++++++++++++++++++++ sessionlifecycle_test.go | 23 +++++---- 4 files changed, 194 insertions(+), 10 deletions(-) diff --git a/oauth2.go b/oauth2.go index 6660e2b..bab639b 100644 --- a/oauth2.go +++ b/oauth2.go @@ -166,6 +166,10 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link boo return } oauth2cfg, location := srv.begin(hr) + if link && oauth2cfg == nil { + srv.writeResult(hw, http.StatusInternalServerError, ErrOAuth2NotConfigured, nil) + return + } if oauth2cfg != nil { sess := srv.Jaws.GetSession(hr) if sess == nil && !link { @@ -368,8 +372,12 @@ func (srv *Server) fetchUserInfo(ctx context.Context, userinfoURL string, tokenS if userinfoURL != "" && tokenSource != nil { client := oauth2.NewClient(ctx, tokenSource) client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + var request *http.Request + if request, err = http.NewRequestWithContext(ctx, http.MethodGet, userinfoURL, nil); err != nil { + return nil, err + } var resp *http.Response - if resp, err = client.Get(userinfoURL); /*#nosec G704*/ err == nil { + if resp, err = client.Do(request); /*#nosec G704*/ err == nil { defer func() { if closeErr := resp.Body.Close(); err == nil && closeErr != nil { err = closeErr @@ -475,7 +483,20 @@ func (srv *Server) verifyOAuthIdentity(ctx context.Context, token *oauth2.Token, } func profileTokenUnexpired(token *oauth2.Token) bool { - return token != nil && token.AccessToken != "" && (token.Expiry.IsZero() || token.Expiry.After(time.Now())) + if token == nil || token.AccessToken == "" { + return false + } + if token.Expiry.IsZero() { + // oauth2 uses zero Expiry for both omitted and explicit zero expires_in. + switch expiresIn := token.Extra("expires_in").(type) { + case nil: + return true + case string: + return expiresIn == "" // omitted in a form-encoded token response + } + return false + } + return token.Expiry.After(time.Now()) } // HandleAuthResponse handles the OIDC or OAuth2 profile callback endpoint. diff --git a/oauth2_test.go b/oauth2_test.go index b81b4e2..bd916f1 100644 --- a/oauth2_test.go +++ b/oauth2_test.go @@ -780,6 +780,43 @@ func TestServer_fetchUserInfoStatusError(t *testing.T) { } } +func TestServer_fetchUserInfoRespectsRequestCancellation(t *testing.T) { + started := make(chan struct{}) + release := make(chan struct{}) + defer close(release) + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + close(started) + select { + case <-r.Context().Done(): + return nil, r.Context().Err() + case <-release: + return nil, errors.New("transport released without cancellation") + } + })} + ctx, cancel := context.WithCancel(context.WithValue(t.Context(), oauth2.HTTPClient, client)) + defer cancel() + result := make(chan error, 1) + go func() { + _, err := (&Server{}).fetchUserInfo(ctx, "https://provider.example/userinfo", + oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "access"})) + result <- err + }() + select { + case <-started: + case <-time.After(3 * time.Second): + t.Fatal("UserInfo request did not start") + } + cancel() + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + t.Fatalf("canceled UserInfo request: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("UserInfo request ignored cancellation") + } +} + func TestServer_fetchUserInfoOversizedStatusError(t *testing.T) { provider := httptest.NewServer(http.HandlerFunc(func(hw http.ResponseWriter, hr *http.Request) { _ = hr @@ -1544,6 +1581,25 @@ func Test_handleAuthResponseMissingNonce(t *testing.T) { } } +func TestHandleLinkWithoutOAuthConfigReturnsError(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + srv := &Server{Jaws: jw, LinkEvent: func(*jaws.Session, *http.Request, map[string]any) (int, error) { return 0, nil }} + req := httptest.NewRequest(http.MethodPost, "https://app.example/auth/link/provider", nil) + if jw.NewSession(httptest.NewRecorder(), req) == nil { + t.Fatal("missing session") + } + w := httptest.NewRecorder() + srv.HandleLink(w, req) + if w.Code != http.StatusInternalServerError || w.Header().Get("Location") != "" || + !strings.Contains(w.Body.String(), ErrOAuth2NotConfigured.Error()) { + t.Fatalf("unconfigured link: status=%d location=%q body=%q", w.Code, w.Header().Get("Location"), w.Body.String()) + } +} + func Test_handlersRejectNonGet(t *testing.T) { jw, err := jaws.New() if err != nil { diff --git a/profile_link_test.go b/profile_link_test.go index 957f4ae..0da6084 100644 --- a/profile_link_test.go +++ b/profile_link_test.go @@ -608,6 +608,46 @@ func TestOAuthProfileRejectsTokenExpiredDuringUserInfo(t *testing.T) { } } +func TestOAuthProfileUserInfoCallbackDeadline(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + var userinfoErr error + srv.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/token" { + return profileTestResponse(`{"access_token":"access","token_type":"Bearer"}`), nil + } + select { + case <-r.Context().Done(): + userinfoErr = r.Context().Err() + return nil, userinfoErr + case <-time.After(61 * time.Second): + userinfoErr = errors.New("UserInfo missed callback deadline") + return nil, userinfoErr + } + })} + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + response := profileCallback(srv, cookie, state, "code") + request := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + request.RemoteAddr = "192.0.2.1:1234" + request.AddCookie(cookie) + sess := jw.GetSession(request) + var claims any + if sess != nil { + claims = sess.Get(srv.SessionKey) + } + if response.Code != http.StatusBadGateway || !errors.Is(userinfoErr, context.DeadlineExceeded) || + len(response.Result().Cookies()) != 0 || sess == nil || claims != nil { + t.Fatalf("profile deadline: status=%d err=%v cookies=%v claims=%v", response.Code, userinfoErr, response.Result().Cookies(), claims) + } + }) +} + func TestOAuthProfileAcceptsShortUnexpiredToken(t *testing.T) { synctest.Test(t, func(t *testing.T) { jw, err := jaws.New() @@ -637,6 +677,66 @@ func TestOAuthProfileAcceptsShortUnexpiredToken(t *testing.T) { }) } +func TestOAuthProfileExplicitZeroExpiryRejectedButOmittedExpiryCapped(t *testing.T) { + for _, tc := range []struct { + name, expiryField string + form bool + wantStatus int + }{ + {"omitted", "", false, http.StatusFound}, + {"zero", `,"expires_in":0`, false, http.StatusUnauthorized}, + {"zero string", `,"expires_in":"0"`, false, http.StatusUnauthorized}, + {"form omitted", "", true, http.StatusFound}, + {"form zero", "&expires_in=0", true, http.StatusUnauthorized}, + } { + t.Run(tc.name, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + srv.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/token" { + if tc.form { + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/x-www-form-urlencoded"}}, + Body: io.NopCloser(strings.NewReader("access_token=access&token_type=Bearer" + tc.expiryField))}, nil + } + return profileTestResponse(`{"access_token":"access","token_type":"Bearer"` + tc.expiryField + `}`), nil + } + return profileTestResponse(`{"id":42}`), nil + })} + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + response := profileCallback(srv, cookie, state, "code") + if response.Code != tc.wantStatus { + t.Fatalf("status=%d body=%q", response.Code, response.Body.String()) + } + if tc.wantStatus == http.StatusUnauthorized { + if len(response.Result().Cookies()) != 0 || !strings.Contains(response.Body.String(), ErrOAuth2InvalidProfile.Error()) { + t.Fatalf("zero expiry authenticated: cookies=%v body=%q", response.Result().Cookies(), response.Body.String()) + } + return + } + cookies := response.Result().Cookies() + if len(cookies) != 1 { + t.Fatalf("rotated cookies: %v", cookies) + } + r := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + r.RemoteAddr = "192.0.2.1:1234" + r.AddCookie(cookies[0]) + sess := jw.GetSession(r) + if sess == nil || sess.Get(srv.SessionKey) == nil { + t.Fatal("omitted expiry did not authenticate") + } + expiry, _ := sess.Get(srv.sessionKey(oauth2IDTokenExpiryKey)).(time.Time) + if remaining := time.Until(expiry); remaining < 23*time.Hour || remaining > 24*time.Hour { + t.Fatalf("omitted expiry cap = %s", remaining) + } + }) + } +} + func TestOAuthProfileExpiresWithoutRefresh(t *testing.T) { jw, err := jaws.New() if err != nil { diff --git a/sessionlifecycle_test.go b/sessionlifecycle_test.go index 3df4454..f06dd60 100644 --- a/sessionlifecycle_test.go +++ b/sessionlifecycle_test.go @@ -301,9 +301,8 @@ func TestSetAdminsUsesSessionPolicy(t *testing.T) { } } -func TestLoginPreparesUserInfoBeforeRotation(t *testing.T) { - // UserInfo latency can exceed a new session's one-minute idle lifetime. - // synctest covers that interval without a wall-clock delay. +func TestLoginUserInfoDeadlineKeepsOIDCFallbackOptional(t *testing.T) { + // A slow UserInfo fallback must stop at the callback deadline before rotation. synctest.Test(t, func(t *testing.T) { jw, err := jaws.New() if err != nil { @@ -325,13 +324,20 @@ func TestLoginPreparesUserInfoBeforeRotation(t *testing.T) { })).ServeHTTP(httptest.NewRecorder(), hr) raw := makeIDToken(t, map[string]any{"iss": "https://issuer.example", "aud": "client", "sub": "user", "exp": expiry.Unix(), "nonce": "nonce", "email": "user@example.com"}) userinfoCalls := 0 + var userinfoErr error srv.userinfoUrl = "https://provider.example/userinfo" srv.httpClient = &http.Client{Transport: roundTripFunc(func(hr *http.Request) (*http.Response, error) { body := `{"access_token":"dummy","token_type":"Bearer","expires_in":3600,"id_token":"` + raw + `"}` if hr.URL.Path == "/userinfo" { userinfoCalls++ - time.Sleep(61 * time.Second) - body = `{"sub":"user","email":"user@example.com","email_verified":true}` + select { + case <-hr.Context().Done(): + userinfoErr = hr.Context().Err() + return nil, userinfoErr + case <-time.After(61 * time.Second): + userinfoErr = errors.New("UserInfo missed callback deadline") + return nil, userinfoErr + } } return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(body))}, nil })} @@ -348,15 +354,16 @@ func TestLoginPreparesUserInfoBeforeRotation(t *testing.T) { if current != nil { defer srv.Logout(current, nil) } - if hw.Code != http.StatusFound || logins != 1 || logouts != 1 || userinfoCalls != 1 { + if hw.Code != http.StatusFound || logins != 1 || logouts != 1 || userinfoCalls != 1 || + !errors.Is(userinfoErr, context.DeadlineExceeded) { t.Fatalf("status=%d logins=%d logouts=%d userinfo=%d", hw.Code, logins, logouts, userinfoCalls) } waitAuthRequestCancelled(t, ctx) if current == nil || current == sess || sess.Cookie().MaxAge >= 0 || !errors.Is(ctx.Err(), context.Canceled) { t.Fatal("login did not retire the old session and request") } - if current.Get(srv.SessionEmailVerifiedKey) != true { - t.Fatal("login did not store prepared UserInfo claims") + if current.Get(srv.SessionEmailVerifiedKey) == true { + t.Fatal("expired UserInfo fallback supplied verification") } }) } From 2db75e6ed63def629ea59bf1e0ce189a6ce768f0 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 12:15:47 +0100 Subject: [PATCH 09/43] fix: preserve OAuth flows across auth expiry --- README.md | 7 +- authsession.go | 27 +++++- authsession_test.go | 2 +- cmd/go.mod | 2 +- cmd/go.sum | 4 +- go.mod | 2 +- go.sum | 4 +- oauth2.go | 17 ++-- profile_link_test.go | 212 ++++++++++++++++++++++++++++++++++++++++++- server.go | 6 +- 10 files changed, 261 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 0099067..55f1372 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,10 @@ run only while the original JaWS session and one-use flow state remain valid. Successful linking keeps the session whose idle grace was extended for the provider visit. With no connected pages, that authenticated session can retain that grace until the original flow's grace deadline, about ten minutes plus -the callback allowance after link start. +the callback allowance after link start. `LinkEvent` must serialize its final +current-auth check and durable link write with logout and any other provider's +auth revocation. jawsauth cannot roll back an application write if revocation +finishes while `LinkEvent` is running; it rejects the stale callback response. Admin allowlists match parsed addresses with ASCII case ignored; non-ASCII characters are matched exactly. Session email values use the same case rules. @@ -63,6 +66,8 @@ and the existing session's authentication and refresh timer are preserved. Restart login after capacity becomes available. Logout and auth expiry clear authentication and reload the session's JaWS pages. +Auth expiry preserves an ordinary login in progress but cancels a pending link; +explicit logout cancels either flow. Connected pages receive the reload command before their sockets close; pending pages reload when they connect. Protected renders also recheck authorization when the HTTP handler returns, retiring requests attached during a concurrent revocation. diff --git a/authsession.go b/authsession.go index ea61b22..70d0d5d 100644 --- a/authsession.go +++ b/authsession.go @@ -161,11 +161,15 @@ func (srv *Server) storeSessionAuthClaimsIfActive(sess *jaws.Session, claims map delay, replaced := srv.scheduleSessionAuthTimerLocked(sess, expiry) srv.mu.Unlock() srv.Jaws.Dirty(sess) - srv.debugLog("jawsauth: scheduled auth refresh timer", + mode := "oidc" + if srv.profileSubject != nil { + mode = "profile" + } + srv.debugLog("jawsauth: scheduled auth timer", "session_prefix", debugSessionPrefix(sess), + "mode", mode, "expiry", expiry, "delay", delay, - "refresh_skew", authRefreshSkew, "replaced_existing", replaced, ) return nil @@ -386,8 +390,8 @@ func (srv *Server) handleSessionAuthTimer(sess *jaws.Session, entry *authTimerSt } func (srv *Server) clearSessionOAuthFlow(sess *jaws.Session) { - if canceled := srv.oauthCallbacks[sess]; canceled != nil { - close(canceled) + if flow := srv.oauthCallbacks[sess]; flow.canceled != nil { + close(flow.canceled) delete(srv.oauthCallbacks, sess) } sess.Set(srv.sessionKey(oauth2StateKey), nil) @@ -420,7 +424,20 @@ func (srv *Server) clearSessionAuth(sess *jaws.Session, hr *http.Request, entry cleared = srv.stopSessionAuthTimerLocked(sess, entry) var requests []*jaws.Request if cleared { - srv.clearSessionOAuthFlow(sess) + // Auth expiry must not cancel an ordinary login already in progress. + // Explicit logout and link flows still revoke their pending callbacks. + preserveLogin := false + if entry != nil { + if flow := srv.oauthCallbacks[sess]; flow.canceled != nil { + preserveLogin = !flow.link + } else if state, _ := sess.Get(srv.sessionKey(oauth2StateKey)).(string); state != "" { + link, _ := sess.Get(srv.sessionKey(oauth2LinkKey)).(bool) + preserveLogin = !link + } + } + if !preserveLogin { + srv.clearSessionOAuthFlow(sess) + } sess.Set(srv.SessionKey, nil) sess.Set(srv.SessionTokenKey, nil) sess.Set(srv.sessionKey(oauth2IDTokenExpiryKey), nil) diff --git a/authsession_test.go b/authsession_test.go index a4a117a..8954ffa 100644 --- a/authsession_test.go +++ b/authsession_test.go @@ -846,7 +846,7 @@ func TestAuthTimerDebugLogsRefreshFailure(t *testing.T) { factory.timer(0).fire() for _, msg := range []string{ - "jawsauth: scheduled auth refresh timer", + "jawsauth: scheduled auth timer", "jawsauth: auth refresh timer fired", "jawsauth: refresh session auth started", "jawsauth: requesting token from stored token source", diff --git a/cmd/go.mod b/cmd/go.mod index 7a66c9f..d146381 100644 --- a/cmd/go.mod +++ b/cmd/go.mod @@ -4,7 +4,7 @@ go 1.27.0 require ( github.com/linkdata/deadlock v0.5.5 - github.com/linkdata/jaws v0.901.2-0.20261104081709-b5ee7ec23e15 + github.com/linkdata/jaws v0.902.0 github.com/linkdata/jawsauth v0.0.0 github.com/linkdata/webserv v1.4.2 github.com/moby/moby/api v1.55.0 diff --git a/cmd/go.sum b/cmd/go.sum index 6c339ae..f57bc56 100644 --- a/cmd/go.sum +++ b/cmd/go.sum @@ -54,8 +54,8 @@ github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/linkdata/deadlock v0.5.5 h1:d6O+rzEqasSfamGDA8u7bjtaq7hOX8Ha4Zn36Wxrkvo= github.com/linkdata/deadlock v0.5.5/go.mod h1:tXb28stzAD3trzEEK0UJWC+rZKuobCoPktPYzebb1u0= -github.com/linkdata/jaws v0.901.2-0.20261104081709-b5ee7ec23e15 h1:lhEtb6Ok6GnUNHl65ADniyyY1kkrUH1b8ueg3hmvvyc= -github.com/linkdata/jaws v0.901.2-0.20261104081709-b5ee7ec23e15/go.mod h1:4l/hXl4qhhEzBMyej3tXT3tLlkEyz1iAAEwWS6Yxpqg= +github.com/linkdata/jaws v0.902.0 h1:YU/WN3EpAeq/I1uoEh9w1fkoiWvkgJ30LR67Ks1QJOk= +github.com/linkdata/jaws v0.902.0/go.mod h1:4l/hXl4qhhEzBMyej3tXT3tLlkEyz1iAAEwWS6Yxpqg= github.com/linkdata/jq v0.6.0 h1:yxAeWsknIWmt7Wbr5CIEkj2i1WwC2DnJDZlOrPxJkr0= github.com/linkdata/jq v0.6.0/go.mod h1:b76MMuWybyXrVEKSHJkv+r5IEattnghPf1bNRJ5PNlE= github.com/linkdata/rate v1.17.1 h1:b3ld6CaQQzDxk/ChR7x1iDGMQIFyWIZAKQknZWAmiuc= diff --git a/go.mod b/go.mod index b0a0c67..7ef9cc5 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.27.0 require ( github.com/coder/websocket v1.8.15 github.com/coreos/go-oidc/v3 v3.20.0 - github.com/linkdata/jaws v0.901.2-0.20261104081709-b5ee7ec23e15 + github.com/linkdata/jaws v0.902.0 github.com/linkdata/secureheaders v1.5.0 golang.org/x/oauth2 v0.36.0 ) diff --git a/go.sum b/go.sum index 5fb5208..9b5c00f 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/linkdata/deadlock v0.5.5 h1:d6O+rzEqasSfamGDA8u7bjtaq7hOX8Ha4Zn36Wxrkvo= github.com/linkdata/deadlock v0.5.5/go.mod h1:tXb28stzAD3trzEEK0UJWC+rZKuobCoPktPYzebb1u0= -github.com/linkdata/jaws v0.901.2-0.20261104081709-b5ee7ec23e15 h1:lhEtb6Ok6GnUNHl65ADniyyY1kkrUH1b8ueg3hmvvyc= -github.com/linkdata/jaws v0.901.2-0.20261104081709-b5ee7ec23e15/go.mod h1:4l/hXl4qhhEzBMyej3tXT3tLlkEyz1iAAEwWS6Yxpqg= +github.com/linkdata/jaws v0.902.0 h1:YU/WN3EpAeq/I1uoEh9w1fkoiWvkgJ30LR67Ks1QJOk= +github.com/linkdata/jaws v0.902.0/go.mod h1:4l/hXl4qhhEzBMyej3tXT3tLlkEyz1iAAEwWS6Yxpqg= github.com/linkdata/jq v0.6.0 h1:yxAeWsknIWmt7Wbr5CIEkj2i1WwC2DnJDZlOrPxJkr0= github.com/linkdata/jq v0.6.0/go.mod h1:b76MMuWybyXrVEKSHJkv+r5IEattnghPf1bNRJ5PNlE= github.com/linkdata/rate v1.17.1 h1:b3ld6CaQQzDxk/ChR7x1iDGMQIFyWIZAKQknZWAmiuc= diff --git a/oauth2.go b/oauth2.go index bab639b..c99b2f4 100644 --- a/oauth2.go +++ b/oauth2.go @@ -73,9 +73,9 @@ func (srv *Server) takeOAuthFlow(sess *jaws.Session, gotState string) (flow pend } flow.canceled = make(chan struct{}) if srv.oauthCallbacks == nil { - srv.oauthCallbacks = make(map[*jaws.Session]chan struct{}) + srv.oauthCallbacks = make(map[*jaws.Session]pendingOAuthFlow) } - srv.oauthCallbacks[sess] = flow.canceled + srv.oauthCallbacks[sess] = flow return flow, nil } @@ -84,7 +84,7 @@ func (srv *Server) finishOAuthFlow(sess *jaws.Session, flow pendingOAuthFlow) { return } srv.mu.Lock() - if srv.oauthCallbacks[sess] == flow.canceled { + if srv.oauthCallbacks[sess].canceled == flow.canceled { delete(srv.oauthCallbacks, sess) } srv.mu.Unlock() @@ -560,9 +560,14 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) var linkErr error statusCode, linkErr = srv.LinkEvent(sess, hr, claims) if linkErr == nil && statusCode == 0 { - err = nil - sessValue = claims - linked = true + if flow.revoked() || srv.Jaws.GetSession(hr) != sess || !sessionAlive(sess) { + err = ErrOAuth2MissingSession + statusCode = http.StatusForbidden + } else { + err = nil + sessValue = claims + linked = true + } } else { _ = srv.Jaws.Log(linkErr) err = ErrOAuth2LinkRejected diff --git a/profile_link_test.go b/profile_link_test.go index 0da6084..2f85a8f 100644 --- a/profile_link_test.go +++ b/profile_link_test.go @@ -330,6 +330,177 @@ func TestOAuthProfileLinkCanceledByLogout(t *testing.T) { } } +func TestOAuthProfileLinkRevokedDuringEventRejectsCallback(t *testing.T) { + for _, action := range []string{"logout", "auth expiry"} { + t.Run(action, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + factory := &testAuthTimerFactory{} + srv.authTimerAfterFunc = factory.after + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + first := profileCallback(srv, cookie, state, "first") + if first.Code != http.StatusFound { + t.Fatalf("initial login: %d", first.Code) + } + cookie = first.Result().Cookies()[0] + r := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + r.RemoteAddr = "192.0.2.1:1234" + r.AddCookie(cookie) + sess := jw.GetSession(r) + if sess == nil || sess.Get(srv.SessionKey) == nil { + t.Fatal("missing original authentication") + } + entered := make(chan struct{}) + release := make(chan struct{}) + var releaseOnce sync.Once + defer releaseOnce.Do(func() { close(release) }) + links := 0 + srv.LinkEvent = func(*jaws.Session, *http.Request, map[string]any) (int, error) { + links++ + close(entered) + <-release + return 0, nil + } + _, linkState := profileStart(t, srv, http.MethodPost, "/auth/link/github", cookie) + result := make(chan *httptest.ResponseRecorder, 1) + go func() { result <- profileCallback(srv, cookie, linkState, "second") }() + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("LinkEvent was not reached") + } + if action == "logout" { + if !srv.Logout(sess, nil) { + t.Fatal("logout failed") + } + } else { + srv.mu.Lock() + srv.authTimers[sess.ID()].expiry = time.Now().Add(-time.Second) + srv.mu.Unlock() + factory.timer(0).fire() + } + releaseOnce.Do(func() { close(release) }) + var response *httptest.ResponseRecorder + select { + case response = <-result: + case <-time.After(5 * time.Second): + t.Fatal("revoked link callback did not finish") + } + if response.Code != http.StatusForbidden || response.Header().Get("Location") != "" || + len(response.Result().Cookies()) != 0 || links != 1 || sess.Get(srv.SessionKey) != nil { + t.Fatalf("revoked link: status=%d location=%q cookies=%v links=%d claims=%v", + response.Code, response.Header().Get("Location"), response.Result().Cookies(), links, sess.Get(srv.SessionKey)) + } + }) + } +} + +func TestOAuthProfileAuthExpiryPreservesOrdinaryLogin(t *testing.T) { + for _, phase := range []string{"pending", "callback"} { + t.Run(phase, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + factory := &testAuthTimerFactory{} + srv.authTimerAfterFunc = factory.after + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + first := profileCallback(srv, cookie, state, "first") + if first.Code != http.StatusFound { + t.Fatalf("initial login: %d", first.Code) + } + cookie = first.Result().Cookies()[0] + start := httptest.NewRequest(http.MethodGet, "https://app.example/auth/login", nil) + start.RemoteAddr = "192.0.2.1:1234" + start.AddCookie(cookie) + sess := jw.GetSession(start) + if sess == nil { + t.Fatal("missing original session") + } + redirect := httptest.NewRecorder() + srv.HandleLogin(redirect, start) + if redirect.Code != http.StatusFound { + t.Fatalf("new login start: %d", redirect.Code) + } + location, err := url.Parse(redirect.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + state = location.Query().Get("state") + if state == "" { + t.Fatal("missing new login state") + } + var result chan *httptest.ResponseRecorder + var releaseOnce sync.Once + var release chan struct{} + if phase == "callback" { + entered := make(chan struct{}) + release = make(chan struct{}) + defer releaseOnce.Do(func() { close(release) }) + transport := srv.httpClient.Transport + srv.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/token" { + if err := r.ParseForm(); err != nil { + return nil, err + } + if r.PostForm.Get("code") == "second" { + close(entered) + <-release + } + } + return transport.RoundTrip(r) + })} + result = make(chan *httptest.ResponseRecorder, 1) + go func() { result <- profileCallback(srv, cookie, state, "second") }() + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("new login callback did not reach token exchange") + } + } + srv.mu.Lock() + srv.authTimers[sess.ID()].expiry = time.Now().Add(-time.Second) + srv.mu.Unlock() + factory.timer(0).fire() + if sess.Get(srv.SessionKey) != nil { + t.Fatal("expired original auth remained") + } + if phase == "pending" && sess.Get(srv.sessionKey(oauth2StateKey)) != state { + t.Fatal("auth expiry erased pending login state") + } + var response *httptest.ResponseRecorder + if phase == "callback" { + releaseOnce.Do(func() { close(release) }) + select { + case response = <-result: + case <-time.After(5 * time.Second): + t.Fatal("new login callback did not finish") + } + } else { + response = profileCallback(srv, cookie, state, "second") + } + if response.Code != http.StatusFound || len(response.Result().Cookies()) != 1 { + t.Fatalf("new login: status=%d cookies=%v body=%q", response.Code, response.Result().Cookies(), response.Body.String()) + } + check := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + check.RemoteAddr = "192.0.2.1:1234" + check.AddCookie(response.Result().Cookies()[0]) + rotated := jw.GetSession(check) + if rotated == nil || rotated == sess || rotated.Get(srv.SessionKey).(map[string]any)["sub"] != "99" { + t.Fatal("new login did not authenticate a fresh session") + } + }) + } +} + func newProfileTestServer(t *testing.T, jw *jaws.Jaws, calls *int) *Server { t.Helper() client := &http.Client{Timeout: time.Second, Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { @@ -503,7 +674,7 @@ func TestOAuthProfileExpiredLinkStateNeverCallsProvider(t *testing.T) { } } -func TestOAuthLinkRequiresCurrentSameOriginProofAndRejectsDeclinedLink(t *testing.T) { +func TestOAuthLinkRequiresSessionAndSameOriginRequestAndRejectsDeclinedLink(t *testing.T) { for _, reject := range []struct { name string err error @@ -772,6 +943,45 @@ func TestOAuthProfileExpiresWithoutRefresh(t *testing.T) { } } +func TestOAuthProfileAuthTimerLogIsModeSpecific(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + logger := &testAuthDebugLogger{} + jw.Debug = true + jw.Logger = logger + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + factory := &testAuthTimerFactory{} + srv.authTimerAfterFunc = factory.after + req := httptest.NewRequest(http.MethodGet, "https://app.example/", nil) + sess := jw.NewSession(httptest.NewRecorder(), req) + if err := srv.storeSessionAuthClaims(sess, map[string]any{"sub": "42"}, nil, time.Now().Add(time.Hour), nil); err != nil { + t.Fatal(err) + } + record, ok := logger.info("jawsauth: scheduled auth timer") + if !ok { + t.Fatal("missing neutral auth timer log") + } + foundMode := false + for i := 0; i+1 < len(record.args); i += 2 { + if record.args[i] == "mode" { + foundMode = true + if record.args[i+1] != "profile" { + t.Fatalf("timer mode = %v", record.args[i+1]) + } + } + if record.args[i] == "refresh_skew" { + t.Fatal("profile timer log claims refresh skew") + } + } + if !foundMode { + t.Fatal("profile timer mode missing") + } +} + func TestOAuthProfileTimerReleasesClosedSession(t *testing.T) { jw, err := jaws.New() if err != nil { diff --git a/server.go b/server.go index 2711ede..c114426 100644 --- a/server.go +++ b/server.go @@ -72,7 +72,9 @@ type EventFunc func(sess *jaws.Session, hr *http.Request) type FailedFunc func(hw http.ResponseWriter, hr *http.Request, httpCode int, err error, email string) (wroteresponse bool) // LinkFunc receives the current session and a newly verified identity before -// any session rotation. Return zero and nil on success, or an HTTP error status. +// any session rotation. Serialize the final current-auth check and durable link +// write with logout and other provider auth revocation. Return zero and nil on +// success, or an HTTP error status. type LinkFunc func(sess *jaws.Session, hr *http.Request, claims map[string]any) (statusCode int, err error) // Server provides OIDC or verified OAuth2 profile authentication for JaWS sessions. @@ -110,7 +112,7 @@ type Server struct { admins map[string]struct{} // if not empty, emails of admins handle403 http.Handler // handler for 403 Forbidden authTimers map[uint64]*authTimerState - oauthCallbacks map[*jaws.Session]chan struct{} + oauthCallbacks map[*jaws.Session]pendingOAuthFlow authTimerAfterFunc authTimerAfterFunc namespace string } From 1dfddcf8a3f24cdd255c81828ce3c4c3a8f55189 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 12:34:50 +0100 Subject: [PATCH 10/43] fix: reject OAuth token endpoint redirects --- authsession.go | 17 +++++-- config_test.go | 25 ++++++++++ oauth2_redirect_test.go | 103 ++++++++++++++++++++++++++++++++++++++++ wrapper_test.go | 10 ++-- 4 files changed, 146 insertions(+), 9 deletions(-) create mode 100644 oauth2_redirect_test.go diff --git a/authsession.go b/authsession.go index 70d0d5d..00379f3 100644 --- a/authsession.go +++ b/authsession.go @@ -70,15 +70,22 @@ func realAuthTimerAfterFunc(delay time.Duration, callback func()) authTimer { func (srv *Server) oauth2Context(ctx context.Context) (authctx context.Context) { authctx = ctx if srv != nil { - client, ok := authctx.Value(oauth2.HTTPClient).(*http.Client) - if !ok { + client, _ := authctx.Value(oauth2.HTTPClient).(*http.Client) + if client == nil { client = srv.httpClient } + if client == nil { + client = http.DefaultClient + } + clientCopy := *client + // Token POSTs carry secrets, and 307/308 redirects preserve their body. + clientCopy.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } if logger := srv.debugLogger(); logger != nil { - authctx = context.WithValue(authctx, oauth2.HTTPClient, srv.debugHTTPClient(client, logger)) - } else if !ok && client != nil { - authctx = context.WithValue(authctx, oauth2.HTTPClient, client) + client = srv.debugHTTPClient(&clientCopy, logger) + } else { + client = &clientCopy } + authctx = context.WithValue(authctx, oauth2.HTTPClient, client) } return } diff --git a/config_test.go b/config_test.go index ce0fc97..ddfaa6f 100644 --- a/config_test.go +++ b/config_test.go @@ -224,3 +224,28 @@ func TestConfig_buildContextWithHTTPClient(t *testing.T) { t.Fatal("expected oauth2 config") } } + +func TestConfig_buildContextDiscoveryStillFollowsRedirect(t *testing.T) { + var issuer string + target := httptest.NewServer(http.HandlerFunc(func(hw http.ResponseWriter, _ *http.Request) { + hw.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(hw).Encode(map[string]any{ + "issuer": issuer, + "authorization_endpoint": issuer + "/auth", + "token_endpoint": issuer + "/token", + "jwks_uri": issuer + "/jwks", + }) + })) + defer target.Close() + redirector := httptest.NewServer(http.HandlerFunc(func(hw http.ResponseWriter, hr *http.Request) { + http.Redirect(hw, hr, target.URL+"/metadata", http.StatusFound) + })) + defer redirector.Close() + issuer = redirector.URL + cfg := &Config{RedirectURL: "https://app.example/callback", Issuer: issuer, + AllowInsecureIssuer: true, ClientID: "client", HTTPClient: redirector.Client()} + got, _, _, err := cfg.buildContext(t.Context(), "") + if err != nil || got == nil || got.Endpoint.TokenURL != issuer+"/token" { + t.Fatalf("redirected discovery: config=%v err=%v", got, err) + } +} diff --git a/oauth2_redirect_test.go b/oauth2_redirect_test.go new file mode 100644 index 0000000..99f6447 --- /dev/null +++ b/oauth2_redirect_test.go @@ -0,0 +1,103 @@ +package jawsauth + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/linkdata/jaws" + "golang.org/x/oauth2" +) + +func TestOAuthTokenRequestsDoNotFollowRedirect(t *testing.T) { + for _, grant := range []string{"exchange", "refresh"} { + for _, status := range []int{http.StatusTemporaryRedirect, http.StatusPermanentRedirect} { + t.Run(fmt.Sprintf("%s/%d", grant, status), func(t *testing.T) { + var secondHop atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + secondHop.Add(1) + })) + defer target.Close() + var firstHop atomic.Int32 + provider := httptest.NewTLSServer(http.HandlerFunc(func(hw http.ResponseWriter, hr *http.Request) { + firstHop.Add(1) + if hr.URL.Path != "/token" || hr.Method != http.MethodPost { + t.Errorf("token request: %s %s", hr.Method, hr.URL.Path) + } + if err := hr.ParseForm(); err != nil { + t.Error(err) + } + if hr.PostForm.Get("client_secret") != "client-secret" { + t.Errorf("first hop client secret = %q", hr.PostForm.Get("client_secret")) + } + if grant == "exchange" { + if hr.PostForm.Get("grant_type") != "authorization_code" || + hr.PostForm.Get("code") != "auth-code" || hr.PostForm.Get("code_verifier") != "pkce-verifier" { + t.Errorf("first hop exchange form = %v", hr.PostForm) + } + } else if hr.PostForm.Get("grant_type") != "refresh_token" || hr.PostForm.Get("refresh_token") != "refresh-secret" { + t.Errorf("first hop refresh form = %v", hr.PostForm) + } + hw.Header().Set("Location", target.URL+"/leak") + hw.WriteHeader(status) + })) + defer provider.Close() + + client := provider.Client() + client.Timeout = 3 * time.Second + client.CheckRedirect = func(*http.Request, []*http.Request) error { + t.Error("original redirect policy was used") + return nil + } + logger := &testAuthDebugLogger{} + srv := &Server{httpClient: client} + if grant == "refresh" && status == http.StatusPermanentRedirect { + srv.Jaws = &jaws.Jaws{Debug: true, Logger: logger} + } + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + if grant == "exchange" { + // The request context's client takes precedence over Server.httpClient. + srv.httpClient = nil + ctx = context.WithValue(ctx, oauth2.HTTPClient, client) + } + authctx := srv.oauth2Context(ctx) + gotClient, ok := authctx.Value(oauth2.HTTPClient).(*http.Client) + if !ok || gotClient == nil || gotClient == client || gotClient.Timeout != client.Timeout || authctx.Done() != ctx.Done() { + t.Fatal("OAuth context did not clone the selected client or retain its timeout/context") + } + if srv.Jaws == nil { + if gotClient.Transport != client.Transport { + t.Fatal("OAuth client lost the selected transport") + } + } else if wrapped, ok := gotClient.Transport.(debugOAuth2Transport); !ok || wrapped.next != client.Transport { + t.Fatal("OAuth client lost the selected transport or debug wrapper") + } + + cfg := oauth2.Config{ClientID: "client", ClientSecret: "client-secret", + Endpoint: oauth2.Endpoint{TokenURL: provider.URL + "/token", AuthStyle: oauth2.AuthStyleInParams}} + var err error + if grant == "exchange" { + _, err = cfg.Exchange(authctx, "auth-code", oauth2.SetAuthURLParam("code_verifier", "pkce-verifier")) + } else { + _, err = cfg.TokenSource(authctx, &oauth2.Token{RefreshToken: "refresh-secret"}).Token() + } + var retrieve *oauth2.RetrieveError + if !errors.As(err, &retrieve) || retrieve.Response == nil || retrieve.Response.StatusCode != status { + t.Fatalf("redirect response = %v, want HTTP %d", err, status) + } + if firstHop.Load() != 1 || secondHop.Load() != 0 { + t.Fatalf("token request hops: first=%d second=%d", firstHop.Load(), secondHop.Load()) + } + if srv.Jaws != nil && !logger.hasInfo("jawsauth: oauth2 request") { + t.Fatal("debug wrapper did not observe token request") + } + }) + } + } +} diff --git a/wrapper_test.go b/wrapper_test.go index d9facd7..0e94011 100644 --- a/wrapper_test.go +++ b/wrapper_test.go @@ -269,18 +269,20 @@ func TestWrapperServeHTTPRedirectsWhenAuthDataMissing(t *testing.T) { } func TestOAuth2ContextUsesServerHTTPClient(t *testing.T) { - client := &http.Client{} + client := &http.Client{Transport: &http.Transport{}, Timeout: time.Second} srv := &Server{httpClient: client} ctx := srv.oauth2Context(t.Context()) - if got, _ := ctx.Value(oauth2.HTTPClient).(*http.Client); got != client { + if got, _ := ctx.Value(oauth2.HTTPClient).(*http.Client); got == nil || got == client || + got.Transport != client.Transport || got.Timeout != client.Timeout || got.CheckRedirect == nil { t.Fatal(got) } - otherClient := &http.Client{} + otherClient := &http.Client{Transport: &http.Transport{}, Timeout: 2 * time.Second} base := context.WithValue(t.Context(), oauth2.HTTPClient, otherClient) ctx = srv.oauth2Context(base) - if got, _ := ctx.Value(oauth2.HTTPClient).(*http.Client); got != otherClient { + if got, _ := ctx.Value(oauth2.HTTPClient).(*http.Client); got == nil || got == otherClient || + got.Transport != otherClient.Transport || got.Timeout != otherClient.Timeout || got.CheckRedirect == nil { t.Fatal(got) } } From 51fc0b4f4b95b78927e32b7fd309e72e930a7688 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 12:56:50 +0100 Subject: [PATCH 11/43] ci: update Staticcheck for Go 1.27 --- .github/workflows/go.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 32b7df1..d08beb2 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -56,7 +56,7 @@ jobs: - name: Staticcheck run: | - go install honnef.co/go/tools/cmd/staticcheck@1285a6a5ec1e0ebb658f49e82b6c566a878cc3cb # v0.8.1 + go install honnef.co/go/tools/cmd/staticcheck@54455593555daf6bfcf3c480faaada7de3145357 # v0.8.2 staticcheck ./... - name: Staticcheck (cmd) From dee95e795023b76305ccb9e06efed9b1654def33 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 13:00:38 +0100 Subject: [PATCH 12/43] ci: update golangci-lint for Go 1.27 --- .github/workflows/go.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index d08beb2..7b5ac24 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -66,12 +66,12 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: - version: v2.13.2 + version: v2.15.0 - name: golangci-lint (cmd) uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: - version: v2.13.2 + version: v2.15.0 working-directory: cmd - name: Install gosec From da375513b81e865a61629bb34a757bd85b537bc1 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 13:15:45 +0100 Subject: [PATCH 13/43] ci: update gosec for Go 1.27 --- .github/workflows/go.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 7b5ac24..9d39bbc 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -75,7 +75,7 @@ jobs: working-directory: cmd - name: Install gosec - run: go install github.com/securego/gosec/v2/cmd/gosec@v2.29.0 + run: go install github.com/securego/gosec/v2/cmd/gosec@v2.30.0 - name: Run Gosec Security Scanner run: gosec ./... From 6d11b14efcc295ddf42f32e38ffecbc2afb2e0b4 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 13:15:48 +0100 Subject: [PATCH 14/43] feat: serialize OAuth session rotation with link writes --- README.md | 7 +++-- oauth2.go | 10 ++++++- profile_link_test.go | 64 ++++++++++++++++++++++++++++++++++++++++++++ server.go | 1 + 4 files changed, 79 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 55f1372..59cbaa4 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,11 @@ provider visit. With no connected pages, that authenticated session can retain that grace until the original flow's grace deadline, about ten minutes plus the callback allowance after link start. `LinkEvent` must serialize its final current-auth check and durable link write with logout and any other provider's -auth revocation. jawsauth cannot roll back an application write if revocation -finishes while `LinkEvent` is running; it rejects the stale callback response. +auth revocation. Set each provider's `SessionRotationLock` to the same lock so +ordinary login callbacks serialize JaWS session replacement with that write; +the lock is not held during provider requests. jawsauth cannot roll back an +application write if revocation finishes while `LinkEvent` is running; it +rejects the stale callback response. Admin allowlists match parsed addresses with ASCII case ignored; non-ASCII characters are matched exactly. Session email values use the same case rules. diff --git a/oauth2.go b/oauth2.go index c99b2f4..39817a4 100644 --- a/oauth2.go +++ b/oauth2.go @@ -499,6 +499,14 @@ func profileTokenUnexpired(token *oauth2.Token) bool { return token.Expiry.After(time.Now()) } +func (srv *Server) rotateOAuthSession(hw http.ResponseWriter, hr *http.Request) *jaws.Session { + if srv.SessionRotationLock != nil { + srv.SessionRotationLock.Lock() + defer srv.SessionRotationLock.Unlock() + } + return srv.Jaws.NewSession(hw, hr) +} + // HandleAuthResponse handles the OIDC or OAuth2 profile callback endpoint. // // For GET requests it validates the state, exchanges the authorization code using the @@ -587,7 +595,7 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) _, hadAuth := srv.sessionAuthStatus(sess, nil) err = ErrOAuth2MissingSession statusCode = http.StatusServiceUnavailable - if rotated := srv.Jaws.NewSession(hw, hr); rotated != nil { + if rotated := srv.rotateOAuthSession(hw, hr); rotated != nil { if flow.revoked() { http.SetCookie(hw, rotated.Close()) err = ErrOAuth2MissingSession diff --git a/profile_link_test.go b/profile_link_test.go index 2f85a8f..ad259c9 100644 --- a/profile_link_test.go +++ b/profile_link_test.go @@ -136,6 +136,70 @@ func TestOAuthProfileCallbackStateIsSingleUseConcurrently(t *testing.T) { } } +type rotationNoticeLock struct { + mu sync.Mutex + entered chan struct{} + once sync.Once +} + +func (l *rotationNoticeLock) Lock() { + l.once.Do(func() { close(l.entered) }) + l.mu.Lock() +} + +func (l *rotationNoticeLock) Unlock() { l.mu.Unlock() } + +func TestOAuthProfileRotationLockDoesNotBlockProvider(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + providerReached := make(chan struct{}) + srv.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/token" { + close(providerReached) + return profileTestResponse(`{"access_token":"proof","token_type":"Bearer"}`), nil + } + return profileTestResponse(`{"id":42}`), nil + })} + lock := &rotationNoticeLock{entered: make(chan struct{})} + srv.SessionRotationLock = lock + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + oldRequest := httptest.NewRequest(http.MethodGet, "https://app.example/", nil) + oldRequest.AddCookie(cookie) + old := jw.GetSession(oldRequest) + lock.mu.Lock() + release := sync.OnceFunc(func() { lock.mu.Unlock() }) + defer release() + result := make(chan *httptest.ResponseRecorder, 1) + go func() { result <- profileCallback(srv, cookie, state, "proof") }() + select { + case <-providerReached: + case <-time.After(5 * time.Second): + t.Fatal("provider request blocked by session rotation lock") + } + select { + case <-lock.entered: + case <-time.After(5 * time.Second): + t.Fatal("callback did not reach session rotation") + } + if jw.GetSession(oldRequest) != old { + t.Fatal("session rotated before acquiring lock") + } + release() + select { + case response := <-result: + if response.Code != http.StatusFound || len(response.Result().Cookies()) != 1 || jw.GetSession(oldRequest) != nil { + t.Fatalf("callback after rotation lock: status=%d cookies=%v", response.Code, response.Result().Cookies()) + } + case <-time.After(5 * time.Second): + t.Fatal("callback did not finish after rotation lock released") + } +} + func TestOAuthFailedCallbackDoesNotEraseNewFlow(t *testing.T) { jw, err := jaws.New() if err != nil { diff --git a/server.go b/server.go index c114426..e3adce1 100644 --- a/server.go +++ b/server.go @@ -92,6 +92,7 @@ type Server struct { LogoutEvent EventFunc // if not nil, called after auth is cleared; hr may be nil LoginFailed FailedFunc // if not nil, called on failed login LinkEvent LinkFunc // if not nil, enables HandleLink + SessionRotationLock sync.Locker // optional; held only while an ordinary callback replaces its JaWS session Options []oauth2.AuthCodeOption // authorization URL options, e.g. oauth2.AccessTypeOffline; never sent to token exchange // RequireVerifiedAdminEmail requires email_verified for a non-empty admin list. From dd152088aa7b0d0ef34287242de67e9efdba6b4d Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 13:35:45 +0100 Subject: [PATCH 15/43] fix: preserve OAuth flows across concurrent auth changes --- README.md | 4 +- authsession.go | 34 ++++++------ concurrent_providers_test.go | 100 +++++++++++++++++++++++++++++++++++ expiry_wrapper_test.go | 58 ++++++++++++++++++++ oauth2.go | 24 ++++++--- wrapper.go | 4 +- 6 files changed, 198 insertions(+), 26 deletions(-) create mode 100644 concurrent_providers_test.go create mode 100644 expiry_wrapper_test.go diff --git a/README.md b/README.md index 59cbaa4..7dedd13 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ the callback allowance after link start. `LinkEvent` must serialize its final current-auth check and durable link write with logout and any other provider's auth revocation. Set each provider's `SessionRotationLock` to the same lock so ordinary login callbacks serialize JaWS session replacement with that write; -the lock is not held during provider requests. jawsauth cannot roll back an +the original session is rechecked inside that lock, and the lock is not held +during provider requests. jawsauth cannot roll back an application write if revocation finishes while `LinkEvent` is running; it rejects the stale callback response. @@ -71,6 +72,7 @@ Restart login after capacity becomes available. Logout and auth expiry clear authentication and reload the session's JaWS pages. Auth expiry preserves an ordinary login in progress but cancels a pending link; explicit logout cancels either flow. +Protected requests during a pending login return 409 instead of replacing that flow. Connected pages receive the reload command before their sockets close; pending pages reload when they connect. Protected renders also recheck authorization when the HTTP handler returns, retiring requests attached during a concurrent revocation. diff --git a/authsession.go b/authsession.go index 00379f3..748c590 100644 --- a/authsession.go +++ b/authsession.go @@ -157,7 +157,7 @@ func (srv *Server) storeSessionAuthClaimsIfActive(sess *jaws.Session, claims map if entry == nil { return ErrOAuth2MissingSession } - srv.clearSessionAuth(sess, nil, entry) + srv.clearSessionAuth(sess, nil, entry, true) return errAuthTimerStale } sess.Set(srv.SessionKey, claims) @@ -322,7 +322,7 @@ func (srv *Server) handleSessionAuthTimer(sess *jaws.Session, entry *authTimerSt if srv.sessionAuthTimerCurrent(sess, entry) { if srv.profileSubject != nil { if !sessionAlive(sess) || !time.Now().Before(entry.expiry) { - srv.clearSessionAuth(sess, nil, entry) + srv.clearSessionAuth(sess, nil, entry, true) return } srv.mu.Lock() @@ -335,7 +335,7 @@ func (srv *Server) handleSessionAuthTimer(sess *jaws.Session, entry *authTimerSt return } if !sessionAlive(sess) { - srv.clearSessionAuth(sess, nil, entry) + srv.clearSessionAuth(sess, nil, entry, true) return } current, present := srv.sessionAuthStatus(sess, time.Now) @@ -384,7 +384,7 @@ func (srv *Server) handleSessionAuthTimer(sess *jaws.Session, entry *authTimerSt "session_present", present, ) _ = srv.Jaws.Log(err) - srv.clearSessionAuth(sess, nil, entry) + srv.clearSessionAuth(sess, nil, entry, true) } else { srv.debugLog("jawsauth: auth refresh timer completed", "session_prefix", debugSessionPrefix(sess)) } @@ -396,6 +396,17 @@ func (srv *Server) handleSessionAuthTimer(sess *jaws.Session, entry *authTimerSt } } +// pendingOrdinaryLoginLocked requires srv.mu. +func (srv *Server) pendingOrdinaryLoginLocked(sess *jaws.Session) bool { + if flow := srv.oauthCallbacks[sess]; flow.canceled != nil { + return !flow.link && !flow.revoked() + } + state, _ := sess.Get(srv.sessionKey(oauth2StateKey)).(string) + link, _ := sess.Get(srv.sessionKey(oauth2LinkKey)).(bool) + expiry, _ := sess.Get(srv.sessionKey(oauth2FlowExpiryKey)).(time.Time) + return state != "" && !link && time.Now().Before(expiry) +} + func (srv *Server) clearSessionOAuthFlow(sess *jaws.Session) { if flow := srv.oauthCallbacks[sess]; flow.canceled != nil { close(flow.canceled) @@ -420,10 +431,10 @@ func (srv *Server) clearSessionOAuthFlow(sess *jaws.Session) { // response. A JaWS event handler cannot use [jaws.Request.Redirect] after Logout // queues Reload, which closes the connection; use an HTTP logout endpoint instead. func (srv *Server) Logout(sess *jaws.Session, hr *http.Request) (cleared bool) { - return srv.clearSessionAuth(sess, hr, nil) + return srv.clearSessionAuth(sess, hr, nil, false) } -func (srv *Server) clearSessionAuth(sess *jaws.Session, hr *http.Request, entry *authTimerState) (cleared bool) { +func (srv *Server) clearSessionAuth(sess *jaws.Session, hr *http.Request, entry *authTimerState, preservePendingLogin bool) (cleared bool) { if srv == nil || sess == nil { return } @@ -433,16 +444,7 @@ func (srv *Server) clearSessionAuth(sess *jaws.Session, hr *http.Request, entry if cleared { // Auth expiry must not cancel an ordinary login already in progress. // Explicit logout and link flows still revoke their pending callbacks. - preserveLogin := false - if entry != nil { - if flow := srv.oauthCallbacks[sess]; flow.canceled != nil { - preserveLogin = !flow.link - } else if state, _ := sess.Get(srv.sessionKey(oauth2StateKey)).(string); state != "" { - link, _ := sess.Get(srv.sessionKey(oauth2LinkKey)).(bool) - preserveLogin = !link - } - } - if !preserveLogin { + if !preservePendingLogin || !srv.pendingOrdinaryLoginLocked(sess) { srv.clearSessionOAuthFlow(sess) } sess.Set(srv.SessionKey, nil) diff --git a/concurrent_providers_test.go b/concurrent_providers_test.go new file mode 100644 index 0000000..b02c9a7 --- /dev/null +++ b/concurrent_providers_test.go @@ -0,0 +1,100 @@ +package jawsauth + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "sync/atomic" + "testing" + + "github.com/linkdata/jaws" +) + +type gateRotationLock struct { + mu sync.Mutex + entrants atomic.Int32 + both chan struct{} + release chan struct{} +} + +func (l *gateRotationLock) Lock() { + if l.entrants.Add(1) == 2 { + close(l.both) + } + <-l.release + l.mu.Lock() +} +func (l *gateRotationLock) Unlock() { l.mu.Unlock() } + +func TestConcurrentProviderCallbacksRotateOriginalSessionOnce(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/token" { + return profileTestResponse(`{"access_token":"proof","token_type":"Bearer"}`), nil + } + return profileTestResponse(`{"id":42}`), nil + })} + gate := &gateRotationLock{both: make(chan struct{}), release: make(chan struct{})} + mk := func(ns string) *Server { + srv, err := New(jw, &Config{Namespace: ns, RedirectURL: "https://app.example/auth/" + ns + "/callback", Issuer: "https://provider.example", AuthURL: "https://provider.example/authorize", TokenURL: "https://provider.example/token", UserInfoURL: "https://provider.example/user", ClientID: "app", HTTPClient: client, ProfileSubject: func(m map[string]any) (string, error) { return string(m["id"].(json.Number)), nil }}, func(string, http.Handler) {}) + if err != nil { + t.Fatal(err) + } + srv.SessionRotationLock = gate + return srv + } + a, b := mk("a"), mk("b") + cookie, stateA := profileStart(t, a, http.MethodGet, "/auth/a/login", nil) + startB := httptest.NewRequest(http.MethodGet, "https://app.example/auth/b/login", nil) + startB.AddCookie(cookie) + wB := httptest.NewRecorder() + b.HandleLogin(wB, startB) + if wB.Code != http.StatusFound { + t.Fatalf("second start %d", wB.Code) + } + locB, err := url.Parse(wB.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + stateB := locB.Query().Get("state") + callback := func(srv *Server, ns, state string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "https://app.example/auth/"+ns+"/callback?state="+url.QueryEscape(state)+"&code=proof", nil) + req.RemoteAddr = "192.0.2.1:1234" + req.AddCookie(cookie) + w := httptest.NewRecorder() + srv.HandleAuthResponse(w, req) + return w + } + out := make(chan *httptest.ResponseRecorder, 2) + go func() { out <- callback(a, "a", stateA) }() + go func() { out <- callback(b, "b", stateB) }() + <-gate.both + close(gate.release) + first, second := <-out, <-out + success, rejected := 0, 0 + for _, response := range []*httptest.ResponseRecorder{first, second} { + switch response.Code { + case http.StatusFound: + success++ + if len(response.Result().Cookies()) != 1 { + t.Fatalf("successful callback cookies=%v", response.Result().Cookies()) + } + case http.StatusForbidden: + rejected++ + if len(response.Result().Cookies()) != 0 { + t.Fatalf("stale callback published cookie=%v", response.Result().Cookies()) + } + default: + t.Fatalf("callback status=%d", response.Code) + } + } + if success != 1 || rejected != 1 || jw.SessionCount() != 1 { + t.Fatalf("callbacks: success=%d rejected=%d sessions=%d", success, rejected, jw.SessionCount()) + } +} diff --git a/expiry_wrapper_test.go b/expiry_wrapper_test.go new file mode 100644 index 0000000..2b9d51a --- /dev/null +++ b/expiry_wrapper_test.go @@ -0,0 +1,58 @@ +package jawsauth + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/linkdata/jaws" +) + +func TestProtectedRequestPreservesPendingLoginOnAuthExpiry(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + cookie, initialState := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + first := profileCallback(srv, cookie, initialState, "first") + if first.Code != http.StatusFound { + t.Fatalf("initial sign-in %d", first.Code) + } + cookie = first.Result().Cookies()[0] + start := httptest.NewRequest(http.MethodGet, "https://app.example/auth/login", nil) + start.RemoteAddr = "192.0.2.1:1234" + start.AddCookie(cookie) + w := httptest.NewRecorder() + srv.HandleLogin(w, start) + loc, err := url.Parse(w.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + pendingState := loc.Query().Get("state") + sess := jw.GetSession(start) + if sess == nil || pendingState == "" { + t.Fatal("missing pending flow") + } + sess.Set(srv.sessionKey(oauth2IDTokenExpiryKey), time.Now().Add(-time.Second)) + protected := httptest.NewRequest(http.MethodGet, "https://app.example/protected", nil) + protected.RemoteAddr = "192.0.2.1:1234" + protected.AddCookie(cookie) + response := httptest.NewRecorder() + srv.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("expired auth reached protected handler") })).ServeHTTP(response, protected) + if response.Code != http.StatusConflict || len(response.Result().Cookies()) != 0 { + t.Fatalf("protected request during login: status=%d cookies=%v", response.Code, response.Result().Cookies()) + } + gotState, _ := sess.Get(srv.sessionKey(oauth2StateKey)).(string) + if gotState != pendingState { + t.Fatalf("protected request replaced pending login state: want %q got %q", pendingState, gotState) + } + completed := profileCallback(srv, cookie, pendingState, "second") + if completed.Code != http.StatusFound || len(completed.Result().Cookies()) != 1 { + t.Fatalf("pending login callback: status=%d cookies=%v", completed.Code, completed.Result().Cookies()) + } +} diff --git a/oauth2.go b/oauth2.go index 39817a4..92d4fe1 100644 --- a/oauth2.go +++ b/oauth2.go @@ -148,17 +148,17 @@ func (srv *Server) begin(hr *http.Request) (oauth2cfg *oauth2.Config, location s // If no session can be created, it returns 503 with Retry-After instead. // Non-GET requests receive 405. func (srv *Server) HandleLogin(hw http.ResponseWriter, hr *http.Request) { - srv.startOAuth(hw, hr, false) + srv.startOAuth(hw, hr, false, false) } // HandleLink begins a provider flow for linking to the caller's existing // identity. The application must authorize this POST before calling it and // recheck that identity in LinkEvent when the provider returns. func (srv *Server) HandleLink(hw http.ResponseWriter, hr *http.Request) { - srv.startOAuth(hw, hr, true) + srv.startOAuth(hw, hr, true, false) } -func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link bool) { +func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, preservePendingLogin bool) { statusCode := http.StatusMethodNotAllowed if (!link && hr.Method == http.MethodGet) || (link && hr.Method == http.MethodPost) { if link && http.NewCrossOriginProtection().Check(hr) != nil { @@ -206,6 +206,11 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link boo location = "/" } srv.mu.Lock() + if preservePendingLogin && srv.pendingOrdinaryLoginLocked(sess) { + srv.mu.Unlock() + srv.writeResult(hw, http.StatusConflict, errors.New("oauth2 login already in progress"), nil) + return + } srv.clearSessionOAuthFlow(sess) sess.Set(srv.sessionKey(oauth2StateKey), state) sess.Set(srv.sessionKey(oauth2FlowExpiryKey), flowExpiry) @@ -232,7 +237,7 @@ func (srv *Server) HandleLogout(hw http.ResponseWriter, hr *http.Request) { if hr.Method == http.MethodGet { _, location := srv.begin(hr) if sess := srv.Jaws.GetSession(hr); sess != nil { - srv.clearSessionAuth(sess, hr, nil) + srv.clearSessionAuth(sess, hr, nil, false) } hw.Header().Set("Location", location) statusCode = http.StatusFound @@ -499,12 +504,15 @@ func profileTokenUnexpired(token *oauth2.Token) bool { return token.Expiry.After(time.Now()) } -func (srv *Server) rotateOAuthSession(hw http.ResponseWriter, hr *http.Request) *jaws.Session { +func (srv *Server) rotateOAuthSession(hw http.ResponseWriter, hr *http.Request, old *jaws.Session, flow pendingOAuthFlow) (rotated *jaws.Session, stale bool) { if srv.SessionRotationLock != nil { srv.SessionRotationLock.Lock() defer srv.SessionRotationLock.Unlock() } - return srv.Jaws.NewSession(hw, hr) + if flow.revoked() || srv.Jaws.GetSession(hr) != old { + return nil, true + } + return srv.Jaws.NewSession(hw, hr), false } // HandleAuthResponse handles the OIDC or OAuth2 profile callback endpoint. @@ -595,7 +603,7 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) _, hadAuth := srv.sessionAuthStatus(sess, nil) err = ErrOAuth2MissingSession statusCode = http.StatusServiceUnavailable - if rotated := srv.rotateOAuthSession(hw, hr); rotated != nil { + if rotated, stale := srv.rotateOAuthSession(hw, hr, sess, flow); rotated != nil { if flow.revoked() { http.SetCookie(hw, rotated.Close()) err = ErrOAuth2MissingSession @@ -619,6 +627,8 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) } } } + } else if stale { + statusCode = http.StatusForbidden } else { hw.Header().Set("Retry-After", "60") } diff --git a/wrapper.go b/wrapper.go index e29625c..c84a2db 100644 --- a/wrapper.go +++ b/wrapper.go @@ -15,9 +15,9 @@ func (w wrapper) ServeHTTP(hw http.ResponseWriter, hr *http.Request) { sess := w.server.Jaws.GetSession(hr) if current, present := w.server.sessionAuthStatus(sess, time.Now); !current { if present { - w.server.clearSessionAuth(sess, hr, nil) + w.server.clearSessionAuth(sess, hr, nil, true) } - w.server.HandleLogin(hw, hr) + w.server.startOAuth(hw, hr, false, true) return } From a105ae75f2a68994150b16829db15dbdab9bf9ef Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 13:53:26 +0100 Subject: [PATCH 16/43] fix: bound pending login grace and stale auth timers --- README.md | 2 +- authsession.go | 17 +++++++++++++--- authsession_test.go | 6 +++--- config.go | 43 +++++++++++++++++----------------------- errconfig.go | 4 ++-- oauth2.go | 9 +++++---- oauth_flow_grace_test.go | 28 ++++++++++++++++++++++++++ profile_link_test.go | 2 +- sessionlifecycle_test.go | 38 ++++++++++++++++++++++++++++++++++- 9 files changed, 109 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 7dedd13..aaa4006 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Verified OIDC or OAuth2 profile authentication for [JaWS](https://github.com/lin - OIDC mode discovers the issuer, verifies `id_token` and nonce, and refreshes before expiry. - Profile mode accepts explicit HTTPS endpoints and a `Config.ProfileSubject` verifier for providers without ID tokens. Its authenticated profile response must contain a stable subject; email and names are not identity proof. JSON numbers arrive as `json.Number` so large numeric IDs remain exact. - Both modes use authorization-code state and PKCE (S256). Pending flows expire after ten minutes; jawsauth extends only that JaWS session's grace while the user visits the provider. -- For providers that require an offline-access request to issue refresh tokens, set `Server.Options = []oauth2.AuthCodeOption{oauth2.AccessTypeOffline}` before serving. This adds `access_type=offline` to the authorization redirect, not the token request. +- For OIDC providers that require an offline-access request to issue refresh tokens, set `Server.Options = []oauth2.AuthCodeOption{oauth2.AccessTypeOffline}` before serving. This adds `access_type=offline` to the authorization redirect, not the token request. - Profile authentication expires after 24 hours or the provider token's earlier expiry; it does not attempt OIDC refresh. - Supports admin-only handlers: `Wrap`/`Handler` for any authenticated user, `WrapAdmin`/`HandlerAdmin` gated by `SetAdmins`. diff --git a/authsession.go b/authsession.go index 748c590..99935f7 100644 --- a/authsession.go +++ b/authsession.go @@ -12,7 +12,7 @@ import ( ) const authRefreshSkew = 10 * time.Second -const profileAuthCheckInterval = time.Minute +const authSessionCheckInterval = time.Minute var errOIDCStaleIDToken = errors.New("oidc stale id_token") var errAuthTimerStale = errors.New("auth timer stale") @@ -277,8 +277,9 @@ func (srv *Server) refreshSessionAuth(ctx context.Context, sess *jaws.Session, m func (srv *Server) scheduleSessionAuthTimerLocked(sess *jaws.Session, expiry time.Time) (delay time.Duration, replaced bool) { delay = max(time.Until(expiry.Add(-authRefreshSkew)), 0) if srv.profileSubject != nil { - delay = min(max(time.Until(expiry), 0), profileAuthCheckInterval) + delay = max(time.Until(expiry), 0) } + delay = min(delay, authSessionCheckInterval) entry := &authTimerState{expiry: expiry} if srv.authTimers == nil { srv.authTimers = make(map[uint64]*authTimerState) @@ -327,7 +328,7 @@ func (srv *Server) handleSessionAuthTimer(sess *jaws.Session, entry *authTimerSt } srv.mu.Lock() if srv.authTimers[sess.ID()] == entry { - entry.timer = srv.authTimerAfterFunc(min(time.Until(entry.expiry), profileAuthCheckInterval), func() { + entry.timer = srv.authTimerAfterFunc(min(time.Until(entry.expiry), authSessionCheckInterval), func() { srv.handleSessionAuthTimer(sess, entry) }) } @@ -338,6 +339,16 @@ func (srv *Server) handleSessionAuthTimer(sess *jaws.Session, entry *authTimerSt srv.clearSessionAuth(sess, nil, entry, true) return } + if remaining := time.Until(entry.expiry.Add(-authRefreshSkew)); remaining > 0 { + srv.mu.Lock() + if srv.authTimers[sess.ID()] == entry { + entry.timer = srv.authTimerAfterFunc(min(remaining, authSessionCheckInterval), func() { + srv.handleSessionAuthTimer(sess, entry) + }) + } + srv.mu.Unlock() + return + } current, present := srv.sessionAuthStatus(sess, time.Now) srv.debugLog("jawsauth: auth refresh timer fired", "session_prefix", debugSessionPrefix(sess), diff --git a/authsession_test.go b/authsession_test.go index 8954ffa..3a65aa1 100644 --- a/authsession_test.go +++ b/authsession_test.go @@ -396,7 +396,7 @@ func TestAuthTimerRefreshesCachedTokenByForcingRefresh(t *testing.T) { jw.Logger = logger const issuer = "https://issuer.example" - initialExpiry := time.Now().Add(30 * time.Second).Truncate(time.Second) + initialExpiry := time.Now().Add(5 * time.Second).Truncate(time.Second) refreshedExpiry := time.Now().Add(time.Hour).Truncate(time.Second) cachedIDToken := makeIDToken(t, map[string]any{ "iss": issuer, @@ -669,7 +669,7 @@ func TestAuthTimerRefreshFailureKeepsCurrentAuthUntilExpiry(t *testing.T) { } req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) sess := jw.NewSession(httptest.NewRecorder(), req) - expiry := time.Now().Add(time.Minute).Truncate(time.Second) + expiry := time.Now().Add(5 * time.Second).Truncate(time.Second) err = srv.storeSessionAuthClaims(sess, map[string]any{ "exp": expiry.Unix(), "email": "current@example.com", @@ -830,7 +830,7 @@ func TestAuthTimerDebugLogsRefreshFailure(t *testing.T) { srv := newTimerTestServer(t, jw, issuer, factory) req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) sess := jw.NewSession(httptest.NewRecorder(), req) - expiry := time.Now().Add(30 * time.Second).Truncate(time.Second) + expiry := time.Now().Add(5 * time.Second).Truncate(time.Second) err = srv.storeSessionAuthClaims(sess, map[string]any{ "exp": expiry.Unix(), diff --git a/config.go b/config.go index a5653ba..8aa3620 100644 --- a/config.go +++ b/config.go @@ -142,6 +142,14 @@ func ensureScopes(rawScopes []string) (scopes []string) { func (cfg *Config) buildContext(ctx context.Context, overrideUrl string) (oauth2cfg *oauth2.Config, userInfoURL string, verifier *oidc.IDTokenVerifier, err error) { if err = cfg.Validate(); err == nil { + var redir *url.URL + if redir, err = url.Parse(cfg.RedirectURL); err != nil { + return + } + if u, parseErr := url.Parse(overrideUrl); parseErr == nil { + overrideStr(&redir.Scheme, u.Scheme) + overrideStr(&redir.Host, u.Host) + } if cfg.ProfileSubject != nil { var scopes []string for _, set := range cfg.Scopes { @@ -149,14 +157,6 @@ func (cfg *Config) buildContext(ctx context.Context, overrideUrl string) (oauth2 } slices.Sort(scopes) scopes = slices.Compact(scopes) - var redir *url.URL - if redir, err = url.Parse(cfg.RedirectURL); err != nil { - return - } - if u, parseErr := url.Parse(overrideUrl); parseErr == nil { - overrideStr(&redir.Scheme, u.Scheme) - overrideStr(&redir.Host, u.Host) - } oauth2cfg = &oauth2.Config{ ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret, Endpoint: oauth2.Endpoint{AuthURL: cfg.AuthURL, TokenURL: cfg.TokenURL}, @@ -181,24 +181,17 @@ func (cfg *Config) buildContext(ctx context.Context, overrideUrl string) (oauth2 if authURL, err = validateUrl("AuthURL", cfg.AuthURL, metadata.AuthorizationEndpoint, false); wrapOIDC(ErrOIDCProviderMetadata, &err) == nil { if tokenURL, err = validateUrl("TokenURL", cfg.TokenURL, metadata.TokenEndpoint, false); wrapOIDC(ErrOIDCProviderMetadata, &err) == nil { if userInfoURL, err = validateUrl("UserInfoURL", cfg.UserInfoURL, metadata.UserinfoEndpoint, true); wrapOIDC(ErrOIDCProviderMetadata, &err) == nil { - var redir *url.URL - if redir, err = url.Parse(cfg.RedirectURL); err == nil { - if u, e := url.Parse(overrideUrl); e == nil { - overrideStr(&redir.Scheme, u.Scheme) - overrideStr(&redir.Host, u.Host) - } - oauth2cfg = &oauth2.Config{ - ClientID: cfg.ClientID, - ClientSecret: cfg.ClientSecret, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - RedirectURL: redir.String(), - Scopes: ensureScopes(cfg.Scopes), - } - verifier = provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}) + oauth2cfg = &oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + RedirectURL: redir.String(), + Scopes: ensureScopes(cfg.Scopes), } + verifier = provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}) } } } diff --git a/errconfig.go b/errconfig.go index 79f0005..5b092cb 100644 --- a/errconfig.go +++ b/errconfig.go @@ -16,10 +16,10 @@ var ErrConfigURLNotAbsolute = errors.New("url is not absolute") // ErrConfigURLMissingHost means a configured URL does not include a host. var ErrConfigURLMissingHost = errors.New("url host is missing") -// ErrConfigIssuerMustBeHTTPS means Issuer must use the https scheme unless AllowInsecureIssuer is enabled. +// ErrConfigIssuerMustBeHTTPS means Issuer must use HTTPS, or HTTP when AllowInsecureIssuer is enabled. var ErrConfigIssuerMustBeHTTPS = errors.New("issuer url must use https") -// ErrConfigProfileEndpointMustBeHTTPS means a profile-flow endpoint must use HTTPS. +// ErrConfigProfileEndpointMustBeHTTPS means a profile-flow endpoint must use HTTPS, or HTTP when AllowInsecureIssuer is enabled. var ErrConfigProfileEndpointMustBeHTTPS = errors.New("profile endpoint must use https") type errConfig struct { diff --git a/oauth2.go b/oauth2.go index 92d4fe1..8ef5745 100644 --- a/oauth2.go +++ b/oauth2.go @@ -189,10 +189,6 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, pr return } flowExpiry := time.Now().Add(oauth2FlowLifetime) - if !sess.ExtendGrace(oauth2FlowLifetime + oauth2ProviderTimeout + time.Second) { - srv.writeResult(hw, http.StatusServiceUnavailable, ErrOAuth2MissingSession, nil) - return - } authOptions := append([]oauth2.AuthCodeOption{}, srv.Options...) state := randomHexString() var nonce string @@ -211,6 +207,11 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, pr srv.writeResult(hw, http.StatusConflict, errors.New("oauth2 login already in progress"), nil) return } + if !sess.ExtendGrace(oauth2FlowLifetime + oauth2ProviderTimeout + time.Second) { + srv.mu.Unlock() + srv.writeResult(hw, http.StatusServiceUnavailable, ErrOAuth2MissingSession, nil) + return + } srv.clearSessionOAuthFlow(sess) sess.Set(srv.sessionKey(oauth2StateKey), state) sess.Set(srv.sessionKey(oauth2FlowExpiryKey), flowExpiry) diff --git a/oauth_flow_grace_test.go b/oauth_flow_grace_test.go index b9cea30..8f9b105 100644 --- a/oauth_flow_grace_test.go +++ b/oauth_flow_grace_test.go @@ -59,6 +59,34 @@ func TestOAuthProviderVisitKeepsOnlyPendingSessionAlive(t *testing.T) { }) } +func TestPendingLoginConflictDoesNotRenewGrace(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + cookie, _ := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + protected := httptest.NewRequest(http.MethodGet, "https://app.example/protected", nil) + protected.RemoteAddr = "192.0.2.1:1234" + protected.AddCookie(cookie) + time.Sleep(9 * time.Minute) + response := httptest.NewRecorder() + srv.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("pending login reached protected handler") + })).ServeHTTP(response, protected) + if response.Code != http.StatusConflict { + t.Fatalf("pending login: status=%d", response.Code) + } + time.Sleep(time.Minute + oauth2ProviderTimeout + 2*time.Second) + if jw.GetSession(protected) != nil { + t.Fatal("rejected start renewed pending session grace") + } + }) +} + func TestOAuthFlowExpiresEvenWithLiveSession(t *testing.T) { synctest.Test(t, func(t *testing.T) { jw, err := jaws.New() diff --git a/profile_link_test.go b/profile_link_test.go index ad259c9..16b9207 100644 --- a/profile_link_test.go +++ b/profile_link_test.go @@ -987,7 +987,7 @@ func TestOAuthProfileExpiresWithoutRefresh(t *testing.T) { if response.Code != http.StatusFound || factory.len() != 1 { t.Fatalf("profile sign-in: status=%d timers=%d", response.Code, factory.len()) } - if delay := factory.timer(0).delay; delay <= 0 || delay > profileAuthCheckInterval { + if delay := factory.timer(0).delay; delay <= 0 || delay > authSessionCheckInterval { t.Fatalf("profile auth cleanup interval: %s", delay) } r := httptest.NewRequest(http.MethodGet, "https://app.example/", nil) diff --git a/sessionlifecycle_test.go b/sessionlifecycle_test.go index f06dd60..41bdb19 100644 --- a/sessionlifecycle_test.go +++ b/sessionlifecycle_test.go @@ -60,7 +60,7 @@ func TestAuthTimerDiscardsClosedSession(t *testing.T) { srv := newTimerTestServer(t, jw, "https://issuer.example", factory) req := httptest.NewRequest(http.MethodGet, "/", nil) sess := jw.NewSession(httptest.NewRecorder(), req) - expiry := time.Now().Add(time.Hour) + expiry := time.Now().Add(5 * time.Second) raw := makeIDToken(t, map[string]any{"iss": "https://issuer.example", "aud": "client", "sub": "one", "exp": expiry.Add(time.Hour).Unix(), "email": "user@example.com"}) calls := 0 source := tokenSourceFunc(func() (*oauth2.Token, error) { @@ -87,6 +87,42 @@ func TestAuthTimerDiscardsClosedSession(t *testing.T) { } } +func TestOIDCTimerReleasesSessionRotatedByOtherProvider(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + factory := &testAuthTimerFactory{} + srv := newTimerTestServer(t, jw, "https://issuer.example", factory) + req := httptest.NewRequest(http.MethodGet, "/", nil) + first := httptest.NewRecorder() + old := jw.NewSession(first, req) + if old == nil { + t.Fatal("initial session missing") + } + if err := srv.storeSessionAuthClaims(old, map[string]any{"sub": "one"}, nil, time.Now().Add(4*time.Hour), nil); err != nil { + t.Fatal(err) + } + if delay := factory.timer(0).delay; delay <= 0 || delay > time.Minute { + t.Fatalf("old session cleanup delay = %s", delay) + } + factory.timer(0).fire() + if factory.len() != 2 || !srv.sessionAuthTimerCurrent(old, srv.authTimers[old.ID()]) { + t.Fatal("live OIDC session did not schedule its next liveness check") + } + replace := httptest.NewRequest(http.MethodGet, "/", nil) + replace.RemoteAddr = req.RemoteAddr + replace.AddCookie(first.Result().Cookies()[0]) + if next := jw.NewSession(httptest.NewRecorder(), replace); next == nil || next == old || sessionAlive(old) { + t.Fatal("session was not rotated") + } + factory.timer(1).fire() + if len(srv.authTimers) != 0 { + t.Fatal("other provider retained the rotated session") + } +} + func TestAuthorizationWithdrawalCancelsRequests(t *testing.T) { for _, action := range []string{"logout", "httpLogout", "expiry", "demotion", "restrictEveryone"} { t.Run(action, func(t *testing.T) { From cc839ed97817dcc67a61a78b73ddcecb2b7ab5f6 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 13:54:24 +0100 Subject: [PATCH 17/43] docs: describe bounded auth timer cleanup --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index aaa4006..4ff7a97 100644 --- a/README.md +++ b/README.md @@ -83,5 +83,6 @@ their ordinary login remains valid. Use an HTTP endpoint for logout redirects: calling `Request.Redirect` after `Server.Logout` in a JaWS event cannot send the redirect because Reload closes the connection and discards later messages. Closed or expired JaWS sessions have their -tokens and timers discarded at the next scheduled refresh, which calls +tokens and timers discarded at the next auth timer check (normally within one +minute), which calls `LogoutEvent` with a nil HTTP request. From 784685d53cb284c3741e30aef8243e27e47bf130 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 14:06:33 +0100 Subject: [PATCH 18/43] fix: bound OAuth refresh and login redirects --- README.md | 1 + authsession.go | 3 +++ oauth2.go | 6 +++-- oauth2_test.go | 15 +++++++++++++ sessionlifecycle_test.go | 47 ++++++++++++++++++++++++++++++++++++++++ wrapper_test.go | 15 +++++++++++++ 6 files changed, 85 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4ff7a97..c9e28dd 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Protected routes create JaWS sessions for unauthenticated visitors. Set session cannot be created, login returns 503 with `Retry-After: 60`. Post-login return targets longer than 8192 bytes fall back to `/`. Login callback token/profile calls and startup discovery have a 15-second deadline; +OIDC background refresh requests have the same maximum timeout. profile responses are capped at 32 KiB. For multiple providers on one JaWS instance, give each `Config` a distinct diff --git a/authsession.go b/authsession.go index 99935f7..f757954 100644 --- a/authsession.go +++ b/authsession.go @@ -78,6 +78,9 @@ func (srv *Server) oauth2Context(ctx context.Context) (authctx context.Context) client = http.DefaultClient } clientCopy := *client + if clientCopy.Timeout <= 0 || clientCopy.Timeout > oauth2ProviderTimeout { + clientCopy.Timeout = oauth2ProviderTimeout + } // Token POSTs carry secrets, and 307/308 redirects preserve their body. clientCopy.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } if logger := srv.debugLogger(); logger != nil { diff --git a/oauth2.go b/oauth2.go index 8ef5745..b140f70 100644 --- a/oauth2.go +++ b/oauth2.go @@ -135,8 +135,10 @@ func (srv *Server) begin(hr *http.Request) (oauth2cfg *oauth2.Config, location s location = hr.RequestURI } location = sanitizeRedirectTarget(hr.Host, location) - if _, handled := srv.HandledPaths[location]; handled { - location = "/" + if target, err := url.Parse(location); err == nil { + if _, handled := srv.HandledPaths[target.Path]; handled { + location = "/" + } } return } diff --git a/oauth2_test.go b/oauth2_test.go index bd916f1..87a679a 100644 --- a/oauth2_test.go +++ b/oauth2_test.go @@ -136,6 +136,21 @@ func Test_beginReferrerHandling(t *testing.T) { if location != "/app/home" { t.Fatal(location) } + hr = httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/login?next=x", nil) + _, location = srv.begin(hr) + if location != "/" { + t.Fatalf("query-bearing login loops to %q", location) + } + hr.Header.Set("Referer", "https://example.com/oauth2/login?next=x") + _, location = srv.begin(hr) + if location != "/" { + t.Fatalf("query-bearing referrer loops to %q", location) + } + hr.Header.Set("Referer", "https://example.com/app/home?next=x") + _, location = srv.begin(hr) + if location != "/app/home?next=x" { + t.Fatalf("unhandled target changed to %q", location) + } } // Test_beginPathBoundary verifies that handled paths only rewrite exact diff --git a/sessionlifecycle_test.go b/sessionlifecycle_test.go index 41bdb19..35dd8f9 100644 --- a/sessionlifecycle_test.go +++ b/sessionlifecycle_test.go @@ -123,6 +123,53 @@ func TestOIDCTimerReleasesSessionRotatedByOtherProvider(t *testing.T) { } } +func TestOIDCRefreshTimeoutReleasesClosedSession(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + factory := &testAuthTimerFactory{} + srv := newTimerTestServer(t, jw, "https://issuer.example", factory) + started := make(chan struct{}, 1) + srv.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + select { + case started <- struct{}{}: + default: + } + <-req.Context().Done() + return nil, req.Context().Err() + })} + req := httptest.NewRequest(http.MethodGet, "/", nil) + sess := jw.NewSession(httptest.NewRecorder(), req) + if sess == nil { + t.Fatal("initial session missing") + } + source := srv.oauth2cfg.TokenSource(srv.oauth2Context(context.Background()), &oauth2.Token{RefreshToken: "refresh"}) + if err := srv.storeSessionAuthClaims(sess, map[string]any{"sub": "one"}, source, time.Now().Add(5*time.Second), nil); err != nil { + t.Fatal(err) + } + done := make(chan struct{}) + go func() { + factory.timer(0).fire() + close(done) + }() + <-started + sess.Close() + time.Sleep(oauth2ProviderTimeout + time.Second) + synctest.Wait() + select { + case <-done: + default: + t.Fatal("stalled refresh retained closed session") + } + if len(srv.authTimers) != 0 { + t.Fatal("stalled refresh left auth timer") + } + }) +} + func TestAuthorizationWithdrawalCancelsRequests(t *testing.T) { for _, action := range []string{"logout", "httpLogout", "expiry", "demotion", "restrictEveryone"} { t.Run(action, func(t *testing.T) { diff --git a/wrapper_test.go b/wrapper_test.go index 0e94011..8fc42eb 100644 --- a/wrapper_test.go +++ b/wrapper_test.go @@ -285,6 +285,21 @@ func TestOAuth2ContextUsesServerHTTPClient(t *testing.T) { got.Transport != otherClient.Transport || got.Timeout != otherClient.Timeout || got.CheckRedirect == nil { t.Fatal(got) } + + for _, timeout := range []time.Duration{0, 2 * oauth2ProviderTimeout} { + client := &http.Client{Timeout: timeout} + srv.httpClient = client + ctx = srv.oauth2Context(t.Context()) + got, _ := ctx.Value(oauth2.HTTPClient).(*http.Client) + if got == nil || got.Timeout != oauth2ProviderTimeout || client.Timeout != timeout { + t.Fatalf("configured=%s copied=%v", timeout, got) + } + } + srv.httpClient = nil + ctx = srv.oauth2Context(t.Context()) + if got, _ := ctx.Value(oauth2.HTTPClient).(*http.Client); got == nil || got.Timeout != oauth2ProviderTimeout { + t.Fatalf("default OAuth client: %v", got) + } } func newWrapperTestServer(jw *jaws.Jaws, issuer string) *Server { From 5e48aeabcf1536b842a78c14ab9b7c6896022034 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 14:17:53 +0100 Subject: [PATCH 19/43] test: bound OAuth callback synchronization waits --- README.md | 2 +- concurrent_providers_test.go | 24 +++++++++++++++---- profile_link_test.go | 46 +++++++++++++++++++++++++++++------- 3 files changed, 59 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c9e28dd..9b3ca24 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ session cannot be created, login returns 503 with `Retry-After: 60`. Post-login return targets longer than 8192 bytes fall back to `/`. Login callback token/profile calls and startup discovery have a 15-second deadline; OIDC background refresh requests have the same maximum timeout. -profile responses are capped at 32 KiB. +Profile responses are capped at 32 KiB. For multiple providers on one JaWS instance, give each `Config` a distinct `Namespace` and callback directory (for example, `/oauth/github/callback` and diff --git a/concurrent_providers_test.go b/concurrent_providers_test.go index b02c9a7..6aeced1 100644 --- a/concurrent_providers_test.go +++ b/concurrent_providers_test.go @@ -8,6 +8,7 @@ import ( "sync" "sync/atomic" "testing" + "time" "github.com/linkdata/jaws" ) @@ -41,6 +42,9 @@ func TestConcurrentProviderCallbacksRotateOriginalSessionOnce(t *testing.T) { return profileTestResponse(`{"id":42}`), nil })} gate := &gateRotationLock{both: make(chan struct{}), release: make(chan struct{})} + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(gate.release) }) } + defer release() mk := func(ns string) *Server { srv, err := New(jw, &Config{Namespace: ns, RedirectURL: "https://app.example/auth/" + ns + "/callback", Issuer: "https://provider.example", AuthURL: "https://provider.example/authorize", TokenURL: "https://provider.example/token", UserInfoURL: "https://provider.example/user", ClientID: "app", HTTPClient: client, ProfileSubject: func(m map[string]any) (string, error) { return string(m["id"].(json.Number)), nil }}, func(string, http.Handler) {}) if err != nil { @@ -74,11 +78,23 @@ func TestConcurrentProviderCallbacksRotateOriginalSessionOnce(t *testing.T) { out := make(chan *httptest.ResponseRecorder, 2) go func() { out <- callback(a, "a", stateA) }() go func() { out <- callback(b, "b", stateB) }() - <-gate.both - close(gate.release) - first, second := <-out, <-out + select { + case <-gate.both: + case <-time.After(5 * time.Second): + t.Fatal("provider callbacks did not reach rotation") + } + release() + responses := make([]*httptest.ResponseRecorder, 0, 2) + for range 2 { + select { + case response := <-out: + responses = append(responses, response) + case <-time.After(5 * time.Second): + t.Fatal("provider callback did not finish") + } + } success, rejected := 0, 0 - for _, response := range []*httptest.ResponseRecorder{first, second} { + for _, response := range responses { switch response.Code { case http.StatusFound: success++ diff --git a/profile_link_test.go b/profile_link_test.go index 16b9207..a28206d 100644 --- a/profile_link_test.go +++ b/profile_link_test.go @@ -209,6 +209,9 @@ func TestOAuthFailedCallbackDoesNotEraseNewFlow(t *testing.T) { entered := make(chan struct{}) release := make(chan struct{}) var enterOnce sync.Once + var releaseOnce sync.Once + releaseCallback := func() { releaseOnce.Do(func() { close(release) }) } + defer releaseCallback() client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/token" { if err := r.ParseForm(); err != nil { @@ -234,7 +237,11 @@ func TestOAuthFailedCallbackDoesNotEraseNewFlow(t *testing.T) { cookie, firstState := profileStart(t, srv, http.MethodGet, "/auth/login", nil) firstResult := make(chan int, 1) go func() { firstResult <- profileCallback(srv, cookie, firstState, "first").Code }() - <-entered + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("first callback did not reach provider") + } r := httptest.NewRequest(http.MethodGet, "https://app.example/auth/login", nil) r.RemoteAddr = "192.0.2.1:1234" r.Header.Set("Referer", "https://app.example/second") @@ -249,9 +256,14 @@ func TestOAuthFailedCallbackDoesNotEraseNewFlow(t *testing.T) { t.Fatal(err) } secondState := location.Query().Get("state") - close(release) - if code := <-firstResult; code == http.StatusFound { - t.Fatal("failed callback signed in") + releaseCallback() + select { + case code := <-firstResult: + if code == http.StatusFound { + t.Fatal("failed callback signed in") + } + case <-time.After(5 * time.Second): + t.Fatal("failed callback did not finish") } sess := jw.GetSession(r) if sess == nil || sess.Get(srv.sessionKey(oauth2StateKey)) != secondState || @@ -298,7 +310,11 @@ func TestOAuthProfileCallbackCanceledBeforeRotation(t *testing.T) { } result := make(chan *httptest.ResponseRecorder, 1) go func() { result <- profileCallback(srv, cookie, state, "first") }() - <-entered + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("callback did not reach provider") + } var nextState string if action == "logout" { @@ -325,7 +341,12 @@ func TestOAuthProfileCallbackCanceledBeforeRotation(t *testing.T) { } releaseOnce.Do(func() { close(release) }) - w := <-result + var w *httptest.ResponseRecorder + select { + case w = <-result: + case <-time.After(5 * time.Second): + t.Fatal("canceled callback did not finish") + } if w.Code != http.StatusForbidden || len(w.Result().Cookies()) != 0 || logins != 0 { t.Fatalf("canceled callback: status=%d cookies=%v logins=%d", w.Code, w.Result().Cookies(), logins) } @@ -383,12 +404,21 @@ func TestOAuthProfileLinkCanceledByLogout(t *testing.T) { _, linkState := profileStart(t, srv, http.MethodPost, "/auth/link/github", cookie) result := make(chan *httptest.ResponseRecorder, 1) go func() { result <- profileCallback(srv, cookie, linkState, "second") }() - <-entered + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("link callback did not reach provider") + } if !srv.Logout(sess, nil) { t.Fatal("logout did not clear linked session") } releaseOnce.Do(func() { close(release) }) - w := <-result + var w *httptest.ResponseRecorder + select { + case w = <-result: + case <-time.After(5 * time.Second): + t.Fatal("canceled link callback did not finish") + } if w.Code != http.StatusForbidden || len(w.Result().Cookies()) != 0 || links != 0 || sess.Get(srv.SessionKey) != nil { t.Fatalf("canceled link: status=%d cookies=%v links=%d claims=%v", w.Code, w.Result().Cookies(), links, sess.Get(srv.SessionKey)) } From be6d723af3b1f8fbfa46622027a48e4100923753 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 20:25:05 +0100 Subject: [PATCH 20/43] fix: enforce HTTPS for discovered OIDC endpoints --- config.go | 61 +++++++++++++-- config_test.go | 196 +++++++++++++++++++++++++++++++++++++++++++++++++ errconfig.go | 3 + 3 files changed, 254 insertions(+), 6 deletions(-) diff --git a/config.go b/config.go index 8aa3620..6b3dc4a 100644 --- a/config.go +++ b/config.go @@ -4,6 +4,7 @@ package jawsauth import ( "context" + "errors" "net/http" "net/url" "slices" @@ -26,9 +27,9 @@ type Config struct { AuthURL string // OIDC override; required in profile mode TokenURL string // OIDC override; required in profile mode UserInfoURL string // OIDC override; required in profile mode - // AllowInsecureIssuer permits HTTP issuer/profile URLs and is only for tests/dev. + // AllowInsecureIssuer permits HTTP issuer and provider endpoints and is only for tests/dev. AllowInsecureIssuer bool - // HTTPClient is used for OIDC discovery at startup and, unless a per-request + // HTTPClient is used for OIDC discovery and JWKS requests and, unless a per-request // oauth2.HTTPClient is supplied via the request context, as the default client // for token exchange/refresh and UserInfo requests. HTTPClient *http.Client @@ -80,12 +81,25 @@ func validateUrl(k, u, defaultURL string, optional bool) (value string, err erro return } +func validateOIDCEndpoint(field, value string, optional, allowInsecure bool) error { + value, err := validateUrl(field, value, "", optional) + if err != nil || value == "" { + return err + } + parsed, _ := url.Parse(value) + if parsed.Scheme != "https" && (!allowInsecure || parsed.Scheme != "http") { + return errConfig{field: field, cause: ErrConfigOIDCEndpointMustBeHTTPS} + } + return nil +} + // Validate checks whether cfg contains usable OIDC/OAuth2 settings. // // RedirectURL, Issuer and ClientID must be present. URL fields must be absolute // and include a host. OIDC endpoint overrides are optional; profile endpoints // are required and must use HTTPS, or HTTP when AllowInsecureIssuer is true. -// The same scheme rule applies to Issuer. Returned failures match [ErrConfig]. +// OIDC endpoints and Issuer must use HTTPS unless AllowInsecureIssuer is true. +// Returned failures match [ErrConfig]. func (cfg *Config) Validate() (err error) { if _, err = validateUrl("RedirectURL", cfg.RedirectURL, "", false); err == nil { if _, err = validateUrl("Issuer", cfg.Issuer, "", false); err == nil { @@ -108,9 +122,9 @@ func (cfg *Config) Validate() (err error) { } return requireStr("ClientID", cfg.ClientID) } - if _, err = validateUrl("AuthURL", cfg.AuthURL, "", true); err == nil { - if _, err = validateUrl("TokenURL", cfg.TokenURL, "", true); err == nil { - if _, err = validateUrl("UserInfoURL", cfg.UserInfoURL, "", true); err == nil { + if err = validateOIDCEndpoint("AuthURL", cfg.AuthURL, true, cfg.AllowInsecureIssuer); err == nil { + if err = validateOIDCEndpoint("TokenURL", cfg.TokenURL, true, cfg.AllowInsecureIssuer); err == nil { + if err = validateOIDCEndpoint("UserInfoURL", cfg.UserInfoURL, true, cfg.AllowInsecureIssuer); err == nil { err = requireStr("ClientID", cfg.ClientID) } } @@ -167,6 +181,27 @@ func (cfg *Config) buildContext(ctx context.Context, overrideUrl string) (oauth2 if cfg.HTTPClient != nil { ctx = context.WithValue(ctx, oauth2.HTTPClient, cfg.HTTPClient) } + if !cfg.AllowInsecureIssuer { + client, _ := ctx.Value(oauth2.HTTPClient).(*http.Client) + if client == nil { + client = http.DefaultClient + } + clientCopy := *client + checkRedirect := clientCopy.CheckRedirect + clientCopy.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if req.URL.Scheme != "https" { + return errors.New("oidc redirect must use https") + } + if checkRedirect != nil { + return checkRedirect(req, via) + } + if len(via) >= 10 { + return errors.New("stopped after 10 redirects") + } + return nil + } + ctx = context.WithValue(ctx, oauth2.HTTPClient, &clientCopy) + } var provider *oidc.Provider if provider, err = oidc.NewProvider(ctx, cfg.Issuer); wrapOIDC(ErrOIDCDiscovery, &err) == nil { @@ -174,8 +209,22 @@ func (cfg *Config) buildContext(ctx context.Context, overrideUrl string) (oauth2 AuthorizationEndpoint string `json:"authorization_endpoint"` TokenEndpoint string `json:"token_endpoint"` UserinfoEndpoint string `json:"userinfo_endpoint"` + JWKSURI string `json:"jwks_uri"` } if err = provider.Claims(&metadata); wrapOIDC(ErrOIDCProviderMetadata, &err) == nil { + for _, field := range []struct { + name, value string + optional bool + }{ + {"AuthURL", metadata.AuthorizationEndpoint, true}, + {"TokenURL", metadata.TokenEndpoint, true}, + {"UserInfoURL", metadata.UserinfoEndpoint, true}, + {"JWKSURI", metadata.JWKSURI, cfg.AllowInsecureIssuer}, + } { + if err = validateOIDCEndpoint(field.name, field.value, field.optional, cfg.AllowInsecureIssuer); wrapOIDC(ErrOIDCProviderMetadata, &err) != nil { + return + } + } var authURL string var tokenURL string if authURL, err = validateUrl("AuthURL", cfg.AuthURL, metadata.AuthorizationEndpoint, false); wrapOIDC(ErrOIDCProviderMetadata, &err) == nil { diff --git a/config_test.go b/config_test.go index ddfaa6f..5149d5f 100644 --- a/config_test.go +++ b/config_test.go @@ -2,11 +2,15 @@ package jawsauth import ( "encoding/json" + "errors" "net/http" "net/http/httptest" "reflect" "slices" + "strings" + "sync/atomic" "testing" + "time" "golang.org/x/oauth2" ) @@ -249,3 +253,195 @@ func TestConfig_buildContextDiscoveryStillFollowsRedirect(t *testing.T) { t.Fatalf("redirected discovery: config=%v err=%v", got, err) } } + +func TestConfigValidateRejectsInsecureOIDCOverrides(t *testing.T) { + for _, field := range []string{"AuthURL", "TokenURL", "UserInfoURL"} { + t.Run(field, func(t *testing.T) { + cfg := Config{RedirectURL: "https://app.example/callback", Issuer: "https://issuer.example", ClientID: "client"} + var endpoint *string + switch field { + case "AuthURL": + endpoint = &cfg.AuthURL + case "TokenURL": + endpoint = &cfg.TokenURL + case "UserInfoURL": + endpoint = &cfg.UserInfoURL + } + *endpoint = "http://provider.example/endpoint" + err := cfg.Validate() + var configErr errConfig + if !errors.Is(err, ErrConfigOIDCEndpointMustBeHTTPS) || !errors.As(err, &configErr) || configErr.field != field { + t.Fatalf("%s override validation: %v", field, err) + } + cfg.AllowInsecureIssuer = true + if err := cfg.Validate(); err != nil { + t.Fatalf("dev override validation: %v", err) + } + *endpoint = "ftp://provider.example/endpoint" + if err := cfg.Validate(); !errors.Is(err, ErrConfigOIDCEndpointMustBeHTTPS) { + t.Fatalf("non-HTTP dev override validation: %v", err) + } + }) + } +} + +func TestConfigBuildContextRejectsInsecureOIDCMetadata(t *testing.T) { + for _, tc := range []struct { + key, field, value string + allowInsecure bool + }{ + {"authorization_endpoint", "AuthURL", "http://provider.example/auth", false}, + {"token_endpoint", "TokenURL", "http://provider.example/token", false}, + {"userinfo_endpoint", "UserInfoURL", "http://provider.example/userinfo", false}, + {"jwks_uri", "JWKSURI", "http://provider.example/jwks", false}, + {"jwks_uri", "JWKSURI", "", false}, + {"jwks_uri", "JWKSURI", "ftp://provider.example/jwks", true}, + } { + t.Run(tc.key+"/"+tc.value, func(t *testing.T) { + var issuer string + provider := httptest.NewTLSServer(http.HandlerFunc(func(hw http.ResponseWriter, _ *http.Request) { + metadata := map[string]string{ + "issuer": issuer, "authorization_endpoint": issuer + "/auth", "token_endpoint": issuer + "/token", + "userinfo_endpoint": issuer + "/userinfo", "jwks_uri": issuer + "/jwks", + } + metadata[tc.key] = tc.value + _ = json.NewEncoder(hw).Encode(metadata) + })) + defer provider.Close() + issuer = provider.URL + cfg := Config{RedirectURL: "https://app.example/callback", Issuer: issuer, ClientID: "client", HTTPClient: provider.Client(), + AllowInsecureIssuer: tc.allowInsecure} + _, _, _, err := cfg.buildContext(t.Context(), "") + var configErr errConfig + if !errors.Is(err, ErrOIDCProviderMetadata) || !errors.As(err, &configErr) || configErr.field != tc.field { + t.Fatalf("%s metadata validation: %v", tc.key, err) + } + if tc.value != "" && !errors.Is(err, ErrConfigOIDCEndpointMustBeHTTPS) { + t.Fatalf("%s did not reject HTTP: %v", tc.key, err) + } + }) + } +} + +func TestConfigBuildContextDiscoveryRedirectScheme(t *testing.T) { + for _, tc := range []struct { + name, target string + allowInsecure bool + wantSuccess bool + }{ + {"https", "https", false, true}, + {"http blocked", "http", false, false}, + {"http dev", "http", true, true}, + } { + t.Run(tc.name, func(t *testing.T) { + var issuer string + var insecureHits atomic.Int32 + insecure := httptest.NewServer(http.HandlerFunc(func(hw http.ResponseWriter, _ *http.Request) { + insecureHits.Add(1) + _ = json.NewEncoder(hw).Encode(map[string]string{ + "issuer": issuer, "authorization_endpoint": issuer + "/auth", + "token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks", + }) + })) + defer insecure.Close() + var secureHits atomic.Int32 + provider := httptest.NewTLSServer(http.HandlerFunc(func(hw http.ResponseWriter, hr *http.Request) { + if hr.URL.Path == "/metadata" { + secureHits.Add(1) + _ = json.NewEncoder(hw).Encode(map[string]string{ + "issuer": issuer, "authorization_endpoint": issuer + "/auth", + "token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks", + }) + return + } + location := issuer + "/metadata" + if tc.target == "http" { + location = insecure.URL + "/metadata" + } + http.Redirect(hw, hr, location, http.StatusFound) + })) + defer provider.Close() + issuer = provider.URL + client := provider.Client() + var redirectChecks atomic.Int32 + client.CheckRedirect = func(*http.Request, []*http.Request) error { + redirectChecks.Add(1) + return nil + } + cfg := Config{RedirectURL: "https://app.example/callback", Issuer: issuer, ClientID: "client", + AllowInsecureIssuer: tc.allowInsecure, HTTPClient: client} + _, _, _, err := cfg.buildContext(t.Context(), "") + if (err == nil) != tc.wantSuccess { + t.Fatalf("discovery redirect: %v, want success %t", err, tc.wantSuccess) + } + if !tc.wantSuccess && !errors.Is(err, ErrOIDCDiscovery) { + t.Fatalf("discovery error kind: %v", err) + } + if tc.target == "https" && (secureHits.Load() != 1 || redirectChecks.Load() != 1) { + t.Fatalf("HTTPS redirect not followed: hits=%d checks=%d", secureHits.Load(), redirectChecks.Load()) + } + wantInsecureHits := int32(0) + if tc.allowInsecure { + wantInsecureHits = 1 + } + if tc.target == "http" && insecureHits.Load() != wantInsecureHits { + t.Fatalf("HTTP redirect hits=%d, allow insecure=%t", insecureHits.Load(), tc.allowInsecure) + } + }) + } +} + +func TestConfigBuildContextJWKSRedirectScheme(t *testing.T) { + for _, secure := range []bool{false, true} { + name := "http blocked" + if secure { + name = "https allowed" + } + t.Run(name, func(t *testing.T) { + var insecureHits atomic.Int32 + insecure := httptest.NewServer(http.HandlerFunc(func(hw http.ResponseWriter, _ *http.Request) { + insecureHits.Add(1) + _, _ = hw.Write([]byte(`{"keys":[]}`)) + })) + defer insecure.Close() + var issuer string + var secureHits atomic.Int32 + provider := httptest.NewTLSServer(http.HandlerFunc(func(hw http.ResponseWriter, hr *http.Request) { + switch hr.URL.Path { + case "/.well-known/openid-configuration": + _ = json.NewEncoder(hw).Encode(map[string]string{ + "issuer": issuer, "authorization_endpoint": issuer + "/auth", + "token_endpoint": issuer + "/token", "jwks_uri": issuer + "/jwks", + }) + case "/jwks": + location := issuer + "/keys" + if !secure { + location = insecure.URL + "/keys" + } + http.Redirect(hw, hr, location, http.StatusFound) + case "/keys": + secureHits.Add(1) + _, _ = hw.Write([]byte(`{"keys":[]}`)) + } + })) + defer provider.Close() + issuer = provider.URL + cfg := Config{RedirectURL: "https://app.example/callback", Issuer: issuer, ClientID: "client", HTTPClient: provider.Client()} + _, _, verifier, err := cfg.buildContext(t.Context(), "") + if err != nil { + t.Fatal(err) + } + token := makeIDToken(t, map[string]any{"iss": issuer, "aud": "client", "exp": time.Now().Add(time.Hour).Unix()}) + _, err = verifier.Verify(t.Context(), token) + if err == nil { + t.Fatal("unsigned test token unexpectedly verified") + } + if secure && secureHits.Load() != 1 { + t.Fatalf("HTTPS JWKS redirect hits=%d, error=%v", secureHits.Load(), err) + } + if !secure && (insecureHits.Load() != 0 || !strings.Contains(err.Error(), "oidc redirect must use https")) { + t.Fatalf("HTTP JWKS redirect hits=%d, error=%v", insecureHits.Load(), err) + } + }) + } +} diff --git a/errconfig.go b/errconfig.go index 5b092cb..52b9ab0 100644 --- a/errconfig.go +++ b/errconfig.go @@ -22,6 +22,9 @@ var ErrConfigIssuerMustBeHTTPS = errors.New("issuer url must use https") // ErrConfigProfileEndpointMustBeHTTPS means a profile-flow endpoint must use HTTPS, or HTTP when AllowInsecureIssuer is enabled. var ErrConfigProfileEndpointMustBeHTTPS = errors.New("profile endpoint must use https") +// ErrConfigOIDCEndpointMustBeHTTPS means an OIDC endpoint must use HTTPS, or HTTP when AllowInsecureIssuer is enabled. +var ErrConfigOIDCEndpointMustBeHTTPS = errors.New("oidc endpoint must use https") + type errConfig struct { field string cause error From 86fc1950d66483b49d448d68b09c9ac4867d00a1 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 20:44:43 +0100 Subject: [PATCH 21/43] fix: serialize auth revocation and bound OIDC verification --- README.md | 10 +++- authsession.go | 20 +++++-- authsession_test.go | 6 +- config.go | 21 ++++--- config_test.go | 47 +++++++++++++++ oauth2.go | 2 +- profile_link_test.go | 120 +++++++++++++++++++++++++++++++++++++++ server.go | 2 +- sessionlifecycle_test.go | 2 +- wrapper.go | 2 +- 10 files changed, 207 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 9b3ca24..bfcef70 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,11 @@ that grace until the original flow's grace deadline, about ten minutes plus the callback allowance after link start. `LinkEvent` must serialize its final current-auth check and durable link write with logout and any other provider's auth revocation. Set each provider's `SessionRotationLock` to the same lock so -ordinary login callbacks serialize JaWS session replacement with that write; +ordinary login callbacks and timer or wrapper auth revocation serialize with +that write; the original session is rechecked inside that lock, and the lock is not held -during provider requests. jawsauth cannot roll back an -application write if revocation finishes while `LinkEvent` is running; it +during provider requests. Without this serialization, jawsauth cannot roll back +an application write if revocation finishes while `LinkEvent` is running; it rejects the stale callback response. Admin allowlists match parsed addresses with ASCII case ignored; non-ASCII @@ -73,6 +74,9 @@ Restart login after capacity becomes available. Logout and auth expiry clear authentication and reload the session's JaWS pages. Auth expiry preserves an ordinary login in progress but cancels a pending link; explicit logout cancels either flow. +For a successful ordinary callback, this cancellation applies until its final +revocation check. Logout of the old session after that check cannot sign out +the rotated successor session. Protected requests during a pending login return 409 instead of replacing that flow. Connected pages receive the reload command before their sockets close; pending pages reload when they connect. Protected renders also recheck authorization when diff --git a/authsession.go b/authsession.go index f757954..e7a1103 100644 --- a/authsession.go +++ b/authsession.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "sync" "time" "github.com/coreos/go-oidc/v3/oidc" @@ -160,7 +161,7 @@ func (srv *Server) storeSessionAuthClaimsIfActive(sess *jaws.Session, claims map if entry == nil { return ErrOAuth2MissingSession } - srv.clearSessionAuth(sess, nil, entry, true) + srv.clearSessionAuth(sess, nil, entry, true, srv.SessionRotationLock) return errAuthTimerStale } sess.Set(srv.SessionKey, claims) @@ -326,7 +327,7 @@ func (srv *Server) handleSessionAuthTimer(sess *jaws.Session, entry *authTimerSt if srv.sessionAuthTimerCurrent(sess, entry) { if srv.profileSubject != nil { if !sessionAlive(sess) || !time.Now().Before(entry.expiry) { - srv.clearSessionAuth(sess, nil, entry, true) + srv.clearSessionAuth(sess, nil, entry, true, srv.SessionRotationLock) return } srv.mu.Lock() @@ -339,7 +340,7 @@ func (srv *Server) handleSessionAuthTimer(sess *jaws.Session, entry *authTimerSt return } if !sessionAlive(sess) { - srv.clearSessionAuth(sess, nil, entry, true) + srv.clearSessionAuth(sess, nil, entry, true, srv.SessionRotationLock) return } if remaining := time.Until(entry.expiry.Add(-authRefreshSkew)); remaining > 0 { @@ -398,7 +399,7 @@ func (srv *Server) handleSessionAuthTimer(sess *jaws.Session, entry *authTimerSt "session_present", present, ) _ = srv.Jaws.Log(err) - srv.clearSessionAuth(sess, nil, entry, true) + srv.clearSessionAuth(sess, nil, entry, true, srv.SessionRotationLock) } else { srv.debugLog("jawsauth: auth refresh timer completed", "session_prefix", debugSessionPrefix(sess)) } @@ -445,13 +446,17 @@ func (srv *Server) clearSessionOAuthFlow(sess *jaws.Session) { // response. A JaWS event handler cannot use [jaws.Request.Redirect] after Logout // queues Reload, which closes the connection; use an HTTP logout endpoint instead. func (srv *Server) Logout(sess *jaws.Session, hr *http.Request) (cleared bool) { - return srv.clearSessionAuth(sess, hr, nil, false) + return srv.clearSessionAuth(sess, hr, nil, false, nil) } -func (srv *Server) clearSessionAuth(sess *jaws.Session, hr *http.Request, entry *authTimerState, preservePendingLogin bool) (cleared bool) { +// Public logout may already hold SessionRotationLock; internal revocation passes it here. +func (srv *Server) clearSessionAuth(sess *jaws.Session, hr *http.Request, entry *authTimerState, preservePendingLogin bool, rotationLock sync.Locker) (cleared bool) { if srv == nil || sess == nil { return } + if rotationLock != nil { + rotationLock.Lock() + } srv.mu.Lock() cleared = srv.stopSessionAuthTimerLocked(sess, entry) var requests []*jaws.Request @@ -469,6 +474,9 @@ func (srv *Server) clearSessionAuth(sess *jaws.Session, hr *http.Request, entry requests = sess.Requests() } srv.mu.Unlock() + if rotationLock != nil { + rotationLock.Unlock() + } if cleared { reloadAuthRequests(requests) if srv.LogoutEvent != nil { diff --git a/authsession_test.go b/authsession_test.go index 3a65aa1..dd5dc3b 100644 --- a/authsession_test.go +++ b/authsession_test.go @@ -396,7 +396,7 @@ func TestAuthTimerRefreshesCachedTokenByForcingRefresh(t *testing.T) { jw.Logger = logger const issuer = "https://issuer.example" - initialExpiry := time.Now().Add(5 * time.Second).Truncate(time.Second) + initialExpiry := time.Now().Add(authRefreshSkew - time.Second).Truncate(time.Second) refreshedExpiry := time.Now().Add(time.Hour).Truncate(time.Second) cachedIDToken := makeIDToken(t, map[string]any{ "iss": issuer, @@ -669,7 +669,7 @@ func TestAuthTimerRefreshFailureKeepsCurrentAuthUntilExpiry(t *testing.T) { } req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) sess := jw.NewSession(httptest.NewRecorder(), req) - expiry := time.Now().Add(5 * time.Second).Truncate(time.Second) + expiry := time.Now().Add(authRefreshSkew - time.Second).Truncate(time.Second) err = srv.storeSessionAuthClaims(sess, map[string]any{ "exp": expiry.Unix(), "email": "current@example.com", @@ -830,7 +830,7 @@ func TestAuthTimerDebugLogsRefreshFailure(t *testing.T) { srv := newTimerTestServer(t, jw, issuer, factory) req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) sess := jw.NewSession(httptest.NewRecorder(), req) - expiry := time.Now().Add(5 * time.Second).Truncate(time.Second) + expiry := time.Now().Add(authRefreshSkew - time.Second).Truncate(time.Second) err = srv.storeSessionAuthClaims(sess, map[string]any{ "exp": expiry.Unix(), diff --git a/config.go b/config.go index 6b3dc4a..9c23d0b 100644 --- a/config.go +++ b/config.go @@ -178,15 +178,18 @@ func (cfg *Config) buildContext(ctx context.Context, overrideUrl string) (oauth2 } return oauth2cfg, cfg.UserInfoURL, nil, nil } - if cfg.HTTPClient != nil { - ctx = context.WithValue(ctx, oauth2.HTTPClient, cfg.HTTPClient) + client := cfg.HTTPClient + if client == nil { + client, _ = ctx.Value(oauth2.HTTPClient).(*http.Client) + } + if client == nil { + client = http.DefaultClient + } + clientCopy := *client + if clientCopy.Timeout <= 0 || clientCopy.Timeout > oauth2ProviderTimeout { + clientCopy.Timeout = oauth2ProviderTimeout } if !cfg.AllowInsecureIssuer { - client, _ := ctx.Value(oauth2.HTTPClient).(*http.Client) - if client == nil { - client = http.DefaultClient - } - clientCopy := *client checkRedirect := clientCopy.CheckRedirect clientCopy.CheckRedirect = func(req *http.Request, via []*http.Request) error { if req.URL.Scheme != "https" { @@ -200,8 +203,8 @@ func (cfg *Config) buildContext(ctx context.Context, overrideUrl string) (oauth2 } return nil } - ctx = context.WithValue(ctx, oauth2.HTTPClient, &clientCopy) } + ctx = context.WithValue(ctx, oauth2.HTTPClient, &clientCopy) var provider *oidc.Provider if provider, err = oidc.NewProvider(ctx, cfg.Issuer); wrapOIDC(ErrOIDCDiscovery, &err) == nil { @@ -219,7 +222,7 @@ func (cfg *Config) buildContext(ctx context.Context, overrideUrl string) (oauth2 {"AuthURL", metadata.AuthorizationEndpoint, true}, {"TokenURL", metadata.TokenEndpoint, true}, {"UserInfoURL", metadata.UserinfoEndpoint, true}, - {"JWKSURI", metadata.JWKSURI, cfg.AllowInsecureIssuer}, + {"JWKSURI", metadata.JWKSURI, false}, } { if err = validateOIDCEndpoint(field.name, field.value, field.optional, cfg.AllowInsecureIssuer); wrapOIDC(ErrOIDCProviderMetadata, &err) != nil { return diff --git a/config_test.go b/config_test.go index 5149d5f..5b1a8ed 100644 --- a/config_test.go +++ b/config_test.go @@ -295,6 +295,7 @@ func TestConfigBuildContextRejectsInsecureOIDCMetadata(t *testing.T) { {"userinfo_endpoint", "UserInfoURL", "http://provider.example/userinfo", false}, {"jwks_uri", "JWKSURI", "http://provider.example/jwks", false}, {"jwks_uri", "JWKSURI", "", false}, + {"jwks_uri", "JWKSURI", "", true}, {"jwks_uri", "JWKSURI", "ftp://provider.example/jwks", true}, } { t.Run(tc.key+"/"+tc.value, func(t *testing.T) { @@ -445,3 +446,49 @@ func TestConfigBuildContextJWKSRedirectScheme(t *testing.T) { }) } } + +func TestConfigBuildContextJWKSTimeout(t *testing.T) { + for _, configuredTimeout := range []time.Duration{0, 100 * time.Millisecond} { + t.Run(configuredTimeout.String(), func(t *testing.T) { + const issuer = "https://issuer.example" + var jwksHits atomic.Int32 + deadlineObserved := make(chan time.Duration, 1) + client := &http.Client{Timeout: configuredTimeout, Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch req.URL.Path { + case "/.well-known/openid-configuration": + return profileTestResponse(`{"issuer":"https://issuer.example","authorization_endpoint":"https://issuer.example/auth","token_endpoint":"https://issuer.example/token","jwks_uri":"https://issuer.example/jwks"}`), nil + case "/jwks": + jwksHits.Add(1) + deadline, ok := req.Context().Deadline() + if !ok { + deadlineObserved <- -1 + return nil, errors.New("JWKS request has no deadline") + } + deadlineObserved <- time.Until(deadline) + if configuredTimeout > 0 { + <-req.Context().Done() + return nil, req.Context().Err() + } + return nil, errors.New("JWKS request stopped by test") + } + return nil, errors.New("unexpected provider request") + })} + cfg := Config{RedirectURL: "https://app.example/callback", Issuer: issuer, ClientID: "client", HTTPClient: client} + _, _, verifier, err := cfg.buildContext(t.Context(), "") + if err != nil { + t.Fatal(err) + } + _, err = verifier.Verify(t.Context(), makeIDToken(t, map[string]any{"iss": issuer, "aud": "client", "exp": time.Now().Add(time.Hour).Unix()})) + if err == nil || jwksHits.Load() != 1 || client.Timeout != configuredTimeout { + t.Fatalf("JWKS verify: err=%v hits=%d original timeout=%s", err, jwksHits.Load(), client.Timeout) + } + want := configuredTimeout + if want == 0 { + want = oauth2ProviderTimeout + } + if remaining := <-deadlineObserved; remaining <= 0 || remaining > want { + t.Fatalf("JWKS deadline %s, want at most %s", remaining, want) + } + }) + } +} diff --git a/oauth2.go b/oauth2.go index b140f70..3041e32 100644 --- a/oauth2.go +++ b/oauth2.go @@ -240,7 +240,7 @@ func (srv *Server) HandleLogout(hw http.ResponseWriter, hr *http.Request) { if hr.Method == http.MethodGet { _, location := srv.begin(hr) if sess := srv.Jaws.GetSession(hr); sess != nil { - srv.clearSessionAuth(sess, hr, nil, false) + srv.clearSessionAuth(sess, hr, nil, false, nil) } hw.Header().Set("Location", location) statusCode = http.StatusFound diff --git a/profile_link_test.go b/profile_link_test.go index a28206d..6a3e6f7 100644 --- a/profile_link_test.go +++ b/profile_link_test.go @@ -494,6 +494,126 @@ func TestOAuthProfileLinkRevokedDuringEventRejectsCallback(t *testing.T) { } } +type observedRotationLock struct { + sync.Mutex + attempts atomic.Int32 + second chan struct{} +} + +func (lock *observedRotationLock) Lock() { + if lock.attempts.Add(1) == 2 { + close(lock.second) + } + lock.Mutex.Lock() +} + +func TestOAuthProfileLinkSerializesInternalRevocation(t *testing.T) { + for _, revocation := range []string{"timer", "wrapper"} { + for _, revocationFirst := range []bool{false, true} { + name := revocation + "/event first" + if revocationFirst { + name = revocation + "/revocation first" + } + t.Run(name, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + factory := &testAuthTimerFactory{} + srv.authTimerAfterFunc = factory.after + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + first := profileCallback(srv, cookie, state, "first") + if first.Code != http.StatusFound { + t.Fatalf("initial login: %d", first.Code) + } + cookie = first.Result().Cookies()[0] + req := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + req.RemoteAddr = "192.0.2.1:1234" + req.AddCookie(cookie) + sess := jw.GetSession(req) + lock := &observedRotationLock{second: make(chan struct{})} + srv.SessionRotationLock = lock + srv.LogoutEvent = func(*jaws.Session, *http.Request) { + lock.Lock() + lock.Unlock() + } + checked := make(chan struct{}) + release := make(chan struct{}) + releaseEvent := sync.OnceFunc(func() { close(release) }) + defer releaseEvent() + writes := 0 + srv.LinkEvent = func(*jaws.Session, *http.Request, map[string]any) (int, error) { + lock.Lock() + defer lock.Unlock() + if current, _ := srv.sessionAuthStatus(sess, time.Now); !current { + return http.StatusForbidden, ErrOAuth2MissingSession + } + close(checked) + <-release + writes++ // durable application write + return 0, nil + } + _, linkState := profileStart(t, srv, http.MethodPost, "/auth/link/github", cookie) + revoke := func() { + if revocation == "timer" { + srv.mu.Lock() + srv.authTimers[sess.ID()].expiry = time.Now().Add(-time.Second) + srv.mu.Unlock() + factory.timer(0).fire() + } else { + sess.Set(srv.sessionKey(oauth2IDTokenExpiryKey), time.Now().Add(-time.Second)) + wrapper{server: srv}.ServeHTTP(httptest.NewRecorder(), req) + } + } + if revocationFirst { + revoke() + response := profileCallback(srv, cookie, linkState, "second") + if response.Code < http.StatusBadRequest || writes != 0 { + t.Fatalf("revocation won: status=%d writes=%d", response.Code, writes) + } + return + } + callbackDone := make(chan *httptest.ResponseRecorder, 1) + go func() { callbackDone <- profileCallback(srv, cookie, linkState, "second") }() + select { + case <-checked: + case <-time.After(5 * time.Second): + t.Fatal("link did not check current auth") + } + revoked := make(chan struct{}) + go func() { revoke(); close(revoked) }() + select { + case <-lock.second: + case <-time.After(5 * time.Second): + t.Fatal("revocation did not reach rotation lock") + } + select { + case <-revoked: + t.Fatal("revocation completed before durable link write") + default: + } + releaseEvent() + select { + case <-revoked: + case <-time.After(5 * time.Second): + t.Fatal("revocation did not finish after link write") + } + select { + case <-callbackDone: + case <-time.After(5 * time.Second): + t.Fatal("link callback did not finish") + } + if writes != 1 { + t.Fatalf("durable link writes = %d", writes) + } + }) + } + } +} + func TestOAuthProfileAuthExpiryPreservesOrdinaryLogin(t *testing.T) { for _, phase := range []string{"pending", "callback"} { t.Run(phase, func(t *testing.T) { diff --git a/server.go b/server.go index e3adce1..66c3b66 100644 --- a/server.go +++ b/server.go @@ -92,7 +92,7 @@ type Server struct { LogoutEvent EventFunc // if not nil, called after auth is cleared; hr may be nil LoginFailed FailedFunc // if not nil, called on failed login LinkEvent LinkFunc // if not nil, enables HandleLink - SessionRotationLock sync.Locker // optional; held only while an ordinary callback replaces its JaWS session + SessionRotationLock sync.Locker // optional; serializes session rotation and internal auth revocation with application link writes Options []oauth2.AuthCodeOption // authorization URL options, e.g. oauth2.AccessTypeOffline; never sent to token exchange // RequireVerifiedAdminEmail requires email_verified for a non-empty admin list. diff --git a/sessionlifecycle_test.go b/sessionlifecycle_test.go index 35dd8f9..4ba4338 100644 --- a/sessionlifecycle_test.go +++ b/sessionlifecycle_test.go @@ -60,7 +60,7 @@ func TestAuthTimerDiscardsClosedSession(t *testing.T) { srv := newTimerTestServer(t, jw, "https://issuer.example", factory) req := httptest.NewRequest(http.MethodGet, "/", nil) sess := jw.NewSession(httptest.NewRecorder(), req) - expiry := time.Now().Add(5 * time.Second) + expiry := time.Now().Add(authRefreshSkew - time.Second) raw := makeIDToken(t, map[string]any{"iss": "https://issuer.example", "aud": "client", "sub": "one", "exp": expiry.Add(time.Hour).Unix(), "email": "user@example.com"}) calls := 0 source := tokenSourceFunc(func() (*oauth2.Token, error) { diff --git a/wrapper.go b/wrapper.go index c84a2db..b39a54c 100644 --- a/wrapper.go +++ b/wrapper.go @@ -15,7 +15,7 @@ func (w wrapper) ServeHTTP(hw http.ResponseWriter, hr *http.Request) { sess := w.server.Jaws.GetSession(hr) if current, present := w.server.sessionAuthStatus(sess, time.Now); !current { if present { - w.server.clearSessionAuth(sess, hr, nil, true) + w.server.clearSessionAuth(sess, hr, nil, true, w.server.SessionRotationLock) } w.server.startOAuth(hw, hr, false, true) return From 9111a3ef2c17b204a8de040e968eae57f0f67904 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 20:52:29 +0100 Subject: [PATCH 22/43] test: assert locked revocation callback --- profile_link_test.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/profile_link_test.go b/profile_link_test.go index 6a3e6f7..a408422 100644 --- a/profile_link_test.go +++ b/profile_link_test.go @@ -536,8 +536,10 @@ func TestOAuthProfileLinkSerializesInternalRevocation(t *testing.T) { sess := jw.GetSession(req) lock := &observedRotationLock{second: make(chan struct{})} srv.SessionRotationLock = lock + var logoutEvents atomic.Int32 srv.LogoutEvent = func(*jaws.Session, *http.Request) { lock.Lock() + logoutEvents.Add(1) lock.Unlock() } checked := make(chan struct{}) @@ -571,8 +573,8 @@ func TestOAuthProfileLinkSerializesInternalRevocation(t *testing.T) { if revocationFirst { revoke() response := profileCallback(srv, cookie, linkState, "second") - if response.Code < http.StatusBadRequest || writes != 0 { - t.Fatalf("revocation won: status=%d writes=%d", response.Code, writes) + if response.Code < http.StatusBadRequest || writes != 0 || logoutEvents.Load() != 1 { + t.Fatalf("revocation won: status=%d writes=%d logout events=%d", response.Code, writes, logoutEvents.Load()) } return } @@ -606,8 +608,8 @@ func TestOAuthProfileLinkSerializesInternalRevocation(t *testing.T) { case <-time.After(5 * time.Second): t.Fatal("link callback did not finish") } - if writes != 1 { - t.Fatalf("durable link writes = %d", writes) + if writes != 1 || logoutEvents.Load() != 1 { + t.Fatalf("durable link writes = %d, logout events = %d", writes, logoutEvents.Load()) } }) } From aaa671416a2ab3b7d060a227899534d0ab50dc97 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 21:02:06 +0100 Subject: [PATCH 23/43] fix: serialize built-in logout with account links --- README.md | 7 ++++--- oauth2.go | 2 +- profile_link_test.go | 11 +++++++---- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index bfcef70..fd49b4e 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,10 @@ that grace until the original flow's grace deadline, about ten minutes plus the callback allowance after link start. `LinkEvent` must serialize its final current-auth check and durable link write with logout and any other provider's auth revocation. Set each provider's `SessionRotationLock` to the same lock so -ordinary login callbacks and timer or wrapper auth revocation serialize with -that write; -the original session is rechecked inside that lock, and the lock is not held +ordinary login callbacks, timer or wrapper auth revocation, and the built-in +`HandleLogout` serialize with that write. Applications calling `Server.Logout` +must take the shared lock around that call when they use `LinkEvent` for durable writes. +The original session is rechecked inside that lock, and the lock is not held during provider requests. Without this serialization, jawsauth cannot roll back an application write if revocation finishes while `LinkEvent` is running; it rejects the stale callback response. diff --git a/oauth2.go b/oauth2.go index 3041e32..a2df2dd 100644 --- a/oauth2.go +++ b/oauth2.go @@ -240,7 +240,7 @@ func (srv *Server) HandleLogout(hw http.ResponseWriter, hr *http.Request) { if hr.Method == http.MethodGet { _, location := srv.begin(hr) if sess := srv.Jaws.GetSession(hr); sess != nil { - srv.clearSessionAuth(sess, hr, nil, false, nil) + srv.clearSessionAuth(sess, hr, nil, false, srv.SessionRotationLock) } hw.Header().Set("Location", location) statusCode = http.StatusFound diff --git a/profile_link_test.go b/profile_link_test.go index a408422..42ff32e 100644 --- a/profile_link_test.go +++ b/profile_link_test.go @@ -507,8 +507,8 @@ func (lock *observedRotationLock) Lock() { lock.Mutex.Lock() } -func TestOAuthProfileLinkSerializesInternalRevocation(t *testing.T) { - for _, revocation := range []string{"timer", "wrapper"} { +func TestOAuthProfileLinkSerializesManagedRevocation(t *testing.T) { + for _, revocation := range []string{"timer", "wrapper", "handler logout"} { for _, revocationFirst := range []bool{false, true} { name := revocation + "/event first" if revocationFirst { @@ -560,14 +560,17 @@ func TestOAuthProfileLinkSerializesInternalRevocation(t *testing.T) { } _, linkState := profileStart(t, srv, http.MethodPost, "/auth/link/github", cookie) revoke := func() { - if revocation == "timer" { + switch revocation { + case "timer": srv.mu.Lock() srv.authTimers[sess.ID()].expiry = time.Now().Add(-time.Second) srv.mu.Unlock() factory.timer(0).fire() - } else { + case "wrapper": sess.Set(srv.sessionKey(oauth2IDTokenExpiryKey), time.Now().Add(-time.Second)) wrapper{server: srv}.ServeHTTP(httptest.NewRecorder(), req) + case "handler logout": + srv.HandleLogout(httptest.NewRecorder(), req) } } if revocationFirst { From d8024f601390f5a839949d205e51feafdee7d2ed Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 22:11:25 +0100 Subject: [PATCH 24/43] fix(auth): preserve pending login and simplify provider flows --- README.md | 48 +++--- authsession.go | 139 +++++++-------- authsession_test.go | 2 +- cmd/integration/jawsauth_test.go | 19 +++ concurrent_providers_test.go | 14 +- config.go | 97 ++++------- config_test.go | 31 +++- currentclaims_test.go | 6 +- debug.go | 7 + errconfig.go | 7 +- erroauth2callback_test.go | 16 +- expiry_wrapper_test.go | 9 +- namespace_test.go | 29 ++-- oauth2.go | 281 ++++++++++++++++--------------- oauth2_callback_edge_test.go | 29 ++-- oauth2_review_test.go | 227 +++++++++++++++++++++++++ oauth2_test.go | 124 +++++--------- oauth_flow_grace_test.go | 26 ++- profile_link_test.go | 186 +++++--------------- server.go | 14 +- sessionlifecycle_test.go | 10 +- testwait_test.go | 18 ++ wrapper.go | 2 +- wrapper_test.go | 10 +- 24 files changed, 719 insertions(+), 632 deletions(-) create mode 100644 oauth2_review_test.go create mode 100644 testwait_test.go diff --git a/README.md b/README.md index fd49b4e..96430c5 100644 --- a/README.md +++ b/README.md @@ -21,29 +21,27 @@ Login callback token/profile calls and startup discovery have a 15-second deadli OIDC background refresh requests have the same maximum timeout. Profile responses are capped at 32 KiB. -For multiple providers on one JaWS instance, give each `Config` a distinct -`Namespace` and callback directory (for example, `/oauth/github/callback` and -`/oauth/gitlab/callback`). `New` sets `Jaws.MakeAuth` for its own Server, so -install a composite `MakeAuth` after creating all provider Servers if pages need -to recognize more than one provider. Use `Server.CurrentClaims` to check each -provider's live session auth there. `HandleLink` is an opt-in POST handler: -authorize the existing session before calling it, then set `Server.LinkEvent` to -recheck the original identity and accept the newly verified subject. A successful -link keeps the original session; ordinary login still rotates it. Link callbacks -run only while the original JaWS session and one-use flow state remain valid. -Successful linking keeps the session whose idle grace was extended for the -provider visit. With no connected pages, that authenticated session can retain -that grace until the original flow's grace deadline, about ten minutes plus -the callback allowance after link start. `LinkEvent` must serialize its final -current-auth check and durable link write with logout and any other provider's -auth revocation. Set each provider's `SessionRotationLock` to the same lock so -ordinary login callbacks, timer or wrapper auth revocation, and the built-in -`HandleLogout` serialize with that write. Applications calling `Server.Logout` -must take the shared lock around that call when they use `LinkEvent` for durable writes. -The original session is rechecked inside that lock, and the lock is not held -during provider requests. Without this serialization, jawsauth cannot roll back -an application write if revocation finishes while `LinkEvent` is running; it -rejects the stale callback response. +For multiple providers on one JaWS instance: + +- Give each `Config` a distinct `Namespace` and callback directory, such as + `/oauth/github/callback` and `/oauth/gitlab/callback`. +- `New` sets `Jaws.MakeAuth` for one provider. Install a composite `MakeAuth` + after creating the servers if pages need both. Use `Server.CurrentClaims` + to check each provider's live authentication. +- `HandleLink` is an opt-in POST handler. Authorize the existing session before + calling it. Set `Server.LinkEvent` to recheck that identity and accept the + newly verified subject. A link keeps the original session; login rotates it. + A link callback requires the original live JaWS session and one-use flow state. +- Serialize `LinkEvent`'s final current-auth check and durable write with logout + and other provider auth revocation. Give each provider's `SessionRotationLock` + the same lock. This serializes login rotation, timer and wrapper revocation, + and built-in `HandleLogout` with the write. Hold that lock around direct calls + to `Server.Logout` when linking writes are durable. Provider requests run + outside the lock. A stale callback is rejected, but an application link write + cannot be rolled back after revocation. + +The provider visit extends the original session's idle grace. A successful +link can retain that grace even with no connected pages until its deadline. Admin allowlists match parsed addresses with ASCII case ignored; non-ASCII characters are matched exactly. Session email values use the same case rules. @@ -78,7 +76,9 @@ explicit logout cancels either flow. For a successful ordinary callback, this cancellation applies until its final revocation check. Logout of the old session after that check cannot sign out the rotated successor session. -Protected requests during a pending login return 409 instead of replacing that flow. +Protected requests during a pending login return to that provider flow without +replacing its state or renewing its session grace. A request during the callback +returns 409 until it finishes. Connected pages receive the reload command before their sockets close; pending pages reload when they connect. Protected renders also recheck authorization when the HTTP handler returns, retiring requests attached during a concurrent revocation. diff --git a/authsession.go b/authsession.go index e7a1103..6a7e593 100644 --- a/authsession.go +++ b/authsession.go @@ -4,7 +4,6 @@ import ( "context" "errors" "net/http" - "sync" "time" "github.com/coreos/go-oidc/v3/oidc" @@ -96,7 +95,7 @@ func (srv *Server) oauth2Context(ctx context.Context) (authctx context.Context) func (srv *Server) sessionAuthStatus(sess *jaws.Session, now func() time.Time) (current, present bool) { if srv != nil && sess != nil { - expiryValue := sess.Get(srv.sessionKey(oauth2IDTokenExpiryKey)) + expiryValue := sess.Get(srv.sessionKey(oauth2AuthExpiryKey)) authValue := sess.Get(srv.SessionKey) present = authValue != nil || expiryValue != nil if authValue != nil && now != nil { @@ -123,6 +122,22 @@ func (srv *Server) identityExpiryError(expiry time.Time) error { return errOIDC{kind: ErrOIDCInvalidIDToken, cause: errOIDCInvalidExpiry} } +func (srv *Server) verifyIDToken(ctx context.Context, raw string) (idToken *oidc.IDToken, claims map[string]any, err error) { + if raw == "" { + return nil, nil, ErrOIDCMissingIDToken + } + idToken, err = srv.idTokenVerifier.Verify(ctx, raw) + if wrapOIDC(ErrOIDCInvalidIDToken, &err) != nil { + return + } + if idToken.Subject == "" { + return nil, nil, errOIDC{kind: ErrOIDCInvalidIDToken, cause: errOIDCMissingSubject} + } + err = idToken.Claims(&claims) + wrapOIDC(ErrOIDCInvalidIDToken, &err) + return +} + func (srv *Server) storeSessionAuthClaims(sess *jaws.Session, claims map[string]any, tokenSource oauth2.TokenSource, expiry time.Time, entry *authTimerState) error { return srv.storeSessionAuthClaimsIfActive(sess, claims, tokenSource, expiry, entry, nil) } @@ -161,12 +176,12 @@ func (srv *Server) storeSessionAuthClaimsIfActive(sess *jaws.Session, claims map if entry == nil { return ErrOAuth2MissingSession } - srv.clearSessionAuth(sess, nil, entry, true, srv.SessionRotationLock) + srv.clearSessionAuth(sess, nil, entry, true, true) return errAuthTimerStale } sess.Set(srv.SessionKey, claims) sess.Set(srv.SessionTokenKey, tokenSource) - sess.Set(srv.sessionKey(oauth2IDTokenExpiryKey), expiry) + sess.Set(srv.sessionKey(oauth2AuthExpiryKey), expiry) sess.Set(srv.SessionEmailKey, email) sess.Set(srv.SessionEmailVerifiedKey, verified) delay, replaced := srv.scheduleSessionAuthTimerLocked(sess, expiry) @@ -186,36 +201,29 @@ func (srv *Server) storeSessionAuthClaimsIfActive(sess *jaws.Session, claims map return nil } -func (srv *Server) setSessionAuthFromToken(ctx context.Context, sess *jaws.Session, tokenSource oauth2.TokenSource, token *oauth2.Token, minExpiry time.Time, entry *authTimerState) (err error) { - err = ErrOAuth2NotConfigured - if srv != nil && srv.idTokenVerifier != nil { - err = ErrOIDCMissingIDToken - if token != nil { - rawIDToken, _ := token.Extra("id_token").(string) - if rawIDToken != "" { - var idToken *oidc.IDToken - if idToken, err = srv.idTokenVerifier.Verify(ctx, rawIDToken); wrapOIDC(ErrOIDCInvalidIDToken, &err) == nil { - if idToken.Subject == "" { - return errOIDC{kind: ErrOIDCInvalidIDToken, cause: errOIDCMissingSubject} - } - var claims map[string]any - if err = idToken.Claims(&claims); wrapOIDC(ErrOIDCInvalidIDToken, &err) == nil { - if idToken.Expiry.IsZero() { - err = errOIDC{kind: ErrOIDCInvalidIDToken, cause: errOIDCInvalidExpiry} - } else if !minExpiry.IsZero() && !idToken.Expiry.After(minExpiry) { - err = errOIDC{kind: ErrOIDCInvalidIDToken, cause: errOIDCStaleIDToken} - } else { - srv.addUserInfoClaims(ctx, claims, tokenSource) - if err = srv.identityExpiryError(idToken.Expiry); err == nil { - err = srv.storeSessionAuthClaims(sess, claims, tokenSource, idToken.Expiry, entry) - } - } - } - } - } - } +func (srv *Server) setSessionAuthFromToken(ctx context.Context, sess *jaws.Session, tokenSource oauth2.TokenSource, token *oauth2.Token, minExpiry time.Time, entry *authTimerState) error { + if srv == nil || srv.idTokenVerifier == nil { + return ErrOAuth2NotConfigured } - return + var raw string + if token != nil { + raw, _ = token.Extra("id_token").(string) + } + idToken, claims, err := srv.verifyIDToken(ctx, raw) + if err != nil { + return err + } + if idToken.Expiry.IsZero() { + return errOIDC{kind: ErrOIDCInvalidIDToken, cause: errOIDCInvalidExpiry} + } + if !minExpiry.IsZero() && !idToken.Expiry.After(minExpiry) { + return errOIDC{kind: ErrOIDCInvalidIDToken, cause: errOIDCStaleIDToken} + } + srv.addUserInfoClaims(ctx, claims, tokenSource) + if err := srv.identityExpiryError(idToken.Expiry); err != nil { + return err + } + return srv.storeSessionAuthClaims(sess, claims, tokenSource, idToken.Expiry, entry) } func (srv *Server) refreshSessionAuth(ctx context.Context, sess *jaws.Session, minExpiry time.Time, entry *authTimerState) (err error) { @@ -279,11 +287,7 @@ func (srv *Server) refreshSessionAuth(ctx context.Context, sess *jaws.Session, m // Caller holds srv.mu across the auth writes and timer replacement. func (srv *Server) scheduleSessionAuthTimerLocked(sess *jaws.Session, expiry time.Time) (delay time.Duration, replaced bool) { - delay = max(time.Until(expiry.Add(-authRefreshSkew)), 0) - if srv.profileSubject != nil { - delay = max(time.Until(expiry), 0) - } - delay = min(delay, authSessionCheckInterval) + delay = min(max(time.Until(srv.authTimerDue(expiry)), 0), authSessionCheckInterval) entry := &authTimerState{expiry: expiry} if srv.authTimers == nil { srv.authTimers = make(map[uint64]*authTimerState) @@ -325,25 +329,12 @@ func (srv *Server) stopSessionAuthTimerLocked(sess *jaws.Session, entry *authTim func (srv *Server) handleSessionAuthTimer(sess *jaws.Session, entry *authTimerState) { if srv.sessionAuthTimerCurrent(sess, entry) { - if srv.profileSubject != nil { - if !sessionAlive(sess) || !time.Now().Before(entry.expiry) { - srv.clearSessionAuth(sess, nil, entry, true, srv.SessionRotationLock) - return - } - srv.mu.Lock() - if srv.authTimers[sess.ID()] == entry { - entry.timer = srv.authTimerAfterFunc(min(time.Until(entry.expiry), authSessionCheckInterval), func() { - srv.handleSessionAuthTimer(sess, entry) - }) - } - srv.mu.Unlock() - return - } - if !sessionAlive(sess) { - srv.clearSessionAuth(sess, nil, entry, true, srv.SessionRotationLock) + remaining := time.Until(srv.authTimerDue(entry.expiry)) + if !sessionAlive(sess) || (srv.profileSubject != nil && remaining <= 0) { + srv.clearSessionAuth(sess, nil, entry, true, true) return } - if remaining := time.Until(entry.expiry.Add(-authRefreshSkew)); remaining > 0 { + if remaining > 0 { srv.mu.Lock() if srv.authTimers[sess.ID()] == entry { entry.timer = srv.authTimerAfterFunc(min(remaining, authSessionCheckInterval), func() { @@ -399,7 +390,7 @@ func (srv *Server) handleSessionAuthTimer(sess *jaws.Session, entry *authTimerSt "session_present", present, ) _ = srv.Jaws.Log(err) - srv.clearSessionAuth(sess, nil, entry, true, srv.SessionRotationLock) + srv.clearSessionAuth(sess, nil, entry, true, true) } else { srv.debugLog("jawsauth: auth refresh timer completed", "session_prefix", debugSessionPrefix(sess)) } @@ -411,15 +402,20 @@ func (srv *Server) handleSessionAuthTimer(sess *jaws.Session, entry *authTimerSt } } +func (srv *Server) authTimerDue(expiry time.Time) time.Time { + if srv.profileSubject != nil { + return expiry + } + return expiry.Add(-authRefreshSkew) +} + // pendingOrdinaryLoginLocked requires srv.mu. func (srv *Server) pendingOrdinaryLoginLocked(sess *jaws.Session) bool { if flow := srv.oauthCallbacks[sess]; flow.canceled != nil { return !flow.link && !flow.revoked() } - state, _ := sess.Get(srv.sessionKey(oauth2StateKey)).(string) - link, _ := sess.Get(srv.sessionKey(oauth2LinkKey)).(bool) - expiry, _ := sess.Get(srv.sessionKey(oauth2FlowExpiryKey)).(time.Time) - return state != "" && !link && time.Now().Before(expiry) + flow, _ := sess.Get(srv.sessionKey(oauth2FlowKey)).(pendingOAuthFlow) + return flow.state != "" && !flow.link && time.Now().Before(flow.expiry) } func (srv *Server) clearSessionOAuthFlow(sess *jaws.Session) { @@ -427,12 +423,7 @@ func (srv *Server) clearSessionOAuthFlow(sess *jaws.Session) { close(flow.canceled) delete(srv.oauthCallbacks, sess) } - sess.Set(srv.sessionKey(oauth2StateKey), nil) - sess.Set(srv.sessionKey(oauth2FlowExpiryKey), nil) - sess.Set(srv.sessionKey(oauth2LinkKey), nil) - sess.Set(srv.sessionKey(oauth2PKCEVerifierKey), nil) - sess.Set(srv.sessionKey(oauth2NonceKey), nil) - sess.Set(srv.sessionKey(oauth2ReferrerKey), nil) + sess.Set(srv.sessionKey(oauth2FlowKey), nil) } // Logout clears all authentication state for sess. @@ -446,16 +437,16 @@ func (srv *Server) clearSessionOAuthFlow(sess *jaws.Session) { // response. A JaWS event handler cannot use [jaws.Request.Redirect] after Logout // queues Reload, which closes the connection; use an HTTP logout endpoint instead. func (srv *Server) Logout(sess *jaws.Session, hr *http.Request) (cleared bool) { - return srv.clearSessionAuth(sess, hr, nil, false, nil) + return srv.clearSessionAuth(sess, hr, nil, false, false) } -// Public logout may already hold SessionRotationLock; internal revocation passes it here. -func (srv *Server) clearSessionAuth(sess *jaws.Session, hr *http.Request, entry *authTimerState, preservePendingLogin bool, rotationLock sync.Locker) (cleared bool) { +// Public logout may already hold SessionRotationLock; internal revocation takes it here. +func (srv *Server) clearSessionAuth(sess *jaws.Session, hr *http.Request, entry *authTimerState, preservePendingLogin, lockRotation bool) (cleared bool) { if srv == nil || sess == nil { return } - if rotationLock != nil { - rotationLock.Lock() + if lockRotation && srv.SessionRotationLock != nil { + srv.SessionRotationLock.Lock() } srv.mu.Lock() cleared = srv.stopSessionAuthTimerLocked(sess, entry) @@ -468,14 +459,14 @@ func (srv *Server) clearSessionAuth(sess *jaws.Session, hr *http.Request, entry } sess.Set(srv.SessionKey, nil) sess.Set(srv.SessionTokenKey, nil) - sess.Set(srv.sessionKey(oauth2IDTokenExpiryKey), nil) + sess.Set(srv.sessionKey(oauth2AuthExpiryKey), nil) sess.Set(srv.SessionEmailKey, nil) sess.Set(srv.SessionEmailVerifiedKey, nil) requests = sess.Requests() } srv.mu.Unlock() - if rotationLock != nil { - rotationLock.Unlock() + if lockRotation && srv.SessionRotationLock != nil { + srv.SessionRotationLock.Unlock() } if cleared { reloadAuthRequests(requests) diff --git a/authsession_test.go b/authsession_test.go index dd5dc3b..9abfe2b 100644 --- a/authsession_test.go +++ b/authsession_test.go @@ -270,7 +270,7 @@ func TestStoreSessionAuthClaimsSchedulesTimer(t *testing.T) { if gotVerified, _ := sess.Get(srv.SessionEmailVerifiedKey).(bool); !gotVerified { t.Fatal(gotVerified) } - if gotExpiry, _ := sess.Get(oauth2IDTokenExpiryKey).(time.Time); !gotExpiry.Equal(expiry) { + if gotExpiry, _ := sess.Get(oauth2AuthExpiryKey).(time.Time); !gotExpiry.Equal(expiry) { t.Fatal(gotExpiry) } if factory.len() != 1 { diff --git a/cmd/integration/jawsauth_test.go b/cmd/integration/jawsauth_test.go index b389b57..c649efc 100644 --- a/cmd/integration/jawsauth_test.go +++ b/cmd/integration/jawsauth_test.go @@ -107,6 +107,16 @@ func serverHandlerTest(t *testing.T, baseURL, realm, clientID, clientSecret stri var logincount int asrv.LoginEvent = func(sess *jaws.Session, hr *http.Request) { logincount++ } asrv.LogoutEvent = func(sess *jaws.Session, hr *http.Request) { logincount-- } + var linkcount int + asrv.LinkEvent = func(sess *jaws.Session, _ *http.Request, claims map[string]any) (int, error) { + current := asrv.CurrentClaims(sess) + if current == nil || current["sub"] != claims["sub"] { + return http.StatusForbidden, nil + } + linkcount++ + return 0, nil + } + mux.Handle(http.MethodPost+" /oauth2/link", asrv.Wrap(http.HandlerFunc(asrv.HandleLink))) if !asrv.Valid() { t.Fatal() @@ -201,6 +211,15 @@ func serverHandlerTest(t *testing.T, baseURL, realm, clientID, clientSecret stri t.Fatal(resphtml) } + resp, err = client.Post(hsrv.URL+"/oauth2/link", "application/x-www-form-urlencoded", nil) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK || resp.Request.URL.Path != "/" || linkcount != 1 || logincount != 1 { + t.Fatalf("OIDC link: status=%s path=%q links=%d logins=%d", resp.Status, resp.Request.URL.Path, linkcount, logincount) + } + resp, err = client.Get(hsrv.URL + "/needauth") if err != nil { t.Fatal(err) diff --git a/concurrent_providers_test.go b/concurrent_providers_test.go index 6aeced1..5c8f2ef 100644 --- a/concurrent_providers_test.go +++ b/concurrent_providers_test.go @@ -8,7 +8,6 @@ import ( "sync" "sync/atomic" "testing" - "time" "github.com/linkdata/jaws" ) @@ -78,20 +77,11 @@ func TestConcurrentProviderCallbacksRotateOriginalSessionOnce(t *testing.T) { out := make(chan *httptest.ResponseRecorder, 2) go func() { out <- callback(a, "a", stateA) }() go func() { out <- callback(b, "b", stateB) }() - select { - case <-gate.both: - case <-time.After(5 * time.Second): - t.Fatal("provider callbacks did not reach rotation") - } + waitFor(t, gate.both, "provider callbacks did not reach rotation") release() responses := make([]*httptest.ResponseRecorder, 0, 2) for range 2 { - select { - case response := <-out: - responses = append(responses, response) - case <-time.After(5 * time.Second): - t.Fatal("provider callback did not finish") - } + responses = append(responses, waitFor(t, out, "provider callback did not finish")) } success, rejected := 0, 0 for _, response := range responses { diff --git a/config.go b/config.go index 9c23d0b..d317981 100644 --- a/config.go +++ b/config.go @@ -3,6 +3,7 @@ package jawsauth //gosec:disable G117 import ( + "cmp" "context" "errors" "net/http" @@ -53,11 +54,7 @@ func requireStr(k, u string) error { return requireLen(k, len(strings.TrimSpace(u))) } -func validateUrl(k, u, defaultURL string, optional bool) (value string, err error) { - value = u - if value == "" { - value = defaultURL - } +func validateUrl(k, value string, optional bool) (err error) { if optional && value == "" { return } @@ -82,13 +79,13 @@ func validateUrl(k, u, defaultURL string, optional bool) (value string, err erro } func validateOIDCEndpoint(field, value string, optional, allowInsecure bool) error { - value, err := validateUrl(field, value, "", optional) + err := validateUrl(field, value, optional) if err != nil || value == "" { return err } parsed, _ := url.Parse(value) if parsed.Scheme != "https" && (!allowInsecure || parsed.Scheme != "http") { - return errConfig{field: field, cause: ErrConfigOIDCEndpointMustBeHTTPS} + return errConfig{field: field, cause: ErrConfigEndpointMustBeHTTPS} } return nil } @@ -101,34 +98,21 @@ func validateOIDCEndpoint(field, value string, optional, allowInsecure bool) err // OIDC endpoints and Issuer must use HTTPS unless AllowInsecureIssuer is true. // Returned failures match [ErrConfig]. func (cfg *Config) Validate() (err error) { - if _, err = validateUrl("RedirectURL", cfg.RedirectURL, "", false); err == nil { - if _, err = validateUrl("Issuer", cfg.Issuer, "", false); err == nil { + if err = validateUrl("RedirectURL", cfg.RedirectURL, false); err == nil { + if err = validateUrl("Issuer", cfg.Issuer, false); err == nil { issuer, _ := url.Parse(cfg.Issuer) if issuer.Scheme != "https" && (!cfg.AllowInsecureIssuer || issuer.Scheme != "http") { err = errConfig{field: "Issuer", cause: ErrConfigIssuerMustBeHTTPS} } if err == nil { - if cfg.ProfileSubject != nil { - for _, field := range []struct{ name, value string }{ - {"AuthURL", cfg.AuthURL}, {"TokenURL", cfg.TokenURL}, {"UserInfoURL", cfg.UserInfoURL}, - } { - if _, err = validateUrl(field.name, field.value, "", false); err != nil { - return err - } - parsed, _ := url.Parse(field.value) - if parsed.Scheme != "https" && (!cfg.AllowInsecureIssuer || parsed.Scheme != "http") { - return errConfig{field: field.name, cause: ErrConfigProfileEndpointMustBeHTTPS} - } - } - return requireStr("ClientID", cfg.ClientID) - } - if err = validateOIDCEndpoint("AuthURL", cfg.AuthURL, true, cfg.AllowInsecureIssuer); err == nil { - if err = validateOIDCEndpoint("TokenURL", cfg.TokenURL, true, cfg.AllowInsecureIssuer); err == nil { - if err = validateOIDCEndpoint("UserInfoURL", cfg.UserInfoURL, true, cfg.AllowInsecureIssuer); err == nil { - err = requireStr("ClientID", cfg.ClientID) - } + for _, field := range []struct{ name, value string }{ + {"AuthURL", cfg.AuthURL}, {"TokenURL", cfg.TokenURL}, {"UserInfoURL", cfg.UserInfoURL}, + } { + if err = validateOIDCEndpoint(field.name, field.value, cfg.ProfileSubject == nil, cfg.AllowInsecureIssuer); err != nil { + return } } + err = requireStr("ClientID", cfg.ClientID) } } } @@ -141,9 +125,8 @@ func overrideStr(a *string, b string) { } } -func ensureScopes(rawScopes []string) (scopes []string) { - scopes = append(scopes, "openid") - scopes = append(scopes, "email") +func ensureScopes(rawScopes []string, required ...string) (scopes []string) { + scopes = append(scopes, required...) for _, set := range rawScopes { for scope := range strings.FieldsSeq(set) { scopes = append(scopes, scope) @@ -156,21 +139,13 @@ func ensureScopes(rawScopes []string) (scopes []string) { func (cfg *Config) buildContext(ctx context.Context, overrideUrl string) (oauth2cfg *oauth2.Config, userInfoURL string, verifier *oidc.IDTokenVerifier, err error) { if err = cfg.Validate(); err == nil { - var redir *url.URL - if redir, err = url.Parse(cfg.RedirectURL); err != nil { - return - } + redir, _ := url.Parse(cfg.RedirectURL) if u, parseErr := url.Parse(overrideUrl); parseErr == nil { overrideStr(&redir.Scheme, u.Scheme) overrideStr(&redir.Host, u.Host) } if cfg.ProfileSubject != nil { - var scopes []string - for _, set := range cfg.Scopes { - scopes = append(scopes, strings.Fields(set)...) - } - slices.Sort(scopes) - scopes = slices.Compact(scopes) + scopes := ensureScopes(cfg.Scopes) oauth2cfg = &oauth2.Config{ ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret, Endpoint: oauth2.Endpoint{AuthURL: cfg.AuthURL, TokenURL: cfg.TokenURL}, @@ -179,9 +154,6 @@ func (cfg *Config) buildContext(ctx context.Context, overrideUrl string) (oauth2 return oauth2cfg, cfg.UserInfoURL, nil, nil } client := cfg.HTTPClient - if client == nil { - client, _ = ctx.Value(oauth2.HTTPClient).(*http.Client) - } if client == nil { client = http.DefaultClient } @@ -215,38 +187,33 @@ func (cfg *Config) buildContext(ctx context.Context, overrideUrl string) (oauth2 JWKSURI string `json:"jwks_uri"` } if err = provider.Claims(&metadata); wrapOIDC(ErrOIDCProviderMetadata, &err) == nil { + authURL := cmp.Or(cfg.AuthURL, metadata.AuthorizationEndpoint) + tokenURL := cmp.Or(cfg.TokenURL, metadata.TokenEndpoint) + userInfoURL = cmp.Or(cfg.UserInfoURL, metadata.UserinfoEndpoint) for _, field := range []struct { name, value string optional bool }{ - {"AuthURL", metadata.AuthorizationEndpoint, true}, - {"TokenURL", metadata.TokenEndpoint, true}, - {"UserInfoURL", metadata.UserinfoEndpoint, true}, + {"AuthURL", authURL, false}, + {"TokenURL", tokenURL, false}, + {"UserInfoURL", userInfoURL, true}, {"JWKSURI", metadata.JWKSURI, false}, } { if err = validateOIDCEndpoint(field.name, field.value, field.optional, cfg.AllowInsecureIssuer); wrapOIDC(ErrOIDCProviderMetadata, &err) != nil { return } } - var authURL string - var tokenURL string - if authURL, err = validateUrl("AuthURL", cfg.AuthURL, metadata.AuthorizationEndpoint, false); wrapOIDC(ErrOIDCProviderMetadata, &err) == nil { - if tokenURL, err = validateUrl("TokenURL", cfg.TokenURL, metadata.TokenEndpoint, false); wrapOIDC(ErrOIDCProviderMetadata, &err) == nil { - if userInfoURL, err = validateUrl("UserInfoURL", cfg.UserInfoURL, metadata.UserinfoEndpoint, true); wrapOIDC(ErrOIDCProviderMetadata, &err) == nil { - oauth2cfg = &oauth2.Config{ - ClientID: cfg.ClientID, - ClientSecret: cfg.ClientSecret, - Endpoint: oauth2.Endpoint{ - AuthURL: authURL, - TokenURL: tokenURL, - }, - RedirectURL: redir.String(), - Scopes: ensureScopes(cfg.Scopes), - } - verifier = provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}) - } - } + oauth2cfg = &oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + }, + RedirectURL: redir.String(), + Scopes: ensureScopes(cfg.Scopes, "openid", "email"), } + verifier = provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}) } } } diff --git a/config_test.go b/config_test.go index 5b1a8ed..7b262b8 100644 --- a/config_test.go +++ b/config_test.go @@ -19,8 +19,7 @@ func newOIDCDiscoveryServer(t *testing.T) *httptest.Server { t.Helper() var server *httptest.Server mux := http.NewServeMux() - mux.HandleFunc("/.well-known/openid-configuration", func(hw http.ResponseWriter, hr *http.Request) { - _ = hr + mux.HandleFunc("/.well-known/openid-configuration", func(hw http.ResponseWriter, _ *http.Request) { data := map[string]any{ "issuer": server.URL, "authorization_endpoint": server.URL + "/oauth2/auth", @@ -31,8 +30,7 @@ func newOIDCDiscoveryServer(t *testing.T) *httptest.Server { hw.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(hw).Encode(data) }) - mux.HandleFunc("/oauth2/jwks", func(hw http.ResponseWriter, hr *http.Request) { - _ = hr + mux.HandleFunc("/oauth2/jwks", func(hw http.ResponseWriter, _ *http.Request) { hw.Header().Set("Content-Type", "application/json") _, _ = hw.Write([]byte(`{"keys":[]}`)) }) @@ -270,7 +268,7 @@ func TestConfigValidateRejectsInsecureOIDCOverrides(t *testing.T) { *endpoint = "http://provider.example/endpoint" err := cfg.Validate() var configErr errConfig - if !errors.Is(err, ErrConfigOIDCEndpointMustBeHTTPS) || !errors.As(err, &configErr) || configErr.field != field { + if !errors.Is(err, ErrConfigEndpointMustBeHTTPS) || !errors.As(err, &configErr) || configErr.field != field { t.Fatalf("%s override validation: %v", field, err) } cfg.AllowInsecureIssuer = true @@ -278,7 +276,7 @@ func TestConfigValidateRejectsInsecureOIDCOverrides(t *testing.T) { t.Fatalf("dev override validation: %v", err) } *endpoint = "ftp://provider.example/endpoint" - if err := cfg.Validate(); !errors.Is(err, ErrConfigOIDCEndpointMustBeHTTPS) { + if err := cfg.Validate(); !errors.Is(err, ErrConfigEndpointMustBeHTTPS) { t.Fatalf("non-HTTP dev override validation: %v", err) } }) @@ -317,13 +315,32 @@ func TestConfigBuildContextRejectsInsecureOIDCMetadata(t *testing.T) { if !errors.Is(err, ErrOIDCProviderMetadata) || !errors.As(err, &configErr) || configErr.field != tc.field { t.Fatalf("%s metadata validation: %v", tc.key, err) } - if tc.value != "" && !errors.Is(err, ErrConfigOIDCEndpointMustBeHTTPS) { + if tc.value != "" && !errors.Is(err, ErrConfigEndpointMustBeHTTPS) { t.Fatalf("%s did not reject HTTP: %v", tc.key, err) } }) } } +func TestConfigBuildContextUsesSecureOverridesForInsecureMetadata(t *testing.T) { + var issuer string + provider := httptest.NewTLSServer(http.HandlerFunc(func(hw http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(hw).Encode(map[string]string{ + "issuer": issuer, "authorization_endpoint": "http://provider.example/auth", + "token_endpoint": "http://provider.example/token", + "userinfo_endpoint": "http://provider.example/userinfo", "jwks_uri": issuer + "/jwks", + }) + })) + defer provider.Close() + issuer = provider.URL + cfg := Config{RedirectURL: "https://app.example/callback", Issuer: issuer, ClientID: "client", HTTPClient: provider.Client(), + AuthURL: "https://override.example/auth", TokenURL: "https://override.example/token", UserInfoURL: "https://override.example/userinfo"} + got, userInfoURL, _, err := cfg.buildContext(t.Context(), "") + if err != nil || got == nil || got.Endpoint.AuthURL != cfg.AuthURL || got.Endpoint.TokenURL != cfg.TokenURL || userInfoURL != cfg.UserInfoURL { + t.Fatalf("secure overrides: config=%v userInfoURL=%q err=%v", got, userInfoURL, err) + } +} + func TestConfigBuildContextDiscoveryRedirectScheme(t *testing.T) { for _, tc := range []struct { name, target string diff --git a/currentclaims_test.go b/currentclaims_test.go index 0ded57e..50e124f 100644 --- a/currentclaims_test.go +++ b/currentclaims_test.go @@ -28,7 +28,7 @@ func TestServerCurrentClaimsChecksExpiryAndJaWSSession(t *testing.T) { if srv.CurrentClaims(sess) != nil { t.Fatal("claims without verified expiry were accepted") } - sess.Set(srv.sessionKey(oauth2IDTokenExpiryKey), time.Now().Add(time.Hour)) + sess.Set(srv.sessionKey(oauth2AuthExpiryKey), time.Now().Add(time.Hour)) if got := srv.CurrentClaims(sess); got["sub"] != "42" { t.Fatalf("live claims: %v", got) } @@ -40,11 +40,11 @@ func TestServerCurrentClaimsChecksExpiryAndJaWSSession(t *testing.T) { if (&Server{Jaws: other, SessionKey: srv.SessionKey, namespace: srv.namespace}).CurrentClaims(sess) != nil { t.Fatal("another JaWS instance accepted session") } - sess.Set(srv.sessionKey(oauth2IDTokenExpiryKey), time.Now().Add(-time.Second)) + sess.Set(srv.sessionKey(oauth2AuthExpiryKey), time.Now().Add(-time.Second)) if srv.CurrentClaims(sess) != nil { t.Fatal("expired authentication returned claims") } - sess.Set(srv.sessionKey(oauth2IDTokenExpiryKey), time.Now().Add(time.Hour)) + sess.Set(srv.sessionKey(oauth2AuthExpiryKey), time.Now().Add(time.Hour)) sess.Close() if srv.CurrentClaims(sess) != nil { t.Fatal("closed session returned claims") diff --git a/debug.go b/debug.go index 07e685b..cd2ce32 100644 --- a/debug.go +++ b/debug.go @@ -275,9 +275,16 @@ func errorDebugClasses(err error) (classes []string) { classes = appendErrorDebugClass(classes, err, ErrOAuth2MissingSession, "oauth2_missing_session") classes = appendErrorDebugClass(classes, err, ErrOAuth2MissingState, "oauth2_missing_state") classes = appendErrorDebugClass(classes, err, ErrOAuth2WrongState, "oauth2_wrong_state") + classes = appendErrorDebugClass(classes, err, ErrOAuth2ExpiredState, "oauth2_expired_state") classes = appendErrorDebugClass(classes, err, ErrOAuth2MissingPKCEVerifier, "oauth2_missing_pkce_verifier") + classes = appendErrorDebugClass(classes, err, ErrOAuth2LoginInProgress, "oauth2_login_in_progress") classes = appendErrorDebugClass(classes, err, ErrOAuth2Callback, "oauth2_callback") classes = appendErrorDebugClass(classes, err, ErrUserInfoStatus, "userinfo_status") + classes = appendErrorDebugClass(classes, err, ErrUserInfoTooLarge, "userinfo_too_large") + classes = appendErrorDebugClass(classes, err, ErrUserInfoInvalidResponse, "userinfo_invalid_response") + classes = appendErrorDebugClass(classes, err, ErrOAuth2InvalidProfile, "oauth2_invalid_profile") + classes = appendErrorDebugClass(classes, err, ErrOAuth2LinkRejected, "oauth2_link_rejected") + classes = appendErrorDebugClass(classes, err, ErrOAuth2InvalidOrigin, "oauth2_invalid_origin") classes = appendErrorDebugClass(classes, err, ErrOIDCDiscovery, "oidc_discovery") classes = appendErrorDebugClass(classes, err, ErrOIDCProviderMetadata, "oidc_provider_metadata") classes = appendErrorDebugClass(classes, err, ErrOIDCMissingIDToken, "oidc_missing_id_token") diff --git a/errconfig.go b/errconfig.go index 52b9ab0..32dd694 100644 --- a/errconfig.go +++ b/errconfig.go @@ -19,11 +19,8 @@ var ErrConfigURLMissingHost = errors.New("url host is missing") // ErrConfigIssuerMustBeHTTPS means Issuer must use HTTPS, or HTTP when AllowInsecureIssuer is enabled. var ErrConfigIssuerMustBeHTTPS = errors.New("issuer url must use https") -// ErrConfigProfileEndpointMustBeHTTPS means a profile-flow endpoint must use HTTPS, or HTTP when AllowInsecureIssuer is enabled. -var ErrConfigProfileEndpointMustBeHTTPS = errors.New("profile endpoint must use https") - -// ErrConfigOIDCEndpointMustBeHTTPS means an OIDC endpoint must use HTTPS, or HTTP when AllowInsecureIssuer is enabled. -var ErrConfigOIDCEndpointMustBeHTTPS = errors.New("oidc endpoint must use https") +// ErrConfigEndpointMustBeHTTPS means a provider endpoint must use HTTPS, or HTTP when AllowInsecureIssuer is enabled. +var ErrConfigEndpointMustBeHTTPS = errors.New("provider endpoint must use https") type errConfig struct { field string diff --git a/erroauth2callback_test.go b/erroauth2callback_test.go index 0ebbd3e..4755069 100644 --- a/erroauth2callback_test.go +++ b/erroauth2callback_test.go @@ -128,8 +128,7 @@ func Test_handleAuthResponseOAuthErrorCallback(t *testing.T) { nil, ) sess := jw.NewSession(rec, req) - sess.Set(oauth2StateKey, "state123") - sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) + setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime)}) srv.HandleAuthResponse(rec, req) if callbackStatus != http.StatusForbidden { t.Fatal(callbackStatus) @@ -160,9 +159,8 @@ func Test_handleAuthResponseOAuthErrorCallback(t *testing.T) { if !strings.Contains(string(body), "oauth2 callback error: access_denied") { t.Fatal(string(body)) } - state, _ := sess.Get(oauth2StateKey).(string) - if state != "" { - t.Fatal(state) + if flow := sess.Get(oauth2FlowKey); flow != nil { + t.Fatal(flow) } } @@ -187,10 +185,7 @@ func Test_handleAuthResponseOAuthErrorCallbackReceivesSessionEmail(t *testing.T) userinfoUrl: "https://provider.example/userinfo", } var callbackEmail string - srv.LoginFailed = func(hw http.ResponseWriter, hr *http.Request, httpCode int, callErr error, email string) bool { - _ = hw - _ = hr - _ = httpCode + srv.LoginFailed = func(_ http.ResponseWriter, _ *http.Request, _ int, callErr error, email string) bool { if !errors.Is(callErr, ErrOAuth2Callback) { t.Fatal(callErr) } @@ -204,8 +199,7 @@ func Test_handleAuthResponseOAuthErrorCallbackReceivesSessionEmail(t *testing.T) nil, ) sess := jw.NewSession(rec, req) - sess.Set(oauth2StateKey, "state123") - sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) + setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime)}) sess.Set(srv.SessionKey, map[string]any{"email": "user@example.com"}) sess.Set(srv.SessionEmailKey, "user@example.com") diff --git a/expiry_wrapper_test.go b/expiry_wrapper_test.go index 2b9d51a..95f6041 100644 --- a/expiry_wrapper_test.go +++ b/expiry_wrapper_test.go @@ -38,16 +38,19 @@ func TestProtectedRequestPreservesPendingLoginOnAuthExpiry(t *testing.T) { if sess == nil || pendingState == "" { t.Fatal("missing pending flow") } - sess.Set(srv.sessionKey(oauth2IDTokenExpiryKey), time.Now().Add(-time.Second)) + sess.Set(srv.sessionKey(oauth2AuthExpiryKey), time.Now().Add(-time.Second)) protected := httptest.NewRequest(http.MethodGet, "https://app.example/protected", nil) protected.RemoteAddr = "192.0.2.1:1234" protected.AddCookie(cookie) response := httptest.NewRecorder() srv.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("expired auth reached protected handler") })).ServeHTTP(response, protected) - if response.Code != http.StatusConflict || len(response.Result().Cookies()) != 0 { + if response.Code != http.StatusFound || len(response.Result().Cookies()) != 0 { t.Fatalf("protected request during login: status=%d cookies=%v", response.Code, response.Result().Cookies()) } - gotState, _ := sess.Get(srv.sessionKey(oauth2StateKey)).(string) + if location, err := url.Parse(response.Header().Get("Location")); err != nil || location.Query().Get("state") != pendingState { + t.Fatalf("pending redirect: location=%q err=%v", response.Header().Get("Location"), err) + } + gotState := testOAuthFlow(sess, srv).state if gotState != pendingState { t.Fatalf("protected request replaced pending login state: want %q got %q", pendingState, gotState) } diff --git a/namespace_test.go b/namespace_test.go index 550f260..14a6b4b 100644 --- a/namespace_test.go +++ b/namespace_test.go @@ -10,6 +10,23 @@ import ( "golang.org/x/oauth2" ) +func testOAuthFlow(sess *jaws.Session, srv *Server) pendingOAuthFlow { + key := oauth2FlowKey + if srv != nil { + key = srv.sessionKey(key) + } + flow, _ := sess.Get(key).(pendingOAuthFlow) + return flow +} + +func setTestOAuthFlow(sess *jaws.Session, srv *Server, flow pendingOAuthFlow) { + key := oauth2FlowKey + if srv != nil { + key = srv.sessionKey(key) + } + sess.Set(key, flow) +} + func TestOAuthFlowNamespacesShareSessionWithoutSharingState(t *testing.T) { jw, err := jaws.New() if err != nil { @@ -50,17 +67,7 @@ func TestOAuthFlowNamespacesShareSessionWithoutSharingState(t *testing.T) { if sess == nil || jw.GetSession(linkRequest) != sess { t.Fatal("shared session missing") } - type flowState struct { - state, verifier, nonce string - link bool - } - read := func(srv *Server) flowState { - state, _ := sess.Get(srv.sessionKey(oauth2StateKey)).(string) - verifier, _ := sess.Get(srv.sessionKey(oauth2PKCEVerifierKey)).(string) - nonce, _ := sess.Get(srv.sessionKey(oauth2NonceKey)).(string) - link, _ := sess.Get(srv.sessionKey(oauth2LinkKey)).(bool) - return flowState{state, verifier, nonce, link} - } + read := func(srv *Server) pendingOAuthFlow { return testOAuthFlow(sess, srv) } githubState, gitlabState := read(github), read(gitlab) if githubState.state == "" || githubState.verifier == "" || githubState.nonce == "" || gitlabState.state == "" || gitlabState.verifier == "" || gitlabState.nonce == "" || diff --git a/oauth2.go b/oauth2.go index a2df2dd..e419f2d 100644 --- a/oauth2.go +++ b/oauth2.go @@ -24,18 +24,20 @@ import ( "golang.org/x/oauth2" ) -const oauth2ReferrerKey = "oauth2referrer" -const oauth2StateKey = "oauth2state" -const oauth2PKCEVerifierKey = "oauth2pkceverifier" -const oauth2NonceKey = "oauth2nonce" -const oauth2IDTokenExpiryKey = "oauth2idtokenexpiry" // #nosec G101 -const oauth2FlowExpiryKey = "oauth2flowexpiry" -const oauth2LinkKey = "oauth2link" +const oauth2FlowKey = "oauth2flow" +const oauth2AuthExpiryKey = "oauth2idtokenexpiry" // #nosec G101 const oauth2FlowLifetime = 10 * time.Minute const oauth2ProviderTimeout = 15 * time.Second +const profileAuthLifetime = 24 * time.Hour +const maxUserInfoBytes = 32 << 10 + +// Leave one second after the provider deadline for the callback to finish. +const oauth2FlowGrace = oauth2FlowLifetime + oauth2ProviderTimeout + time.Second type pendingOAuthFlow struct { + state string + expiry time.Time verifier string nonce string referrer string @@ -55,20 +57,15 @@ func (flow pendingOAuthFlow) revoked() bool { func (srv *Server) takeOAuthFlow(sess *jaws.Session, gotState string) (flow pendingOAuthFlow, err error) { srv.mu.Lock() defer srv.mu.Unlock() - wantState, _ := sess.Get(srv.sessionKey(oauth2StateKey)).(string) - if wantState == "" { + flow, _ = sess.Get(srv.sessionKey(oauth2FlowKey)).(pendingOAuthFlow) + if flow.state == "" { return flow, ErrOAuth2MissingState } - if gotState != wantState { + if gotState != flow.state { return flow, ErrOAuth2WrongState } - flow.verifier, _ = sess.Get(srv.sessionKey(oauth2PKCEVerifierKey)).(string) - flow.nonce, _ = sess.Get(srv.sessionKey(oauth2NonceKey)).(string) - flow.referrer, _ = sess.Get(srv.sessionKey(oauth2ReferrerKey)).(string) - flow.link, _ = sess.Get(srv.sessionKey(oauth2LinkKey)).(bool) - expiry, _ := sess.Get(srv.sessionKey(oauth2FlowExpiryKey)).(time.Time) srv.clearSessionOAuthFlow(sess) - if !time.Now().Before(expiry) { + if !time.Now().Before(flow.expiry) { return flow, ErrOAuth2ExpiredState } flow.canceled = make(chan struct{}) @@ -80,9 +77,6 @@ func (srv *Server) takeOAuthFlow(sess *jaws.Session, gotState string) (flow pend } func (srv *Server) finishOAuthFlow(sess *jaws.Session, flow pendingOAuthFlow) { - if flow.canceled == nil { - return - } srv.mu.Lock() if srv.oauthCallbacks[sess].canceled == flow.canceled { delete(srv.oauthCallbacks, sess) @@ -157,34 +151,31 @@ func (srv *Server) HandleLogin(hw http.ResponseWriter, hr *http.Request) { // identity. The application must authorize this POST before calling it and // recheck that identity in LinkEvent when the provider returns. func (srv *Server) HandleLink(hw http.ResponseWriter, hr *http.Request) { - srv.startOAuth(hw, hr, true, false) + switch { + case hr.Method != http.MethodPost: + srv.writeResult(hw, http.StatusMethodNotAllowed, nil, nil) + case linkOriginCheck.Check(hr) != nil: + srv.writeResult(hw, http.StatusForbidden, ErrOAuth2InvalidOrigin, nil) + case srv.oauth2cfg == nil || srv.LinkEvent == nil: + srv.writeResult(hw, http.StatusInternalServerError, ErrOAuth2NotConfigured, nil) + case srv.Jaws.GetSession(hr) == nil: + srv.writeResult(hw, http.StatusForbidden, ErrOAuth2MissingSession, nil) + default: + srv.startOAuth(hw, hr, true, false) + } } +var linkOriginCheck = http.NewCrossOriginProtection() + func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, preservePendingLogin bool) { statusCode := http.StatusMethodNotAllowed - if (!link && hr.Method == http.MethodGet) || (link && hr.Method == http.MethodPost) { - if link && http.NewCrossOriginProtection().Check(hr) != nil { - srv.writeResult(hw, http.StatusForbidden, ErrOAuth2LinkRejected, nil) - return - } + if link || hr.Method == http.MethodGet { oauth2cfg, location := srv.begin(hr) - if link && oauth2cfg == nil { - srv.writeResult(hw, http.StatusInternalServerError, ErrOAuth2NotConfigured, nil) - return - } if oauth2cfg != nil { sess := srv.Jaws.GetSession(hr) if sess == nil && !link { sess = srv.Jaws.NewSession(hw, hr) } - if link && sess == nil { - srv.writeResult(hw, http.StatusForbidden, ErrOAuth2MissingSession, nil) - return - } - if link && srv.LinkEvent == nil { - srv.writeResult(hw, http.StatusInternalServerError, ErrOAuth2NotConfigured, nil) - return - } if sess == nil { hw.Header().Set("Retry-After", "60") srv.writeResult(hw, http.StatusServiceUnavailable, ErrOAuth2MissingSession, nil) @@ -200,27 +191,38 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, pr } verifier := oauth2.GenerateVerifier() authOptions = append(authOptions, oauth2.S256ChallengeOption(verifier)) - if link && location == hr.RequestURI { + if link && strings.TrimSpace(hr.Referer()) == "" { location = "/" } srv.mu.Lock() if preservePendingLogin && srv.pendingOrdinaryLoginLocked(sess) { + stored, _ := sess.Get(srv.sessionKey(oauth2FlowKey)).(pendingOAuthFlow) srv.mu.Unlock() - srv.writeResult(hw, http.StatusConflict, errors.New("oauth2 login already in progress"), nil) + if stored.state != "" && stored.verifier != "" { + options := append([]oauth2.AuthCodeOption{}, srv.Options...) + if stored.nonce != "" { + options = append(options, oidc.Nonce(stored.nonce)) + } + options = append(options, oauth2.S256ChallengeOption(stored.verifier)) + hw.Header().Set("Location", oauth2cfg.AuthCodeURL(stored.state, options...)) + SetHeaders(hw, srv.ishttps) + hw.WriteHeader(http.StatusFound) + } else { + srv.writeResult(hw, http.StatusConflict, ErrOAuth2LoginInProgress, nil) + } return } - if !sess.ExtendGrace(oauth2FlowLifetime + oauth2ProviderTimeout + time.Second) { + if !sess.ExtendGrace(oauth2FlowGrace) { srv.mu.Unlock() + hw.Header().Set("Retry-After", "60") srv.writeResult(hw, http.StatusServiceUnavailable, ErrOAuth2MissingSession, nil) return } srv.clearSessionOAuthFlow(sess) - sess.Set(srv.sessionKey(oauth2StateKey), state) - sess.Set(srv.sessionKey(oauth2FlowExpiryKey), flowExpiry) - sess.Set(srv.sessionKey(oauth2LinkKey), link) - sess.Set(srv.sessionKey(oauth2NonceKey), nonce) - sess.Set(srv.sessionKey(oauth2PKCEVerifierKey), verifier) - sess.Set(srv.sessionKey(oauth2ReferrerKey), location) + sess.Set(srv.sessionKey(oauth2FlowKey), pendingOAuthFlow{ + state: state, expiry: flowExpiry, link: link, nonce: nonce, + verifier: verifier, referrer: location, + }) srv.mu.Unlock() location = oauth2cfg.AuthCodeURL(state, authOptions...) } @@ -240,7 +242,7 @@ func (srv *Server) HandleLogout(hw http.ResponseWriter, hr *http.Request) { if hr.Method == http.MethodGet { _, location := srv.begin(hr) if sess := srv.Jaws.GetSession(hr); sess != nil { - srv.clearSessionAuth(sess, hr, nil, false, srv.SessionRotationLock) + srv.clearSessionAuth(sess, hr, nil, false, true) } hw.Header().Set("Location", location) statusCode = http.StatusFound @@ -295,12 +297,21 @@ var ErrOAuth2ExpiredState = errors.New("oauth2 expired state") // ErrOAuth2MissingPKCEVerifier means the callback session did not contain the required PKCE verifier. var ErrOAuth2MissingPKCEVerifier = errors.New("oauth2 missing pkce verifier") +// ErrOAuth2LoginInProgress means a callback has consumed the pending login state. +var ErrOAuth2LoginInProgress = errors.New("oauth2 login already in progress") + +// ErrOAuth2InvalidOrigin means a link start request failed the same-origin check. +var ErrOAuth2InvalidOrigin = errors.New("oauth2 invalid origin") + // ErrUserInfoStatus means the UserInfo endpoint returned a non-200 HTTP status. var ErrUserInfoStatus = errors.New("userinfo status") // ErrUserInfoTooLarge means the authenticated profile exceeded the response limit. var ErrUserInfoTooLarge = errors.New("userinfo response too large") +// ErrUserInfoInvalidResponse means the UserInfo response contained trailing JSON data. +var ErrUserInfoInvalidResponse = errors.New("userinfo invalid response") + // ErrOAuth2InvalidProfile means an authenticated profile lacked a stable subject. var ErrOAuth2InvalidProfile = errors.New("oauth2 invalid profile") @@ -395,8 +406,8 @@ func (srv *Server) fetchUserInfo(ctx context.Context, userinfoURL string, tokenS return nil, fmt.Errorf("%w %s", ErrUserInfoStatus, resp.Status) } var body []byte - if body, err = io.ReadAll(io.LimitReader(resp.Body, 32769)); err == nil { - if len(body) > 32768 { + if body, err = io.ReadAll(io.LimitReader(resp.Body, maxUserInfoBytes+1)); err == nil { + if len(body) > maxUserInfoBytes { err = ErrUserInfoTooLarge } else { decoder := json.NewDecoder(bytes.NewReader(body)) @@ -407,7 +418,8 @@ func (srv *Server) fetchUserInfo(ctx context.Context, userinfoURL string, tokenS if err == nil { var extra any if trailingErr := decoder.Decode(&extra); trailingErr != io.EOF { - err = ErrOAuth2InvalidProfile + err = ErrUserInfoInvalidResponse + userinfo = nil } } } @@ -426,18 +438,13 @@ func (srv *Server) addUserInfoClaims(ctx context.Context, claims map[string]any, func (srv *Server) verifyOAuthIdentity(ctx context.Context, token *oauth2.Token, nonce string) (claims map[string]any, tokenSource oauth2.TokenSource, expiry time.Time, status int, err error) { status = http.StatusUnauthorized if srv.idTokenVerifier != nil { - err = ErrOIDCMissingIDToken - raw, _ := token.Extra("id_token").(string) - if raw == "" { - return - } - var idToken *oidc.IDToken - idToken, err = srv.idTokenVerifier.Verify(ctx, raw) - if wrapOIDC(ErrOIDCInvalidIDToken, &err) != nil { - return + var raw string + if token != nil { + raw, _ = token.Extra("id_token").(string) } - if idToken.Subject == "" { - return nil, nil, time.Time{}, status, errOIDC{kind: ErrOIDCInvalidIDToken, cause: errOIDCMissingSubject} + idToken, verifiedClaims, verifyErr := srv.verifyIDToken(ctx, raw) + if verifyErr != nil { + return nil, nil, time.Time{}, status, verifyErr } err = ErrOIDCMissingNonce if nonce == "" { @@ -447,10 +454,7 @@ func (srv *Server) verifyOAuthIdentity(ctx context.Context, token *oauth2.Token, if idToken.Nonce != nonce { return } - err = idToken.Claims(&claims) - if wrapOIDC(ErrOIDCInvalidIDToken, &err) != nil { - return - } + claims = verifiedClaims tokenSource = srv.oauth2cfg.TokenSource(srv.oauth2Context(context.Background()), token) srv.addUserInfoClaims(ctx, claims, oauth2.StaticTokenSource(token)) if err = srv.identityExpiryError(idToken.Expiry); err != nil { @@ -475,12 +479,15 @@ func (srv *Server) verifyOAuthIdentity(ctx context.Context, token *oauth2.Token, } var subject string subject, err = srv.profileSubject(claims) + if err != nil && srv.Jaws != nil { + _ = srv.Jaws.Log(err) + } if err != nil || subject == "" { return nil, nil, time.Time{}, status, ErrOAuth2InvalidProfile } claims["iss"] = srv.issuer claims["sub"] = subject - expiry = time.Now().Add(24 * time.Hour) + expiry = time.Now().Add(profileAuthLifetime) if !token.Expiry.IsZero() && token.Expiry.Before(expiry) { expiry = token.Expiry } @@ -518,12 +525,67 @@ func (srv *Server) rotateOAuthSession(hw http.ResponseWriter, hr *http.Request, return srv.Jaws.NewSession(hw, hr), false } +func (srv *Server) completeLink(sess *jaws.Session, hr *http.Request, claims map[string]any, flow pendingOAuthFlow) (status int, err error) { + if srv.LinkEvent == nil || flow.revoked() || srv.Jaws.GetSession(hr) != sess { + return http.StatusForbidden, ErrOAuth2MissingSession + } + status, linkErr := srv.LinkEvent(sess, hr, claims) + if linkErr != nil || status != 0 { + _ = srv.Jaws.Log(linkErr) + if status < 400 || status > 599 { + status = http.StatusForbidden + } + return status, ErrOAuth2LinkRejected + } + if flow.revoked() || srv.Jaws.GetSession(hr) != sess || !sessionAlive(sess) { + return http.StatusForbidden, ErrOAuth2MissingSession + } + return http.StatusFound, nil +} + +func (srv *Server) completeLogin(hw http.ResponseWriter, hr *http.Request, old *jaws.Session, flow pendingOAuthFlow, claims map[string]any, tokenSource oauth2.TokenSource, expiry time.Time) (sess *jaws.Session, status int, err error) { + sess, status, err = old, http.StatusServiceUnavailable, ErrOAuth2MissingSession + _, hadAuth := srv.sessionAuthStatus(old, nil) + rotated, stale := srv.rotateOAuthSession(hw, hr, old, flow) + if rotated == nil { + if stale { + status = http.StatusForbidden + } else { + hw.Header().Set("Retry-After", "60") + } + return + } + if flow.revoked() { + http.SetCookie(hw, rotated.Close()) + return old, http.StatusForbidden, ErrOAuth2MissingSession + } + srv.mu.Lock() + hadTimer := srv.authTimers[old.ID()] != nil + srv.stopSessionAuthTimerLocked(old, nil) + srv.mu.Unlock() + if hadAuth && hadTimer && srv.LogoutEvent != nil { + srv.LogoutEvent(old, hr) + } + sess = rotated + err = srv.storeSessionAuthClaimsIfActive(sess, claims, tokenSource, expiry, nil, flow.canceled) + if err != nil { + http.SetCookie(hw, rotated.Close()) + if errors.Is(err, ErrOAuth2MissingSession) { + status = http.StatusForbidden + } else { + status = http.StatusUnauthorized + } + return + } + return sess, http.StatusFound, nil +} + // HandleAuthResponse handles the OIDC or OAuth2 profile callback endpoint. // // For GET requests it validates the state, exchanges the authorization code using the // stored PKCE verifier, verifies the ID token or authenticated profile, and stores -// claims in a new session. A link flow instead invokes [Server.LinkEvent] before -// rotation and keeps the original session. It invokes [Server.LoginEvent] after a +// claims in a new session. A link flow instead invokes [Server.LinkEvent] +// and keeps the original session. It invokes [Server.LoginEvent] after a // new sign-in or [Server.LoginFailed] on failure. Non-GET requests receive 405. // // Rotation discards pre-login application data and requires a free JaWS session slot. @@ -535,9 +597,7 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) if hr.Method == http.MethodGet { oauth2Config, location := srv.begin(hr) - var sessValue any var sessEmail string - var linked bool var flow pendingOAuthFlow providerCtx, cancel := context.WithTimeout(hr.Context(), oauth2ProviderTimeout) defer cancel() @@ -573,78 +633,19 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) err = ErrOAuth2MissingSession statusCode = http.StatusForbidden } else if flow.link { - statusCode = http.StatusForbidden - err = ErrOAuth2MissingSession - if srv.LinkEvent != nil && !flow.revoked() && srv.Jaws.GetSession(hr) == sess { - var linkErr error - statusCode, linkErr = srv.LinkEvent(sess, hr, claims) - if linkErr == nil && statusCode == 0 { - if flow.revoked() || srv.Jaws.GetSession(hr) != sess || !sessionAlive(sess) { - err = ErrOAuth2MissingSession - statusCode = http.StatusForbidden - } else { - err = nil - sessValue = claims - linked = true - } - } else { - _ = srv.Jaws.Log(linkErr) - err = ErrOAuth2LinkRejected - } - } - if err != nil && (statusCode < 400 || statusCode > 599) { - statusCode = http.StatusForbidden - } - if err == nil { - if flow.referrer != "" { - location = sanitizeRedirectTarget(hr.Host, flow.referrer) - } - hw.Header().Set("Location", location) - statusCode = http.StatusFound - } + statusCode, err = srv.completeLink(sess, hr, claims, flow) } else { - _, hadAuth := srv.sessionAuthStatus(sess, nil) - err = ErrOAuth2MissingSession - statusCode = http.StatusServiceUnavailable - if rotated, stale := srv.rotateOAuthSession(hw, hr, sess, flow); rotated != nil { - if flow.revoked() { - http.SetCookie(hw, rotated.Close()) - err = ErrOAuth2MissingSession - statusCode = http.StatusForbidden - } else { - srv.mu.Lock() - hadTimer := srv.authTimers[sess.ID()] != nil - srv.stopSessionAuthTimerLocked(sess, nil) - srv.mu.Unlock() - if hadAuth && hadTimer && srv.LogoutEvent != nil { - srv.LogoutEvent(sess, hr) - } - sess = rotated - err = srv.storeSessionAuthClaimsIfActive(sess, claims, tokenSource, expiry, nil, flow.canceled) - if err != nil { - http.SetCookie(hw, rotated.Close()) - if errors.Is(err, ErrOAuth2MissingSession) { - statusCode = http.StatusForbidden - } else { - statusCode = http.StatusUnauthorized - } - } - } - } else if stale { - statusCode = http.StatusForbidden - } else { - hw.Header().Set("Retry-After", "60") - } + sess, statusCode, err = srv.completeLogin(hw, hr, sess, flow, claims, tokenSource, expiry) if err == nil { - sessValue = claims sessEmail, _ = sess.Get(srv.SessionEmailKey).(string) - if flow.referrer != "" { - location = sanitizeRedirectTarget(hr.Host, flow.referrer) - } - hw.Header().Set("Location", location) - statusCode = http.StatusFound } } + if err == nil { + if flow.referrer != "" { + location = sanitizeRedirectTarget(hr.Host, flow.referrer) + } + hw.Header().Set("Location", location) + } } } } @@ -653,16 +654,16 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) } } if sess != nil { - if sessValue == nil { + if err != nil { srv.Jaws.Dirty(sess) - } else if !linked { + } else if !flow.link { if srv.LoginEvent != nil && !flow.revoked() { srv.LoginEvent(sess, hr) } current, present := srv.sessionAuthStatus(sess, time.Now) if flow.revoked() || !current || !sessionAlive(sess) { if present { - srv.Logout(sess, hr) + srv.clearSessionAuth(sess, hr, nil, false, true) } http.SetCookie(hw, sess.Close()) hw.Header().Del("Location") diff --git a/oauth2_callback_edge_test.go b/oauth2_callback_edge_test.go index d599510..39a1b1a 100644 --- a/oauth2_callback_edge_test.go +++ b/oauth2_callback_edge_test.go @@ -44,11 +44,10 @@ func TestOIDCCallbackRejectsEmptySubject(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state&code=code", nil) w := httptest.NewRecorder() sess := jw.NewSession(w, req) - sess.Set(oauth2StateKey, "state") - sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) - sess.Set(oauth2PKCEVerifierKey, oauth2.GenerateVerifier()) - sess.Set(oauth2NonceKey, "nonce") - sess.Set(oauth2LinkKey, link) + setTestOAuthFlow(sess, nil, pendingOAuthFlow{ + state: "state", expiry: time.Now().Add(oauth2FlowLifetime), + verifier: oauth2.GenerateVerifier(), nonce: "nonce", link: link, + }) if link { sess.Set(srv.SessionKey, map[string]any{"sub": "existing"}) } @@ -99,10 +98,10 @@ func TestOIDCExpiryDuringUserInfoRejected(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state&code=code", nil) w := httptest.NewRecorder() sess := jw.NewSession(w, req) - sess.Set(oauth2StateKey, "state") - sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) - sess.Set(oauth2PKCEVerifierKey, oauth2.GenerateVerifier()) - sess.Set(oauth2NonceKey, "nonce") + setTestOAuthFlow(sess, nil, pendingOAuthFlow{ + state: "state", expiry: time.Now().Add(oauth2FlowLifetime), + verifier: oauth2.GenerateVerifier(), nonce: "nonce", + }) srv.HandleAuthResponse(w, req) if w.Code != http.StatusUnauthorized || !strings.Contains(w.Body.String(), ErrOIDCInvalidIDToken.Error()) || @@ -186,11 +185,7 @@ func TestOAuthLogoutDuringLoginEventClosesRotatedSession(t *testing.T) { result := make(chan *httptest.ResponseRecorder, 1) go func() { result <- profileCallback(srv, cookie, state, "first") }() var rotated *jaws.Session - select { - case rotated = <-entered: - case <-time.After(5 * time.Second): - t.Fatal("LoginEvent was not reached") - } + rotated = waitFor(t, entered, "LoginEvent was not reached") if rotated == preauth || rotated.Get(srv.SessionKey) == nil { t.Fatal("LoginEvent did not receive an authenticated rotated session") } @@ -199,11 +194,7 @@ func TestOAuthLogoutDuringLoginEventClosesRotatedSession(t *testing.T) { } releaseOnce.Do(func() { close(release) }) var w *httptest.ResponseRecorder - select { - case w = <-result: - case <-time.After(5 * time.Second): - t.Fatal("canceled callback did not finish") - } + w = waitFor(t, result, "canceled callback did not finish") cookies := w.Result().Cookies() if w.Code != http.StatusForbidden || w.Header().Get("Location") != "" || len(cookies) != 2 || cookies[1].MaxAge >= 0 || cookies[1].Name != cookies[0].Name || cookies[1].Value != cookies[0].Value || diff --git a/oauth2_review_test.go b/oauth2_review_test.go new file mode 100644 index 0000000..05e73e1 --- /dev/null +++ b/oauth2_review_test.go @@ -0,0 +1,227 @@ +package jawsauth + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + + "github.com/linkdata/jaws" + "golang.org/x/oauth2" +) + +func TestLinkWithoutEventDoesNotStartFlow(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + srv.LinkEvent = nil + r := httptest.NewRequest(http.MethodPost, "https://app.example/auth/link/github", nil) + r.RemoteAddr = "192.0.2.1:1234" + if jw.NewSession(httptest.NewRecorder(), r) == nil { + t.Fatal("missing session") + } + w := httptest.NewRecorder() + srv.HandleLink(w, r) + if w.Code != http.StatusInternalServerError || w.Header().Get("Location") != "" || + !strings.Contains(w.Body.String(), ErrOAuth2NotConfigured.Error()) || calls != 0 { + t.Fatalf("missing LinkEvent: status=%d location=%q body=%q calls=%d", w.Code, w.Header().Get("Location"), w.Body.String(), calls) + } +} + +func TestLinkRejectsInvalidEventResults(t *testing.T) { + for _, tc := range []struct { + name string + status int + err error + }{ + {"error without status", 0, errors.New("secret persistence detail")}, + {"success status without acceptance", http.StatusOK, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + first := profileCallback(srv, cookie, state, "first") + if first.Code != http.StatusFound { + t.Fatalf("sign-in: %d", first.Code) + } + cookie = first.Result().Cookies()[0] + srv.LinkEvent = func(*jaws.Session, *http.Request, map[string]any) (int, error) { + return tc.status, tc.err + } + _, state = profileStart(t, srv, http.MethodPost, "/auth/link/github", cookie) + w := profileCallback(srv, cookie, state, "second") + if w.Code != http.StatusForbidden || w.Header().Get("Location") != "" || + len(w.Result().Cookies()) != 0 || !strings.Contains(w.Body.String(), ErrOAuth2LinkRejected.Error()) || + strings.Contains(w.Body.String(), "secret persistence detail") { + t.Fatalf("invalid LinkEvent result: status=%d location=%q body=%q cookies=%v", w.Code, w.Header().Get("Location"), w.Body.String(), w.Result().Cookies()) + } + }) + } +} + +func TestUserInfoRejectsTrailingJSON(t *testing.T) { + for _, profile := range []bool{false, true} { + t.Run(map[bool]string{false: "OIDC", true: "profile"}[profile], func(t *testing.T) { + srv := &Server{} + if profile { + srv.profileSubject = func(map[string]any) (string, error) { return "42", nil } + } + ctx := context.WithValue(t.Context(), oauth2.HTTPClient, &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return profileTestResponse(`{"id":42} {"id":43}`), nil + })}) + claims, err := srv.fetchUserInfo(ctx, "https://provider.example/user", oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "proof"})) + if !errors.Is(err, ErrUserInfoInvalidResponse) || claims != nil { + t.Fatalf("trailing JSON accepted: claims=%v err=%v", claims, err) + } + }) + } +} + +func TestLinkWithoutRefererReturnsHome(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + first := profileCallback(srv, cookie, state, "first") + if first.Code != http.StatusFound { + t.Fatalf("sign-in: %d", first.Code) + } + cookie = first.Result().Cookies()[0] + srv.LinkEvent = func(*jaws.Session, *http.Request, map[string]any) (int, error) { return 0, nil } + _, state = profileStart(t, srv, http.MethodPost, "/auth/link/github", cookie) + w := profileCallback(srv, cookie, state, "second") + if w.Code != http.StatusFound || w.Header().Get("Location") != "/" || len(w.Result().Cookies()) != 0 { + t.Fatalf("link return: status=%d location=%q cookies=%v", w.Code, w.Header().Get("Location"), w.Result().Cookies()) + } +} + +func TestProfileUserInfoNullDoesNotCallSubject(t *testing.T) { + called := false + srv := &Server{ + userinfoUrl: "https://provider.example/user", + profileSubject: func(map[string]any) (string, error) { + called = true + return "42", nil + }, + } + ctx := context.WithValue(t.Context(), oauth2.HTTPClient, &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return profileTestResponse(`null`), nil + })}) + claims, source, expiry, status, err := srv.verifyOAuthIdentity(ctx, &oauth2.Token{AccessToken: "proof", TokenType: "Bearer"}, "") + if !errors.Is(err, ErrOAuth2InvalidProfile) || claims != nil || source != nil || !expiry.IsZero() || status != http.StatusUnauthorized || called { + t.Fatalf("null profile: claims=%v source=%t expiry=%v status=%d err=%v subject_called=%t", claims, source != nil, expiry, status, err, called) + } +} + +func TestCallbackClosesRotatedSessionOnStoreFailure(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + first := profileCallback(srv, cookie, state, "first") + if first.Code != http.StatusFound { + t.Fatalf("sign-in: %d", first.Code) + } + cookie = first.Result().Cookies()[0] + state = startProfileLoginWithCookie(t, srv, cookie) + closedRotated := false + srv.LogoutEvent = func(old *jaws.Session, r *http.Request) { + if rotated := jw.GetSession(r); rotated != nil && rotated != old { + rotated.Close() + closedRotated = true + } + } + w := profileCallback(srv, cookie, state, "second") + cookies := w.Result().Cookies() + if !closedRotated || w.Code != http.StatusForbidden || w.Header().Get("Location") != "" || + len(cookies) != 2 || cookies[1].MaxAge >= 0 || cookies[1].Value != cookies[0].Value { + t.Fatalf("store after close: closed=%t status=%d location=%q cookies=%v", closedRotated, w.Code, w.Header().Get("Location"), cookies) + } +} + +func startProfileLoginWithCookie(t *testing.T, srv *Server, cookie *http.Cookie) string { + t.Helper() + start := httptest.NewRequest(http.MethodGet, "https://app.example/auth/login", nil) + start.RemoteAddr = "192.0.2.1:1234" + start.AddCookie(cookie) + begin := httptest.NewRecorder() + srv.HandleLogin(begin, start) + if begin.Code != http.StatusFound { + t.Fatalf("second start: %d", begin.Code) + } + providerURL, err := url.Parse(begin.Header().Get("Location")) + if err != nil || providerURL.Query().Get("state") == "" { + t.Fatalf("second provider redirect: %v %q", err, begin.Header().Get("Location")) + } + return providerURL.Query().Get("state") +} + +type revokeAfterRotationLock struct { + sync.Mutex + logout func() +} + +func (lock *revokeAfterRotationLock) Unlock() { + lock.Mutex.Unlock() + if lock.logout != nil { + logout := lock.logout + lock.logout = nil + logout() + } +} + +func TestCallbackRevokedAfterRotationClosesNewSession(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + first := profileCallback(srv, cookie, state, "first") + if first.Code != http.StatusFound { + t.Fatalf("sign-in: %d", first.Code) + } + cookie = first.Result().Cookies()[0] + oldRequest := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + oldRequest.RemoteAddr = "192.0.2.1:1234" + oldRequest.AddCookie(cookie) + old := jw.GetSession(oldRequest) + if old == nil { + t.Fatal("old session missing") + } + state = startProfileLoginWithCookie(t, srv, cookie) + revoked := false + srv.SessionRotationLock = &revokeAfterRotationLock{logout: func() { + revoked = srv.Logout(old, nil) + }} + w := profileCallback(srv, cookie, state, "second") + cookies := w.Result().Cookies() + if !revoked || w.Code != http.StatusForbidden || w.Header().Get("Location") != "" || + len(cookies) != 2 || cookies[1].MaxAge >= 0 || cookies[1].Value != cookies[0].Value { + t.Fatalf("revoked after rotation: revoked=%t status=%d location=%q cookies=%v", revoked, w.Code, w.Header().Get("Location"), cookies) + } +} diff --git a/oauth2_test.go b/oauth2_test.go index 87a679a..e11a612 100644 --- a/oauth2_test.go +++ b/oauth2_test.go @@ -209,14 +209,15 @@ func Test_handleLoginGeneratesOpaqueState(t *testing.T) { if sess == nil { t.Fatal("missing session") } - state, _ := sess.Get(oauth2StateKey).(string) + flow := testOAuthFlow(sess, nil) + state := flow.state if len(state) != 64 { t.Fatal(state) } if _, err = hex.DecodeString(state); err != nil { t.Fatal(err) } - verifier, _ := sess.Get(oauth2PKCEVerifierKey).(string) + verifier := flow.verifier if verifier == "" { t.Fatal("missing pkce verifier") } @@ -231,14 +232,14 @@ func Test_handleLoginGeneratesOpaqueState(t *testing.T) { if gotMethod := values.Get("code_challenge_method"); gotMethod != "S256" { t.Fatal(gotMethod) } - nonce, _ := sess.Get(oauth2NonceKey).(string) + nonce := flow.nonce if len(nonce) != 64 { t.Fatal(nonce) } if gotNonce := values.Get("nonce"); gotNonce != nonce { t.Fatal(gotNonce) } - referrer, _ := sess.Get(oauth2ReferrerKey).(string) + referrer := flow.referrer if referrer != "/secure" { t.Fatal(referrer) } @@ -278,14 +279,14 @@ func Test_handleLoginCreatesSessionAndRotatesState(t *testing.T) { if sess == nil { t.Fatal("missing session") } - firstState, _ := sess.Get(oauth2StateKey).(string) + firstState := testOAuthFlow(sess, nil).state if firstState == "" { t.Fatal("missing state") } rec = httptest.NewRecorder() srv.HandleLogin(rec, req) - secondState, _ := sess.Get(oauth2StateKey).(string) + secondState := testOAuthFlow(sess, nil).state if secondState == "" { t.Fatal("missing replacement state") } @@ -310,14 +311,10 @@ func Test_handleLogoutClearsPendingOAuthState(t *testing.T) { } req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/logout", nil) sess := jw.NewSession(httptest.NewRecorder(), req) - sess.Set(oauth2StateKey, "state123") - sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) - sess.Set(oauth2PKCEVerifierKey, "verifier123") - sess.Set(oauth2NonceKey, "nonce123") - sess.Set(oauth2ReferrerKey, "/protected") + setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime), verifier: "verifier123", nonce: "nonce123", referrer: "/protected"}) sess.Set(srv.SessionKey, map[string]any{"email": "user@example.com"}) sess.Set(srv.SessionTokenKey, oauth2.StaticTokenSource(makeOAuth2Token("access", "", ""))) - sess.Set(oauth2IDTokenExpiryKey, time.Now().Add(time.Hour)) + sess.Set(oauth2AuthExpiryKey, time.Now().Add(time.Hour)) sess.Set(srv.SessionEmailKey, "user@example.com") sess.Set(srv.SessionEmailVerifiedKey, true) @@ -330,19 +327,10 @@ func Test_handleLogoutClearsPendingOAuthState(t *testing.T) { t.Fatal(resp.Status) } closeResponseBody(t, resp) - if value := sess.Get(oauth2StateKey); value != nil { + if value := sess.Get(oauth2FlowKey); value != nil { t.Fatal(value) } - if value := sess.Get(oauth2PKCEVerifierKey); value != nil { - t.Fatal(value) - } - if value := sess.Get(oauth2NonceKey); value != nil { - t.Fatal(value) - } - if value := sess.Get(oauth2ReferrerKey); value != nil { - t.Fatal(value) - } - if value := sess.Get(oauth2IDTokenExpiryKey); value != nil { + if value := sess.Get(oauth2AuthExpiryKey); value != nil { t.Fatal(value) } } @@ -441,11 +429,7 @@ func Test_handleAuthResponseUsesPKCEVerifier(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state="+wantState+"&code="+wantCode, nil) sess := jw.NewSession(rec, req) - sess.Set(oauth2StateKey, wantState) - sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) - sess.Set(oauth2PKCEVerifierKey, verifier) - sess.Set(oauth2NonceKey, wantNonce) - sess.Set(oauth2ReferrerKey, "/secure") + setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: wantState, expiry: time.Now().Add(oauth2FlowLifetime), verifier: verifier, nonce: wantNonce, referrer: "/secure"}) oldSession := sess srv.HandleAuthResponse(rec, req) @@ -487,14 +471,8 @@ func Test_handleAuthResponseUsesPKCEVerifier(t *testing.T) { if receivedProviderErr != nil { t.Fatal(receivedProviderErr) } - if gotState, _ := sess.Get(oauth2StateKey).(string); gotState != "" { - t.Fatal(gotState) - } - if gotVerifier, _ := sess.Get(oauth2PKCEVerifierKey).(string); gotVerifier != "" { - t.Fatal(gotVerifier) - } - if gotNonce, _ := sess.Get(oauth2NonceKey).(string); gotNonce != "" { - t.Fatal(gotNonce) + if value := sess.Get(oauth2FlowKey); value != nil { + t.Fatal(value) } if gotEmail, _ := sess.Get(srv.SessionEmailKey).(string); gotEmail != "idtoken@example.com" { t.Fatal(gotEmail) @@ -572,10 +550,7 @@ func Test_handleAuthResponseStoredTokenSourceSurvivesRequestCancel(t *testing.T) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state&code=code", nil).WithContext(ctx) sess := jw.NewSession(rec, req) - sess.Set(oauth2StateKey, "state") - sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) - sess.Set(oauth2PKCEVerifierKey, oauth2.GenerateVerifier()) - sess.Set(oauth2NonceKey, wantNonce) + setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state", expiry: time.Now().Add(oauth2FlowLifetime), verifier: oauth2.GenerateVerifier(), nonce: wantNonce}) oldSession := sess srv.HandleAuthResponse(rec, req) @@ -640,9 +615,7 @@ func Test_handleAuthResponseMissingPKCEVerifier(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state123&code=authcode123", nil) sess := jw.NewSession(rec, req) - sess.Set(oauth2StateKey, "state123") - sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) - sess.Set(oauth2NonceKey, "nonce123") + setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime), nonce: "nonce123"}) srv.HandleAuthResponse(rec, req) @@ -681,14 +654,10 @@ func Test_handleAuthResponseWrongStatePreservesCurrentAuth(t *testing.T) { expiry := time.Now().Add(time.Hour).Truncate(time.Second) sess.Set(srv.SessionKey, claims) sess.Set(srv.SessionTokenKey, tokenSource) - sess.Set(oauth2IDTokenExpiryKey, expiry) + sess.Set(oauth2AuthExpiryKey, expiry) sess.Set(srv.SessionEmailKey, "user@example.com") sess.Set(srv.SessionEmailVerifiedKey, true) - sess.Set(oauth2StateKey, "legit") - sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) - sess.Set(oauth2PKCEVerifierKey, oauth2.GenerateVerifier()) - sess.Set(oauth2NonceKey, "nonce") - sess.Set(oauth2ReferrerKey, "/secure") + setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "legit", expiry: time.Now().Add(oauth2FlowLifetime), verifier: oauth2.GenerateVerifier(), nonce: "nonce", referrer: "/secure"}) srv.HandleAuthResponse(rec, req) @@ -710,7 +679,7 @@ func Test_handleAuthResponseWrongStatePreservesCurrentAuth(t *testing.T) { if got := sess.Get(srv.SessionTokenKey); got != tokenSource { t.Fatal(got) } - if got, _ := sess.Get(oauth2IDTokenExpiryKey).(time.Time); !got.Equal(expiry) { + if got, _ := sess.Get(oauth2AuthExpiryKey).(time.Time); !got.Equal(expiry) { t.Fatal(got) } if got, _ := sess.Get(srv.SessionEmailKey).(string); got != "user@example.com" { @@ -719,16 +688,17 @@ func Test_handleAuthResponseWrongStatePreservesCurrentAuth(t *testing.T) { if got, _ := sess.Get(srv.SessionEmailVerifiedKey).(bool); !got { t.Fatal(got) } - if got := sess.Get(oauth2StateKey); got != "legit" { + flow := testOAuthFlow(sess, nil) + if got := flow.state; got != "legit" { t.Fatal(got) } - if got := sess.Get(oauth2PKCEVerifierKey); got == nil { + if got := flow.verifier; got == "" { t.Fatal(got) } - if got := sess.Get(oauth2NonceKey); got != "nonce" { + if got := flow.nonce; got != "nonce" { t.Fatal(got) } - if got := sess.Get(oauth2ReferrerKey); got != "/secure" { + if got := flow.referrer; got != "/secure" { t.Fatal(got) } } @@ -771,8 +741,7 @@ func Test_extractEmailVerified(t *testing.T) { } func TestServer_fetchUserInfoStatusError(t *testing.T) { - provider := httptest.NewServer(http.HandlerFunc(func(hw http.ResponseWriter, hr *http.Request) { - _ = hr + provider := httptest.NewServer(http.HandlerFunc(func(hw http.ResponseWriter, _ *http.Request) { hw.WriteHeader(http.StatusUnauthorized) })) defer provider.Close() @@ -833,8 +802,7 @@ func TestServer_fetchUserInfoRespectsRequestCancellation(t *testing.T) { } func TestServer_fetchUserInfoOversizedStatusError(t *testing.T) { - provider := httptest.NewServer(http.HandlerFunc(func(hw http.ResponseWriter, hr *http.Request) { - _ = hr + provider := httptest.NewServer(http.HandlerFunc(func(hw http.ResponseWriter, _ *http.Request) { hw.WriteHeader(http.StatusServiceUnavailable) _, _ = io.WriteString(hw, strings.Repeat("x", 32769)) })) @@ -1013,10 +981,7 @@ func Test_handleAuthResponseMissingIDToken(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state123&code=authcode123", nil) sess := jw.NewSession(rec, req) - sess.Set(oauth2StateKey, "state123") - sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) - sess.Set(oauth2PKCEVerifierKey, oauth2.GenerateVerifier()) - sess.Set(oauth2NonceKey, "nonce123") + setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime), verifier: oauth2.GenerateVerifier(), nonce: "nonce123"}) srv.HandleAuthResponse(rec, req) @@ -1080,10 +1045,7 @@ func Test_handleAuthResponseInvalidIDToken(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state123&code=authcode123", nil) sess := jw.NewSession(rec, req) - sess.Set(oauth2StateKey, "state123") - sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) - sess.Set(oauth2PKCEVerifierKey, oauth2.GenerateVerifier()) - sess.Set(oauth2NonceKey, "nonce123") + setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime), verifier: oauth2.GenerateVerifier(), nonce: "nonce123"}) srv.HandleAuthResponse(rec, req) @@ -1147,10 +1109,7 @@ func Test_handleAuthResponseAudienceMismatch(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state123&code=authcode123", nil) sess := jw.NewSession(rec, req) - sess.Set(oauth2StateKey, "state123") - sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) - sess.Set(oauth2PKCEVerifierKey, oauth2.GenerateVerifier()) - sess.Set(oauth2NonceKey, "nonce123") + setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime), verifier: oauth2.GenerateVerifier(), nonce: "nonce123"}) srv.HandleAuthResponse(rec, req) @@ -1214,10 +1173,7 @@ func Test_handleAuthResponseNonceMismatch(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state123&code=authcode123", nil) sess := jw.NewSession(rec, req) - sess.Set(oauth2StateKey, "state123") - sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) - sess.Set(oauth2PKCEVerifierKey, oauth2.GenerateVerifier()) - sess.Set(oauth2NonceKey, "nonce123") + setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime), verifier: oauth2.GenerateVerifier(), nonce: "nonce123"}) srv.HandleAuthResponse(rec, req) @@ -1575,10 +1531,8 @@ func Test_handleAuthResponseMissingNonce(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state123&code=authcode123", nil) sess := jw.NewSession(rec, req) - sess.Set(oauth2StateKey, "state123") - sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) - sess.Set(oauth2PKCEVerifierKey, oauth2.GenerateVerifier()) - // deliberately leave oauth2NonceKey unset to exercise the missing-nonce branch + setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime), verifier: oauth2.GenerateVerifier()}) + // deliberately leave nonce unset to exercise the missing-nonce branch srv.HandleAuthResponse(rec, req) @@ -1650,10 +1604,7 @@ func Test_handlersRejectNonGet(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(method, "http://example.com/oauth2/callback", nil) sess := jw.NewSession(rec, req) - sess.Set(oauth2StateKey, "state123") - sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) - sess.Set(oauth2PKCEVerifierKey, "verifier123") - sess.Set(oauth2NonceKey, "nonce123") + setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime), verifier: "verifier123", nonce: "nonce123"}) h.handler(srv)(rec, req) @@ -1665,13 +1616,14 @@ func Test_handlersRejectNonGet(t *testing.T) { if loc := resp.Header.Get("Location"); loc != "" { t.Fatalf("unexpected Location header: %q", loc) } - if got, _ := sess.Get(oauth2StateKey).(string); got != "state123" { + flow := testOAuthFlow(sess, nil) + if got := flow.state; got != "state123" { t.Fatal(got) } - if got, _ := sess.Get(oauth2PKCEVerifierKey).(string); got != "verifier123" { + if got := flow.verifier; got != "verifier123" { t.Fatal(got) } - if got, _ := sess.Get(oauth2NonceKey).(string); got != "nonce123" { + if got := flow.nonce; got != "nonce123" { t.Fatal(got) } }) @@ -1729,7 +1681,7 @@ func TestRedirectTargetBounds(t *testing.T) { if !tc.accepted { want = "/" } - if got := jw.GetSession(req).Get(oauth2ReferrerKey); got != want { + if got := testOAuthFlow(jw.GetSession(req), nil).referrer; got != want { t.Errorf("referer=%v length=%d: unexpected stored target", referer, tc.length) } jw.Close() diff --git a/oauth_flow_grace_test.go b/oauth_flow_grace_test.go index 8f9b105..575a3fd 100644 --- a/oauth_flow_grace_test.go +++ b/oauth_flow_grace_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "strings" "testing" "testing/synctest" @@ -59,7 +60,7 @@ func TestOAuthProviderVisitKeepsOnlyPendingSessionAlive(t *testing.T) { }) } -func TestPendingLoginConflictDoesNotRenewGrace(t *testing.T) { +func TestPendingLoginRedirectDoesNotRenewGrace(t *testing.T) { synctest.Test(t, func(t *testing.T) { jw, err := jaws.New() if err != nil { @@ -68,7 +69,8 @@ func TestPendingLoginConflictDoesNotRenewGrace(t *testing.T) { defer jw.Close() calls := 0 srv := newProfileTestServer(t, jw, &calls) - cookie, _ := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + srv.Options = []oauth2.AuthCodeOption{oauth2.AccessTypeOffline} + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) protected := httptest.NewRequest(http.MethodGet, "https://app.example/protected", nil) protected.RemoteAddr = "192.0.2.1:1234" protected.AddCookie(cookie) @@ -77,9 +79,15 @@ func TestPendingLoginConflictDoesNotRenewGrace(t *testing.T) { srv.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("pending login reached protected handler") })).ServeHTTP(response, protected) - if response.Code != http.StatusConflict { + if response.Code != http.StatusFound { t.Fatalf("pending login: status=%d", response.Code) } + location, err := url.Parse(response.Header().Get("Location")) + stored := testOAuthFlow(jw.GetSession(protected), srv) + original, _ := url.Parse(srv.oauth2cfg.AuthCodeURL(state, oauth2.S256ChallengeOption(stored.verifier))) + if err != nil || location.Query().Get("state") != state || location.Query().Get("code_challenge") != original.Query().Get("code_challenge") || location.Query().Get("access_type") != "offline" { + t.Fatalf("pending login redirect: location=%q err=%v", response.Header().Get("Location"), err) + } time.Sleep(time.Minute + oauth2ProviderTimeout + 2*time.Second) if jw.GetSession(protected) != nil { t.Fatal("rejected start renewed pending session grace") @@ -117,7 +125,7 @@ func TestOAuthFlowExpiresEvenWithLiveSession(t *testing.T) { if sess == nil { t.Fatal("session missing") } - state, _ := sess.Get(oauth2StateKey).(string) + state := testOAuthFlow(sess, nil).state callback.URL.RawQuery = "state=" + state + "&code=unused" page := jw.NewRequest(httptest.NewRecorder(), callback) if page.Session() != sess { @@ -129,8 +137,8 @@ func TestOAuthFlowExpiresEvenWithLiveSession(t *testing.T) { } result := httptest.NewRecorder() srv.HandleAuthResponse(result, callback) - if result.Code != http.StatusBadRequest || !strings.Contains(result.Body.String(), ErrOAuth2ExpiredState.Error()) || sess.Get(oauth2StateKey) != nil || providerCalls != 0 { - t.Fatalf("expired flow: status=%d state=%v provider_calls=%d body=%q", result.Code, sess.Get(oauth2StateKey), providerCalls, result.Body.String()) + if result.Code != http.StatusBadRequest || !strings.Contains(result.Body.String(), ErrOAuth2ExpiredState.Error()) || sess.Get(oauth2FlowKey) != nil || providerCalls != 0 { + t.Fatalf("expired flow: status=%d state=%v provider_calls=%d body=%q", result.Code, sess.Get(oauth2FlowKey), providerCalls, result.Body.String()) } }) } @@ -164,8 +172,8 @@ func TestOIDCInitialCallbackAfterProviderVisit(t *testing.T) { } cookie := response.Result().Cookies()[0] preauth := jw.GetSession(start) - state, _ := preauth.Get(oauth2StateKey).(string) - nonce, _ := preauth.Get(oauth2NonceKey).(string) + flow := testOAuthFlow(preauth, nil) + state, nonce := flow.state, flow.nonce idToken = makeIDToken(t, map[string]any{ "iss": "https://issuer.example", "aud": "app", "sub": "subject-1", "nonce": nonce, "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(), @@ -234,7 +242,7 @@ func TestOAuthLinkCallbackNearDeadlineCanFinishProviderExchange(t *testing.T) { if startResult.Code != http.StatusFound { t.Fatalf("link start: %d", startResult.Code) } - state, _ := sess.Get(oauth2StateKey).(string) + state := testOAuthFlow(sess, nil).state cookie := created.Result().Cookies()[0] time.Sleep(oauth2FlowLifetime - time.Second) callback := httptest.NewRequest(http.MethodGet, "https://app.example/auth/callback?state="+state+"&code=code", nil) diff --git a/profile_link_test.go b/profile_link_test.go index 42ff32e..44ad86a 100644 --- a/profile_link_test.go +++ b/profile_link_test.go @@ -37,7 +37,7 @@ func TestOAuthProfileConfigRequiresExplicitSecureEndpoints(t *testing.T) { t.Fatalf("missing profile URL: %v", err) } cfg.UserInfoURL = "http://provider.example/user" - if err := cfg.Validate(); !errors.Is(err, ErrConfigProfileEndpointMustBeHTTPS) { + if err := cfg.Validate(); !errors.Is(err, ErrConfigEndpointMustBeHTTPS) { t.Fatalf("insecure profile URL: %v", err) } } @@ -53,9 +53,9 @@ func TestOAuthProfileConfigInsecureModeAllowsHTTPOnly(t *testing.T) { want error }{ {"issuer", func(c *Config) { c.Issuer = "ftp://provider.example" }, ErrConfigIssuerMustBeHTTPS}, - {"authorize", func(c *Config) { c.AuthURL = "ftp://provider.example/authorize" }, ErrConfigProfileEndpointMustBeHTTPS}, - {"token", func(c *Config) { c.TokenURL = "ftp://provider.example/token" }, ErrConfigProfileEndpointMustBeHTTPS}, - {"userinfo", func(c *Config) { c.UserInfoURL = "ftp://provider.example/user" }, ErrConfigProfileEndpointMustBeHTTPS}, + {"authorize", func(c *Config) { c.AuthURL = "ftp://provider.example/authorize" }, ErrConfigEndpointMustBeHTTPS}, + {"token", func(c *Config) { c.TokenURL = "ftp://provider.example/token" }, ErrConfigEndpointMustBeHTTPS}, + {"userinfo", func(c *Config) { c.UserInfoURL = "ftp://provider.example/user" }, ErrConfigEndpointMustBeHTTPS}, } { t.Run(tc.name, func(t *testing.T) { cfg := base @@ -176,27 +176,15 @@ func TestOAuthProfileRotationLockDoesNotBlockProvider(t *testing.T) { defer release() result := make(chan *httptest.ResponseRecorder, 1) go func() { result <- profileCallback(srv, cookie, state, "proof") }() - select { - case <-providerReached: - case <-time.After(5 * time.Second): - t.Fatal("provider request blocked by session rotation lock") - } - select { - case <-lock.entered: - case <-time.After(5 * time.Second): - t.Fatal("callback did not reach session rotation") - } + waitFor(t, providerReached, "provider request blocked by session rotation lock") + waitFor(t, lock.entered, "callback did not reach session rotation") if jw.GetSession(oldRequest) != old { t.Fatal("session rotated before acquiring lock") } release() - select { - case response := <-result: - if response.Code != http.StatusFound || len(response.Result().Cookies()) != 1 || jw.GetSession(oldRequest) != nil { - t.Fatalf("callback after rotation lock: status=%d cookies=%v", response.Code, response.Result().Cookies()) - } - case <-time.After(5 * time.Second): - t.Fatal("callback did not finish after rotation lock released") + response := waitFor(t, result, "callback did not finish after rotation lock released") + if response.Code != http.StatusFound || len(response.Result().Cookies()) != 1 || jw.GetSession(oldRequest) != nil { + t.Fatalf("callback after rotation lock: status=%d cookies=%v", response.Code, response.Result().Cookies()) } } @@ -237,11 +225,7 @@ func TestOAuthFailedCallbackDoesNotEraseNewFlow(t *testing.T) { cookie, firstState := profileStart(t, srv, http.MethodGet, "/auth/login", nil) firstResult := make(chan int, 1) go func() { firstResult <- profileCallback(srv, cookie, firstState, "first").Code }() - select { - case <-entered: - case <-time.After(5 * time.Second): - t.Fatal("first callback did not reach provider") - } + waitFor(t, entered, "first callback did not reach provider") r := httptest.NewRequest(http.MethodGet, "https://app.example/auth/login", nil) r.RemoteAddr = "192.0.2.1:1234" r.Header.Set("Referer", "https://app.example/second") @@ -257,17 +241,12 @@ func TestOAuthFailedCallbackDoesNotEraseNewFlow(t *testing.T) { } secondState := location.Query().Get("state") releaseCallback() - select { - case code := <-firstResult: - if code == http.StatusFound { - t.Fatal("failed callback signed in") - } - case <-time.After(5 * time.Second): - t.Fatal("failed callback did not finish") + if code := waitFor(t, firstResult, "failed callback did not finish"); code == http.StatusFound { + t.Fatal("failed callback signed in") } sess := jw.GetSession(r) - if sess == nil || sess.Get(srv.sessionKey(oauth2StateKey)) != secondState || - sess.Get(srv.sessionKey(oauth2ReferrerKey)) != "/second" { + if sess == nil || testOAuthFlow(sess, srv).state != secondState || + testOAuthFlow(sess, srv).referrer != "/second" { t.Fatal("failed callback erased the newer flow") } second := profileCallback(srv, cookie, secondState, "second") @@ -310,11 +289,7 @@ func TestOAuthProfileCallbackCanceledBeforeRotation(t *testing.T) { } result := make(chan *httptest.ResponseRecorder, 1) go func() { result <- profileCallback(srv, cookie, state, "first") }() - select { - case <-entered: - case <-time.After(5 * time.Second): - t.Fatal("callback did not reach provider") - } + waitFor(t, entered, "callback did not reach provider") var nextState string if action == "logout" { @@ -342,17 +317,13 @@ func TestOAuthProfileCallbackCanceledBeforeRotation(t *testing.T) { releaseOnce.Do(func() { close(release) }) var w *httptest.ResponseRecorder - select { - case w = <-result: - case <-time.After(5 * time.Second): - t.Fatal("canceled callback did not finish") - } + w = waitFor(t, result, "canceled callback did not finish") if w.Code != http.StatusForbidden || len(w.Result().Cookies()) != 0 || logins != 0 { t.Fatalf("canceled callback: status=%d cookies=%v logins=%d", w.Code, w.Result().Cookies(), logins) } - storedState, _ := sess.Get(srv.sessionKey(oauth2StateKey)).(string) + storedState := testOAuthFlow(sess, srv).state if sess.Get(srv.SessionKey) != nil || storedState != nextState { - t.Fatalf("canceled callback changed session or new flow: claims=%v state=%v", sess.Get(srv.SessionKey), sess.Get(srv.sessionKey(oauth2StateKey))) + t.Fatalf("canceled callback changed session or new flow: claims=%v state=%v", sess.Get(srv.SessionKey), storedState) } }) } @@ -404,21 +375,13 @@ func TestOAuthProfileLinkCanceledByLogout(t *testing.T) { _, linkState := profileStart(t, srv, http.MethodPost, "/auth/link/github", cookie) result := make(chan *httptest.ResponseRecorder, 1) go func() { result <- profileCallback(srv, cookie, linkState, "second") }() - select { - case <-entered: - case <-time.After(5 * time.Second): - t.Fatal("link callback did not reach provider") - } + waitFor(t, entered, "link callback did not reach provider") if !srv.Logout(sess, nil) { t.Fatal("logout did not clear linked session") } releaseOnce.Do(func() { close(release) }) var w *httptest.ResponseRecorder - select { - case w = <-result: - case <-time.After(5 * time.Second): - t.Fatal("canceled link callback did not finish") - } + w = waitFor(t, result, "canceled link callback did not finish") if w.Code != http.StatusForbidden || len(w.Result().Cookies()) != 0 || links != 0 || sess.Get(srv.SessionKey) != nil { t.Fatalf("canceled link: status=%d cookies=%v links=%d claims=%v", w.Code, w.Result().Cookies(), links, sess.Get(srv.SessionKey)) } @@ -463,11 +426,7 @@ func TestOAuthProfileLinkRevokedDuringEventRejectsCallback(t *testing.T) { _, linkState := profileStart(t, srv, http.MethodPost, "/auth/link/github", cookie) result := make(chan *httptest.ResponseRecorder, 1) go func() { result <- profileCallback(srv, cookie, linkState, "second") }() - select { - case <-entered: - case <-time.After(5 * time.Second): - t.Fatal("LinkEvent was not reached") - } + waitFor(t, entered, "LinkEvent was not reached") if action == "logout" { if !srv.Logout(sess, nil) { t.Fatal("logout failed") @@ -480,11 +439,7 @@ func TestOAuthProfileLinkRevokedDuringEventRejectsCallback(t *testing.T) { } releaseOnce.Do(func() { close(release) }) var response *httptest.ResponseRecorder - select { - case response = <-result: - case <-time.After(5 * time.Second): - t.Fatal("revoked link callback did not finish") - } + response = waitFor(t, result, "revoked link callback did not finish") if response.Code != http.StatusForbidden || response.Header().Get("Location") != "" || len(response.Result().Cookies()) != 0 || links != 1 || sess.Get(srv.SessionKey) != nil { t.Fatalf("revoked link: status=%d location=%q cookies=%v links=%d claims=%v", @@ -567,7 +522,7 @@ func TestOAuthProfileLinkSerializesManagedRevocation(t *testing.T) { srv.mu.Unlock() factory.timer(0).fire() case "wrapper": - sess.Set(srv.sessionKey(oauth2IDTokenExpiryKey), time.Now().Add(-time.Second)) + sess.Set(srv.sessionKey(oauth2AuthExpiryKey), time.Now().Add(-time.Second)) wrapper{server: srv}.ServeHTTP(httptest.NewRecorder(), req) case "handler logout": srv.HandleLogout(httptest.NewRecorder(), req) @@ -583,34 +538,18 @@ func TestOAuthProfileLinkSerializesManagedRevocation(t *testing.T) { } callbackDone := make(chan *httptest.ResponseRecorder, 1) go func() { callbackDone <- profileCallback(srv, cookie, linkState, "second") }() - select { - case <-checked: - case <-time.After(5 * time.Second): - t.Fatal("link did not check current auth") - } + waitFor(t, checked, "link did not check current auth") revoked := make(chan struct{}) go func() { revoke(); close(revoked) }() - select { - case <-lock.second: - case <-time.After(5 * time.Second): - t.Fatal("revocation did not reach rotation lock") - } + waitFor(t, lock.second, "revocation did not reach rotation lock") select { case <-revoked: t.Fatal("revocation completed before durable link write") default: } releaseEvent() - select { - case <-revoked: - case <-time.After(5 * time.Second): - t.Fatal("revocation did not finish after link write") - } - select { - case <-callbackDone: - case <-time.After(5 * time.Second): - t.Fatal("link callback did not finish") - } + waitFor(t, revoked, "revocation did not finish after link write") + waitFor(t, callbackDone, "link callback did not finish") if writes != 1 || logoutEvents.Load() != 1 { t.Fatalf("durable link writes = %d, logout events = %d", writes, logoutEvents.Load()) } @@ -679,11 +618,7 @@ func TestOAuthProfileAuthExpiryPreservesOrdinaryLogin(t *testing.T) { })} result = make(chan *httptest.ResponseRecorder, 1) go func() { result <- profileCallback(srv, cookie, state, "second") }() - select { - case <-entered: - case <-time.After(5 * time.Second): - t.Fatal("new login callback did not reach token exchange") - } + waitFor(t, entered, "new login callback did not reach token exchange") } srv.mu.Lock() srv.authTimers[sess.ID()].expiry = time.Now().Add(-time.Second) @@ -692,17 +627,25 @@ func TestOAuthProfileAuthExpiryPreservesOrdinaryLogin(t *testing.T) { if sess.Get(srv.SessionKey) != nil { t.Fatal("expired original auth remained") } - if phase == "pending" && sess.Get(srv.sessionKey(oauth2StateKey)) != state { + if phase == "pending" && testOAuthFlow(sess, srv).state != state { t.Fatal("auth expiry erased pending login state") } + if phase == "callback" { + protected := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + protected.RemoteAddr = start.RemoteAddr + protected.AddCookie(cookie) + conflict := httptest.NewRecorder() + srv.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("pending callback reached protected handler") + })).ServeHTTP(conflict, protected) + if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), ErrOAuth2LoginInProgress.Error()) { + t.Fatalf("pending callback: status=%d body=%q", conflict.Code, conflict.Body.String()) + } + } var response *httptest.ResponseRecorder if phase == "callback" { releaseOnce.Do(func() { close(release) }) - select { - case response = <-result: - case <-time.After(5 * time.Second): - t.Fatal("new login callback did not finish") - } + response = waitFor(t, result, "new login callback did not finish") } else { response = profileCallback(srv, cookie, state, "second") } @@ -885,7 +828,9 @@ func TestOAuthProfileExpiredLinkStateNeverCallsProvider(t *testing.T) { r.RemoteAddr = "192.0.2.1:1234" r.AddCookie(cookie) sess := jw.GetSession(r) - sess.Set(srv.sessionKey(oauth2FlowExpiryKey), time.Now().Add(-time.Second)) + flow := testOAuthFlow(sess, srv) + flow.expiry = time.Now().Add(-time.Second) + setTestOAuthFlow(sess, srv, flow) before := calls response := profileCallback(srv, cookie, linkState, "second") if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), ErrOAuth2ExpiredState.Error()) || calls != before { @@ -1119,7 +1064,7 @@ func TestOAuthProfileExplicitZeroExpiryRejectedButOmittedExpiryCapped(t *testing if sess == nil || sess.Get(srv.SessionKey) == nil { t.Fatal("omitted expiry did not authenticate") } - expiry, _ := sess.Get(srv.sessionKey(oauth2IDTokenExpiryKey)).(time.Time) + expiry, _ := sess.Get(srv.sessionKey(oauth2AuthExpiryKey)).(time.Time) if remaining := time.Until(expiry); remaining < 23*time.Hour || remaining > 24*time.Hour { t.Fatalf("omitted expiry cap = %s", remaining) } @@ -1162,45 +1107,6 @@ func TestOAuthProfileExpiresWithoutRefresh(t *testing.T) { } } -func TestOAuthProfileAuthTimerLogIsModeSpecific(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() - logger := &testAuthDebugLogger{} - jw.Debug = true - jw.Logger = logger - calls := 0 - srv := newProfileTestServer(t, jw, &calls) - factory := &testAuthTimerFactory{} - srv.authTimerAfterFunc = factory.after - req := httptest.NewRequest(http.MethodGet, "https://app.example/", nil) - sess := jw.NewSession(httptest.NewRecorder(), req) - if err := srv.storeSessionAuthClaims(sess, map[string]any{"sub": "42"}, nil, time.Now().Add(time.Hour), nil); err != nil { - t.Fatal(err) - } - record, ok := logger.info("jawsauth: scheduled auth timer") - if !ok { - t.Fatal("missing neutral auth timer log") - } - foundMode := false - for i := 0; i+1 < len(record.args); i += 2 { - if record.args[i] == "mode" { - foundMode = true - if record.args[i+1] != "profile" { - t.Fatalf("timer mode = %v", record.args[i+1]) - } - } - if record.args[i] == "refresh_skew" { - t.Fatal("profile timer log claims refresh skew") - } - } - if !foundMode { - t.Fatal("profile timer mode missing") - } -} - func TestOAuthProfileTimerReleasesClosedSession(t *testing.T) { jw, err := jaws.New() if err != nil { diff --git a/server.go b/server.go index 66c3b66..9713597 100644 --- a/server.go +++ b/server.go @@ -71,8 +71,8 @@ type EventFunc func(sess *jaws.Session, hr *http.Request) // when logging them and escape them for the output format when rendering them. type FailedFunc func(hw http.ResponseWriter, hr *http.Request, httpCode int, err error, email string) (wroteresponse bool) -// LinkFunc receives the current session and a newly verified identity before -// any session rotation. Serialize the final current-auth check and durable link +// LinkFunc receives the current session and a newly verified identity. Serialize +// the final current-auth check and durable link // write with logout and other provider auth revocation. Return zero and nil on // success, or an HTTP error status. type LinkFunc func(sess *jaws.Session, hr *http.Request, claims map[string]any) (statusCode int, err error) @@ -148,12 +148,10 @@ func NewDebug(jw *jaws.Jaws, cfg *Config, handleFn HandleFunc, overrideUrl strin if cfg != nil { srv.namespace = cfg.Namespace srv.issuer = cfg.Issuer - if cfg.Namespace != "" { - srv.SessionKey = srv.sessionKey(srv.SessionKey) - srv.SessionTokenKey = srv.sessionKey(srv.SessionTokenKey) - srv.SessionEmailKey = srv.sessionKey(srv.SessionEmailKey) - srv.SessionEmailVerifiedKey = srv.sessionKey(srv.SessionEmailVerifiedKey) - } + srv.SessionKey = srv.sessionKey(srv.SessionKey) + srv.SessionTokenKey = srv.sessionKey(srv.SessionTokenKey) + srv.SessionEmailKey = srv.sessionKey(srv.SessionEmailKey) + srv.SessionEmailVerifiedKey = srv.sessionKey(srv.SessionEmailVerifiedKey) } if cfg != nil && handleFn != nil && cfg.RedirectURL != "" { setupCtx, cancel := context.WithTimeout(context.Background(), oauth2ProviderTimeout) diff --git a/sessionlifecycle_test.go b/sessionlifecycle_test.go index 4ba4338..d831c2b 100644 --- a/sessionlifecycle_test.go +++ b/sessionlifecycle_test.go @@ -255,10 +255,7 @@ func TestLoginRotationExistingAuth(t *testing.T) { t.Fatal("login reused old session") } } - sess.Set(oauth2StateKey, "state") - sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) - sess.Set(oauth2NonceKey, "nonce") - sess.Set(oauth2PKCEVerifierKey, oauth2.GenerateVerifier()) + setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state", expiry: time.Now().Add(oauth2FlowLifetime), nonce: "nonce", verifier: oauth2.GenerateVerifier()}) rec := httptest.NewRecorder() srv.HandleAuthResponse(rec, req) if limit == 1 { @@ -427,10 +424,7 @@ func TestLoginUserInfoDeadlineKeepsOIDCFallbackOptional(t *testing.T) { logins, logouts := 0, 0 srv.LoginEvent = func(*jaws.Session, *http.Request) { logins++ } srv.LogoutEvent = func(*jaws.Session, *http.Request) { logouts++ } - sess.Set(oauth2StateKey, "state") - sess.Set(oauth2FlowExpiryKey, time.Now().Add(oauth2FlowLifetime)) - sess.Set(oauth2NonceKey, "nonce") - sess.Set(oauth2PKCEVerifierKey, oauth2.GenerateVerifier()) + setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state", expiry: time.Now().Add(oauth2FlowLifetime), nonce: "nonce", verifier: oauth2.GenerateVerifier()}) hw := httptest.NewRecorder() srv.HandleAuthResponse(hw, hr) current := jw.GetSession(hr) diff --git a/testwait_test.go b/testwait_test.go new file mode 100644 index 0000000..a10145e --- /dev/null +++ b/testwait_test.go @@ -0,0 +1,18 @@ +package jawsauth + +import ( + "testing" + "time" +) + +func waitFor[T any](t *testing.T, ch <-chan T, msg string) T { + t.Helper() + select { + case value := <-ch: + return value + case <-time.After(5 * time.Second): + t.Fatal(msg) + } + var zero T + return zero +} diff --git a/wrapper.go b/wrapper.go index b39a54c..ee7d427 100644 --- a/wrapper.go +++ b/wrapper.go @@ -15,7 +15,7 @@ func (w wrapper) ServeHTTP(hw http.ResponseWriter, hr *http.Request) { sess := w.server.Jaws.GetSession(hr) if current, present := w.server.sessionAuthStatus(sess, time.Now); !current { if present { - w.server.clearSessionAuth(sess, hr, nil, true, w.server.SessionRotationLock) + w.server.clearSessionAuth(sess, hr, nil, true, true) } w.server.startOAuth(hw, hr, false, true) return diff --git a/wrapper_test.go b/wrapper_test.go index 8fc42eb..b7dae96 100644 --- a/wrapper_test.go +++ b/wrapper_test.go @@ -45,7 +45,7 @@ func TestWrapperServeHTTPSet403HandlerConcurrent(t *testing.T) { "exp": time.Now().Add(time.Hour).Unix(), "ok": true, }) - sess.Set(oauth2IDTokenExpiryKey, time.Now().Add(time.Hour)) + sess.Set(oauth2AuthExpiryKey, time.Now().Add(time.Hour)) sess.Set(srv.SessionEmailKey, "user@example.com") w := wrapper{ @@ -92,7 +92,7 @@ func TestWrapperServeHTTPAllowsPresentAuthData(t *testing.T) { "exp": time.Now().Add(time.Hour).Unix(), "email": "user@example.com", }) - sess.Set(oauth2IDTokenExpiryKey, time.Now().Add(time.Hour)) + sess.Set(oauth2AuthExpiryKey, time.Now().Add(time.Hour)) w := wrapper{ server: srv, @@ -140,7 +140,7 @@ func TestWrapperServeHTTPClearsInvalidAuthData(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) sess := jw.NewSession(httptest.NewRecorder(), req) sess.Set(srv.SessionKey, tc.authValue) - sess.Set(oauth2IDTokenExpiryKey, tc.expiry) + sess.Set(oauth2AuthExpiryKey, tc.expiry) w := wrapper{ server: srv, @@ -222,7 +222,7 @@ func TestSessionAuthStatus(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) sess := jw.NewSession(httptest.NewRecorder(), req) sess.Set(srv.SessionKey, tc.authValue) - sess.Set(oauth2IDTokenExpiryKey, tc.expiryValue) + sess.Set(oauth2AuthExpiryKey, tc.expiryValue) gotCurrent, gotPresent := srv.sessionAuthStatus(sess, func() time.Time { return now }) if gotCurrent != tc.wantCurrent { @@ -333,7 +333,7 @@ func assertWrapperAuthCleared(t *testing.T, srv *Server, sess *jaws.Session) { if value := sess.Get(srv.SessionTokenKey); value != nil { t.Fatal(value) } - if value := sess.Get(oauth2IDTokenExpiryKey); value != nil { + if value := sess.Get(oauth2AuthExpiryKey); value != nil { t.Fatal(value) } if value := sess.Get(srv.SessionEmailKey); value != nil { From 7e1831bbf932d0405d72e62ca5d8e4c1544892f6 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 22:47:42 +0100 Subject: [PATCH 25/43] fix: serialize link callbacks with auth revocation --- README.md | 25 ++++++---- authsession.go | 2 +- concurrent_providers_test.go | 92 ++++++++++++++++++++++++++++++++++++ config.go | 3 +- debug.go | 7 --- oauth2.go | 76 +++++++++++++---------------- oauth2_callback_edge_test.go | 6 +-- oauth2_review_test.go | 22 +++++++++ profile_link_test.go | 76 ++--------------------------- server.go | 10 ++-- 10 files changed, 179 insertions(+), 140 deletions(-) diff --git a/README.md b/README.md index 96430c5..f226a68 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,13 @@ Verified OIDC or OAuth2 profile authentication for [JaWS](https://github.com/linkdata/jaws) sessions. - OIDC mode discovers the issuer, verifies `id_token` and nonce, and refreshes before expiry. -- Profile mode accepts explicit HTTPS endpoints and a `Config.ProfileSubject` verifier for providers without ID tokens. Its authenticated profile response must contain a stable subject; email and names are not identity proof. JSON numbers arrive as `json.Number` so large numeric IDs remain exact. +- Profile mode accepts explicit HTTPS endpoints and a `Config.ProfileSubject` + verifier for providers without ID tokens. Its authenticated profile response + must contain a stable subject; email and names are not identity proof. JSON + numbers arrive as `json.Number` so large numeric IDs remain exact. Profile + email feeds `SetAdmins`; if the provider allows unverified email, set + `RequireVerifiedAdminEmail = true` and map its verification signal to + `email_verified` in `ProfileSubject`. - Both modes use authorization-code state and PKCE (S256). Pending flows expire after ten minutes; jawsauth extends only that JaWS session's grace while the user visits the provider. - For OIDC providers that require an offline-access request to issue refresh tokens, set `Server.Options = []oauth2.AuthCodeOption{oauth2.AccessTypeOffline}` before serving. This adds `access_type=offline` to the authorization redirect, not the token request. - Profile authentication expires after 24 hours or the provider token's earlier expiry; it does not attempt OIDC refresh. @@ -16,6 +22,9 @@ Verified OIDC or OAuth2 profile authentication for [JaWS](https://github.com/lin Protected routes create JaWS sessions for unauthenticated visitors. Set `Jaws.MaxSessions` and `Jaws.MaxSessionsPerIP` to bound session counts. When a session cannot be created, login returns 503 with `Retry-After: 60`. +Each login attempt can retain an anonymous session for about ten minutes while +the user visits the provider. Limit requests to login and protected routes when +serving untrusted traffic. Post-login return targets longer than 8192 bytes fall back to `/`. Login callback token/profile calls and startup discovery have a 15-second deadline; OIDC background refresh requests have the same maximum timeout. @@ -32,13 +41,13 @@ For multiple providers on one JaWS instance: calling it. Set `Server.LinkEvent` to recheck that identity and accept the newly verified subject. A link keeps the original session; login rotates it. A link callback requires the original live JaWS session and one-use flow state. -- Serialize `LinkEvent`'s final current-auth check and durable write with logout - and other provider auth revocation. Give each provider's `SessionRotationLock` - the same lock. This serializes login rotation, timer and wrapper revocation, - and built-in `HandleLogout` with the write. Hold that lock around direct calls - to `Server.Logout` when linking writes are durable. Provider requests run - outside the lock. A stale callback is rejected, but an application link write - cannot be rolled back after revocation. +- Give each provider's `SessionRotationLock` the same lock. jawsauth holds it + across `LinkEvent`'s current-auth check and durable write, as well as login + rotation, timer and wrapper revocation, and built-in `HandleLogout`. Do not + acquire it again inside `LinkEvent`. Hold it around direct calls to + `Server.Logout` when linking writes are durable. Provider requests run + outside the lock. A callback canceled before the write is rejected; a + completed write succeeds even if logout follows it. The provider visit extends the original session's idle grace. A successful link can retain that grace even with no connected pages until its deadline. diff --git a/authsession.go b/authsession.go index 6a7e593..2e6e25e 100644 --- a/authsession.go +++ b/authsession.go @@ -134,7 +134,7 @@ func (srv *Server) verifyIDToken(ctx context.Context, raw string) (idToken *oidc return nil, nil, errOIDC{kind: ErrOIDCInvalidIDToken, cause: errOIDCMissingSubject} } err = idToken.Claims(&claims) - wrapOIDC(ErrOIDCInvalidIDToken, &err) + _ = wrapOIDC(ErrOIDCInvalidIDToken, &err) return } diff --git a/concurrent_providers_test.go b/concurrent_providers_test.go index 5c8f2ef..10b23ff 100644 --- a/concurrent_providers_test.go +++ b/concurrent_providers_test.go @@ -104,3 +104,95 @@ func TestConcurrentProviderCallbacksRotateOriginalSessionOnce(t *testing.T) { t.Fatalf("callbacks: success=%d rejected=%d sessions=%d", success, rejected, jw.SessionCount()) } } + +// holdFirstRotationLock lets logout acquire the lock before a callback that +// reached Lock first, so the callback must recheck its flow after acquisition. +type holdFirstRotationLock struct { + mu sync.Mutex + attempts atomic.Int32 + first chan struct{} + releaseFirst chan struct{} +} + +func (l *holdFirstRotationLock) Lock() { + if l.attempts.Add(1) == 1 { + close(l.first) + <-l.releaseFirst + } + l.mu.Lock() +} + +func (l *holdFirstRotationLock) Unlock() { l.mu.Unlock() } + +func TestLinkedProviderLogoutBeforeLinkWrite(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/token" { + return profileTestResponse(`{"access_token":"proof","token_type":"Bearer"}`), nil + } + return profileTestResponse(`{"id":42}`), nil + })} + mk := func(ns string) *Server { + srv, err := New(jw, &Config{Namespace: ns, RedirectURL: "https://app.example/auth/" + ns + "/callback", Issuer: "https://provider.example", AuthURL: "https://provider.example/authorize", TokenURL: "https://provider.example/token", UserInfoURL: "https://provider.example/user", ClientID: "app", HTTPClient: client, ProfileSubject: func(m map[string]any) (string, error) { return string(m["id"].(json.Number)), nil }}, func(string, http.Handler) {}) + if err != nil { + t.Fatal(err) + } + return srv + } + a, b := mk("a"), mk("b") + cookie, state := profileStart(t, a, http.MethodGet, "/auth/a/login", nil) + login := profileCallback(a, cookie, state, "proof") + if login.Code != http.StatusFound || len(login.Result().Cookies()) != 1 { + t.Fatalf("initial login: status=%d cookies=%v", login.Code, login.Result().Cookies()) + } + cookie = login.Result().Cookies()[0] + req := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + req.AddCookie(cookie) + sess := jw.GetSession(req) + if a.CurrentClaims(sess) == nil { + t.Fatal("missing first provider authentication") + } + lock := &holdFirstRotationLock{first: make(chan struct{}), releaseFirst: make(chan struct{})} + a.SessionRotationLock, b.SessionRotationLock = lock, lock + var writes atomic.Int32 + b.LinkEvent = func(current *jaws.Session, _ *http.Request, _ map[string]any) (int, error) { + if a.CurrentClaims(current) == nil { + return http.StatusForbidden, ErrOAuth2MissingSession + } + writes.Add(1) + return 0, nil + } + _, linkState := profileStart(t, b, http.MethodPost, "/auth/b/link", cookie) + callbackDone := make(chan *httptest.ResponseRecorder, 1) + go func() { + callbackRequest := httptest.NewRequest(http.MethodGet, "https://app.example/auth/b/callback?state="+url.QueryEscape(linkState)+"&code=proof", nil) + callbackRequest.RemoteAddr = "192.0.2.1:1234" + callbackRequest.AddCookie(cookie) + callback := httptest.NewRecorder() + b.HandleAuthResponse(callback, callbackRequest) + callbackDone <- callback + }() + release := sync.OnceFunc(func() { close(lock.releaseFirst) }) + defer release() + waitFor(t, lock.first, "link callback did not reach shared lock") + b.mu.Lock() + flow := b.oauthCallbacks[sess] + b.mu.Unlock() + if flow.canceled == nil || flow.revoked() { + t.Fatal("link callback was not active before logout") + } + logout := httptest.NewRecorder() + b.HandleLogout(logout, req) + if logout.Code != http.StatusFound || !flow.revoked() || a.CurrentClaims(sess) == nil { + t.Fatalf("second provider logout: status=%d revoked=%t first provider valid=%t", logout.Code, flow.revoked(), a.CurrentClaims(sess) != nil) + } + release() + callback := waitFor(t, callbackDone, "revoked link callback did not finish") + if callback.Code != http.StatusForbidden || writes.Load() != 0 || len(callback.Result().Cookies()) != 0 { + t.Fatalf("revoked link: status=%d writes=%d cookies=%v", callback.Code, writes.Load(), callback.Result().Cookies()) + } +} diff --git a/config.go b/config.go index d317981..578f70a 100644 --- a/config.go +++ b/config.go @@ -32,7 +32,8 @@ type Config struct { AllowInsecureIssuer bool // HTTPClient is used for OIDC discovery and JWKS requests and, unless a per-request // oauth2.HTTPClient is supplied via the request context, as the default client - // for token exchange/refresh and UserInfo requests. + // for token exchange/refresh and UserInfo requests. Those requests cap its + // timeout at 15 seconds and do not follow redirects. HTTPClient *http.Client Scopes []string // optional; OIDC adds "openid" and "email", profile mode uses only these scopes ClientID string diff --git a/debug.go b/debug.go index cd2ce32..07e685b 100644 --- a/debug.go +++ b/debug.go @@ -275,16 +275,9 @@ func errorDebugClasses(err error) (classes []string) { classes = appendErrorDebugClass(classes, err, ErrOAuth2MissingSession, "oauth2_missing_session") classes = appendErrorDebugClass(classes, err, ErrOAuth2MissingState, "oauth2_missing_state") classes = appendErrorDebugClass(classes, err, ErrOAuth2WrongState, "oauth2_wrong_state") - classes = appendErrorDebugClass(classes, err, ErrOAuth2ExpiredState, "oauth2_expired_state") classes = appendErrorDebugClass(classes, err, ErrOAuth2MissingPKCEVerifier, "oauth2_missing_pkce_verifier") - classes = appendErrorDebugClass(classes, err, ErrOAuth2LoginInProgress, "oauth2_login_in_progress") classes = appendErrorDebugClass(classes, err, ErrOAuth2Callback, "oauth2_callback") classes = appendErrorDebugClass(classes, err, ErrUserInfoStatus, "userinfo_status") - classes = appendErrorDebugClass(classes, err, ErrUserInfoTooLarge, "userinfo_too_large") - classes = appendErrorDebugClass(classes, err, ErrUserInfoInvalidResponse, "userinfo_invalid_response") - classes = appendErrorDebugClass(classes, err, ErrOAuth2InvalidProfile, "oauth2_invalid_profile") - classes = appendErrorDebugClass(classes, err, ErrOAuth2LinkRejected, "oauth2_link_rejected") - classes = appendErrorDebugClass(classes, err, ErrOAuth2InvalidOrigin, "oauth2_invalid_origin") classes = appendErrorDebugClass(classes, err, ErrOIDCDiscovery, "oidc_discovery") classes = appendErrorDebugClass(classes, err, ErrOIDCProviderMetadata, "oidc_provider_metadata") classes = appendErrorDebugClass(classes, err, ErrOIDCMissingIDToken, "oidc_missing_id_token") diff --git a/oauth2.go b/oauth2.go index e419f2d..a0e48ad 100644 --- a/oauth2.go +++ b/oauth2.go @@ -139,7 +139,7 @@ func (srv *Server) begin(hr *http.Request) (oauth2cfg *oauth2.Config, location s // HandleLogin begins the configured OIDC or OAuth2 profile login flow. // -// For GET requests it generates and stores the state, nonce and PKCE verifier in the +// For GET requests it stores the state, PKCE verifier and, for OIDC, nonce in the // session, then responds with a 302 redirect to the provider's authorization URL. // If no session can be created, it returns 503 with Retry-After instead. // Non-GET requests receive 405. @@ -181,50 +181,42 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, pr srv.writeResult(hw, http.StatusServiceUnavailable, ErrOAuth2MissingSession, nil) return } - flowExpiry := time.Now().Add(oauth2FlowLifetime) - authOptions := append([]oauth2.AuthCodeOption{}, srv.Options...) - state := randomHexString() - var nonce string - if srv.profileSubject == nil { - nonce = randomHexString() - authOptions = append(authOptions, oidc.Nonce(nonce)) - } - verifier := oauth2.GenerateVerifier() - authOptions = append(authOptions, oauth2.S256ChallengeOption(verifier)) if link && strings.TrimSpace(hr.Referer()) == "" { location = "/" } + var flow pendingOAuthFlow srv.mu.Lock() if preservePendingLogin && srv.pendingOrdinaryLoginLocked(sess) { - stored, _ := sess.Get(srv.sessionKey(oauth2FlowKey)).(pendingOAuthFlow) - srv.mu.Unlock() - if stored.state != "" && stored.verifier != "" { - options := append([]oauth2.AuthCodeOption{}, srv.Options...) - if stored.nonce != "" { - options = append(options, oidc.Nonce(stored.nonce)) - } - options = append(options, oauth2.S256ChallengeOption(stored.verifier)) - hw.Header().Set("Location", oauth2cfg.AuthCodeURL(stored.state, options...)) - SetHeaders(hw, srv.ishttps) - hw.WriteHeader(http.StatusFound) - } else { + flow, _ = sess.Get(srv.sessionKey(oauth2FlowKey)).(pendingOAuthFlow) + if flow.state == "" || flow.verifier == "" { + srv.mu.Unlock() srv.writeResult(hw, http.StatusConflict, ErrOAuth2LoginInProgress, nil) + return } - return - } - if !sess.ExtendGrace(oauth2FlowGrace) { - srv.mu.Unlock() - hw.Header().Set("Retry-After", "60") - srv.writeResult(hw, http.StatusServiceUnavailable, ErrOAuth2MissingSession, nil) - return + } else { + flow = pendingOAuthFlow{ + state: randomHexString(), expiry: time.Now().Add(oauth2FlowLifetime), + link: link, verifier: oauth2.GenerateVerifier(), referrer: location, + } + if srv.profileSubject == nil { + flow.nonce = randomHexString() + } + if !sess.ExtendGrace(oauth2FlowGrace) { + srv.mu.Unlock() + hw.Header().Set("Retry-After", "60") + srv.writeResult(hw, http.StatusServiceUnavailable, ErrOAuth2MissingSession, nil) + return + } + srv.clearSessionOAuthFlow(sess) + sess.Set(srv.sessionKey(oauth2FlowKey), flow) } - srv.clearSessionOAuthFlow(sess) - sess.Set(srv.sessionKey(oauth2FlowKey), pendingOAuthFlow{ - state: state, expiry: flowExpiry, link: link, nonce: nonce, - verifier: verifier, referrer: location, - }) srv.mu.Unlock() - location = oauth2cfg.AuthCodeURL(state, authOptions...) + authOptions := append([]oauth2.AuthCodeOption{}, srv.Options...) + if flow.nonce != "" { + authOptions = append(authOptions, oidc.Nonce(flow.nonce)) + } + authOptions = append(authOptions, oauth2.S256ChallengeOption(flow.verifier)) + location = oauth2cfg.AuthCodeURL(flow.state, authOptions...) } hw.Header().Set("Location", location) statusCode = http.StatusFound @@ -479,7 +471,7 @@ func (srv *Server) verifyOAuthIdentity(ctx context.Context, token *oauth2.Token, } var subject string subject, err = srv.profileSubject(claims) - if err != nil && srv.Jaws != nil { + if err != nil { _ = srv.Jaws.Log(err) } if err != nil || subject == "" { @@ -526,6 +518,10 @@ func (srv *Server) rotateOAuthSession(hw http.ResponseWriter, hr *http.Request, } func (srv *Server) completeLink(sess *jaws.Session, hr *http.Request, claims map[string]any, flow pendingOAuthFlow) (status int, err error) { + if srv.SessionRotationLock != nil { + srv.SessionRotationLock.Lock() + defer srv.SessionRotationLock.Unlock() + } if srv.LinkEvent == nil || flow.revoked() || srv.Jaws.GetSession(hr) != sess { return http.StatusForbidden, ErrOAuth2MissingSession } @@ -537,9 +533,6 @@ func (srv *Server) completeLink(sess *jaws.Session, hr *http.Request, claims map } return status, ErrOAuth2LinkRejected } - if flow.revoked() || srv.Jaws.GetSession(hr) != sess || !sessionAlive(sess) { - return http.StatusForbidden, ErrOAuth2MissingSession - } return http.StatusFound, nil } @@ -629,9 +622,6 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) if err == nil { if err = srv.identityExpiryError(expiry); err != nil { statusCode = http.StatusUnauthorized - } else if flow.revoked() || srv.Jaws.GetSession(hr) != sess { - err = ErrOAuth2MissingSession - statusCode = http.StatusForbidden } else if flow.link { statusCode, err = srv.completeLink(sess, hr, claims, flow) } else { diff --git a/oauth2_callback_edge_test.go b/oauth2_callback_edge_test.go index 39a1b1a..c13b8b9 100644 --- a/oauth2_callback_edge_test.go +++ b/oauth2_callback_edge_test.go @@ -184,8 +184,7 @@ func TestOAuthLogoutDuringLoginEventClosesRotatedSession(t *testing.T) { } result := make(chan *httptest.ResponseRecorder, 1) go func() { result <- profileCallback(srv, cookie, state, "first") }() - var rotated *jaws.Session - rotated = waitFor(t, entered, "LoginEvent was not reached") + rotated := waitFor(t, entered, "LoginEvent was not reached") if rotated == preauth || rotated.Get(srv.SessionKey) == nil { t.Fatal("LoginEvent did not receive an authenticated rotated session") } @@ -193,8 +192,7 @@ func TestOAuthLogoutDuringLoginEventClosesRotatedSession(t *testing.T) { t.Fatal("logout failed") } releaseOnce.Do(func() { close(release) }) - var w *httptest.ResponseRecorder - w = waitFor(t, result, "canceled callback did not finish") + w := waitFor(t, result, "canceled callback did not finish") cookies := w.Result().Cookies() if w.Code != http.StatusForbidden || w.Header().Get("Location") != "" || len(cookies) != 2 || cookies[1].MaxAge >= 0 || cookies[1].Name != cookies[0].Name || cookies[1].Value != cookies[0].Value || diff --git a/oauth2_review_test.go b/oauth2_review_test.go index 05e73e1..74bec5f 100644 --- a/oauth2_review_test.go +++ b/oauth2_review_test.go @@ -36,6 +36,28 @@ func TestLinkWithoutEventDoesNotStartFlow(t *testing.T) { } } +func TestLinkRejectsGETBeforeStartingFlow(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + srv.LinkEvent = func(*jaws.Session, *http.Request, map[string]any) (int, error) { return 0, nil } + r := httptest.NewRequest(http.MethodGet, "https://app.example/auth/link/github", nil) + r.RemoteAddr = "192.0.2.1:1234" + sess := jw.NewSession(httptest.NewRecorder(), r) + if sess == nil { + t.Fatal("missing session") + } + w := httptest.NewRecorder() + srv.HandleLink(w, r) + if w.Code != http.StatusMethodNotAllowed || w.Header().Get("Location") != "" || sess.Get(srv.sessionKey(oauth2FlowKey)) != nil || calls != 0 { + t.Fatalf("GET link: status=%d location=%q flow=%v calls=%d", w.Code, w.Header().Get("Location"), sess.Get(srv.sessionKey(oauth2FlowKey)), calls) + } +} + func TestLinkRejectsInvalidEventResults(t *testing.T) { for _, tc := range []struct { name string diff --git a/profile_link_test.go b/profile_link_test.go index 44ad86a..3f97afe 100644 --- a/profile_link_test.go +++ b/profile_link_test.go @@ -316,8 +316,7 @@ func TestOAuthProfileCallbackCanceledBeforeRotation(t *testing.T) { } releaseOnce.Do(func() { close(release) }) - var w *httptest.ResponseRecorder - w = waitFor(t, result, "canceled callback did not finish") + w := waitFor(t, result, "canceled callback did not finish") if w.Code != http.StatusForbidden || len(w.Result().Cookies()) != 0 || logins != 0 { t.Fatalf("canceled callback: status=%d cookies=%v logins=%d", w.Code, w.Result().Cookies(), logins) } @@ -380,75 +379,12 @@ func TestOAuthProfileLinkCanceledByLogout(t *testing.T) { t.Fatal("logout did not clear linked session") } releaseOnce.Do(func() { close(release) }) - var w *httptest.ResponseRecorder - w = waitFor(t, result, "canceled link callback did not finish") + w := waitFor(t, result, "canceled link callback did not finish") if w.Code != http.StatusForbidden || len(w.Result().Cookies()) != 0 || links != 0 || sess.Get(srv.SessionKey) != nil { t.Fatalf("canceled link: status=%d cookies=%v links=%d claims=%v", w.Code, w.Result().Cookies(), links, sess.Get(srv.SessionKey)) } } -func TestOAuthProfileLinkRevokedDuringEventRejectsCallback(t *testing.T) { - for _, action := range []string{"logout", "auth expiry"} { - t.Run(action, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() - calls := 0 - srv := newProfileTestServer(t, jw, &calls) - factory := &testAuthTimerFactory{} - srv.authTimerAfterFunc = factory.after - cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) - first := profileCallback(srv, cookie, state, "first") - if first.Code != http.StatusFound { - t.Fatalf("initial login: %d", first.Code) - } - cookie = first.Result().Cookies()[0] - r := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) - r.RemoteAddr = "192.0.2.1:1234" - r.AddCookie(cookie) - sess := jw.GetSession(r) - if sess == nil || sess.Get(srv.SessionKey) == nil { - t.Fatal("missing original authentication") - } - entered := make(chan struct{}) - release := make(chan struct{}) - var releaseOnce sync.Once - defer releaseOnce.Do(func() { close(release) }) - links := 0 - srv.LinkEvent = func(*jaws.Session, *http.Request, map[string]any) (int, error) { - links++ - close(entered) - <-release - return 0, nil - } - _, linkState := profileStart(t, srv, http.MethodPost, "/auth/link/github", cookie) - result := make(chan *httptest.ResponseRecorder, 1) - go func() { result <- profileCallback(srv, cookie, linkState, "second") }() - waitFor(t, entered, "LinkEvent was not reached") - if action == "logout" { - if !srv.Logout(sess, nil) { - t.Fatal("logout failed") - } - } else { - srv.mu.Lock() - srv.authTimers[sess.ID()].expiry = time.Now().Add(-time.Second) - srv.mu.Unlock() - factory.timer(0).fire() - } - releaseOnce.Do(func() { close(release) }) - var response *httptest.ResponseRecorder - response = waitFor(t, result, "revoked link callback did not finish") - if response.Code != http.StatusForbidden || response.Header().Get("Location") != "" || - len(response.Result().Cookies()) != 0 || links != 1 || sess.Get(srv.SessionKey) != nil { - t.Fatalf("revoked link: status=%d location=%q cookies=%v links=%d claims=%v", - response.Code, response.Header().Get("Location"), response.Result().Cookies(), links, sess.Get(srv.SessionKey)) - } - }) - } -} - type observedRotationLock struct { sync.Mutex attempts atomic.Int32 @@ -503,8 +439,6 @@ func TestOAuthProfileLinkSerializesManagedRevocation(t *testing.T) { defer releaseEvent() writes := 0 srv.LinkEvent = func(*jaws.Session, *http.Request, map[string]any) (int, error) { - lock.Lock() - defer lock.Unlock() if current, _ := srv.sessionAuthStatus(sess, time.Now); !current { return http.StatusForbidden, ErrOAuth2MissingSession } @@ -549,9 +483,9 @@ func TestOAuthProfileLinkSerializesManagedRevocation(t *testing.T) { } releaseEvent() waitFor(t, revoked, "revocation did not finish after link write") - waitFor(t, callbackDone, "link callback did not finish") - if writes != 1 || logoutEvents.Load() != 1 { - t.Fatalf("durable link writes = %d, logout events = %d", writes, logoutEvents.Load()) + response := waitFor(t, callbackDone, "link callback did not finish") + if response.Code != http.StatusFound || writes != 1 || logoutEvents.Load() != 1 { + t.Fatalf("callback status = %d, durable link writes = %d, logout events = %d", response.Code, writes, logoutEvents.Load()) } }) } diff --git a/server.go b/server.go index 9713597..4a32aa4 100644 --- a/server.go +++ b/server.go @@ -71,10 +71,10 @@ type EventFunc func(sess *jaws.Session, hr *http.Request) // when logging them and escape them for the output format when rendering them. type FailedFunc func(hw http.ResponseWriter, hr *http.Request, httpCode int, err error, email string) (wroteresponse bool) -// LinkFunc receives the current session and a newly verified identity. Serialize -// the final current-auth check and durable link -// write with logout and other provider auth revocation. Return zero and nil on -// success, or an HTTP error status. +// LinkFunc receives the current session and a newly verified identity. Recheck +// the current authentication before a durable link write. When configured, +// jawsauth holds SessionRotationLock while calling LinkFunc; do not acquire it +// again in the callback. Return zero and nil on success, or an HTTP error status. type LinkFunc func(sess *jaws.Session, hr *http.Request, claims map[string]any) (statusCode int, err error) // Server provides OIDC or verified OAuth2 profile authentication for JaWS sessions. @@ -92,7 +92,7 @@ type Server struct { LogoutEvent EventFunc // if not nil, called after auth is cleared; hr may be nil LoginFailed FailedFunc // if not nil, called on failed login LinkEvent LinkFunc // if not nil, enables HandleLink - SessionRotationLock sync.Locker // optional; serializes session rotation and internal auth revocation with application link writes + SessionRotationLock sync.Locker // optional; held during rotation, internal revocation, and LinkEvent Options []oauth2.AuthCodeOption // authorization URL options, e.g. oauth2.AccessTypeOffline; never sent to token exchange // RequireVerifiedAdminEmail requires email_verified for a non-empty admin list. From 679a0757ba76d9bc47e6f9cbb1a9b662b76faf17 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 23:21:59 +0100 Subject: [PATCH 26/43] fix: simplify OAuth flows and guard refresh identity --- README.md | 18 +++++--- authsession.go | 39 +++++++++-------- authsession_test.go | 38 ++++++++++++++++ config.go | 35 ++++++++------- oauth2.go | 34 +++++++------- oauth2_review_test.go | 95 +++++++++++++++++++++++++++++++++++++++- oauth2_test.go | 12 ++--- oauth_flow_grace_test.go | 4 +- profile_link_test.go | 52 ++++++++++++++++++++++ server.go | 34 ++++++++------ testwait_test.go | 2 +- wrapper.go | 2 +- 12 files changed, 278 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index f226a68..4d716ec 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Verified OIDC or OAuth2 profile authentication for [JaWS](https://github.com/lin - Both modes use authorization-code state and PKCE (S256). Pending flows expire after ten minutes; jawsauth extends only that JaWS session's grace while the user visits the provider. - For OIDC providers that require an offline-access request to issue refresh tokens, set `Server.Options = []oauth2.AuthCodeOption{oauth2.AccessTypeOffline}` before serving. This adds `access_type=offline` to the authorization redirect, not the token request. - Profile authentication expires after 24 hours or the provider token's earlier expiry; it does not attempt OIDC refresh. +- Profile mode does not store an OIDC token source in `Server.SessionTokenKey`. - Supports admin-only handlers: `Wrap`/`Handler` for any authenticated user, `WrapAdmin`/`HandlerAdmin` gated by `SetAdmins`. Protected routes create JaWS sessions for unauthenticated visitors. Set @@ -34,20 +35,25 @@ For multiple providers on one JaWS instance: - Give each `Config` a distinct `Namespace` and callback directory, such as `/oauth/github/callback` and `/oauth/gitlab/callback`. +- Add every provider's registered paths to the other servers' `HandledPaths` + before serving requests, so a return target cannot lead to another provider's + login, logout or callback endpoint. - `New` sets `Jaws.MakeAuth` for one provider. Install a composite `MakeAuth` after creating the servers if pages need both. Use `Server.CurrentClaims` to check each provider's live authentication. - `HandleLink` is an opt-in POST handler. Authorize the existing session before calling it. Set `Server.LinkEvent` to recheck that identity and accept the newly verified subject. A link keeps the original session; login rotates it. - A link callback requires the original live JaWS session and one-use flow state. + An ordinary login with another provider signs out the first. A link callback + requires the original live JaWS session and one-use flow state. - Give each provider's `SessionRotationLock` the same lock. jawsauth holds it across `LinkEvent`'s current-auth check and durable write, as well as login - rotation, timer and wrapper revocation, and built-in `HandleLogout`. Do not - acquire it again inside `LinkEvent`. Hold it around direct calls to - `Server.Logout` when linking writes are durable. Provider requests run - outside the lock. A callback canceled before the write is rejected; a - completed write succeeds even if logout follows it. + rotation, timer and wrapper revocation, `HandleLogout`, and `Server.Logout`. + Do not acquire it again or call `Server.Logout` inside `LinkEvent`, and do not + acquire it around `Server.Logout`. + This lock spans all sessions using it; keep `LinkEvent` brief. Provider + requests run outside the lock. A callback canceled before the write is + rejected; a completed write succeeds even if logout follows it. The provider visit extends the original session's idle grace. A successful link can retain that grace even with no connected pages until its deadline. diff --git a/authsession.go b/authsession.go index 2e6e25e..88db152 100644 --- a/authsession.go +++ b/authsession.go @@ -18,6 +18,7 @@ var errOIDCStaleIDToken = errors.New("oidc stale id_token") var errAuthTimerStale = errors.New("auth timer stale") var errOIDCInvalidExpiry = errors.New("oidc invalid exp") var errOIDCMissingSubject = errors.New("oidc missing sub") +var errOIDCSubjectMismatch = errors.New("oidc sub changed on refresh") type authTimer interface { Stop() bool @@ -74,13 +75,7 @@ func (srv *Server) oauth2Context(ctx context.Context) (authctx context.Context) if client == nil { client = srv.httpClient } - if client == nil { - client = http.DefaultClient - } - clientCopy := *client - if clientCopy.Timeout <= 0 || clientCopy.Timeout > oauth2ProviderTimeout { - clientCopy.Timeout = oauth2ProviderTimeout - } + clientCopy := boundedProviderClient(client) // Token POSTs carry secrets, and 307/308 redirects preserve their body. clientCopy.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } if logger := srv.debugLogger(); logger != nil { @@ -176,9 +171,18 @@ func (srv *Server) storeSessionAuthClaimsIfActive(sess *jaws.Session, claims map if entry == nil { return ErrOAuth2MissingSession } - srv.clearSessionAuth(sess, nil, entry, true, true) + srv.clearSessionAuth(sess, nil, entry, true) return errAuthTimerStale } + if entry != nil { + previous, _ := sess.Get(srv.SessionKey).(map[string]any) + oldSubject, _ := previous["sub"].(string) + newSubject, _ := claims["sub"].(string) + if oldSubject == "" || oldSubject != newSubject { + srv.mu.Unlock() + return errOIDC{kind: ErrOIDCInvalidIDToken, cause: errOIDCSubjectMismatch} + } + } sess.Set(srv.SessionKey, claims) sess.Set(srv.SessionTokenKey, tokenSource) sess.Set(srv.sessionKey(oauth2AuthExpiryKey), expiry) @@ -213,9 +217,6 @@ func (srv *Server) setSessionAuthFromToken(ctx context.Context, sess *jaws.Sessi if err != nil { return err } - if idToken.Expiry.IsZero() { - return errOIDC{kind: ErrOIDCInvalidIDToken, cause: errOIDCInvalidExpiry} - } if !minExpiry.IsZero() && !idToken.Expiry.After(minExpiry) { return errOIDC{kind: ErrOIDCInvalidIDToken, cause: errOIDCStaleIDToken} } @@ -331,7 +332,7 @@ func (srv *Server) handleSessionAuthTimer(sess *jaws.Session, entry *authTimerSt if srv.sessionAuthTimerCurrent(sess, entry) { remaining := time.Until(srv.authTimerDue(entry.expiry)) if !sessionAlive(sess) || (srv.profileSubject != nil && remaining <= 0) { - srv.clearSessionAuth(sess, nil, entry, true, true) + srv.clearSessionAuth(sess, nil, entry, true) return } if remaining > 0 { @@ -390,7 +391,7 @@ func (srv *Server) handleSessionAuthTimer(sess *jaws.Session, entry *authTimerSt "session_present", present, ) _ = srv.Jaws.Log(err) - srv.clearSessionAuth(sess, nil, entry, true, true) + srv.clearSessionAuth(sess, nil, entry, true) } else { srv.debugLog("jawsauth: auth refresh timer completed", "session_prefix", debugSessionPrefix(sess)) } @@ -412,7 +413,7 @@ func (srv *Server) authTimerDue(expiry time.Time) time.Time { // pendingOrdinaryLoginLocked requires srv.mu. func (srv *Server) pendingOrdinaryLoginLocked(sess *jaws.Session) bool { if flow := srv.oauthCallbacks[sess]; flow.canceled != nil { - return !flow.link && !flow.revoked() + return !flow.link } flow, _ := sess.Get(srv.sessionKey(oauth2FlowKey)).(pendingOAuthFlow) return flow.state != "" && !flow.link && time.Now().Before(flow.expiry) @@ -436,16 +437,16 @@ func (srv *Server) clearSessionOAuthFlow(sess *jaws.Session) { // It performs no HTTP redirect. An HTTP handler can build its own post-logout // response. A JaWS event handler cannot use [jaws.Request.Redirect] after Logout // queues Reload, which closes the connection; use an HTTP logout endpoint instead. +// It takes SessionRotationLock, if set, and releases it before LogoutEvent runs. func (srv *Server) Logout(sess *jaws.Session, hr *http.Request) (cleared bool) { - return srv.clearSessionAuth(sess, hr, nil, false, false) + return srv.clearSessionAuth(sess, hr, nil, false) } -// Public logout may already hold SessionRotationLock; internal revocation takes it here. -func (srv *Server) clearSessionAuth(sess *jaws.Session, hr *http.Request, entry *authTimerState, preservePendingLogin, lockRotation bool) (cleared bool) { +func (srv *Server) clearSessionAuth(sess *jaws.Session, hr *http.Request, entry *authTimerState, preservePendingLogin bool) (cleared bool) { if srv == nil || sess == nil { return } - if lockRotation && srv.SessionRotationLock != nil { + if srv.SessionRotationLock != nil { srv.SessionRotationLock.Lock() } srv.mu.Lock() @@ -465,7 +466,7 @@ func (srv *Server) clearSessionAuth(sess *jaws.Session, hr *http.Request, entry requests = sess.Requests() } srv.mu.Unlock() - if lockRotation && srv.SessionRotationLock != nil { + if srv.SessionRotationLock != nil { srv.SessionRotationLock.Unlock() } if cleared { diff --git a/authsession_test.go b/authsession_test.go index 9abfe2b..c2a91cf 100644 --- a/authsession_test.go +++ b/authsession_test.go @@ -385,6 +385,36 @@ func TestSetSessionAuthFromTokenErrors(t *testing.T) { } } +func TestOIDCRefreshRejectsChangedSubject(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + const issuer = "https://issuer.example" + factory := &testAuthTimerFactory{} + srv := newTimerTestServer(t, jw, issuer, factory) + req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) + sess := jw.NewSession(httptest.NewRecorder(), req) + oldExpiry := time.Now().Add(time.Minute).Truncate(time.Second) + if err := srv.storeSessionAuthClaims(sess, map[string]any{"sub": "person-a", "email": "a@example.com"}, nil, oldExpiry, nil); err != nil { + t.Fatal(err) + } + entry := srv.authTimers[sess.ID()] + newExpiry := time.Now().Add(time.Hour).Truncate(time.Second) + raw := makeIDToken(t, map[string]any{ + "iss": issuer, "aud": "client", "sub": "person-b", "exp": newExpiry.Unix(), + }) + err = srv.setSessionAuthFromToken(t.Context(), sess, nil, makeOAuth2Token("access", raw, ""), oldExpiry, entry) + if !errors.Is(err, ErrOIDCInvalidIDToken) || !errors.Is(err, errOIDCSubjectMismatch) { + t.Fatalf("changed subject: %v", err) + } + claims, _ := sess.Get(srv.SessionKey).(map[string]any) + if claims["sub"] != "person-a" || claims["email"] != "a@example.com" || srv.authTimers[sess.ID()] != entry { + t.Fatalf("refresh changed prior auth: claims=%v timer=%v", claims, srv.authTimers[sess.ID()]) + } +} + func TestAuthTimerRefreshesCachedTokenByForcingRefresh(t *testing.T) { jw, err := jaws.New() if err != nil { @@ -458,6 +488,7 @@ func TestAuthTimerRefreshesCachedTokenByForcingRefresh(t *testing.T) { tokenSource := oauth2.StaticTokenSource(makeOAuth2Token("cached-access", cachedIDToken, "refresh123")) err = srv.storeSessionAuthClaims(sess, map[string]any{ "exp": initialExpiry.Unix(), + "sub": "sub-123", "email": "cached@example.com", "email_verified": false, }, tokenSource, initialExpiry, nil) @@ -1082,9 +1113,16 @@ func TestServerLogout(t *testing.T) { factory := &testAuthTimerFactory{} srv := newTimerTestServer(t, jw, "https://issuer.example", factory) + lock := &observedRotationLock{second: make(chan struct{})} + srv.SessionRotationLock = lock var logoutCount int var logoutRequest *http.Request srv.LogoutEvent = func(_ *jaws.Session, hr *http.Request) { + if lock.attempts.Load() != 1 || !lock.TryLock() { + t.Error("LogoutEvent ran while the rotation lock was held or Logout skipped it") + return + } + lock.Unlock() logoutCount++ logoutRequest = hr } diff --git a/config.go b/config.go index 578f70a..e6195ea 100644 --- a/config.go +++ b/config.go @@ -30,10 +30,11 @@ type Config struct { UserInfoURL string // OIDC override; required in profile mode // AllowInsecureIssuer permits HTTP issuer and provider endpoints and is only for tests/dev. AllowInsecureIssuer bool - // HTTPClient is used for OIDC discovery and JWKS requests and, unless a per-request - // oauth2.HTTPClient is supplied via the request context, as the default client - // for token exchange/refresh and UserInfo requests. Those requests cap its - // timeout at 15 seconds and do not follow redirects. + // HTTPClient is used for OIDC discovery and JWKS, and by default for token + // exchange/refresh and UserInfo. All provider requests cap its timeout at 15 + // seconds. Discovery/JWKS follow only HTTPS redirects unless AllowInsecureIssuer + // is set; token and UserInfo requests follow none. A request context may supply + // a different oauth2.HTTPClient for token and UserInfo requests. HTTPClient *http.Client Scopes []string // optional; OIDC adds "openid" and "email", profile mode uses only these scopes ClientID string @@ -79,7 +80,7 @@ func validateUrl(k, value string, optional bool) (err error) { return } -func validateOIDCEndpoint(field, value string, optional, allowInsecure bool) error { +func validateEndpoint(field, value string, optional, allowInsecure bool) error { err := validateUrl(field, value, optional) if err != nil || value == "" { return err @@ -109,7 +110,7 @@ func (cfg *Config) Validate() (err error) { for _, field := range []struct{ name, value string }{ {"AuthURL", cfg.AuthURL}, {"TokenURL", cfg.TokenURL}, {"UserInfoURL", cfg.UserInfoURL}, } { - if err = validateOIDCEndpoint(field.name, field.value, cfg.ProfileSubject == nil, cfg.AllowInsecureIssuer); err != nil { + if err = validateEndpoint(field.name, field.value, cfg.ProfileSubject == nil, cfg.AllowInsecureIssuer); err != nil { return } } @@ -138,6 +139,17 @@ func ensureScopes(rawScopes []string, required ...string) (scopes []string) { return } +func boundedProviderClient(client *http.Client) (bounded http.Client) { + if client == nil { + client = http.DefaultClient + } + bounded = *client + if bounded.Timeout <= 0 || bounded.Timeout > oauth2ProviderTimeout { + bounded.Timeout = oauth2ProviderTimeout + } + return +} + func (cfg *Config) buildContext(ctx context.Context, overrideUrl string) (oauth2cfg *oauth2.Config, userInfoURL string, verifier *oidc.IDTokenVerifier, err error) { if err = cfg.Validate(); err == nil { redir, _ := url.Parse(cfg.RedirectURL) @@ -154,14 +166,7 @@ func (cfg *Config) buildContext(ctx context.Context, overrideUrl string) (oauth2 } return oauth2cfg, cfg.UserInfoURL, nil, nil } - client := cfg.HTTPClient - if client == nil { - client = http.DefaultClient - } - clientCopy := *client - if clientCopy.Timeout <= 0 || clientCopy.Timeout > oauth2ProviderTimeout { - clientCopy.Timeout = oauth2ProviderTimeout - } + clientCopy := boundedProviderClient(cfg.HTTPClient) if !cfg.AllowInsecureIssuer { checkRedirect := clientCopy.CheckRedirect clientCopy.CheckRedirect = func(req *http.Request, via []*http.Request) error { @@ -200,7 +205,7 @@ func (cfg *Config) buildContext(ctx context.Context, overrideUrl string) (oauth2 {"UserInfoURL", userInfoURL, true}, {"JWKSURI", metadata.JWKSURI, false}, } { - if err = validateOIDCEndpoint(field.name, field.value, field.optional, cfg.AllowInsecureIssuer); wrapOIDC(ErrOIDCProviderMetadata, &err) != nil { + if err = validateEndpoint(field.name, field.value, field.optional, cfg.AllowInsecureIssuer); wrapOIDC(ErrOIDCProviderMetadata, &err) != nil { return } } diff --git a/oauth2.go b/oauth2.go index a0e48ad..fcaf437 100644 --- a/oauth2.go +++ b/oauth2.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "crypto/rand" - "encoding/hex" "encoding/json" "errors" "fmt" @@ -153,13 +152,12 @@ func (srv *Server) HandleLogin(hw http.ResponseWriter, hr *http.Request) { func (srv *Server) HandleLink(hw http.ResponseWriter, hr *http.Request) { switch { case hr.Method != http.MethodPost: + hw.Header().Set("Allow", http.MethodPost) srv.writeResult(hw, http.StatusMethodNotAllowed, nil, nil) case linkOriginCheck.Check(hr) != nil: srv.writeResult(hw, http.StatusForbidden, ErrOAuth2InvalidOrigin, nil) case srv.oauth2cfg == nil || srv.LinkEvent == nil: srv.writeResult(hw, http.StatusInternalServerError, ErrOAuth2NotConfigured, nil) - case srv.Jaws.GetSession(hr) == nil: - srv.writeResult(hw, http.StatusForbidden, ErrOAuth2MissingSession, nil) default: srv.startOAuth(hw, hr, true, false) } @@ -177,6 +175,10 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, pr sess = srv.Jaws.NewSession(hw, hr) } if sess == nil { + if link { + srv.writeResult(hw, http.StatusForbidden, ErrOAuth2MissingSession, nil) + return + } hw.Header().Set("Retry-After", "60") srv.writeResult(hw, http.StatusServiceUnavailable, ErrOAuth2MissingSession, nil) return @@ -188,18 +190,18 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, pr srv.mu.Lock() if preservePendingLogin && srv.pendingOrdinaryLoginLocked(sess) { flow, _ = sess.Get(srv.sessionKey(oauth2FlowKey)).(pendingOAuthFlow) - if flow.state == "" || flow.verifier == "" { + if flow.state == "" { srv.mu.Unlock() srv.writeResult(hw, http.StatusConflict, ErrOAuth2LoginInProgress, nil) return } } else { flow = pendingOAuthFlow{ - state: randomHexString(), expiry: time.Now().Add(oauth2FlowLifetime), + state: rand.Text(), expiry: time.Now().Add(oauth2FlowLifetime), link: link, verifier: oauth2.GenerateVerifier(), referrer: location, } if srv.profileSubject == nil { - flow.nonce = randomHexString() + flow.nonce = rand.Text() } if !sess.ExtendGrace(oauth2FlowGrace) { srv.mu.Unlock() @@ -234,7 +236,7 @@ func (srv *Server) HandleLogout(hw http.ResponseWriter, hr *http.Request) { if hr.Method == http.MethodGet { _, location := srv.begin(hr) if sess := srv.Jaws.GetSession(hr); sess != nil { - srv.clearSessionAuth(sess, hr, nil, false, true) + srv.clearSessionAuth(sess, hr, nil, false) } hw.Header().Set("Location", location) statusCode = http.StatusFound @@ -274,7 +276,7 @@ func (srv *Server) writeResult(hw http.ResponseWriter, statusCode int, err error // ErrOAuth2NotConfigured means OAuth2/OIDC is not configured on the Server. var ErrOAuth2NotConfigured = errors.New("oauth2 not configured") -// ErrOAuth2MissingSession means no jaws session was available to carry the OAuth2 flow state. +// ErrOAuth2MissingSession means the session or its pending flow is no longer usable. var ErrOAuth2MissingSession = errors.New("oauth2 missing session") // ErrOAuth2MissingState means the callback session did not contain the expected state value. @@ -304,18 +306,12 @@ var ErrUserInfoTooLarge = errors.New("userinfo response too large") // ErrUserInfoInvalidResponse means the UserInfo response contained trailing JSON data. var ErrUserInfoInvalidResponse = errors.New("userinfo invalid response") -// ErrOAuth2InvalidProfile means an authenticated profile lacked a stable subject. +// ErrOAuth2InvalidProfile means the profile or access token could not prove a stable subject. var ErrOAuth2InvalidProfile = errors.New("oauth2 invalid profile") // ErrOAuth2LinkRejected means the application did not accept a proven link. var ErrOAuth2LinkRejected = errors.New("oauth2 link rejected") -func randomHexString() string { - b := [32]byte{} - _, _ = rand.Read(b[:]) // never returns an error, always fills all of b - return hex.EncodeToString(b[:]) -} - // mergeUserInfoClaims accepts fallback claims only for the verified subject. // Email verification stays paired with the email from the same response. func mergeUserInfoClaims(dst, src map[string]any) { @@ -579,7 +575,7 @@ func (srv *Server) completeLogin(hw http.ResponseWriter, hr *http.Request, old * // stored PKCE verifier, verifies the ID token or authenticated profile, and stores // claims in a new session. A link flow instead invokes [Server.LinkEvent] // and keeps the original session. It invokes [Server.LoginEvent] after a -// new sign-in or [Server.LoginFailed] on failure. Non-GET requests receive 405. +// new sign-in or [Server.LoginFailed] on login or link failure. Non-GET requests receive 405. // // Rotation discards pre-login application data and requires a free JaWS session slot. // If no slot is available, existing authentication is preserved and the default @@ -632,7 +628,7 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) } if err == nil { if flow.referrer != "" { - location = sanitizeRedirectTarget(hr.Host, flow.referrer) + location = flow.referrer } hw.Header().Set("Location", location) } @@ -647,13 +643,13 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) if err != nil { srv.Jaws.Dirty(sess) } else if !flow.link { - if srv.LoginEvent != nil && !flow.revoked() { + if srv.LoginEvent != nil { srv.LoginEvent(sess, hr) } current, present := srv.sessionAuthStatus(sess, time.Now) if flow.revoked() || !current || !sessionAlive(sess) { if present { - srv.clearSessionAuth(sess, hr, nil, false, true) + srv.clearSessionAuth(sess, hr, nil, false) } http.SetCookie(hw, sess.Close()) hw.Header().Del("Location") diff --git a/oauth2_review_test.go b/oauth2_review_test.go index 74bec5f..cf5e0f8 100644 --- a/oauth2_review_test.go +++ b/oauth2_review_test.go @@ -9,11 +9,102 @@ import ( "strings" "sync" "testing" + "testing/synctest" + "time" "github.com/linkdata/jaws" "golang.org/x/oauth2" ) +type infoHookLogger func(string) + +func (hook infoHookLogger) Info(msg string, _ ...any) { hook(msg) } +func (infoHookLogger) Warn(string, ...any) {} +func (infoHookLogger) Error(string, ...any) {} + +func TestCallbackRevokedAfterStoreBalancesEvents(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + r := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + r.RemoteAddr = "192.0.2.1:1234" + r.AddCookie(cookie) + old := jw.GetSession(r) + if old == nil { + t.Fatal("pending session missing") + } + var rotated *jaws.Session + logins, logouts := 0, 0 + srv.LoginEvent = func(sess *jaws.Session, _ *http.Request) { rotated = sess; logins++ } + srv.LogoutEvent = func(sess *jaws.Session, _ *http.Request) { + if sess != old { + logouts++ + } + } + revoked := false + jw.Debug = true + jw.Logger = infoHookLogger(func(msg string) { + if msg == "jawsauth: scheduled auth timer" { + revoked = srv.Logout(old, nil) + } + }) + w := profileCallback(srv, cookie, state, "proof") + if !revoked || w.Code != http.StatusForbidden || logins != 1 || logouts != 1 || rotated == nil || sessionAlive(rotated) || len(srv.authTimers) != 0 { + t.Fatalf("post-store revocation: revoked=%t status=%d logins=%d logouts=%d rotated=%v timers=%d", revoked, w.Code, logins, logouts, rotated, len(srv.authTimers)) + } +} + +func TestProfileTokenExpiresBetweenRotationAndStore(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + first := profileCallback(srv, cookie, state, "first") + if first.Code != http.StatusFound { + t.Fatalf("initial login: %d", first.Code) + } + cookie = first.Result().Cookies()[0] + r := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + r.RemoteAddr = "192.0.2.1:1234" + r.AddCookie(cookie) + old := jw.GetSession(r) + if old == nil { + t.Fatal("old session missing") + } + state = startProfileLoginWithCookie(t, srv, cookie) + srv.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Path == "/token" { + return profileTestResponse(`{"access_token":"short","token_type":"Bearer","expires_in":30}`), nil + } + return profileTestResponse(`{"id":42}`), nil + })} + logins := 0 + srv.LoginEvent = func(*jaws.Session, *http.Request) { logins++ } + srv.LogoutEvent = func(sess *jaws.Session, _ *http.Request) { + if sess == old { + time.Sleep(31 * time.Second) + } + } + w := profileCallback(srv, cookie, state, "second") + cookies := w.Result().Cookies() + if w.Code != http.StatusUnauthorized || w.Header().Get("Location") != "" || + len(cookies) != 2 || cookies[1].MaxAge >= 0 || cookies[1].Value != cookies[0].Value || + logins != 0 || len(srv.authTimers) != 0 { + t.Fatalf("expired before store: status=%d location=%q cookies=%v logins=%d timers=%d", w.Code, w.Header().Get("Location"), cookies, logins, len(srv.authTimers)) + } + }) +} + func TestLinkWithoutEventDoesNotStartFlow(t *testing.T) { jw, err := jaws.New() if err != nil { @@ -53,8 +144,8 @@ func TestLinkRejectsGETBeforeStartingFlow(t *testing.T) { } w := httptest.NewRecorder() srv.HandleLink(w, r) - if w.Code != http.StatusMethodNotAllowed || w.Header().Get("Location") != "" || sess.Get(srv.sessionKey(oauth2FlowKey)) != nil || calls != 0 { - t.Fatalf("GET link: status=%d location=%q flow=%v calls=%d", w.Code, w.Header().Get("Location"), sess.Get(srv.sessionKey(oauth2FlowKey)), calls) + if w.Code != http.StatusMethodNotAllowed || w.Header().Get("Allow") != http.MethodPost || w.Header().Get("Location") != "" || sess.Get(srv.sessionKey(oauth2FlowKey)) != nil || calls != 0 { + t.Fatalf("GET link: status=%d allow=%q location=%q flow=%v calls=%d", w.Code, w.Header().Get("Allow"), w.Header().Get("Location"), sess.Get(srv.sessionKey(oauth2FlowKey)), calls) } } diff --git a/oauth2_test.go b/oauth2_test.go index e11a612..83014c2 100644 --- a/oauth2_test.go +++ b/oauth2_test.go @@ -3,7 +3,6 @@ package jawsauth import ( "context" "encoding/base64" - "encoding/hex" "encoding/json" "errors" "fmt" @@ -211,11 +210,8 @@ func Test_handleLoginGeneratesOpaqueState(t *testing.T) { } flow := testOAuthFlow(sess, nil) state := flow.state - if len(state) != 64 { - t.Fatal(state) - } - if _, err = hex.DecodeString(state); err != nil { - t.Fatal(err) + if state == "" { + t.Fatal("missing state") } verifier := flow.verifier if verifier == "" { @@ -233,8 +229,8 @@ func Test_handleLoginGeneratesOpaqueState(t *testing.T) { t.Fatal(gotMethod) } nonce := flow.nonce - if len(nonce) != 64 { - t.Fatal(nonce) + if nonce == "" || nonce == state { + t.Fatalf("nonce %q is missing or matches state", nonce) } if gotNonce := values.Get("nonce"); gotNonce != nonce { t.Fatal(gotNonce) diff --git a/oauth_flow_grace_test.go b/oauth_flow_grace_test.go index 575a3fd..9174353 100644 --- a/oauth_flow_grace_test.go +++ b/oauth_flow_grace_test.go @@ -38,7 +38,7 @@ func TestOAuthProviderVisitKeepsOnlyPendingSessionAlive(t *testing.T) { if len(cookies) != 1 { t.Fatalf("cookies: %d", len(cookies)) } - callback := httptest.NewRequest(http.MethodGet, "https://app.example/auth/callback?state=wrong&code=unused", nil) + callback := httptest.NewRequest(http.MethodGet, "https://app.example/auth/callback", nil) callback.RemoteAddr = start.RemoteAddr callback.AddCookie(cookies[0]) unrelated := httptest.NewRequest(http.MethodGet, "https://app.example/other", nil) @@ -118,7 +118,7 @@ func TestOAuthFlowExpiresEvenWithLiveSession(t *testing.T) { t.Fatalf("start: %d", response.Code) } cookies := response.Result().Cookies() - callback := httptest.NewRequest(http.MethodGet, "https://app.example/auth/callback?state=wrong&code=unused", nil) + callback := httptest.NewRequest(http.MethodGet, "https://app.example/auth/callback", nil) callback.RemoteAddr = start.RemoteAddr callback.AddCookie(cookies[0]) sess := jw.GetSession(callback) diff --git a/profile_link_test.go b/profile_link_test.go index 3f97afe..139fb96 100644 --- a/profile_link_test.go +++ b/profile_link_test.go @@ -117,14 +117,21 @@ func TestOAuthProfileCallbackStateIsSingleUseConcurrently(t *testing.T) { } cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) responses := make(chan int, 2) + ready := make(chan struct{}, 2) + start := make(chan struct{}) var workers sync.WaitGroup for range 2 { workers.Add(1) go func() { defer workers.Done() + ready <- struct{}{} + <-start responses <- profileCallback(srv, cookie, state, "first").Code }() } + <-ready + <-ready + close(start) workers.Wait() close(responses) seen := map[int]int{} @@ -328,6 +335,51 @@ func TestOAuthProfileCallbackCanceledBeforeRotation(t *testing.T) { } } +func TestOAuthProfileCallbackCanceledBeforeFinalStore(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + factory := &testAuthTimerFactory{} + srv.authTimerAfterFunc = factory.after + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + first := profileCallback(srv, cookie, state, "first") + if first.Code != http.StatusFound { + t.Fatalf("first login: %d", first.Code) + } + cookie = first.Result().Cookies()[0] + request := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + request.RemoteAddr = "192.0.2.1:1234" + request.AddCookie(cookie) + old := jw.GetSession(request) + if old == nil { + t.Fatal("missing original session") + } + state = startProfileLoginWithCookie(t, srv, cookie) + canceled := false + srv.LogoutEvent = func(sess *jaws.Session, _ *http.Request) { + if sess == old && !canceled { + canceled = true + srv.Logout(old, nil) + } + } + w := profileCallback(srv, cookie, state, "second") + cookies := w.Result().Cookies() + if !canceled || w.Code != http.StatusForbidden || w.Header().Get("Location") != "" || + len(cookies) != 2 || cookies[1].MaxAge >= 0 || cookies[1].Value != cookies[0].Value { + t.Fatalf("canceled before store: canceled=%t status=%d location=%q cookies=%v", canceled, w.Code, w.Header().Get("Location"), cookies) + } + request = httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + request.RemoteAddr = "192.0.2.1:1234" + request.AddCookie(cookies[0]) + if jw.GetSession(request) != nil || old.Get(srv.SessionKey) != nil || len(srv.authTimers) != 0 { + t.Fatalf("canceled login retained auth or timer: old=%v timers=%d", old.Get(srv.SessionKey), len(srv.authTimers)) + } +} + func TestOAuthProfileLinkCanceledByLogout(t *testing.T) { jw, err := jaws.New() if err != nil { diff --git a/server.go b/server.go index 4a32aa4..3896619 100644 --- a/server.go +++ b/server.go @@ -61,10 +61,11 @@ type HandleFunc func(uri string, handler http.Handler) // EventFunc is called for login and logout lifecycle events. // -// For [Server.LogoutEvent], hr may be nil. +// For [Server.LogoutEvent], hr may be nil. jawsauth calls it after releasing +// SessionRotationLock. type EventFunc func(sess *jaws.Session, hr *http.Request) -// FailedFunc is called when a login attempt fails. +// FailedFunc is called when a login or link attempt fails. // // It returns true if it wrote the HTTP response itself, in which case jawsauth writes // nothing further. Errors can contain untrusted browser-supplied text. Quote errors @@ -74,7 +75,9 @@ type FailedFunc func(hw http.ResponseWriter, hr *http.Request, httpCode int, err // LinkFunc receives the current session and a newly verified identity. Recheck // the current authentication before a durable link write. When configured, // jawsauth holds SessionRotationLock while calling LinkFunc; do not acquire it -// again in the callback. Return zero and nil on success, or an HTTP error status. +// again or call Server.Logout in the callback. Claims contain raw provider fields, so their types +// (including email_verified) may differ from stored session values. Return zero +// and nil on success, or an HTTP error status. type LinkFunc func(sess *jaws.Session, hr *http.Request, claims map[string]any) (statusCode int, err error) // Server provides OIDC or verified OAuth2 profile authentication for JaWS sessions. @@ -83,17 +86,20 @@ type LinkFunc func(sess *jaws.Session, hr *http.Request, claims map[string]any) // exported configuration fields and callbacks should be set before serving requests. type Server struct { Jaws *jaws.Jaws - SessionKey string // default is "oidc_claims", value will be of type map[string]any - SessionTokenKey string // default is "oauth2_tokensource", value will be of type oauth2.TokenSource - SessionEmailKey string // default is "email", value is a parsed string address with ASCII letters lowercased - SessionEmailVerifiedKey string // default is "email_verified", value will be of type bool - HandledPaths map[string]struct{} // URI paths we have registered handlers for - LoginEvent EventFunc // if not nil, called after a successful login - LogoutEvent EventFunc // if not nil, called after auth is cleared; hr may be nil - LoginFailed FailedFunc // if not nil, called on failed login - LinkEvent LinkFunc // if not nil, enables HandleLink - SessionRotationLock sync.Locker // optional; held during rotation, internal revocation, and LinkEvent - Options []oauth2.AuthCodeOption // authorization URL options, e.g. oauth2.AccessTypeOffline; never sent to token exchange + SessionKey string // default is "oidc_claims", value will be of type map[string]any + SessionTokenKey string // default is "oauth2_tokensource"; an oauth2.TokenSource in OIDC mode, empty in profile mode + SessionEmailKey string // default is "email", value is a parsed string address with ASCII letters lowercased + SessionEmailVerifiedKey string // default is "email_verified", value will be of type bool + HandledPaths map[string]struct{} // URI paths we have registered handlers for + LoginEvent EventFunc // if not nil, called after claims are stored; revocation may follow + LogoutEvent EventFunc // if not nil, called after auth is cleared; hr may be nil + LoginFailed FailedFunc // if not nil, called on failed login or link + LinkEvent LinkFunc // if not nil, enables HandleLink + // SessionRotationLock is held during rotation, revocation, HandleLogout, + // Logout, and LinkEvent. + // shortcut: One lock serializes all sessions; shard it if link writes delay auth traffic. + SessionRotationLock sync.Locker + Options []oauth2.AuthCodeOption // authorization URL options, e.g. oauth2.AccessTypeOffline; never sent to token exchange // RequireVerifiedAdminEmail requires email_verified for a non-empty admin list. // diff --git a/testwait_test.go b/testwait_test.go index a10145e..8e597d3 100644 --- a/testwait_test.go +++ b/testwait_test.go @@ -10,7 +10,7 @@ func waitFor[T any](t *testing.T, ch <-chan T, msg string) T { select { case value := <-ch: return value - case <-time.After(5 * time.Second): + case <-time.After(30 * time.Second): t.Fatal(msg) } var zero T diff --git a/wrapper.go b/wrapper.go index ee7d427..c84a2db 100644 --- a/wrapper.go +++ b/wrapper.go @@ -15,7 +15,7 @@ func (w wrapper) ServeHTTP(hw http.ResponseWriter, hr *http.Request) { sess := w.server.Jaws.GetSession(hr) if current, present := w.server.sessionAuthStatus(sess, time.Now); !current { if present { - w.server.clearSessionAuth(sess, hr, nil, true, true) + w.server.clearSessionAuth(sess, hr, nil, true) } w.server.startOAuth(hw, hr, false, true) return From 78858410e955e4e2b3d461f115f8a2bd6ef75eaa Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Wed, 4 Nov 2026 23:57:34 +0100 Subject: [PATCH 27/43] fix: simplify OAuth callbacks and clarify provider docs --- README.md | 160 +++++++++++++++++------------------------- authsession.go | 4 +- config.go | 6 +- debug.go | 1 + doc.go | 2 +- oauth2.go | 28 ++++---- oauth2_review_test.go | 8 ++- server.go | 30 ++++---- 8 files changed, 110 insertions(+), 129 deletions(-) diff --git a/README.md b/README.md index 4d716ec..c968875 100644 --- a/README.md +++ b/README.md @@ -4,106 +4,78 @@ # jawsauth -Verified OIDC or OAuth2 profile authentication for [JaWS](https://github.com/linkdata/jaws) sessions. +OIDC and OAuth2 profile authentication for [JaWS](https://github.com/linkdata/jaws) +sessions. Both modes use authorization-code state and PKCE (S256). `Wrap` and +`Handler` require a login; `WrapAdmin` and `HandlerAdmin` also apply the +`SetAdmins` allowlist. -- OIDC mode discovers the issuer, verifies `id_token` and nonce, and refreshes before expiry. -- Profile mode accepts explicit HTTPS endpoints and a `Config.ProfileSubject` - verifier for providers without ID tokens. Its authenticated profile response - must contain a stable subject; email and names are not identity proof. JSON - numbers arrive as `json.Number` so large numeric IDs remain exact. Profile - email feeds `SetAdmins`; if the provider allows unverified email, set - `RequireVerifiedAdminEmail = true` and map its verification signal to - `email_verified` in `ProfileSubject`. -- Both modes use authorization-code state and PKCE (S256). Pending flows expire after ten minutes; jawsauth extends only that JaWS session's grace while the user visits the provider. -- For OIDC providers that require an offline-access request to issue refresh tokens, set `Server.Options = []oauth2.AuthCodeOption{oauth2.AccessTypeOffline}` before serving. This adds `access_type=offline` to the authorization redirect, not the token request. -- Profile authentication expires after 24 hours or the provider token's earlier expiry; it does not attempt OIDC refresh. -- Profile mode does not store an OIDC token source in `Server.SessionTokenKey`. -- Supports admin-only handlers: `Wrap`/`Handler` for any authenticated user, `WrapAdmin`/`HandlerAdmin` gated by `SetAdmins`. +## Setup -Protected routes create JaWS sessions for unauthenticated visitors. Set -`Jaws.MaxSessions` and `Jaws.MaxSessionsPerIP` to bound session counts. When a -session cannot be created, login returns 503 with `Retry-After: 60`. -Each login attempt can retain an anonymous session for about ten minutes while -the user visits the provider. Limit requests to login and protected routes when -serving untrusted traffic. -Post-login return targets longer than 8192 bytes fall back to `/`. -Login callback token/profile calls and startup discovery have a 15-second deadline; -OIDC background refresh requests have the same maximum timeout. -Profile responses are capped at 32 KiB. +`Config` requires `RedirectURL`, `Issuer` and `ClientID`. OIDC mode discovers +provider endpoints, verifies the ID token and nonce, and attempts refresh before +expiry when the provider issues a refresh token. Providers that issue refresh +tokens only with offline access can use +`Server.Options = []oauth2.AuthCodeOption{oauth2.AccessTypeOffline}`. -For multiple providers on one JaWS instance: +Profile mode requires `AuthURL`, `TokenURL` and `UserInfoURL` over HTTPS, plus a +`ProfileSubject` function that extracts a stable subject from the authenticated +profile. HTTP endpoints require `AllowInsecureIssuer` for development. Email +and names are not identity proof. Profile JSON numbers arrive as `json.Number` +to preserve large IDs. Profile authentication expires after 24 hours or the +access token's earlier expiry. -- Give each `Config` a distinct `Namespace` and callback directory, such as - `/oauth/github/callback` and `/oauth/gitlab/callback`. -- Add every provider's registered paths to the other servers' `HandledPaths` - before serving requests, so a return target cannot lead to another provider's - login, logout or callback endpoint. -- `New` sets `Jaws.MakeAuth` for one provider. Install a composite `MakeAuth` - after creating the servers if pages need both. Use `Server.CurrentClaims` - to check each provider's live authentication. +Set `Jaws.MaxSessions` and `Jaws.MaxSessionsPerIP` to bound sessions created by +unauthenticated requests. A login attempt can retain an anonymous session for +ten minutes while the user visits the provider. Login rotates the session and +discards its application data; rotation needs a free slot under both limits. +In particular, `MaxSessionsPerIP = 1` prevents login. If a slot is unavailable, +login returns 503 with `Retry-After: 60` and preserves the existing session. + +## Multiple providers + +- Give each `Config` a distinct `Namespace` and callback directory. Add each + provider's registered paths to the other servers' `HandledPaths` so return + targets cannot enter another provider's auth endpoints. +- `New` installs that provider's `Jaws.MakeAuth`. Install a composite `MakeAuth` + if JaWS pages need data from several providers. Each server's `Wrap` and + `Handler` require its own authentication; `CurrentClaims` checks that + provider's live claims. - `HandleLink` is an opt-in POST handler. Authorize the existing session before - calling it. Set `Server.LinkEvent` to recheck that identity and accept the - newly verified subject. A link keeps the original session; login rotates it. - An ordinary login with another provider signs out the first. A link callback - requires the original live JaWS session and one-use flow state. -- Give each provider's `SessionRotationLock` the same lock. jawsauth holds it - across `LinkEvent`'s current-auth check and durable write, as well as login - rotation, timer and wrapper revocation, `HandleLogout`, and `Server.Logout`. - Do not acquire it again or call `Server.Logout` inside `LinkEvent`, and do not - acquire it around `Server.Logout`. - This lock spans all sessions using it; keep `LinkEvent` brief. Provider - requests run outside the lock. A callback canceled before the write is - rejected; a completed write succeeds even if logout follows it. + calling it, then recheck the original provider's `CurrentClaims` in + `LinkEvent` before writing the link. A link keeps the session; an ordinary + login rotates it and signs out the other providers. +- `New` gives each server a private `SessionRotationLock`. For cross-provider + linking, give the involved servers one shared lock before serving. jawsauth + holds it during logout, expiry and `LinkEvent`, so do not acquire it around + `Server.Logout` or inside `LinkEvent`; keep the callback brief. Logout of one + provider does not cancel another provider's pending link, which must recheck + the original authentication. + +## Admin access + +Email verification is optional by default. With a non-empty admin list, set +`Server.RequireVerifiedAdminEmail = true` unless the provider guarantees +verified addresses. Profile providers can map their verification signal to +`email_verified` in `ProfileSubject`; providers without such a signal cannot +grant admin access in strict mode. Some providers omit `email_verified`. +[Microsoft warns against using its email claim for authorization](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference); +use application policy based on a stable identifier for Entra ID. -The provider visit extends the original session's idle grace. A successful -link can retain that grace even with no connected pages until its deadline. +`SetAdmins` compares parsed email addresses with ASCII case ignored. An empty +list allows every authenticated user through the admin handlers. +`Server.IsAdmin(email)` checks only list membership; `JawsAuth.IsAdmin` and the +admin handlers enforce email verification when required. -Admin allowlists match parsed addresses with ASCII case ignored; non-ASCII -characters are matched exactly. Session email values use the same case rules. -Email verification is not required by default. This supports providers such as -Microsoft Entra ID that omit `email_verified`, but relies on the provider's policy -for who may claim an address. Microsoft [advises against using its email claim for -authorization](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference). -To require verified email for a non-empty admin allowlist, set -`Server.RequireVerifiedAdminEmail = true` before serving requests. This affects -`WrapAdmin`, `HandlerAdmin`, and `JawsAuth.IsAdmin`; ordinary login stays available. -Providers that omit the verification claim cannot grant admin access in this mode. -`Server.IsAdmin(email)` only checks address membership. `mail` and `public_email` -are always considered unverified; they can match the allowlist only when strict -mode is disabled. -An empty admin list allows every authenticated user through the HTTP admin gates. -UserInfo fallback requires a matching non-empty `sub`; its verification flag is -used only when that response supplies the email or confirms the exact same email -claim, including case and whitespace. +## Session lifetime -Successful login rotates the JaWS session and discards pre-login application data. -UserInfo is fetched before rotation so its latency does not consume the new -session's idle lifetime. -Rotation requires a free slot under both session limits until the new cookie is -published. In particular, `MaxSessionsPerIP = 1` prevents login; allow room for -rotation when setting limits. If no slot is available, the default response is 503 -and the existing session's authentication and refresh timer are preserved. -Restart login after capacity becomes available. +Pending OAuth flows expire after ten minutes. jawsauth extends the original +JaWS session's grace while the user visits the provider. Logout and +authentication expiry clear that server's credentials and reload the session's +JaWS pages. Auth expiry preserves a pending login but cancels a pending link; +explicit logout cancels either flow. Protected operations should check +`CurrentClaims`, plus `JawsAuth.IsAdmin` for administrator actions. Protected +requests during a callback may return 409. -Logout and auth expiry clear authentication and reload the session's JaWS pages. -Auth expiry preserves an ordinary login in progress but cancels a pending link; -explicit logout cancels either flow. -For a successful ordinary callback, this cancellation applies until its final -revocation check. Logout of the old session after that check cannot sign out -the rotated successor session. -Protected requests during a pending login return to that provider flow without -replacing its state or renewing its session grace. A request during the callback -returns 409 until it finishes. -Connected pages receive the reload command before their sockets close; pending -pages reload when they connect. Protected renders also recheck authorization when -the HTTP handler returns, retiring requests attached during a concurrent revocation. -Events and callbacks run normally until disconnection. Protected operations must -check current claims and expiry, plus `JawsAuth.IsAdmin` for administrator actions. -Changing `SetAdmins` also reloads every page of sessions that lose admin access; -their ordinary login remains valid. -Use an HTTP endpoint for logout redirects: calling `Request.Redirect` after -`Server.Logout` in a JaWS event cannot send the redirect because Reload closes the -connection and discards later messages. Closed or expired JaWS sessions have their -tokens and timers discarded at the next auth timer check (normally within one -minute), which calls -`LogoutEvent` with a nil HTTP request. +Use an HTTP endpoint for a post-logout redirect. `Server.Logout` reloads pages +and closes their sockets, so a later JaWS `Request.Redirect` cannot deliver the +redirect. diff --git a/authsession.go b/authsession.go index 88db152..3333f42 100644 --- a/authsession.go +++ b/authsession.go @@ -427,7 +427,7 @@ func (srv *Server) clearSessionOAuthFlow(sess *jaws.Session) { sess.Set(srv.sessionKey(oauth2FlowKey), nil) } -// Logout clears all authentication state for sess. +// Logout clears this server's authentication state for sess. // // It stops the auth timer, clears verified claims, token source, email, expiry // and any in-flight OAuth flow keys, then reloads its pages with [jaws.Request.Reload], @@ -486,8 +486,6 @@ func (srv *Server) clearSessionAuth(sess *jaws.Session, hr *http.Request, entry } func reloadAuthRequests(requests []*jaws.Request) { - // ponytail: JaWS never reuses Request identities; replace this snapshot - // reload with a Session-level API when JaWS provides one. for _, rq := range requests { rq.Reload() } diff --git a/config.go b/config.go index e6195ea..6ae2cf7 100644 --- a/config.go +++ b/config.go @@ -23,7 +23,8 @@ import ( type Config struct { RedirectURL string // required. e.g. "https://application.example.com/oauth2/callback" Issuer string // required. e.g. "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0" - // Namespace isolates this provider's session values when multiple Servers share one JaWS instance. + // Namespace isolates providers sharing one JaWS instance. New appends it to + // the four exported Server session-key fields; use a distinct value per provider. Namespace string AuthURL string // OIDC override; required in profile mode TokenURL string // OIDC override; required in profile mode @@ -42,6 +43,9 @@ type Config struct { // ProfileSubject enables OAuth2 profile authentication without an OIDC ID token. // AuthURL, TokenURL and UserInfoURL are required in this mode. It must return // a stable, non-empty provider subject from the authenticated profile response. + // Profile emails can be unverified. For a non-empty admin list, set + // Server.RequireVerifiedAdminEmail unless the provider guarantees verified + // addresses; ProfileSubject may map a provider signal to claims["email_verified"]. ProfileSubject func(map[string]any) (string, error) } diff --git a/debug.go b/debug.go index 07e685b..0046859 100644 --- a/debug.go +++ b/debug.go @@ -287,6 +287,7 @@ func errorDebugClasses(err error) (classes []string) { classes = appendErrorDebugClass(classes, err, errOIDCStaleIDToken, "oidc_stale_id_token") classes = appendErrorDebugClass(classes, err, errOIDCInvalidExpiry, "oidc_invalid_expiry") classes = appendErrorDebugClass(classes, err, errOIDCMissingSubject, "oidc_missing_subject") + classes = appendErrorDebugClass(classes, err, errOIDCSubjectMismatch, "oidc_subject_mismatch") classes = appendErrorDebugClass(classes, err, errAuthTimerStale, "auth_timer_stale") return } diff --git a/doc.go b/doc.go index d4df707..2dea1cb 100644 --- a/doc.go +++ b/doc.go @@ -3,7 +3,7 @@ // OIDC mode uses discovery, PKCE, nonce and ID token verification. Profile mode // uses explicit provider endpoints, PKCE and an authenticated profile response // with an application-supplied stable-subject verifier. The verified claims are -// stored in a JaWS session. OIDC ID tokens refresh before expiry; profile auth +// stored in a JaWS session. OIDC attempts refresh before expiry; profile auth // expires after a bounded lifetime. Both modes support admin email gates. // // Create a [Server] with [New] (or [NewDebug]) and protect handlers with diff --git a/oauth2.go b/oauth2.go index fcaf437..426aa7d 100644 --- a/oauth2.go +++ b/oauth2.go @@ -24,7 +24,7 @@ import ( ) const oauth2FlowKey = "oauth2flow" -const oauth2AuthExpiryKey = "oauth2idtokenexpiry" // #nosec G101 +const oauth2AuthExpiryKey = "oauth2authexpiry" const oauth2FlowLifetime = 10 * time.Minute const oauth2ProviderTimeout = 15 * time.Second @@ -205,6 +205,10 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, pr } if !sess.ExtendGrace(oauth2FlowGrace) { srv.mu.Unlock() + if link { + srv.writeResult(hw, http.StatusForbidden, ErrOAuth2MissingSession, nil) + return + } hw.Header().Set("Retry-After", "60") srv.writeResult(hw, http.StatusServiceUnavailable, ErrOAuth2MissingSession, nil) return @@ -227,7 +231,7 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, pr hw.WriteHeader(statusCode) } -// HandleLogout clears the session's stored authentication and redirects. +// HandleLogout clears this server's authentication in the session and redirects. // // For GET requests it clears the auth (firing LogoutEvent if set) and responds with a // 302 redirect back to the sanitized referrer (or "/"). Non-GET requests receive 405. @@ -303,7 +307,7 @@ var ErrUserInfoStatus = errors.New("userinfo status") // ErrUserInfoTooLarge means the authenticated profile exceeded the response limit. var ErrUserInfoTooLarge = errors.New("userinfo response too large") -// ErrUserInfoInvalidResponse means the UserInfo response contained trailing JSON data. +// ErrUserInfoInvalidResponse means the UserInfo response was not one valid JSON value. var ErrUserInfoInvalidResponse = errors.New("userinfo invalid response") // ErrOAuth2InvalidProfile means the profile or access token could not prove a stable subject. @@ -397,19 +401,14 @@ func (srv *Server) fetchUserInfo(ctx context.Context, userinfoURL string, tokenS if body, err = io.ReadAll(io.LimitReader(resp.Body, maxUserInfoBytes+1)); err == nil { if len(body) > maxUserInfoBytes { err = ErrUserInfoTooLarge + } else if !json.Valid(body) { + err = ErrUserInfoInvalidResponse } else { decoder := json.NewDecoder(bytes.NewReader(body)) if srv.profileSubject != nil { decoder.UseNumber() } err = decoder.Decode(&userinfo) - if err == nil { - var extra any - if trailingErr := decoder.Decode(&extra); trailingErr != io.EOF { - err = ErrUserInfoInvalidResponse - userinfo = nil - } - } } } } @@ -458,6 +457,7 @@ func (srv *Server) verifyOAuthIdentity(ctx context.Context, token *oauth2.Token, if err != nil { return } + // UserInfo can outlive the token; reject it before calling application code. if !profileTokenUnexpired(token) { return nil, nil, time.Time{}, http.StatusUnauthorized, ErrOAuth2InvalidProfile } @@ -585,7 +585,7 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) err := ErrOAuth2Callback if hr.Method == http.MethodGet { - oauth2Config, location := srv.begin(hr) + oauth2Config := srv.oauth2cfg var sessEmail string var flow pendingOAuthFlow providerCtx, cancel := context.WithTimeout(hr.Context(), oauth2ProviderTimeout) @@ -616,6 +616,7 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) var expiry time.Time claims, tokenSource, expiry, statusCode, err = srv.verifyOAuthIdentity(authctx, token, flow.nonce) if err == nil { + // A link has no later store check if identity expires here. if err = srv.identityExpiryError(expiry); err != nil { statusCode = http.StatusUnauthorized } else if flow.link { @@ -627,10 +628,7 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) } } if err == nil { - if flow.referrer != "" { - location = flow.referrer - } - hw.Header().Set("Location", location) + hw.Header().Set("Location", flow.referrer) } } } diff --git a/oauth2_review_test.go b/oauth2_review_test.go index cf5e0f8..e0a2acd 100644 --- a/oauth2_review_test.go +++ b/oauth2_review_test.go @@ -154,9 +154,11 @@ func TestLinkRejectsInvalidEventResults(t *testing.T) { name string status int err error + want int }{ - {"error without status", 0, errors.New("secret persistence detail")}, - {"success status without acceptance", http.StatusOK, nil}, + {"error without status", 0, errors.New("secret persistence detail"), http.StatusForbidden}, + {"success status without acceptance", http.StatusOK, nil, http.StatusForbidden}, + {"conflict status", http.StatusConflict, errors.New("secret persistence detail"), http.StatusConflict}, } { t.Run(tc.name, func(t *testing.T) { jw, err := jaws.New() @@ -177,7 +179,7 @@ func TestLinkRejectsInvalidEventResults(t *testing.T) { } _, state = profileStart(t, srv, http.MethodPost, "/auth/link/github", cookie) w := profileCallback(srv, cookie, state, "second") - if w.Code != http.StatusForbidden || w.Header().Get("Location") != "" || + if w.Code != tc.want || w.Header().Get("Location") != "" || len(w.Result().Cookies()) != 0 || !strings.Contains(w.Body.String(), ErrOAuth2LinkRejected.Error()) || strings.Contains(w.Body.String(), "secret persistence detail") { t.Fatalf("invalid LinkEvent result: status=%d location=%q body=%q cookies=%v", w.Code, w.Header().Get("Location"), w.Body.String(), w.Result().Cookies()) diff --git a/server.go b/server.go index 3896619..744a054 100644 --- a/server.go +++ b/server.go @@ -73,11 +73,13 @@ type EventFunc func(sess *jaws.Session, hr *http.Request) type FailedFunc func(hw http.ResponseWriter, hr *http.Request, httpCode int, err error, email string) (wroteresponse bool) // LinkFunc receives the current session and a newly verified identity. Recheck -// the current authentication before a durable link write. When configured, -// jawsauth holds SessionRotationLock while calling LinkFunc; do not acquire it -// again or call Server.Logout in the callback. Claims contain raw provider fields, so their types -// (including email_verified) may differ from stored session values. Return zero -// and nil on success, or an HTTP error status. +// the existing authentication before a durable link write; with multiple +// providers, use the original provider's CurrentClaims. Logout and flow +// cancellation affect only their own Server. jawsauth holds +// SessionRotationLock while calling LinkFunc; do not acquire it again or call +// Server.Logout in the callback. Claims contain raw provider fields, so their +// types (including email_verified) may differ from stored session values. +// Return zero and nil on success, or an HTTP error status. type LinkFunc func(sess *jaws.Session, hr *http.Request, claims map[string]any) (statusCode int, err error) // Server provides OIDC or verified OAuth2 profile authentication for JaWS sessions. @@ -85,7 +87,8 @@ type LinkFunc func(sess *jaws.Session, hr *http.Request, claims map[string]any) // Create one with New or NewDebug. Its methods are safe for concurrent use; the // exported configuration fields and callbacks should be set before serving requests. type Server struct { - Jaws *jaws.Jaws + Jaws *jaws.Jaws + // New appends Config.Namespace to these four session keys when configured. SessionKey string // default is "oidc_claims", value will be of type map[string]any SessionTokenKey string // default is "oauth2_tokensource"; an oauth2.TokenSource in OIDC mode, empty in profile mode SessionEmailKey string // default is "email", value is a parsed string address with ASCII letters lowercased @@ -96,8 +99,7 @@ type Server struct { LoginFailed FailedFunc // if not nil, called on failed login or link LinkEvent LinkFunc // if not nil, enables HandleLink // SessionRotationLock is held during rotation, revocation, HandleLogout, - // Logout, and LinkEvent. - // shortcut: One lock serializes all sessions; shard it if link writes delay auth traffic. + // Logout, and LinkEvent. New supplies a private lock; share one for cross-provider links. SessionRotationLock sync.Locker Options []oauth2.AuthCodeOption // authorization URL options, e.g. oauth2.AccessTypeOffline; never sent to token exchange @@ -146,6 +148,7 @@ func NewDebug(jw *jaws.Jaws, cfg *Config, handleFn HandleFunc, overrideUrl strin SessionEmailKey: "email", SessionEmailVerifiedKey: "email_verified", HandledPaths: make(map[string]struct{}), + SessionRotationLock: &sync.Mutex{}, admins: make(map[string]struct{}), handle403: default403handler{}, authTimers: make(map[uint64]*authTimerState), @@ -346,7 +349,8 @@ func (srv *Server) wrap(h http.Handler, admin bool) (rh http.Handler) { // Unauthenticated requests are redirected into the configured login flow (HandleLogin); // authenticated users whose email is not an admin (see SetAdmins and IsAdmin) are // served the 403 handler instead of h. [Server.RequireVerifiedAdminEmail] also -// applies. If the Server is not Valid, returns h. +// applies. A protected request during a callback may return 409. +// If the Server is not Valid, returns h. func (srv *Server) WrapAdmin(h http.Handler) (rh http.Handler) { return srv.wrap(h, true) } @@ -355,7 +359,8 @@ func (srv *Server) WrapAdmin(h http.Handler) (rh http.Handler) { // // Unauthenticated requests are redirected into the configured login flow // (HandleLogin), which stores verified claims in srv.SessionKey before the user -// returns. If the Server is not Valid, returns h. +// returns. A protected request during a callback may return 409. +// If the Server is not Valid, returns h. func (srv *Server) Wrap(h http.Handler) (rh http.Handler) { return srv.wrap(h, false) } @@ -365,7 +370,7 @@ func (srv *Server) Wrap(h http.Handler) (rh http.Handler) { // // Unauthenticated requests are redirected into the configured login flow (HandleLogin); // authenticated non-admins (see SetAdmins and IsAdmin) are served the 403 handler. -// [Server.RequireVerifiedAdminEmail] also applies. +// [Server.RequireVerifiedAdminEmail] also applies. A protected request during a callback may return 409. // If the Server is not Valid, the template handler is returned without the // authentication requirement. func (srv *Server) HandlerAdmin(name string, dot any) http.Handler { @@ -377,7 +382,8 @@ func (srv *Server) HandlerAdmin(name string, dot any) http.Handler { // // Unauthenticated requests are redirected into the configured login flow // (HandleLogin), which stores verified claims in srv.SessionKey before the user -// returns. If the Server is not Valid, the template +// returns. A protected request during a callback may return 409. +// If the Server is not Valid, the template // handler is returned without the authentication requirement. func (srv *Server) Handler(name string, dot any) http.Handler { return srv.wrap(ui.Handler(srv.Jaws, name, dot), false) From 6eb62b4bad9320b9de4babbe93a86f88fbd86d06 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Thu, 5 Nov 2026 00:33:27 +0100 Subject: [PATCH 28/43] fix: guard linked identity expiry and simplify OAuth callbacks --- README.md | 46 ++-- authsession.go | 10 +- concurrent_providers_test.go | 39 ++-- config.go | 77 +++--- config_test.go | 15 ++ debug.go | 14 -- debug_test.go | 9 +- expiry_wrapper_test.go | 11 +- oauth2.go | 219 +++++++++--------- oauth2_callback_edge_test.go | 1 - ...view_test.go => oauth2_flow_guards_test.go | 48 +++- oauth_flow_grace_test.go | 19 +- profile_link_test.go | 21 -- server.go | 8 +- 14 files changed, 274 insertions(+), 263 deletions(-) rename oauth2_review_test.go => oauth2_flow_guards_test.go (89%) diff --git a/README.md b/README.md index c968875..8cd85e9 100644 --- a/README.md +++ b/README.md @@ -11,18 +11,19 @@ sessions. Both modes use authorization-code state and PKCE (S256). `Wrap` and ## Setup -`Config` requires `RedirectURL`, `Issuer` and `ClientID`. OIDC mode discovers -provider endpoints, verifies the ID token and nonce, and attempts refresh before -expiry when the provider issues a refresh token. Providers that issue refresh -tokens only with offline access can use +`Config` requires `RedirectURL`, `Issuer` and `ClientID`. The issuer, endpoint +overrides, discovered endpoints and JWKS URI must use HTTPS; +`AllowInsecureIssuer` permits HTTP for tests and development. OIDC mode +discovers provider endpoints, verifies the ID token and nonce, and attempts +refresh before expiry when the provider issues a refresh token. Providers that +issue refresh tokens only with offline access can use `Server.Options = []oauth2.AuthCodeOption{oauth2.AccessTypeOffline}`. -Profile mode requires `AuthURL`, `TokenURL` and `UserInfoURL` over HTTPS, plus a +Profile mode requires `AuthURL`, `TokenURL` and `UserInfoURL`, plus a `ProfileSubject` function that extracts a stable subject from the authenticated -profile. HTTP endpoints require `AllowInsecureIssuer` for development. Email -and names are not identity proof. Profile JSON numbers arrive as `json.Number` -to preserve large IDs. Profile authentication expires after 24 hours or the -access token's earlier expiry. +profile. Email and names are not identity proof. Profile JSON numbers arrive as +`json.Number` to preserve large IDs. Profile authentication expires after 24 +hours or the access token's earlier expiry. Set `Jaws.MaxSessions` and `Jaws.MaxSessionsPerIP` to bound sessions created by unauthenticated requests. A login attempt can retain an anonymous session for @@ -43,13 +44,16 @@ login returns 503 with `Retry-After: 60` and preserves the existing session. - `HandleLink` is an opt-in POST handler. Authorize the existing session before calling it, then recheck the original provider's `CurrentClaims` in `LinkEvent` before writing the link. A link keeps the session; an ordinary - login rotates it and signs out the other providers. + login rotates it and signs out the other providers. Their `LogoutEvent` may + run later at their next auth timer check, with a nil request. - `New` gives each server a private `SessionRotationLock`. For cross-provider linking, give the involved servers one shared lock before serving. jawsauth - holds it during logout, expiry and `LinkEvent`, so do not acquire it around - `Server.Logout` or inside `LinkEvent`; keep the callback brief. Logout of one - provider does not cancel another provider's pending link, which must recheck - the original authentication. + holds it during login rotation, logout, expiry, protected-handler revocation + and `LinkEvent`. Do not hold it around any jawsauth handler or `Server.Logout`, + or inside `LinkEvent`. Keep `LinkEvent` brief: while it runs, login, logout + and expiry for other sessions sharing the lock wait. Logout of one provider + does not cancel another provider's pending link, which must recheck the + original authentication. ## Admin access @@ -69,12 +73,14 @@ admin handlers enforce email verification when required. ## Session lifetime Pending OAuth flows expire after ten minutes. jawsauth extends the original -JaWS session's grace while the user visits the provider. Logout and -authentication expiry clear that server's credentials and reload the session's -JaWS pages. Auth expiry preserves a pending login but cancels a pending link; -explicit logout cancels either flow. Protected operations should check -`CurrentClaims`, plus `JawsAuth.IsAdmin` for administrator actions. Protected -requests during a callback may return 409. +JaWS session's grace while the user visits the provider. Protected requests +during a pending login may return 409 without restarting or extending the flow; +the original callback keeps its return target. Logout and authentication expiry +clear that server's credentials and reload the session's JaWS pages. Auth expiry +preserves a pending login but cancels a pending link; +explicit logout cancels either flow. A new login on the same server, including +one started by `Wrap` or `Handler`, cancels a pending link. Protected operations +should check `CurrentClaims`, plus `JawsAuth.IsAdmin` for administrator actions. Use an HTTP endpoint for a post-logout redirect. `Server.Logout` reloads pages and closes their sockets, so a later JaWS `Request.Redirect` cannot deliver the diff --git a/authsession.go b/authsession.go index 3333f42..9993b8a 100644 --- a/authsession.go +++ b/authsession.go @@ -79,11 +79,13 @@ func (srv *Server) oauth2Context(ctx context.Context) (authctx context.Context) // Token POSTs carry secrets, and 307/308 redirects preserve their body. clientCopy.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } if logger := srv.debugLogger(); logger != nil { - client = srv.debugHTTPClient(&clientCopy, logger) - } else { - client = &clientCopy + next := clientCopy.Transport + if next == nil { + next = http.DefaultTransport + } + clientCopy.Transport = debugOAuth2Transport{logger: logger, next: next} } - authctx = context.WithValue(authctx, oauth2.HTTPClient, client) + authctx = context.WithValue(authctx, oauth2.HTTPClient, &clientCopy) } return } diff --git a/concurrent_providers_test.go b/concurrent_providers_test.go index 10b23ff..2271c8a 100644 --- a/concurrent_providers_test.go +++ b/concurrent_providers_test.go @@ -12,6 +12,21 @@ import ( "github.com/linkdata/jaws" ) +func newConcurrentProfileProvider(t *testing.T, jw *jaws.Jaws, client *http.Client, namespace string) *Server { + t.Helper() + srv, err := New(jw, &Config{ + Namespace: namespace, RedirectURL: "https://app.example/auth/" + namespace + "/callback", + Issuer: "https://provider.example", AuthURL: "https://provider.example/authorize", + TokenURL: "https://provider.example/token", UserInfoURL: "https://provider.example/user", + ClientID: "app", HTTPClient: client, + ProfileSubject: func(profile map[string]any) (string, error) { return string(profile["id"].(json.Number)), nil }, + }, func(string, http.Handler) {}) + if err != nil { + t.Fatal(err) + } + return srv +} + type gateRotationLock struct { mu sync.Mutex entrants atomic.Int32 @@ -44,15 +59,9 @@ func TestConcurrentProviderCallbacksRotateOriginalSessionOnce(t *testing.T) { var releaseOnce sync.Once release := func() { releaseOnce.Do(func() { close(gate.release) }) } defer release() - mk := func(ns string) *Server { - srv, err := New(jw, &Config{Namespace: ns, RedirectURL: "https://app.example/auth/" + ns + "/callback", Issuer: "https://provider.example", AuthURL: "https://provider.example/authorize", TokenURL: "https://provider.example/token", UserInfoURL: "https://provider.example/user", ClientID: "app", HTTPClient: client, ProfileSubject: func(m map[string]any) (string, error) { return string(m["id"].(json.Number)), nil }}, func(string, http.Handler) {}) - if err != nil { - t.Fatal(err) - } - srv.SessionRotationLock = gate - return srv - } - a, b := mk("a"), mk("b") + a := newConcurrentProfileProvider(t, jw, client, "a") + b := newConcurrentProfileProvider(t, jw, client, "b") + a.SessionRotationLock, b.SessionRotationLock = gate, gate cookie, stateA := profileStart(t, a, http.MethodGet, "/auth/a/login", nil) startB := httptest.NewRequest(http.MethodGet, "https://app.example/auth/b/login", nil) startB.AddCookie(cookie) @@ -68,7 +77,6 @@ func TestConcurrentProviderCallbacksRotateOriginalSessionOnce(t *testing.T) { stateB := locB.Query().Get("state") callback := func(srv *Server, ns, state string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodGet, "https://app.example/auth/"+ns+"/callback?state="+url.QueryEscape(state)+"&code=proof", nil) - req.RemoteAddr = "192.0.2.1:1234" req.AddCookie(cookie) w := httptest.NewRecorder() srv.HandleAuthResponse(w, req) @@ -136,14 +144,8 @@ func TestLinkedProviderLogoutBeforeLinkWrite(t *testing.T) { } return profileTestResponse(`{"id":42}`), nil })} - mk := func(ns string) *Server { - srv, err := New(jw, &Config{Namespace: ns, RedirectURL: "https://app.example/auth/" + ns + "/callback", Issuer: "https://provider.example", AuthURL: "https://provider.example/authorize", TokenURL: "https://provider.example/token", UserInfoURL: "https://provider.example/user", ClientID: "app", HTTPClient: client, ProfileSubject: func(m map[string]any) (string, error) { return string(m["id"].(json.Number)), nil }}, func(string, http.Handler) {}) - if err != nil { - t.Fatal(err) - } - return srv - } - a, b := mk("a"), mk("b") + a := newConcurrentProfileProvider(t, jw, client, "a") + b := newConcurrentProfileProvider(t, jw, client, "b") cookie, state := profileStart(t, a, http.MethodGet, "/auth/a/login", nil) login := profileCallback(a, cookie, state, "proof") if login.Code != http.StatusFound || len(login.Result().Cookies()) != 1 { @@ -170,7 +172,6 @@ func TestLinkedProviderLogoutBeforeLinkWrite(t *testing.T) { callbackDone := make(chan *httptest.ResponseRecorder, 1) go func() { callbackRequest := httptest.NewRequest(http.MethodGet, "https://app.example/auth/b/callback?state="+url.QueryEscape(linkState)+"&code=proof", nil) - callbackRequest.RemoteAddr = "192.0.2.1:1234" callbackRequest.AddCookie(cookie) callback := httptest.NewRecorder() b.HandleAuthResponse(callback, callbackRequest) diff --git a/config.go b/config.go index 6ae2cf7..279454d 100644 --- a/config.go +++ b/config.go @@ -43,6 +43,7 @@ type Config struct { // ProfileSubject enables OAuth2 profile authentication without an OIDC ID token. // AuthURL, TokenURL and UserInfoURL are required in this mode. It must return // a stable, non-empty provider subject from the authenticated profile response. + // JSON numbers arrive as json.Number to preserve large numeric IDs. // Profile emails can be unverified. For a non-empty admin list, set // Server.RequireVerifiedAdminEmail unless the provider guarantees verified // addresses; ProfileSubject may map a provider signal to claims["email_verified"]. @@ -60,36 +61,31 @@ func requireStr(k, u string) error { return requireLen(k, len(strings.TrimSpace(u))) } -func validateUrl(k, value string, optional bool) (err error) { +func validateUrl(k, value string, optional bool) (*url.URL, error) { if optional && value == "" { - return - } - if err = requireStr(k, value); err == nil { - var parsed *url.URL - if parsed, err = url.Parse(value); err == nil { - err = ErrConfigURLNotAbsolute - if parsed.IsAbs() { - err = ErrConfigURLMissingHost - if parsed.Hostname() != "" { - err = nil - } - } - if err != nil { - err = errConfig{field: k, cause: err} - } - } else { - err = errConfig{field: k, cause: err} - } + return nil, nil } - return + if err := requireStr(k, value); err != nil { + return nil, err + } + parsed, err := url.Parse(value) + if err != nil { + return nil, errConfig{field: k, cause: err} + } + if !parsed.IsAbs() { + return nil, errConfig{field: k, cause: ErrConfigURLNotAbsolute} + } + if parsed.Hostname() == "" { + return nil, errConfig{field: k, cause: ErrConfigURLMissingHost} + } + return parsed, nil } func validateEndpoint(field, value string, optional, allowInsecure bool) error { - err := validateUrl(field, value, optional) - if err != nil || value == "" { + parsed, err := validateUrl(field, value, optional) + if err != nil || parsed == nil { return err } - parsed, _ := url.Parse(value) if parsed.Scheme != "https" && (!allowInsecure || parsed.Scheme != "http") { return errConfig{field: field, cause: ErrConfigEndpointMustBeHTTPS} } @@ -103,26 +99,25 @@ func validateEndpoint(field, value string, optional, allowInsecure bool) error { // are required and must use HTTPS, or HTTP when AllowInsecureIssuer is true. // OIDC endpoints and Issuer must use HTTPS unless AllowInsecureIssuer is true. // Returned failures match [ErrConfig]. -func (cfg *Config) Validate() (err error) { - if err = validateUrl("RedirectURL", cfg.RedirectURL, false); err == nil { - if err = validateUrl("Issuer", cfg.Issuer, false); err == nil { - issuer, _ := url.Parse(cfg.Issuer) - if issuer.Scheme != "https" && (!cfg.AllowInsecureIssuer || issuer.Scheme != "http") { - err = errConfig{field: "Issuer", cause: ErrConfigIssuerMustBeHTTPS} - } - if err == nil { - for _, field := range []struct{ name, value string }{ - {"AuthURL", cfg.AuthURL}, {"TokenURL", cfg.TokenURL}, {"UserInfoURL", cfg.UserInfoURL}, - } { - if err = validateEndpoint(field.name, field.value, cfg.ProfileSubject == nil, cfg.AllowInsecureIssuer); err != nil { - return - } - } - err = requireStr("ClientID", cfg.ClientID) - } +func (cfg *Config) Validate() error { + if _, err := validateUrl("RedirectURL", cfg.RedirectURL, false); err != nil { + return err + } + issuer, err := validateUrl("Issuer", cfg.Issuer, false) + if err != nil { + return err + } + if issuer.Scheme != "https" && (!cfg.AllowInsecureIssuer || issuer.Scheme != "http") { + return errConfig{field: "Issuer", cause: ErrConfigIssuerMustBeHTTPS} + } + for _, field := range []struct{ name, value string }{ + {"AuthURL", cfg.AuthURL}, {"TokenURL", cfg.TokenURL}, {"UserInfoURL", cfg.UserInfoURL}, + } { + if err := validateEndpoint(field.name, field.value, cfg.ProfileSubject == nil, cfg.AllowInsecureIssuer); err != nil { + return err } } - return + return requireStr("ClientID", cfg.ClientID) } func overrideStr(a *string, b string) { diff --git a/config_test.go b/config_test.go index 7b262b8..fa2e9b9 100644 --- a/config_test.go +++ b/config_test.go @@ -409,6 +409,21 @@ func TestConfigBuildContextDiscoveryRedirectScheme(t *testing.T) { } } +func TestConfigBuildContextDiscoveryRedirectLimit(t *testing.T) { + var hits atomic.Int32 + provider := httptest.NewTLSServer(http.HandlerFunc(func(hw http.ResponseWriter, hr *http.Request) { + hits.Add(1) + http.Redirect(hw, hr, "/again", http.StatusFound) + })) + defer provider.Close() + cfg := Config{RedirectURL: "https://app.example/callback", Issuer: provider.URL, + ClientID: "client", HTTPClient: provider.Client()} + _, _, _, err := cfg.buildContext(t.Context(), "") + if !errors.Is(err, ErrOIDCDiscovery) || hits.Load() != 10 || !strings.Contains(err.Error(), "stopped after 10 redirects") { + t.Fatalf("discovery redirect limit: err=%v hits=%d", err, hits.Load()) + } +} + func TestConfigBuildContextJWKSRedirectScheme(t *testing.T) { for _, secure := range []bool{false, true} { name := "http blocked" diff --git a/debug.go b/debug.go index 0046859..b684b66 100644 --- a/debug.go +++ b/debug.go @@ -50,20 +50,6 @@ func (transport debugOAuth2Transport) RoundTrip(req *http.Request) (resp *http.R return } -func (srv *Server) debugHTTPClient(client *http.Client, logger jaws.Logger) (debugClient *http.Client) { - debugClient = &http.Client{Transport: http.DefaultTransport} - if client != nil { - clientCopy := *client - debugClient = &clientCopy - } - next := debugClient.Transport - if next == nil { - next = http.DefaultTransport - } - debugClient.Transport = debugOAuth2Transport{logger: logger, next: next} - return -} - func oauth2RequestDebugAttrs(req *http.Request) (attrs []any) { attrs = []any{"request_nil", req == nil} if req != nil { diff --git a/debug_test.go b/debug_test.go index 65d01eb..3b6710d 100644 --- a/debug_test.go +++ b/debug_test.go @@ -161,13 +161,14 @@ func TestDebugOAuth2TransportLogsRequestAndPreservesBody(t *testing.T) { } } -func TestDebugHTTPClientWrapsExistingClient(t *testing.T) { - srv := &Server{} +func TestOAuth2ContextDebugWrapsExistingClient(t *testing.T) { client := &http.Client{Timeout: time.Second} + srv := &Server{Jaws: &jaws.Jaws{Debug: true, Logger: &testAuthDebugLogger{}}, httpClient: client} - debugClient := srv.debugHTTPClient(client, &testAuthDebugLogger{}) + ctx := srv.oauth2Context(t.Context()) + debugClient, _ := ctx.Value(oauth2.HTTPClient).(*http.Client) - if debugClient == client { + if debugClient == nil || debugClient == client || client.Transport != nil { t.Fatal("debug client reused original client") } if debugClient.Timeout != time.Second { diff --git a/expiry_wrapper_test.go b/expiry_wrapper_test.go index 95f6041..839f9c3 100644 --- a/expiry_wrapper_test.go +++ b/expiry_wrapper_test.go @@ -4,6 +4,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "testing" "time" @@ -25,7 +26,6 @@ func TestProtectedRequestPreservesPendingLoginOnAuthExpiry(t *testing.T) { } cookie = first.Result().Cookies()[0] start := httptest.NewRequest(http.MethodGet, "https://app.example/auth/login", nil) - start.RemoteAddr = "192.0.2.1:1234" start.AddCookie(cookie) w := httptest.NewRecorder() srv.HandleLogin(w, start) @@ -40,15 +40,12 @@ func TestProtectedRequestPreservesPendingLoginOnAuthExpiry(t *testing.T) { } sess.Set(srv.sessionKey(oauth2AuthExpiryKey), time.Now().Add(-time.Second)) protected := httptest.NewRequest(http.MethodGet, "https://app.example/protected", nil) - protected.RemoteAddr = "192.0.2.1:1234" protected.AddCookie(cookie) response := httptest.NewRecorder() srv.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("expired auth reached protected handler") })).ServeHTTP(response, protected) - if response.Code != http.StatusFound || len(response.Result().Cookies()) != 0 { - t.Fatalf("protected request during login: status=%d cookies=%v", response.Code, response.Result().Cookies()) - } - if location, err := url.Parse(response.Header().Get("Location")); err != nil || location.Query().Get("state") != pendingState { - t.Fatalf("pending redirect: location=%q err=%v", response.Header().Get("Location"), err) + if response.Code != http.StatusConflict || len(response.Result().Cookies()) != 0 || + response.Header().Get("Location") != "" || !strings.Contains(response.Body.String(), ErrOAuth2LoginInProgress.Error()) { + t.Fatalf("protected request during login: status=%d location=%q body=%q cookies=%v", response.Code, response.Header().Get("Location"), response.Body.String(), response.Result().Cookies()) } gotState := testOAuthFlow(sess, srv).state if gotState != pendingState { diff --git a/oauth2.go b/oauth2.go index 426aa7d..61e0ac8 100644 --- a/oauth2.go +++ b/oauth2.go @@ -166,6 +166,14 @@ func (srv *Server) HandleLink(hw http.ResponseWriter, hr *http.Request) { var linkOriginCheck = http.NewCrossOriginProtection() func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, preservePendingLogin bool) { + missingSession := func() { + status := http.StatusForbidden + if !link { + status = http.StatusServiceUnavailable + hw.Header().Set("Retry-After", "60") + } + srv.writeResult(hw, status, ErrOAuth2MissingSession, nil) + } statusCode := http.StatusMethodNotAllowed if link || hr.Method == http.MethodGet { oauth2cfg, location := srv.begin(hr) @@ -175,12 +183,7 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, pr sess = srv.Jaws.NewSession(hw, hr) } if sess == nil { - if link { - srv.writeResult(hw, http.StatusForbidden, ErrOAuth2MissingSession, nil) - return - } - hw.Header().Set("Retry-After", "60") - srv.writeResult(hw, http.StatusServiceUnavailable, ErrOAuth2MissingSession, nil) + missingSession() return } if link && strings.TrimSpace(hr.Referer()) == "" { @@ -189,33 +192,24 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, pr var flow pendingOAuthFlow srv.mu.Lock() if preservePendingLogin && srv.pendingOrdinaryLoginLocked(sess) { - flow, _ = sess.Get(srv.sessionKey(oauth2FlowKey)).(pendingOAuthFlow) - if flow.state == "" { - srv.mu.Unlock() - srv.writeResult(hw, http.StatusConflict, ErrOAuth2LoginInProgress, nil) - return - } - } else { - flow = pendingOAuthFlow{ - state: rand.Text(), expiry: time.Now().Add(oauth2FlowLifetime), - link: link, verifier: oauth2.GenerateVerifier(), referrer: location, - } - if srv.profileSubject == nil { - flow.nonce = rand.Text() - } - if !sess.ExtendGrace(oauth2FlowGrace) { - srv.mu.Unlock() - if link { - srv.writeResult(hw, http.StatusForbidden, ErrOAuth2MissingSession, nil) - return - } - hw.Header().Set("Retry-After", "60") - srv.writeResult(hw, http.StatusServiceUnavailable, ErrOAuth2MissingSession, nil) - return - } - srv.clearSessionOAuthFlow(sess) - sess.Set(srv.sessionKey(oauth2FlowKey), flow) + srv.mu.Unlock() + srv.writeResult(hw, http.StatusConflict, ErrOAuth2LoginInProgress, nil) + return + } + flow = pendingOAuthFlow{ + state: rand.Text(), expiry: time.Now().Add(oauth2FlowLifetime), + link: link, verifier: oauth2.GenerateVerifier(), referrer: location, } + if srv.profileSubject == nil { + flow.nonce = rand.Text() + } + if !sess.ExtendGrace(oauth2FlowGrace) { + srv.mu.Unlock() + missingSession() + return + } + srv.clearSessionOAuthFlow(sess) + sess.Set(srv.sessionKey(oauth2FlowKey), flow) srv.mu.Unlock() authOptions := append([]oauth2.AuthCodeOption{}, srv.Options...) if flow.nonce != "" { @@ -295,7 +289,7 @@ var ErrOAuth2ExpiredState = errors.New("oauth2 expired state") // ErrOAuth2MissingPKCEVerifier means the callback session did not contain the required PKCE verifier. var ErrOAuth2MissingPKCEVerifier = errors.New("oauth2 missing pkce verifier") -// ErrOAuth2LoginInProgress means a callback has consumed the pending login state. +// ErrOAuth2LoginInProgress means an ordinary login is pending or its callback is running. var ErrOAuth2LoginInProgress = errors.New("oauth2 login already in progress") // ErrOAuth2InvalidOrigin means a link start request failed the same-origin check. @@ -513,7 +507,13 @@ func (srv *Server) rotateOAuthSession(hw http.ResponseWriter, hr *http.Request, return srv.Jaws.NewSession(hw, hr), false } -func (srv *Server) completeLink(sess *jaws.Session, hr *http.Request, claims map[string]any, flow pendingOAuthFlow) (status int, err error) { +func (srv *Server) completeLink(sess *jaws.Session, hr *http.Request, claims map[string]any, expiry time.Time, flow pendingOAuthFlow) (status int, err error) { + var linkErr error + defer func() { + if linkErr != nil { + _ = srv.Jaws.Log(linkErr) + } + }() if srv.SessionRotationLock != nil { srv.SessionRotationLock.Lock() defer srv.SessionRotationLock.Unlock() @@ -521,9 +521,11 @@ func (srv *Server) completeLink(sess *jaws.Session, hr *http.Request, claims map if srv.LinkEvent == nil || flow.revoked() || srv.Jaws.GetSession(hr) != sess { return http.StatusForbidden, ErrOAuth2MissingSession } - status, linkErr := srv.LinkEvent(sess, hr, claims) + if err = srv.identityExpiryError(expiry); err != nil { + return http.StatusUnauthorized, err + } + status, linkErr = srv.LinkEvent(sess, hr, claims) if linkErr != nil || status != 0 { - _ = srv.Jaws.Log(linkErr) if status < 400 || status > 599 { status = http.StatusForbidden } @@ -545,6 +547,7 @@ func (srv *Server) completeLogin(hw http.ResponseWriter, hr *http.Request, old * return } if flow.revoked() { + // Do not stop the old auth timer or fire LogoutEvent for a canceled login. http.SetCookie(hw, rotated.Close()) return old, http.StatusForbidden, ErrOAuth2MissingSession } @@ -569,6 +572,36 @@ func (srv *Server) completeLogin(hw http.ResponseWriter, hr *http.Request, old * return sess, http.StatusFound, nil } +func (srv *Server) completeOAuthCallback(hw http.ResponseWriter, hr *http.Request, ctx context.Context, config *oauth2.Config, sess *jaws.Session, flow pendingOAuthFlow) (*jaws.Session, int, error) { + status, err := oauth2CallbackError(http.StatusBadRequest, hr) + if err != nil { + return sess, status, err + } + if flow.verifier == "" { + return sess, http.StatusBadRequest, ErrOAuth2MissingPKCEVerifier + } + token, err := config.Exchange(ctx, hr.FormValue("code") /* #nosec G120 */, oauth2.VerifierOption(flow.verifier)) + if srv.Jaws.Log(err) != nil { + return sess, http.StatusBadRequest, err + } + claims, tokenSource, expiry, status, err := srv.verifyOAuthIdentity(ctx, token, flow.nonce) + if err != nil { + return sess, status, err + } + if flow.link { + status, err = srv.completeLink(sess, hr, claims, expiry, flow) + } else if err = srv.identityExpiryError(expiry); err != nil { + // Reject expiry before rotating the session. + return sess, http.StatusUnauthorized, err + } else { + sess, status, err = srv.completeLogin(hw, hr, sess, flow, claims, tokenSource, expiry) + } + if err == nil { + hw.Header().Set("Location", flow.referrer) + } + return sess, status, err +} + // HandleAuthResponse handles the OIDC or OAuth2 profile callback endpoint. // // For GET requests it validates the state, exchanges the authorization code using the @@ -581,85 +614,55 @@ func (srv *Server) completeLogin(hw http.ResponseWriter, hr *http.Request, old * // If no slot is available, existing authentication is preserved and the default // response is 503. The user must restart login after capacity becomes available. func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) { - statusCode := http.StatusMethodNotAllowed - err := ErrOAuth2Callback - - if hr.Method == http.MethodGet { - oauth2Config := srv.oauth2cfg - var sessEmail string - var flow pendingOAuthFlow - providerCtx, cancel := context.WithTimeout(hr.Context(), oauth2ProviderTimeout) - defer cancel() - authctx := srv.oauth2Context(providerCtx) - sess := srv.Jaws.GetSession(hr) + if hr.Method != http.MethodGet { + srv.writeResult(hw, http.StatusMethodNotAllowed, ErrOAuth2Callback, nil) + return + } + providerCtx, cancel := context.WithTimeout(hr.Context(), oauth2ProviderTimeout) + defer cancel() + authctx := srv.oauth2Context(providerCtx) + sess := srv.Jaws.GetSession(hr) + var sessEmail string + if sess != nil { + sessEmail, _ = sess.Get(srv.SessionEmailKey).(string) + } + statusCode, err := http.StatusInternalServerError, ErrOAuth2NotConfigured + var flow pendingOAuthFlow + if oauth2Config := srv.oauth2cfg; oauth2Config != nil { + statusCode, err = http.StatusBadRequest, ErrOAuth2MissingSession if sess != nil { - sessEmail, _ = sess.Get(srv.SessionEmailKey).(string) - } - err = ErrOAuth2NotConfigured - statusCode = http.StatusInternalServerError - - if oauth2Config != nil { - err = ErrOAuth2MissingSession - statusCode = http.StatusBadRequest - if sess != nil { - gotState := hr.FormValue("state") // #nosec G120 - flow, err = srv.takeOAuthFlow(sess, gotState) - if err == nil { - defer srv.finishOAuthFlow(sess, flow) - if statusCode, err = oauth2CallbackError(statusCode, hr); err == nil { - err = ErrOAuth2MissingPKCEVerifier - if flow.verifier != "" { - var token *oauth2.Token - if token, err = oauth2Config.Exchange(authctx, hr.FormValue("code") /* #nosec G120 */, oauth2.VerifierOption(flow.verifier)); srv.Jaws.Log(err) == nil { - var claims map[string]any - var tokenSource oauth2.TokenSource - var expiry time.Time - claims, tokenSource, expiry, statusCode, err = srv.verifyOAuthIdentity(authctx, token, flow.nonce) - if err == nil { - // A link has no later store check if identity expires here. - if err = srv.identityExpiryError(expiry); err != nil { - statusCode = http.StatusUnauthorized - } else if flow.link { - statusCode, err = srv.completeLink(sess, hr, claims, flow) - } else { - sess, statusCode, err = srv.completeLogin(hw, hr, sess, flow, claims, tokenSource, expiry) - if err == nil { - sessEmail, _ = sess.Get(srv.SessionEmailKey).(string) - } - } - if err == nil { - hw.Header().Set("Location", flow.referrer) - } - } - } - } - } + flow, err = srv.takeOAuthFlow(sess, hr.FormValue("state") /* #nosec G120 */) + if err == nil { + defer srv.finishOAuthFlow(sess, flow) + sess, statusCode, err = srv.completeOAuthCallback(hw, hr, authctx, oauth2Config, sess, flow) + if err == nil && !flow.link { + sessEmail, _ = sess.Get(srv.SessionEmailKey).(string) } } } - if sess != nil { - if err != nil { - srv.Jaws.Dirty(sess) - } else if !flow.link { - if srv.LoginEvent != nil { - srv.LoginEvent(sess, hr) - } - current, present := srv.sessionAuthStatus(sess, time.Now) - if flow.revoked() || !current || !sessionAlive(sess) { - if present { - srv.clearSessionAuth(sess, hr, nil, false) - } - http.SetCookie(hw, sess.Close()) - hw.Header().Del("Location") - err = ErrOAuth2MissingSession - statusCode = http.StatusForbidden + } + if sess != nil { + if err != nil { + srv.Jaws.Dirty(sess) + } else if !flow.link { + if srv.LoginEvent != nil { + srv.LoginEvent(sess, hr) + } + current, present := srv.sessionAuthStatus(sess, time.Now) + if flow.revoked() || !current || !sessionAlive(sess) { + if present { + srv.clearSessionAuth(sess, hr, nil, false) } + http.SetCookie(hw, sess.Close()) + hw.Header().Del("Location") + err = ErrOAuth2MissingSession + statusCode = http.StatusForbidden } } - if err != nil && srv.LoginFailed != nil { - if srv.LoginFailed(hw, hr, statusCode, err, sessEmail) { - return - } + } + if err != nil && srv.LoginFailed != nil { + if srv.LoginFailed(hw, hr, statusCode, err, sessEmail) { + return } } srv.writeResult(hw, statusCode, err, nil) diff --git a/oauth2_callback_edge_test.go b/oauth2_callback_edge_test.go index c13b8b9..a407286 100644 --- a/oauth2_callback_edge_test.go +++ b/oauth2_callback_edge_test.go @@ -176,7 +176,6 @@ func TestOAuthLogoutDuringLoginEventClosesRotatedSession(t *testing.T) { } cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) req := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) - req.RemoteAddr = "192.0.2.1:1234" req.AddCookie(cookie) preauth := jw.GetSession(req) if preauth == nil { diff --git a/oauth2_review_test.go b/oauth2_flow_guards_test.go similarity index 89% rename from oauth2_review_test.go rename to oauth2_flow_guards_test.go index e0a2acd..b30c09f 100644 --- a/oauth2_review_test.go +++ b/oauth2_flow_guards_test.go @@ -32,7 +32,6 @@ func TestCallbackRevokedAfterStoreBalancesEvents(t *testing.T) { srv := newProfileTestServer(t, jw, &calls) cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) r := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) - r.RemoteAddr = "192.0.2.1:1234" r.AddCookie(cookie) old := jw.GetSession(r) if old == nil { @@ -75,7 +74,6 @@ func TestProfileTokenExpiresBetweenRotationAndStore(t *testing.T) { } cookie = first.Result().Cookies()[0] r := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) - r.RemoteAddr = "192.0.2.1:1234" r.AddCookie(cookie) old := jw.GetSession(r) if old == nil { @@ -115,7 +113,6 @@ func TestLinkWithoutEventDoesNotStartFlow(t *testing.T) { srv := newProfileTestServer(t, jw, &calls) srv.LinkEvent = nil r := httptest.NewRequest(http.MethodPost, "https://app.example/auth/link/github", nil) - r.RemoteAddr = "192.0.2.1:1234" if jw.NewSession(httptest.NewRecorder(), r) == nil { t.Fatal("missing session") } @@ -137,7 +134,6 @@ func TestLinkRejectsGETBeforeStartingFlow(t *testing.T) { srv := newProfileTestServer(t, jw, &calls) srv.LinkEvent = func(*jaws.Session, *http.Request, map[string]any) (int, error) { return 0, nil } r := httptest.NewRequest(http.MethodGet, "https://app.example/auth/link/github", nil) - r.RemoteAddr = "192.0.2.1:1234" sess := jw.NewSession(httptest.NewRecorder(), r) if sess == nil { t.Fatal("missing session") @@ -188,6 +184,48 @@ func TestLinkRejectsInvalidEventResults(t *testing.T) { } } +func TestLinkRejectsIdentityThatExpiresWhileWaitingForLock(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + lock := &holdFirstRotationLock{first: make(chan struct{}), releaseFirst: make(chan struct{})} + srv.SessionRotationLock = lock + release := sync.OnceFunc(func() { close(lock.releaseFirst) }) + defer release() + writes := 0 + srv.LinkEvent = func(*jaws.Session, *http.Request, map[string]any) (int, error) { + writes++ + return 0, nil + } + request := httptest.NewRequest(http.MethodGet, "https://app.example/auth/callback", nil) + sess := jw.NewSession(httptest.NewRecorder(), request) + if sess == nil || jw.GetSession(request) != sess { + t.Fatal("missing session") + } + type completion struct { + status int + err error + } + result := make(chan completion, 1) + go func() { + status, err := srv.completeLink(sess, request, map[string]any{"sub": "42"}, time.Now().Add(time.Second), pendingOAuthFlow{}) + result <- completion{status, err} + }() + <-lock.first + time.Sleep(2 * time.Second) // synctest advances time while completeLink waits. + release() + got := <-result + if got.status != http.StatusUnauthorized || !errors.Is(got.err, ErrOAuth2InvalidProfile) || writes != 0 { + t.Fatalf("expired link: status=%d err=%v writes=%d", got.status, got.err, writes) + } + }) +} + func TestUserInfoRejectsTrailingJSON(t *testing.T) { for _, profile := range []bool{false, true} { t.Run(map[bool]string{false: "OIDC", true: "profile"}[profile], func(t *testing.T) { @@ -279,7 +317,6 @@ func TestCallbackClosesRotatedSessionOnStoreFailure(t *testing.T) { func startProfileLoginWithCookie(t *testing.T, srv *Server, cookie *http.Cookie) string { t.Helper() start := httptest.NewRequest(http.MethodGet, "https://app.example/auth/login", nil) - start.RemoteAddr = "192.0.2.1:1234" start.AddCookie(cookie) begin := httptest.NewRecorder() srv.HandleLogin(begin, start) @@ -322,7 +359,6 @@ func TestCallbackRevokedAfterRotationClosesNewSession(t *testing.T) { } cookie = first.Result().Cookies()[0] oldRequest := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) - oldRequest.RemoteAddr = "192.0.2.1:1234" oldRequest.AddCookie(cookie) old := jw.GetSession(oldRequest) if old == nil { diff --git a/oauth_flow_grace_test.go b/oauth_flow_grace_test.go index 9174353..172d953 100644 --- a/oauth_flow_grace_test.go +++ b/oauth_flow_grace_test.go @@ -5,7 +5,6 @@ import ( "io" "net/http" "net/http/httptest" - "net/url" "strings" "testing" "testing/synctest" @@ -28,7 +27,6 @@ func TestOAuthProviderVisitKeepsOnlyPendingSessionAlive(t *testing.T) { Endpoint: oauth2.Endpoint{AuthURL: "https://provider.example/auth", TokenURL: "https://provider.example/token"}, }} start := httptest.NewRequest(http.MethodGet, "https://app.example/auth/login", nil) - start.RemoteAddr = "192.0.2.1:1234" response := httptest.NewRecorder() srv.HandleLogin(response, start) if response.Code != http.StatusFound { @@ -60,7 +58,7 @@ func TestOAuthProviderVisitKeepsOnlyPendingSessionAlive(t *testing.T) { }) } -func TestPendingLoginRedirectDoesNotRenewGrace(t *testing.T) { +func TestPendingLoginProtectedRequestDoesNotRestartOrRenewGrace(t *testing.T) { synctest.Test(t, func(t *testing.T) { jw, err := jaws.New() if err != nil { @@ -69,24 +67,20 @@ func TestPendingLoginRedirectDoesNotRenewGrace(t *testing.T) { defer jw.Close() calls := 0 srv := newProfileTestServer(t, jw, &calls) - srv.Options = []oauth2.AuthCodeOption{oauth2.AccessTypeOffline} cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) protected := httptest.NewRequest(http.MethodGet, "https://app.example/protected", nil) - protected.RemoteAddr = "192.0.2.1:1234" protected.AddCookie(cookie) time.Sleep(9 * time.Minute) response := httptest.NewRecorder() srv.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("pending login reached protected handler") })).ServeHTTP(response, protected) - if response.Code != http.StatusFound { - t.Fatalf("pending login: status=%d", response.Code) + if response.Code != http.StatusConflict || response.Header().Get("Location") != "" || !strings.Contains(response.Body.String(), ErrOAuth2LoginInProgress.Error()) { + t.Fatalf("pending login: status=%d location=%q body=%q", response.Code, response.Header().Get("Location"), response.Body.String()) } - location, err := url.Parse(response.Header().Get("Location")) stored := testOAuthFlow(jw.GetSession(protected), srv) - original, _ := url.Parse(srv.oauth2cfg.AuthCodeURL(state, oauth2.S256ChallengeOption(stored.verifier))) - if err != nil || location.Query().Get("state") != state || location.Query().Get("code_challenge") != original.Query().Get("code_challenge") || location.Query().Get("access_type") != "offline" { - t.Fatalf("pending login redirect: location=%q err=%v", response.Header().Get("Location"), err) + if stored.state != state { + t.Fatalf("pending login replaced state: want %q got %q", state, stored.state) } time.Sleep(time.Minute + oauth2ProviderTimeout + 2*time.Second) if jw.GetSession(protected) != nil { @@ -111,7 +105,6 @@ func TestOAuthFlowExpiresEvenWithLiveSession(t *testing.T) { return nil, errors.New("provider should not be called") })}} start := httptest.NewRequest(http.MethodGet, "https://app.example/auth/login", nil) - start.RemoteAddr = "192.0.2.1:1234" response := httptest.NewRecorder() srv.HandleLogin(response, start) if response.Code != http.StatusFound { @@ -164,7 +157,6 @@ func TestOIDCInitialCallbackAfterProviderVisit(t *testing.T) { })}, } start := httptest.NewRequest(http.MethodGet, "https://app.example/auth/login", nil) - start.RemoteAddr = "192.0.2.1:1234" response := httptest.NewRecorder() srv.HandleLogin(response, start) if response.Code != http.StatusFound { @@ -223,7 +215,6 @@ func TestOAuthLinkCallbackNearDeadlineCanFinishProviderExchange(t *testing.T) { })}, } start := httptest.NewRequest(http.MethodPost, "https://app.example/auth/link", nil) - start.RemoteAddr = "192.0.2.1:1234" created := httptest.NewRecorder() sess := jw.NewSession(created, start) if sess == nil { diff --git a/profile_link_test.go b/profile_link_test.go index 139fb96..da06dd4 100644 --- a/profile_link_test.go +++ b/profile_link_test.go @@ -234,7 +234,6 @@ func TestOAuthFailedCallbackDoesNotEraseNewFlow(t *testing.T) { go func() { firstResult <- profileCallback(srv, cookie, firstState, "first").Code }() waitFor(t, entered, "first callback did not reach provider") r := httptest.NewRequest(http.MethodGet, "https://app.example/auth/login", nil) - r.RemoteAddr = "192.0.2.1:1234" r.Header.Set("Referer", "https://app.example/second") r.AddCookie(cookie) w := httptest.NewRecorder() @@ -288,7 +287,6 @@ func TestOAuthProfileCallbackCanceledBeforeRotation(t *testing.T) { srv.LoginEvent = func(*jaws.Session, *http.Request) { logins++ } cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) request := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) - request.RemoteAddr = "192.0.2.1:1234" request.AddCookie(cookie) sess := jw.GetSession(request) if sess == nil { @@ -305,7 +303,6 @@ func TestOAuthProfileCallbackCanceledBeforeRotation(t *testing.T) { } } else { start := httptest.NewRequest(http.MethodGet, "https://app.example/auth/login", nil) - start.RemoteAddr = "192.0.2.1:1234" start.AddCookie(cookie) w := httptest.NewRecorder() srv.HandleLogin(w, start) @@ -352,7 +349,6 @@ func TestOAuthProfileCallbackCanceledBeforeFinalStore(t *testing.T) { } cookie = first.Result().Cookies()[0] request := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) - request.RemoteAddr = "192.0.2.1:1234" request.AddCookie(cookie) old := jw.GetSession(request) if old == nil { @@ -373,7 +369,6 @@ func TestOAuthProfileCallbackCanceledBeforeFinalStore(t *testing.T) { t.Fatalf("canceled before store: canceled=%t status=%d location=%q cookies=%v", canceled, w.Code, w.Header().Get("Location"), cookies) } request = httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) - request.RemoteAddr = "192.0.2.1:1234" request.AddCookie(cookies[0]) if jw.GetSession(request) != nil || old.Get(srv.SessionKey) != nil || len(srv.authTimers) != 0 { t.Fatalf("canceled login retained auth or timer: old=%v timers=%d", old.Get(srv.SessionKey), len(srv.authTimers)) @@ -412,7 +407,6 @@ func TestOAuthProfileLinkCanceledByLogout(t *testing.T) { } cookie = login.Result().Cookies()[0] request := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) - request.RemoteAddr = "192.0.2.1:1234" request.AddCookie(cookie) sess := jw.GetSession(request) if sess == nil || sess.Get(srv.SessionKey) == nil { @@ -474,7 +468,6 @@ func TestOAuthProfileLinkSerializesManagedRevocation(t *testing.T) { } cookie = first.Result().Cookies()[0] req := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) - req.RemoteAddr = "192.0.2.1:1234" req.AddCookie(cookie) sess := jw.GetSession(req) lock := &observedRotationLock{second: make(chan struct{})} @@ -563,7 +556,6 @@ func TestOAuthProfileAuthExpiryPreservesOrdinaryLogin(t *testing.T) { } cookie = first.Result().Cookies()[0] start := httptest.NewRequest(http.MethodGet, "https://app.example/auth/login", nil) - start.RemoteAddr = "192.0.2.1:1234" start.AddCookie(cookie) sess := jw.GetSession(start) if sess == nil { @@ -639,7 +631,6 @@ func TestOAuthProfileAuthExpiryPreservesOrdinaryLogin(t *testing.T) { t.Fatalf("new login: status=%d cookies=%v body=%q", response.Code, response.Result().Cookies(), response.Body.String()) } check := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) - check.RemoteAddr = "192.0.2.1:1234" check.AddCookie(response.Result().Cookies()[0]) rotated := jw.GetSession(check) if rotated == nil || rotated == sess || rotated.Get(srv.SessionKey).(map[string]any)["sub"] != "99" { @@ -698,7 +689,6 @@ func newProfileTestServer(t *testing.T, jw *jaws.Jaws, calls *int) *Server { func profileStart(t *testing.T, srv *Server, method, path string, cookie *http.Cookie) (*http.Cookie, string) { t.Helper() r := httptest.NewRequest(method, "https://app.example"+path, nil) - r.RemoteAddr = "192.0.2.1:1234" if cookie != nil { r.AddCookie(cookie) } @@ -730,7 +720,6 @@ func profileStart(t *testing.T, srv *Server, method, path string, cookie *http.C func profileCallback(srv *Server, cookie *http.Cookie, state, code string) *httptest.ResponseRecorder { r := httptest.NewRequest(http.MethodGet, "https://app.example/auth/callback?state="+url.QueryEscape(state)+"&code="+code, nil) - r.RemoteAddr = "192.0.2.1:1234" r.AddCookie(cookie) w := httptest.NewRecorder() srv.HandleAuthResponse(w, r) @@ -764,7 +753,6 @@ func TestOAuthProfileSignInAndDualProofLink(t *testing.T) { } cookie := newCookies[0] request := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) - request.RemoteAddr = "192.0.2.1:1234" request.AddCookie(cookie) sess := jw.GetSession(request) claims, _ := sess.Get(srv.SessionKey).(map[string]any) @@ -811,7 +799,6 @@ func TestOAuthProfileExpiredLinkStateNeverCallsProvider(t *testing.T) { } _, linkState := profileStart(t, srv, http.MethodPost, "/auth/link/github", cookie) r := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) - r.RemoteAddr = "192.0.2.1:1234" r.AddCookie(cookie) sess := jw.GetSession(r) flow := testOAuthFlow(sess, srv) @@ -841,7 +828,6 @@ func TestOAuthLinkRequiresSessionAndSameOriginRequestAndRejectsDeclinedLink(t *t calls := 0 srv := newProfileTestServer(t, jw, &calls) missing := httptest.NewRequest(http.MethodPost, "https://app.example/auth/link/github", nil) - missing.RemoteAddr = "192.0.2.1:1234" w := httptest.NewRecorder() srv.LinkEvent = func(*jaws.Session, *http.Request, map[string]any) (int, error) { return http.StatusForbidden, reject.err @@ -857,7 +843,6 @@ func TestOAuthLinkRequiresSessionAndSameOriginRequestAndRejectsDeclinedLink(t *t } cookie := first.Result().Cookies()[0] cross := httptest.NewRequest(http.MethodPost, "https://app.example/auth/link/github", nil) - cross.RemoteAddr = "192.0.2.1:1234" cross.Header.Set("Origin", "https://evil.example") cross.AddCookie(cookie) w = httptest.NewRecorder() @@ -873,7 +858,6 @@ func TestOAuthLinkRequiresSessionAndSameOriginRequestAndRejectsDeclinedLink(t *t t.Fatalf("declined link: status=%d body=%q", declined.Code, declined.Body.String()) } r := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) - r.RemoteAddr = "192.0.2.1:1234" r.AddCookie(cookie) sess := jw.GetSession(r) if sess == nil || sess.Get(srv.SessionKey).(map[string]any)["sub"] != "9007199254740993" { @@ -898,7 +882,6 @@ func TestOAuthProfileRejectsInvalidSubjectAndOversizedResponse(t *testing.T) { t.Fatalf("invalid subject: status=%d calls=%d", response.Code, calls) } request := httptest.NewRequest(http.MethodGet, "https://app.example/", nil) - request.RemoteAddr = "192.0.2.1:1234" request.AddCookie(cookie) if sess := jw.GetSession(request); sess == nil || sess.Get(srv.SessionKey) != nil { t.Fatal("invalid profile authenticated the preauth session") @@ -955,7 +938,6 @@ func TestOAuthProfileUserInfoCallbackDeadline(t *testing.T) { cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) response := profileCallback(srv, cookie, state, "code") request := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) - request.RemoteAddr = "192.0.2.1:1234" request.AddCookie(cookie) sess := jw.GetSession(request) var claims any @@ -1044,7 +1026,6 @@ func TestOAuthProfileExplicitZeroExpiryRejectedButOmittedExpiryCapped(t *testing t.Fatalf("rotated cookies: %v", cookies) } r := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) - r.RemoteAddr = "192.0.2.1:1234" r.AddCookie(cookies[0]) sess := jw.GetSession(r) if sess == nil || sess.Get(srv.SessionKey) == nil { @@ -1077,7 +1058,6 @@ func TestOAuthProfileExpiresWithoutRefresh(t *testing.T) { t.Fatalf("profile auth cleanup interval: %s", delay) } r := httptest.NewRequest(http.MethodGet, "https://app.example/", nil) - r.RemoteAddr = "192.0.2.1:1234" r.AddCookie(response.Result().Cookies()[0]) sess := jw.GetSession(r) factory.timer(0).fire() @@ -1109,7 +1089,6 @@ func TestOAuthProfileTimerReleasesClosedSession(t *testing.T) { t.Fatalf("profile sign-in: status=%d timers=%d", response.Code, factory.len()) } r := httptest.NewRequest(http.MethodGet, "https://app.example/", nil) - r.RemoteAddr = "192.0.2.1:1234" r.AddCookie(response.Result().Cookies()[0]) sess := jw.GetSession(r) sess.Close() diff --git a/server.go b/server.go index 744a054..ed5d4bd 100644 --- a/server.go +++ b/server.go @@ -349,7 +349,7 @@ func (srv *Server) wrap(h http.Handler, admin bool) (rh http.Handler) { // Unauthenticated requests are redirected into the configured login flow (HandleLogin); // authenticated users whose email is not an admin (see SetAdmins and IsAdmin) are // served the 403 handler instead of h. [Server.RequireVerifiedAdminEmail] also -// applies. A protected request during a callback may return 409. +// applies. A protected request during a pending login may return 409. // If the Server is not Valid, returns h. func (srv *Server) WrapAdmin(h http.Handler) (rh http.Handler) { return srv.wrap(h, true) @@ -359,7 +359,7 @@ func (srv *Server) WrapAdmin(h http.Handler) (rh http.Handler) { // // Unauthenticated requests are redirected into the configured login flow // (HandleLogin), which stores verified claims in srv.SessionKey before the user -// returns. A protected request during a callback may return 409. +// returns. A protected request during a pending login may return 409. // If the Server is not Valid, returns h. func (srv *Server) Wrap(h http.Handler) (rh http.Handler) { return srv.wrap(h, false) @@ -370,7 +370,7 @@ func (srv *Server) Wrap(h http.Handler) (rh http.Handler) { // // Unauthenticated requests are redirected into the configured login flow (HandleLogin); // authenticated non-admins (see SetAdmins and IsAdmin) are served the 403 handler. -// [Server.RequireVerifiedAdminEmail] also applies. A protected request during a callback may return 409. +// [Server.RequireVerifiedAdminEmail] also applies. A protected request during a pending login may return 409. // If the Server is not Valid, the template handler is returned without the // authentication requirement. func (srv *Server) HandlerAdmin(name string, dot any) http.Handler { @@ -382,7 +382,7 @@ func (srv *Server) HandlerAdmin(name string, dot any) http.Handler { // // Unauthenticated requests are redirected into the configured login flow // (HandleLogin), which stores verified claims in srv.SessionKey before the user -// returns. A protected request during a callback may return 409. +// returns. A protected request during a pending login may return 409. // If the Server is not Valid, the template // handler is returned without the authentication requirement. func (srv *Server) Handler(name string, dot any) http.Handler { From f67733218f5b815190a343483f4b8a153628ecf9 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Thu, 5 Nov 2026 01:12:23 +0100 Subject: [PATCH 29/43] fix: reuse pending OAuth login and clarify auth contracts --- README.md | 63 +++--- authsession.go | 13 +- authsession_test.go | 57 ++++-- cmd/integration/jawsauth_test.go | 31 +-- concurrent_providers_test.go | 132 ++++++------ config.go | 13 +- config_test.go | 44 ++-- doc.go | 2 +- erroauth2callback_test.go | 6 +- expiry_wrapper_test.go | 12 +- jawsauth.go | 10 +- namespace_test.go | 2 +- oauth2.go | 84 ++++---- oauth2_callback_edge_test.go | 14 +- oauth2_flow_guards_test.go | 72 +++---- oauth2_redirect_test.go | 12 -- oauth2_test.go | 40 ++-- oauth_flow_grace_test.go | 51 +++-- profile_link_test.go | 333 +++++++++++++++++++------------ server.go | 17 +- sessionlifecycle_test.go | 30 ++- wrapper_test.go | 10 +- 22 files changed, 578 insertions(+), 470 deletions(-) diff --git a/README.md b/README.md index 8cd85e9..3418337 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,18 @@ issue refresh tokens only with offline access can use Profile mode requires `AuthURL`, `TokenURL` and `UserInfoURL`, plus a `ProfileSubject` function that extracts a stable subject from the authenticated -profile. Email and names are not identity proof. Profile JSON numbers arrive as -`json.Number` to preserve large IDs. Profile authentication expires after 24 -hours or the access token's earlier expiry. +profile. Profile mode sets the `iss` claim to `Issuer` and `sub` to the +`ProfileSubject` result. The `iss`+`sub` pair identifies an account, so use a +distinct `Issuer` for each provider. Email and names are not identity proof. +Profile JSON numbers arrive as `json.Number` to preserve large IDs. Profile +authentication expires after 24 hours or the access token's earlier expiry. +An explicit `expires_in: 0` in the token response is rejected. Set `Jaws.MaxSessions` and `Jaws.MaxSessionsPerIP` to bound sessions created by -unauthenticated requests. A login attempt can retain an anonymous session for -ten minutes while the user visits the provider. Login rotates the session and -discards its application data; rotation needs a free slot under both limits. +unauthenticated requests; both limits are unlimited by default. A login attempt +can retain an anonymous session for about ten minutes while the user visits the +provider. Login rotates the session and discards its application data; rotation +needs a free slot under both limits. In particular, `MaxSessionsPerIP = 1` prevents login. If a slot is unavailable, login returns 503 with `Retry-After: 60` and preserves the existing session. @@ -37,23 +41,20 @@ login returns 503 with `Retry-After: 60` and preserves the existing session. - Give each `Config` a distinct `Namespace` and callback directory. Add each provider's registered paths to the other servers' `HandledPaths` so return targets cannot enter another provider's auth endpoints. -- `New` installs that provider's `Jaws.MakeAuth`. Install a composite `MakeAuth` - if JaWS pages need data from several providers. Each server's `Wrap` and - `Handler` require its own authentication; `CurrentClaims` checks that - provider's live claims. -- `HandleLink` is an opt-in POST handler. Authorize the existing session before - calling it, then recheck the original provider's `CurrentClaims` in - `LinkEvent` before writing the link. A link keeps the session; an ordinary - login rotates it and signs out the other providers. Their `LogoutEvent` may - run later at their next auth timer check, with a nil request. +- `New` sets `Jaws.MakeAuth`. Install a composite `MakeAuth` if JaWS pages need + data from several providers. Each server's `Wrap`, `Handler` and + `CurrentClaims` check that provider's authentication. +- Register `HandleLink` as a POST handler to enable linking; `New` does not + register it. Authorize the session before calling it. In `LinkEvent`, recheck + the original provider's `CurrentClaims` before writing the link. Linking keeps + the session; an ordinary login rotates it and signs out other providers. + Their `LogoutEvent` may run later with a nil request. - `New` gives each server a private `SessionRotationLock`. For cross-provider - linking, give the involved servers one shared lock before serving. jawsauth - holds it during login rotation, logout, expiry, protected-handler revocation - and `LinkEvent`. Do not hold it around any jawsauth handler or `Server.Logout`, - or inside `LinkEvent`. Keep `LinkEvent` brief: while it runs, login, logout - and expiry for other sessions sharing the lock wait. Logout of one provider - does not cancel another provider's pending link, which must recheck the - original authentication. + linking, assign one shared lock before serving. jawsauth holds it during + rotation, logout, expiry, revocation and `LinkEvent`. Do not lock around + jawsauth handlers or `Server.Logout`; do not lock or call `Server.Logout` in + `LinkEvent`. Keep `LinkEvent` brief because other sessions sharing the lock + wait. One provider's logout does not cancel another provider's pending link. ## Admin access @@ -65,21 +66,23 @@ grant admin access in strict mode. Some providers omit `email_verified`. [Microsoft warns against using its email claim for authorization](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference); use application policy based on a stable identifier for Entra ID. -`SetAdmins` compares parsed email addresses with ASCII case ignored. An empty -list allows every authenticated user through the admin handlers. +`SetAdmins` parses valid addresses and compares them ignoring ASCII case. An +empty list allows every authenticated user through the admin handlers. `Server.IsAdmin(email)` checks only list membership; `JawsAuth.IsAdmin` and the admin handlers enforce email verification when required. ## Session lifetime Pending OAuth flows expire after ten minutes. jawsauth extends the original -JaWS session's grace while the user visits the provider. Protected requests -during a pending login may return 409 without restarting or extending the flow; -the original callback keeps its return target. Logout and authentication expiry +JaWS session's grace while the user visits the provider. Unauthenticated +protected requests during a pending login redirect to the same provider flow +without replacing its state, return target or grace deadline. Requests during +a running callback return 409 unless `LoginFailed` handles them. Logout and +authentication expiry clear that server's credentials and reload the session's JaWS pages. Auth expiry -preserves a pending login but cancels a pending link; -explicit logout cancels either flow. A new login on the same server, including -one started by `Wrap` or `Handler`, cancels a pending link. Protected operations +preserves a pending login but cancels a pending link. Explicit logout cancels +either flow. A new login on the same server, including one started by `Wrap` or +`Handler`, cancels a pending link. Protected operations should check `CurrentClaims`, plus `JawsAuth.IsAdmin` for administrator actions. Use an HTTP endpoint for a post-logout redirect. `Server.Logout` reloads pages diff --git a/authsession.go b/authsession.go index 9993b8a..314ebd5 100644 --- a/authsession.go +++ b/authsession.go @@ -113,7 +113,7 @@ func (srv *Server) identityExpiryError(expiry time.Time) error { if expiry.After(time.Now()) { return nil } - if srv.idTokenVerifier == nil { + if srv.profileSubject != nil { return ErrOAuth2InvalidProfile } return errOIDC{kind: ErrOIDCInvalidIDToken, cause: errOIDCInvalidExpiry} @@ -158,11 +158,9 @@ func (srv *Server) storeSessionAuthClaimsIfActive(sess *jaws.Session, claims map return ErrOAuth2MissingSession default: } - if canceled != nil { - if err := srv.identityExpiryError(expiry); err != nil { - srv.mu.Unlock() - return err - } + if err := srv.identityExpiryError(expiry); err != nil { + srv.mu.Unlock() + return err } if entry != nil && srv.authTimers[sess.ID()] != entry { srv.mu.Unlock() @@ -223,9 +221,6 @@ func (srv *Server) setSessionAuthFromToken(ctx context.Context, sess *jaws.Sessi return errOIDC{kind: ErrOIDCInvalidIDToken, cause: errOIDCStaleIDToken} } srv.addUserInfoClaims(ctx, claims, tokenSource) - if err := srv.identityExpiryError(idToken.Expiry); err != nil { - return err - } return srv.storeSessionAuthClaims(sess, claims, tokenSource, idToken.Expiry, entry) } diff --git a/authsession_test.go b/authsession_test.go index c2a91cf..2fde4bf 100644 --- a/authsession_test.go +++ b/authsession_test.go @@ -325,6 +325,10 @@ func TestStoreSessionAuthClaimsErrors(t *testing.T) { if !errors.Is(err, ErrOIDCInvalidIDToken) { t.Fatal(err) } + err = srv.storeSessionAuthClaims(sess, map[string]any{"sub": "person"}, nil, time.Now().Add(-time.Second), nil) + if !errors.Is(err, errOIDCInvalidExpiry) || sess.Get(srv.SessionKey) != nil || factory.len() != 0 { + t.Fatalf("expired identity stored: err=%v claims=%v timers=%d", err, sess.Get(srv.SessionKey), factory.len()) + } entry := &authTimerState{} err = srv.storeSessionAuthClaims(sess, map[string]any{}, nil, time.Now().Add(time.Hour), entry) @@ -394,24 +398,36 @@ func TestOIDCRefreshRejectsChangedSubject(t *testing.T) { const issuer = "https://issuer.example" factory := &testAuthTimerFactory{} srv := newTimerTestServer(t, jw, issuer, factory) - req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) - sess := jw.NewSession(httptest.NewRecorder(), req) - oldExpiry := time.Now().Add(time.Minute).Truncate(time.Second) - if err := srv.storeSessionAuthClaims(sess, map[string]any{"sub": "person-a", "email": "a@example.com"}, nil, oldExpiry, nil); err != nil { - t.Fatal(err) - } - entry := srv.authTimers[sess.ID()] - newExpiry := time.Now().Add(time.Hour).Truncate(time.Second) - raw := makeIDToken(t, map[string]any{ - "iss": issuer, "aud": "client", "sub": "person-b", "exp": newExpiry.Unix(), - }) - err = srv.setSessionAuthFromToken(t.Context(), sess, nil, makeOAuth2Token("access", raw, ""), oldExpiry, entry) - if !errors.Is(err, ErrOIDCInvalidIDToken) || !errors.Is(err, errOIDCSubjectMismatch) { - t.Fatalf("changed subject: %v", err) - } - claims, _ := sess.Get(srv.SessionKey).(map[string]any) - if claims["sub"] != "person-a" || claims["email"] != "a@example.com" || srv.authTimers[sess.ID()] != entry { - t.Fatalf("refresh changed prior auth: claims=%v timer=%v", claims, srv.authTimers[sess.ID()]) + for _, tc := range []struct{ name, storedSubject string }{ + {"changed subject", "person-a"}, + {"missing stored subject", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) + sess := jw.NewSession(httptest.NewRecorder(), req) + oldExpiry := time.Now().Add(time.Minute).Truncate(time.Second) + original := map[string]any{"email": "a@example.com"} + if tc.storedSubject != "" { + original["sub"] = tc.storedSubject + } + if err := srv.storeSessionAuthClaims(sess, original, nil, oldExpiry, nil); err != nil { + t.Fatal(err) + } + entry := srv.authTimers[sess.ID()] + newExpiry := time.Now().Add(time.Hour).Truncate(time.Second) + raw := makeIDToken(t, map[string]any{ + "iss": issuer, "aud": "client", "sub": "person-b", "exp": newExpiry.Unix(), + }) + err := srv.setSessionAuthFromToken(t.Context(), sess, nil, makeOAuth2Token("access", raw, ""), oldExpiry, entry) + if !errors.Is(err, ErrOIDCInvalidIDToken) || !errors.Is(err, errOIDCSubjectMismatch) { + t.Fatalf("changed subject: %v", err) + } + claims, _ := sess.Get(srv.SessionKey).(map[string]any) + _, hasSubject := claims["sub"] + if hasSubject != (tc.storedSubject != "") || claims["sub"] != original["sub"] || claims["email"] != "a@example.com" || srv.authTimers[sess.ID()] != entry { + t.Fatalf("refresh changed prior auth: claims=%v timer=%v", claims, srv.authTimers[sess.ID()]) + } + }) } } @@ -810,7 +826,7 @@ func TestAuthTimerRefreshFailureClearsExpiredAuth(t *testing.T) { } req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) sess := jw.NewSession(httptest.NewRecorder(), req) - expiry := time.Now().Add(-time.Second).Truncate(time.Second) + expiry := time.Now().Add(time.Minute).Truncate(time.Second) err = srv.storeSessionAuthClaims(sess, map[string]any{ "exp": expiry.Unix(), "email": "old@example.com", @@ -819,6 +835,9 @@ func TestAuthTimerRefreshFailureClearsExpiredAuth(t *testing.T) { if err != nil { t.Fatal(err) } + expired := time.Now().Add(-time.Second) + sess.Set(srv.sessionKey(oauth2AuthExpiryKey), expired) + srv.authTimers[sess.ID()].expiry = expired factory.timer(0).fire() diff --git a/cmd/integration/jawsauth_test.go b/cmd/integration/jawsauth_test.go index c649efc..1a3d7e4 100644 --- a/cmd/integration/jawsauth_test.go +++ b/cmd/integration/jawsauth_test.go @@ -13,6 +13,7 @@ import ( "net/url" "regexp" "strings" + "sync/atomic" "testing" "time" @@ -104,16 +105,16 @@ func serverHandlerTest(t *testing.T, baseURL, realm, clientID, clientSecret stri if err != nil { t.Fatal(err) } - var logincount int - asrv.LoginEvent = func(sess *jaws.Session, hr *http.Request) { logincount++ } - asrv.LogoutEvent = func(sess *jaws.Session, hr *http.Request) { logincount-- } - var linkcount int + var logincount atomic.Int32 + asrv.LoginEvent = func(sess *jaws.Session, hr *http.Request) { logincount.Add(1) } + asrv.LogoutEvent = func(sess *jaws.Session, hr *http.Request) { logincount.Add(-1) } + var linkcount atomic.Int32 asrv.LinkEvent = func(sess *jaws.Session, _ *http.Request, claims map[string]any) (int, error) { current := asrv.CurrentClaims(sess) if current == nil || current["sub"] != claims["sub"] { return http.StatusForbidden, nil } - linkcount++ + linkcount.Add(1) return 0, nil } mux.Handle(http.MethodPost+" /oauth2/link", asrv.Wrap(http.HandlerFunc(asrv.HandleLink))) @@ -141,8 +142,8 @@ func serverHandlerTest(t *testing.T, baseURL, realm, clientID, clientSecret stri t.Fatal(initialresp.Status) } - if logincount != 0 { - t.Error(logincount) + if logincount.Load() != 0 { + t.Error(logincount.Load()) } b, err := io.ReadAll(initialresp.Body) @@ -179,8 +180,8 @@ func serverHandlerTest(t *testing.T, baseURL, realm, clientID, clientSecret stri t.Fatal(resp.Status) } - if logincount != 1 { - t.Error(logincount) + if logincount.Load() != 1 { + t.Error(logincount.Load()) } b, err = io.ReadAll(resp.Body) @@ -216,8 +217,8 @@ func serverHandlerTest(t *testing.T, baseURL, realm, clientID, clientSecret stri t.Fatal(err) } _ = resp.Body.Close() - if resp.StatusCode != http.StatusOK || resp.Request.URL.Path != "/" || linkcount != 1 || logincount != 1 { - t.Fatalf("OIDC link: status=%s path=%q links=%d logins=%d", resp.Status, resp.Request.URL.Path, linkcount, logincount) + if resp.StatusCode != http.StatusOK || resp.Request.URL.Path != "/" || linkcount.Load() != 1 || logincount.Load() != 1 { + t.Fatalf("OIDC link: status=%s path=%q links=%d logins=%d", resp.Status, resp.Request.URL.Path, linkcount.Load(), logincount.Load()) } resp, err = client.Get(hsrv.URL + "/needauth") @@ -230,8 +231,8 @@ func serverHandlerTest(t *testing.T, baseURL, realm, clientID, clientSecret stri t.Fatal(resp.Status) } - if logincount != 1 { - t.Error(logincount) + if logincount.Load() != 1 { + t.Error(logincount.Load()) } resp, err = client.Get(hsrv.URL + "/oauth2/logout") @@ -244,8 +245,8 @@ func serverHandlerTest(t *testing.T, baseURL, realm, clientID, clientSecret stri t.Fatal(resp.Status) } - if logincount != 0 { - t.Error(logincount) + if logincount.Load() != 0 { + t.Error(logincount.Load()) } if !asrv.IsAdmin("testuser@example.com") { diff --git a/concurrent_providers_test.go b/concurrent_providers_test.go index 2271c8a..e99cb2c 100644 --- a/concurrent_providers_test.go +++ b/concurrent_providers_test.go @@ -133,67 +133,75 @@ func (l *holdFirstRotationLock) Lock() { func (l *holdFirstRotationLock) Unlock() { l.mu.Unlock() } func TestLinkedProviderLogoutBeforeLinkWrite(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() - client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { - if r.URL.Path == "/token" { - return profileTestResponse(`{"access_token":"proof","token_type":"Bearer"}`), nil - } - return profileTestResponse(`{"id":42}`), nil - })} - a := newConcurrentProfileProvider(t, jw, client, "a") - b := newConcurrentProfileProvider(t, jw, client, "b") - cookie, state := profileStart(t, a, http.MethodGet, "/auth/a/login", nil) - login := profileCallback(a, cookie, state, "proof") - if login.Code != http.StatusFound || len(login.Result().Cookies()) != 1 { - t.Fatalf("initial login: status=%d cookies=%v", login.Code, login.Result().Cookies()) - } - cookie = login.Result().Cookies()[0] - req := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) - req.AddCookie(cookie) - sess := jw.GetSession(req) - if a.CurrentClaims(sess) == nil { - t.Fatal("missing first provider authentication") - } - lock := &holdFirstRotationLock{first: make(chan struct{}), releaseFirst: make(chan struct{})} - a.SessionRotationLock, b.SessionRotationLock = lock, lock - var writes atomic.Int32 - b.LinkEvent = func(current *jaws.Session, _ *http.Request, _ map[string]any) (int, error) { - if a.CurrentClaims(current) == nil { - return http.StatusForbidden, ErrOAuth2MissingSession - } - writes.Add(1) - return 0, nil - } - _, linkState := profileStart(t, b, http.MethodPost, "/auth/b/link", cookie) - callbackDone := make(chan *httptest.ResponseRecorder, 1) - go func() { - callbackRequest := httptest.NewRequest(http.MethodGet, "https://app.example/auth/b/callback?state="+url.QueryEscape(linkState)+"&code=proof", nil) - callbackRequest.AddCookie(cookie) - callback := httptest.NewRecorder() - b.HandleAuthResponse(callback, callbackRequest) - callbackDone <- callback - }() - release := sync.OnceFunc(func() { close(lock.releaseFirst) }) - defer release() - waitFor(t, lock.first, "link callback did not reach shared lock") - b.mu.Lock() - flow := b.oauthCallbacks[sess] - b.mu.Unlock() - if flow.canceled == nil || flow.revoked() { - t.Fatal("link callback was not active before logout") - } - logout := httptest.NewRecorder() - b.HandleLogout(logout, req) - if logout.Code != http.StatusFound || !flow.revoked() || a.CurrentClaims(sess) == nil { - t.Fatalf("second provider logout: status=%d revoked=%t first provider valid=%t", logout.Code, flow.revoked(), a.CurrentClaims(sess) != nil) - } - release() - callback := waitFor(t, callbackDone, "revoked link callback did not finish") - if callback.Code != http.StatusForbidden || writes.Load() != 0 || len(callback.Result().Cookies()) != 0 { - t.Fatalf("revoked link: status=%d writes=%d cookies=%v", callback.Code, writes.Load(), callback.Result().Cookies()) + for _, logoutProvider := range []string{"linked", "original"} { + t.Run(logoutProvider, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/token" { + return profileTestResponse(`{"access_token":"proof","token_type":"Bearer"}`), nil + } + return profileTestResponse(`{"id":42}`), nil + })} + a := newConcurrentProfileProvider(t, jw, client, "a") + b := newConcurrentProfileProvider(t, jw, client, "b") + cookie, state := profileStart(t, a, http.MethodGet, "/auth/a/login", nil) + login := profileCallback(a, cookie, state, "proof") + if login.Code != http.StatusFound || len(login.Result().Cookies()) != 1 { + t.Fatalf("initial login: status=%d cookies=%v", login.Code, login.Result().Cookies()) + } + cookie = login.Result().Cookies()[0] + req := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + req.AddCookie(cookie) + sess := jw.GetSession(req) + if a.CurrentClaims(sess) == nil { + t.Fatal("missing first provider authentication") + } + lock := &holdFirstRotationLock{first: make(chan struct{}), releaseFirst: make(chan struct{})} + a.SessionRotationLock, b.SessionRotationLock = lock, lock + var writes atomic.Int32 + b.LinkEvent = func(current *jaws.Session, _ *http.Request, _ map[string]any) (int, error) { + if a.CurrentClaims(current) == nil { + return http.StatusForbidden, ErrOAuth2MissingSession + } + writes.Add(1) + return 0, nil + } + _, linkState := profileStart(t, b, http.MethodPost, "/auth/b/link", cookie) + callbackDone := make(chan *httptest.ResponseRecorder, 1) + go func() { + callbackRequest := httptest.NewRequest(http.MethodGet, "https://app.example/auth/b/callback?state="+url.QueryEscape(linkState)+"&code=proof", nil) + callbackRequest.AddCookie(cookie) + callback := httptest.NewRecorder() + b.HandleAuthResponse(callback, callbackRequest) + callbackDone <- callback + }() + release := sync.OnceFunc(func() { close(lock.releaseFirst) }) + defer release() + waitFor(t, lock.first, "link callback did not reach shared lock") + b.mu.Lock() + flow := b.oauthCallbacks[sess] + b.mu.Unlock() + if flow.canceled == nil || flow.revoked() { + t.Fatal("link callback was not active before logout") + } + logoutServer := b + if logoutProvider == "original" { + logoutServer = a + } + logout := httptest.NewRecorder() + logoutServer.HandleLogout(logout, req) + if logout.Code != http.StatusFound || flow.revoked() != (logoutProvider == "linked") || (a.CurrentClaims(sess) != nil) != (logoutProvider == "linked") { + t.Fatalf("%s provider logout: status=%d revoked=%t original_valid=%t", logoutProvider, logout.Code, flow.revoked(), a.CurrentClaims(sess) != nil) + } + release() + callback := waitFor(t, callbackDone, "revoked link callback did not finish") + if callback.Code != http.StatusForbidden || writes.Load() != 0 || len(callback.Result().Cookies()) != 0 { + t.Fatalf("revoked link: status=%d writes=%d cookies=%v", callback.Code, writes.Load(), callback.Result().Cookies()) + } + }) } } diff --git a/config.go b/config.go index 279454d..0f162af 100644 --- a/config.go +++ b/config.go @@ -17,9 +17,9 @@ import ( // Config holds the OIDC/OAuth2 settings used by New and NewDebug to construct a Server. // -// RedirectURL, Issuer and ClientID are required. OIDC mode discovers endpoints; -// URL fields may override them. Profile mode requires explicit AuthURL, TokenURL, -// UserInfoURL and ProfileSubject. +// RedirectURL, Issuer and ClientID are required. Setting ProfileSubject selects +// profile mode and requires AuthURL, TokenURL and UserInfoURL. Otherwise OIDC +// mode discovers endpoints; URL fields may override them. type Config struct { RedirectURL string // required. e.g. "https://application.example.com/oauth2/callback" Issuer string // required. e.g. "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0" @@ -35,14 +35,17 @@ type Config struct { // exchange/refresh and UserInfo. All provider requests cap its timeout at 15 // seconds. Discovery/JWKS follow only HTTPS redirects unless AllowInsecureIssuer // is set; token and UserInfo requests follow none. A request context may supply - // a different oauth2.HTTPClient for token and UserInfo requests. + // a different oauth2.HTTPClient for code exchange and UserInfo requests; + // background refresh uses this configured client. HTTPClient *http.Client - Scopes []string // optional; OIDC adds "openid" and "email", profile mode uses only these scopes + Scopes []string // optional; OIDC adds "openid" and "email"; profile mode adds none ClientID string ClientSecret string // ProfileSubject enables OAuth2 profile authentication without an OIDC ID token. // AuthURL, TokenURL and UserInfoURL are required in this mode. It must return // a stable, non-empty provider subject from the authenticated profile response. + // Profile mode sets claims["iss"] to Issuer and claims["sub"] to this subject; + // use a distinct Issuer per provider. // JSON numbers arrive as json.Number to preserve large numeric IDs. // Profile emails can be unverified. For a non-empty admin list, set // Server.RequireVerifiedAdminEmail unless the provider guarantees verified diff --git a/config_test.go b/config_test.go index fa2e9b9..06c65aa 100644 --- a/config_test.go +++ b/config_test.go @@ -227,31 +227,6 @@ func TestConfig_buildContextWithHTTPClient(t *testing.T) { } } -func TestConfig_buildContextDiscoveryStillFollowsRedirect(t *testing.T) { - var issuer string - target := httptest.NewServer(http.HandlerFunc(func(hw http.ResponseWriter, _ *http.Request) { - hw.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(hw).Encode(map[string]any{ - "issuer": issuer, - "authorization_endpoint": issuer + "/auth", - "token_endpoint": issuer + "/token", - "jwks_uri": issuer + "/jwks", - }) - })) - defer target.Close() - redirector := httptest.NewServer(http.HandlerFunc(func(hw http.ResponseWriter, hr *http.Request) { - http.Redirect(hw, hr, target.URL+"/metadata", http.StatusFound) - })) - defer redirector.Close() - issuer = redirector.URL - cfg := &Config{RedirectURL: "https://app.example/callback", Issuer: issuer, - AllowInsecureIssuer: true, ClientID: "client", HTTPClient: redirector.Client()} - got, _, _, err := cfg.buildContext(t.Context(), "") - if err != nil || got == nil || got.Endpoint.TokenURL != issuer+"/token" { - t.Fatalf("redirected discovery: config=%v err=%v", got, err) - } -} - func TestConfigValidateRejectsInsecureOIDCOverrides(t *testing.T) { for _, field := range []string{"AuthURL", "TokenURL", "UserInfoURL"} { t.Run(field, func(t *testing.T) { @@ -296,7 +271,11 @@ func TestConfigBuildContextRejectsInsecureOIDCMetadata(t *testing.T) { {"jwks_uri", "JWKSURI", "", true}, {"jwks_uri", "JWKSURI", "ftp://provider.example/jwks", true}, } { - t.Run(tc.key+"/"+tc.value, func(t *testing.T) { + name := tc.key + "/" + tc.value + if tc.allowInsecure { + name += "/dev" + } + t.Run(name, func(t *testing.T) { var issuer string provider := httptest.NewTLSServer(http.HandlerFunc(func(hw http.ResponseWriter, _ *http.Request) { metadata := map[string]string{ @@ -484,7 +463,11 @@ func TestConfigBuildContextJWKSTimeout(t *testing.T) { t.Run(configuredTimeout.String(), func(t *testing.T) { const issuer = "https://issuer.example" var jwksHits atomic.Int32 - deadlineObserved := make(chan time.Duration, 1) + type observedDeadline struct { + remaining time.Duration + ok bool + } + deadlineObserved := make(chan observedDeadline, 1) client := &http.Client{Timeout: configuredTimeout, Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { switch req.URL.Path { case "/.well-known/openid-configuration": @@ -492,11 +475,10 @@ func TestConfigBuildContextJWKSTimeout(t *testing.T) { case "/jwks": jwksHits.Add(1) deadline, ok := req.Context().Deadline() + deadlineObserved <- observedDeadline{time.Until(deadline), ok} if !ok { - deadlineObserved <- -1 return nil, errors.New("JWKS request has no deadline") } - deadlineObserved <- time.Until(deadline) if configuredTimeout > 0 { <-req.Context().Done() return nil, req.Context().Err() @@ -518,8 +500,8 @@ func TestConfigBuildContextJWKSTimeout(t *testing.T) { if want == 0 { want = oauth2ProviderTimeout } - if remaining := <-deadlineObserved; remaining <= 0 || remaining > want { - t.Fatalf("JWKS deadline %s, want at most %s", remaining, want) + if observed := <-deadlineObserved; !observed.ok || observed.remaining > want { + t.Fatalf("JWKS deadline %+v, want at most %s", observed, want) } }) } diff --git a/doc.go b/doc.go index 2dea1cb..e32e538 100644 --- a/doc.go +++ b/doc.go @@ -2,7 +2,7 @@ // // OIDC mode uses discovery, PKCE, nonce and ID token verification. Profile mode // uses explicit provider endpoints, PKCE and an authenticated profile response -// with an application-supplied stable-subject verifier. The verified claims are +// with an application-supplied subject extractor. The verified claims are // stored in a JaWS session. OIDC attempts refresh before expiry; profile auth // expires after a bounded lifetime. Both modes support admin email gates. // diff --git a/erroauth2callback_test.go b/erroauth2callback_test.go index 4755069..8df9359 100644 --- a/erroauth2callback_test.go +++ b/erroauth2callback_test.go @@ -128,7 +128,7 @@ func Test_handleAuthResponseOAuthErrorCallback(t *testing.T) { nil, ) sess := jw.NewSession(rec, req) - setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime)}) + setTestOAuthFlow(sess, srv, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime)}) srv.HandleAuthResponse(rec, req) if callbackStatus != http.StatusForbidden { t.Fatal(callbackStatus) @@ -159,7 +159,7 @@ func Test_handleAuthResponseOAuthErrorCallback(t *testing.T) { if !strings.Contains(string(body), "oauth2 callback error: access_denied") { t.Fatal(string(body)) } - if flow := sess.Get(oauth2FlowKey); flow != nil { + if flow := sess.Get(srv.sessionKey(oauth2FlowKey)); flow != nil { t.Fatal(flow) } } @@ -199,7 +199,7 @@ func Test_handleAuthResponseOAuthErrorCallbackReceivesSessionEmail(t *testing.T) nil, ) sess := jw.NewSession(rec, req) - setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime)}) + setTestOAuthFlow(sess, srv, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime)}) sess.Set(srv.SessionKey, map[string]any{"email": "user@example.com"}) sess.Set(srv.SessionEmailKey, "user@example.com") diff --git a/expiry_wrapper_test.go b/expiry_wrapper_test.go index 839f9c3..d20b434 100644 --- a/expiry_wrapper_test.go +++ b/expiry_wrapper_test.go @@ -4,7 +4,6 @@ import ( "net/http" "net/http/httptest" "net/url" - "strings" "testing" "time" @@ -17,8 +16,7 @@ func TestProtectedRequestPreservesPendingLoginOnAuthExpiry(t *testing.T) { t.Fatal(err) } defer jw.Close() - calls := 0 - srv := newProfileTestServer(t, jw, &calls) + srv := newProfileTestServer(t, jw, nil) cookie, initialState := profileStart(t, srv, http.MethodGet, "/auth/login", nil) first := profileCallback(srv, cookie, initialState, "first") if first.Code != http.StatusFound { @@ -43,8 +41,12 @@ func TestProtectedRequestPreservesPendingLoginOnAuthExpiry(t *testing.T) { protected.AddCookie(cookie) response := httptest.NewRecorder() srv.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("expired auth reached protected handler") })).ServeHTTP(response, protected) - if response.Code != http.StatusConflict || len(response.Result().Cookies()) != 0 || - response.Header().Get("Location") != "" || !strings.Contains(response.Body.String(), ErrOAuth2LoginInProgress.Error()) { + redirect, err := url.Parse(response.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + if response.Code != http.StatusFound || len(response.Result().Cookies()) != 0 || + redirect.Query().Get("state") != pendingState { t.Fatalf("protected request during login: status=%d location=%q body=%q cookies=%v", response.Code, response.Header().Get("Location"), response.Body.String(), response.Result().Cookies()) } gotState := testOAuthFlow(sess, srv).state diff --git a/jawsauth.go b/jawsauth.go index d1af44c..aff3dd8 100644 --- a/jawsauth.go +++ b/jawsauth.go @@ -33,7 +33,8 @@ type JawsAuth struct { sess *jaws.Session } -// Data returns the verified OIDC or OAuth2 profile claims stored in the session, or nil. +// Data returns the OIDC or OAuth2 profile claims stored in the session, or nil. +// It does not check expiry; use [Server.CurrentClaims] for access decisions. // It is safe to call on a nil or zero-value JawsAuth. func (a *JawsAuth) Data() (x map[string]any) { if a != nil && a.server != nil && a.sess != nil { @@ -42,10 +43,10 @@ func (a *JawsAuth) Data() (x map[string]any) { return } -// Email returns the authenticated email stored in the session, or an empty string. +// Email returns the email stored in the session, or an empty string. // // The value is a parsed address with ASCII letters lowercased. -// Use [JawsAuth.IsAdmin] to check administrator status. +// It does not check expiry; use [Server.CurrentClaims] for access decisions. // It is safe to call on a nil or zero-value JawsAuth. func (a *JawsAuth) Email() (s string) { if a != nil && a.server != nil && a.sess != nil { @@ -65,9 +66,10 @@ func (a *JawsAuth) EmailVerified() (yes bool) { return } -// IsAdmin reports whether the authenticated email is an administrator. +// IsAdmin reports whether the stored session email is an administrator. // // It applies [Server.RequireVerifiedAdminEmail] to non-empty admin lists. +// It does not check login expiry; use [Server.CurrentClaims] for access decisions. // A nil or zero-value JawsAuth follows [Server.IsAdmin]'s nil-server behavior and returns true. func (a *JawsAuth) IsAdmin() (yes bool) { if a == nil || a.server == nil { diff --git a/namespace_test.go b/namespace_test.go index 14a6b4b..6d42458 100644 --- a/namespace_test.go +++ b/namespace_test.go @@ -60,7 +60,7 @@ func TestOAuthFlowNamespacesShareSessionWithoutSharingState(t *testing.T) { } link := httptest.NewRecorder() gitlab.HandleLink(link, linkRequest) - if link.Code != http.StatusFound { + if link.Code != http.StatusSeeOther { t.Fatalf("gitlab link start: %d", link.Code) } sess := jw.GetSession(r) diff --git a/oauth2.go b/oauth2.go index 61e0ac8..fedbe05 100644 --- a/oauth2.go +++ b/oauth2.go @@ -148,7 +148,8 @@ func (srv *Server) HandleLogin(hw http.ResponseWriter, hr *http.Request) { // HandleLink begins a provider flow for linking to the caller's existing // identity. The application must authorize this POST before calling it and -// recheck that identity in LinkEvent when the provider returns. +// recheck that identity in LinkEvent when the provider returns. On success it +// responds with a 303 redirect to the provider. func (srv *Server) HandleLink(hw http.ResponseWriter, hr *http.Request) { switch { case hr.Method != http.MethodPost: @@ -192,24 +193,35 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, pr var flow pendingOAuthFlow srv.mu.Lock() if preservePendingLogin && srv.pendingOrdinaryLoginLocked(sess) { - srv.mu.Unlock() - srv.writeResult(hw, http.StatusConflict, ErrOAuth2LoginInProgress, nil) - return - } - flow = pendingOAuthFlow{ - state: rand.Text(), expiry: time.Now().Add(oauth2FlowLifetime), - link: link, verifier: oauth2.GenerateVerifier(), referrer: location, - } - if srv.profileSubject == nil { - flow.nonce = rand.Text() - } - if !sess.ExtendGrace(oauth2FlowGrace) { - srv.mu.Unlock() - missingSession() - return + // Reissue one pending transaction without replacing the first tab's state. + flow, _ = sess.Get(srv.sessionKey(oauth2FlowKey)).(pendingOAuthFlow) + if flow.state == "" { + srv.mu.Unlock() + if srv.LoginFailed != nil { + email, _ := sess.Get(srv.SessionEmailKey).(string) + if srv.LoginFailed(hw, hr, http.StatusConflict, ErrOAuth2LoginInProgress, email) { + return + } + } + srv.writeResult(hw, http.StatusConflict, ErrOAuth2LoginInProgress, nil) + return + } + } else { + flow = pendingOAuthFlow{ + state: rand.Text(), expiry: time.Now().Add(oauth2FlowLifetime), + link: link, verifier: oauth2.GenerateVerifier(), referrer: location, + } + if srv.profileSubject == nil { + flow.nonce = rand.Text() + } + if !sess.ExtendGrace(oauth2FlowGrace) { + srv.mu.Unlock() + missingSession() + return + } + srv.clearSessionOAuthFlow(sess) + sess.Set(srv.sessionKey(oauth2FlowKey), flow) } - srv.clearSessionOAuthFlow(sess) - sess.Set(srv.sessionKey(oauth2FlowKey), flow) srv.mu.Unlock() authOptions := append([]oauth2.AuthCodeOption{}, srv.Options...) if flow.nonce != "" { @@ -220,6 +232,9 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, pr } hw.Header().Set("Location", location) statusCode = http.StatusFound + if link { + statusCode = http.StatusSeeOther + } } SetHeaders(hw, srv.ishttps) hw.WriteHeader(statusCode) @@ -289,7 +304,7 @@ var ErrOAuth2ExpiredState = errors.New("oauth2 expired state") // ErrOAuth2MissingPKCEVerifier means the callback session did not contain the required PKCE verifier. var ErrOAuth2MissingPKCEVerifier = errors.New("oauth2 missing pkce verifier") -// ErrOAuth2LoginInProgress means an ordinary login is pending or its callback is running. +// ErrOAuth2LoginInProgress means a callback has consumed the pending login state. var ErrOAuth2LoginInProgress = errors.New("oauth2 login already in progress") // ErrOAuth2InvalidOrigin means a link start request failed the same-origin check. @@ -427,13 +442,11 @@ func (srv *Server) verifyOAuthIdentity(ctx context.Context, token *oauth2.Token, if verifyErr != nil { return nil, nil, time.Time{}, status, verifyErr } - err = ErrOIDCMissingNonce if nonce == "" { - return + return nil, nil, time.Time{}, status, ErrOIDCMissingNonce } - err = ErrOIDCNonceMismatch if idToken.Nonce != nonce { - return + return nil, nil, time.Time{}, status, ErrOIDCNonceMismatch } claims = verifiedClaims tokenSource = srv.oauth2cfg.TokenSource(srv.oauth2Context(context.Background()), token) @@ -449,7 +462,7 @@ func (srv *Server) verifyOAuthIdentity(ctx context.Context, token *oauth2.Token, status = http.StatusBadGateway claims, err = srv.fetchUserInfo(ctx, srv.userinfoUrl, oauth2.StaticTokenSource(token)) if err != nil { - return + return nil, nil, time.Time{}, status, err } // UserInfo can outlive the token; reject it before calling application code. if !profileTokenUnexpired(token) { @@ -461,10 +474,7 @@ func (srv *Server) verifyOAuthIdentity(ctx context.Context, token *oauth2.Token, } var subject string subject, err = srv.profileSubject(claims) - if err != nil { - _ = srv.Jaws.Log(err) - } - if err != nil || subject == "" { + if srv.Jaws.Log(err) != nil || subject == "" { return nil, nil, time.Time{}, status, ErrOAuth2InvalidProfile } claims["iss"] = srv.issuer @@ -518,7 +528,10 @@ func (srv *Server) completeLink(sess *jaws.Session, hr *http.Request, claims map srv.SessionRotationLock.Lock() defer srv.SessionRotationLock.Unlock() } - if srv.LinkEvent == nil || flow.revoked() || srv.Jaws.GetSession(hr) != sess { + if srv.LinkEvent == nil { + return http.StatusInternalServerError, ErrOAuth2NotConfigured + } + if flow.revoked() || srv.Jaws.GetSession(hr) != sess { return http.StatusForbidden, ErrOAuth2MissingSession } if err = srv.identityExpiryError(expiry); err != nil { @@ -546,11 +559,6 @@ func (srv *Server) completeLogin(hw http.ResponseWriter, hr *http.Request, old * } return } - if flow.revoked() { - // Do not stop the old auth timer or fire LogoutEvent for a canceled login. - http.SetCookie(hw, rotated.Close()) - return old, http.StatusForbidden, ErrOAuth2MissingSession - } srv.mu.Lock() hadTimer := srv.authTimers[old.ID()] != nil srv.stopSessionAuthTimerLocked(old, nil) @@ -572,7 +580,7 @@ func (srv *Server) completeLogin(hw http.ResponseWriter, hr *http.Request, old * return sess, http.StatusFound, nil } -func (srv *Server) completeOAuthCallback(hw http.ResponseWriter, hr *http.Request, ctx context.Context, config *oauth2.Config, sess *jaws.Session, flow pendingOAuthFlow) (*jaws.Session, int, error) { +func (srv *Server) completeOAuthCallback(ctx context.Context, hw http.ResponseWriter, hr *http.Request, sess *jaws.Session, flow pendingOAuthFlow) (*jaws.Session, int, error) { status, err := oauth2CallbackError(http.StatusBadRequest, hr) if err != nil { return sess, status, err @@ -580,7 +588,7 @@ func (srv *Server) completeOAuthCallback(hw http.ResponseWriter, hr *http.Reques if flow.verifier == "" { return sess, http.StatusBadRequest, ErrOAuth2MissingPKCEVerifier } - token, err := config.Exchange(ctx, hr.FormValue("code") /* #nosec G120 */, oauth2.VerifierOption(flow.verifier)) + token, err := srv.oauth2cfg.Exchange(ctx, hr.FormValue("code") /* #nosec G120 */, oauth2.VerifierOption(flow.verifier)) if srv.Jaws.Log(err) != nil { return sess, http.StatusBadRequest, err } @@ -628,13 +636,13 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) } statusCode, err := http.StatusInternalServerError, ErrOAuth2NotConfigured var flow pendingOAuthFlow - if oauth2Config := srv.oauth2cfg; oauth2Config != nil { + if srv.oauth2cfg != nil { statusCode, err = http.StatusBadRequest, ErrOAuth2MissingSession if sess != nil { flow, err = srv.takeOAuthFlow(sess, hr.FormValue("state") /* #nosec G120 */) if err == nil { defer srv.finishOAuthFlow(sess, flow) - sess, statusCode, err = srv.completeOAuthCallback(hw, hr, authctx, oauth2Config, sess, flow) + sess, statusCode, err = srv.completeOAuthCallback(authctx, hw, hr, sess, flow) if err == nil && !flow.link { sessEmail, _ = sess.Get(srv.SessionEmailKey).(string) } diff --git a/oauth2_callback_edge_test.go b/oauth2_callback_edge_test.go index a407286..e697a95 100644 --- a/oauth2_callback_edge_test.go +++ b/oauth2_callback_edge_test.go @@ -123,7 +123,7 @@ func TestOIDCExpiryDuringUserInfoRejected(t *testing.T) { }) } -func TestProfileExpiryDuringSubjectAndFinalStoreRejected(t *testing.T) { +func TestProfileExpiryDuringSubjectRejected(t *testing.T) { synctest.Test(t, func(t *testing.T) { jw, err := jaws.New() if err != nil { @@ -143,18 +143,6 @@ func TestProfileExpiryDuringSubjectAndFinalStoreRejected(t *testing.T) { if !errors.Is(err, ErrOAuth2InvalidProfile) || claims != nil || status != http.StatusUnauthorized { t.Fatalf("expiry during subject: claims=%v status=%d err=%v", claims, status, err) } - - req := httptest.NewRequest(http.MethodGet, "https://app.example/", nil) - sess := jw.NewSession(httptest.NewRecorder(), req) - srv.SessionKey = "claims" - srv.SessionTokenKey = "token" - srv.SessionEmailKey = "email" - srv.SessionEmailVerifiedKey = "verified" - canceled := make(chan struct{}) - err = srv.storeSessionAuthClaimsIfActive(sess, map[string]any{"sub": "person"}, nil, time.Now().Add(-time.Second), nil, canceled) - if !errors.Is(err, ErrOAuth2InvalidProfile) || sess.Get(srv.SessionKey) != nil { - t.Fatalf("final store of expired profile: claims=%v err=%v", sess.Get(srv.SessionKey), err) - } }) } diff --git a/oauth2_flow_guards_test.go b/oauth2_flow_guards_test.go index b30c09f..1ce3113 100644 --- a/oauth2_flow_guards_test.go +++ b/oauth2_flow_guards_test.go @@ -5,7 +5,6 @@ import ( "errors" "net/http" "net/http/httptest" - "net/url" "strings" "sync" "testing" @@ -28,8 +27,7 @@ func TestCallbackRevokedAfterStoreBalancesEvents(t *testing.T) { t.Fatal(err) } defer jw.Close() - calls := 0 - srv := newProfileTestServer(t, jw, &calls) + srv := newProfileTestServer(t, jw, nil) cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) r := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) r.AddCookie(cookie) @@ -58,6 +56,34 @@ func TestCallbackRevokedAfterStoreBalancesEvents(t *testing.T) { } } +func TestCallbackRejectsAuthInvalidatedByLoginEvent(t *testing.T) { + for _, action := range []string{"expired auth", "closed session"} { + t.Run(action, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + srv := newProfileTestServer(t, jw, nil) + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + called := false + srv.LoginEvent = func(sess *jaws.Session, _ *http.Request) { + called = true + if action == "expired auth" { + sess.Set(srv.sessionKey(oauth2AuthExpiryKey), time.Now().Add(-time.Second)) + } else { + sess.Close() + } + } + response := profileCallback(srv, cookie, state, "first") + cookies := response.Result().Cookies() + if !called || response.Code != http.StatusForbidden || response.Header().Get("Location") != "" || len(cookies) != 2 || cookies[1].MaxAge >= 0 { + t.Fatalf("invalidated login: called=%t status=%d location=%q cookies=%v", called, response.Code, response.Header().Get("Location"), cookies) + } + }) + } +} + func TestProfileTokenExpiresBetweenRotationAndStore(t *testing.T) { synctest.Test(t, func(t *testing.T) { jw, err := jaws.New() @@ -65,8 +91,7 @@ func TestProfileTokenExpiresBetweenRotationAndStore(t *testing.T) { t.Fatal(err) } defer jw.Close() - calls := 0 - srv := newProfileTestServer(t, jw, &calls) + srv := newProfileTestServer(t, jw, nil) cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) first := profileCallback(srv, cookie, state, "first") if first.Code != http.StatusFound { @@ -79,7 +104,7 @@ func TestProfileTokenExpiresBetweenRotationAndStore(t *testing.T) { if old == nil { t.Fatal("old session missing") } - state = startProfileLoginWithCookie(t, srv, cookie) + _, state = profileStart(t, srv, http.MethodGet, "/auth/login", cookie) srv.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { if req.URL.Path == "/token" { return profileTestResponse(`{"access_token":"short","token_type":"Bearer","expires_in":30}`), nil @@ -162,8 +187,7 @@ func TestLinkRejectsInvalidEventResults(t *testing.T) { t.Fatal(err) } defer jw.Close() - calls := 0 - srv := newProfileTestServer(t, jw, &calls) + srv := newProfileTestServer(t, jw, nil) cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) first := profileCallback(srv, cookie, state, "first") if first.Code != http.StatusFound { @@ -191,8 +215,7 @@ func TestLinkRejectsIdentityThatExpiresWhileWaitingForLock(t *testing.T) { t.Fatal(err) } defer jw.Close() - calls := 0 - srv := newProfileTestServer(t, jw, &calls) + srv := newProfileTestServer(t, jw, nil) lock := &holdFirstRotationLock{first: make(chan struct{}), releaseFirst: make(chan struct{})} srv.SessionRotationLock = lock release := sync.OnceFunc(func() { close(lock.releaseFirst) }) @@ -250,8 +273,7 @@ func TestLinkWithoutRefererReturnsHome(t *testing.T) { t.Fatal(err) } defer jw.Close() - calls := 0 - srv := newProfileTestServer(t, jw, &calls) + srv := newProfileTestServer(t, jw, nil) cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) first := profileCallback(srv, cookie, state, "first") if first.Code != http.StatusFound { @@ -290,15 +312,14 @@ func TestCallbackClosesRotatedSessionOnStoreFailure(t *testing.T) { t.Fatal(err) } defer jw.Close() - calls := 0 - srv := newProfileTestServer(t, jw, &calls) + srv := newProfileTestServer(t, jw, nil) cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) first := profileCallback(srv, cookie, state, "first") if first.Code != http.StatusFound { t.Fatalf("sign-in: %d", first.Code) } cookie = first.Result().Cookies()[0] - state = startProfileLoginWithCookie(t, srv, cookie) + _, state = profileStart(t, srv, http.MethodGet, "/auth/login", cookie) closedRotated := false srv.LogoutEvent = func(old *jaws.Session, r *http.Request) { if rotated := jw.GetSession(r); rotated != nil && rotated != old { @@ -314,22 +335,6 @@ func TestCallbackClosesRotatedSessionOnStoreFailure(t *testing.T) { } } -func startProfileLoginWithCookie(t *testing.T, srv *Server, cookie *http.Cookie) string { - t.Helper() - start := httptest.NewRequest(http.MethodGet, "https://app.example/auth/login", nil) - start.AddCookie(cookie) - begin := httptest.NewRecorder() - srv.HandleLogin(begin, start) - if begin.Code != http.StatusFound { - t.Fatalf("second start: %d", begin.Code) - } - providerURL, err := url.Parse(begin.Header().Get("Location")) - if err != nil || providerURL.Query().Get("state") == "" { - t.Fatalf("second provider redirect: %v %q", err, begin.Header().Get("Location")) - } - return providerURL.Query().Get("state") -} - type revokeAfterRotationLock struct { sync.Mutex logout func() @@ -350,8 +355,7 @@ func TestCallbackRevokedAfterRotationClosesNewSession(t *testing.T) { t.Fatal(err) } defer jw.Close() - calls := 0 - srv := newProfileTestServer(t, jw, &calls) + srv := newProfileTestServer(t, jw, nil) cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) first := profileCallback(srv, cookie, state, "first") if first.Code != http.StatusFound { @@ -364,7 +368,7 @@ func TestCallbackRevokedAfterRotationClosesNewSession(t *testing.T) { if old == nil { t.Fatal("old session missing") } - state = startProfileLoginWithCookie(t, srv, cookie) + _, state = profileStart(t, srv, http.MethodGet, "/auth/login", cookie) revoked := false srv.SessionRotationLock = &revokeAfterRotationLock{logout: func() { revoked = srv.Logout(old, nil) diff --git a/oauth2_redirect_test.go b/oauth2_redirect_test.go index 99f6447..a300708 100644 --- a/oauth2_redirect_test.go +++ b/oauth2_redirect_test.go @@ -67,18 +67,6 @@ func TestOAuthTokenRequestsDoNotFollowRedirect(t *testing.T) { ctx = context.WithValue(ctx, oauth2.HTTPClient, client) } authctx := srv.oauth2Context(ctx) - gotClient, ok := authctx.Value(oauth2.HTTPClient).(*http.Client) - if !ok || gotClient == nil || gotClient == client || gotClient.Timeout != client.Timeout || authctx.Done() != ctx.Done() { - t.Fatal("OAuth context did not clone the selected client or retain its timeout/context") - } - if srv.Jaws == nil { - if gotClient.Transport != client.Transport { - t.Fatal("OAuth client lost the selected transport") - } - } else if wrapped, ok := gotClient.Transport.(debugOAuth2Transport); !ok || wrapped.next != client.Transport { - t.Fatal("OAuth client lost the selected transport or debug wrapper") - } - cfg := oauth2.Config{ClientID: "client", ClientSecret: "client-secret", Endpoint: oauth2.Endpoint{TokenURL: provider.URL + "/token", AuthStyle: oauth2.AuthStyleInParams}} var err error diff --git a/oauth2_test.go b/oauth2_test.go index 83014c2..f9dcd96 100644 --- a/oauth2_test.go +++ b/oauth2_test.go @@ -210,8 +210,8 @@ func Test_handleLoginGeneratesOpaqueState(t *testing.T) { } flow := testOAuthFlow(sess, nil) state := flow.state - if state == "" { - t.Fatal("missing state") + if len(state) != 26 { + t.Fatalf("state length = %d, want 26", len(state)) } verifier := flow.verifier if verifier == "" { @@ -229,7 +229,7 @@ func Test_handleLoginGeneratesOpaqueState(t *testing.T) { t.Fatal(gotMethod) } nonce := flow.nonce - if nonce == "" || nonce == state { + if len(nonce) != 26 || nonce == state { t.Fatalf("nonce %q is missing or matches state", nonce) } if gotNonce := values.Get("nonce"); gotNonce != nonce { @@ -310,7 +310,7 @@ func Test_handleLogoutClearsPendingOAuthState(t *testing.T) { setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime), verifier: "verifier123", nonce: "nonce123", referrer: "/protected"}) sess.Set(srv.SessionKey, map[string]any{"email": "user@example.com"}) sess.Set(srv.SessionTokenKey, oauth2.StaticTokenSource(makeOAuth2Token("access", "", ""))) - sess.Set(oauth2AuthExpiryKey, time.Now().Add(time.Hour)) + sess.Set(srv.sessionKey(oauth2AuthExpiryKey), time.Now().Add(time.Hour)) sess.Set(srv.SessionEmailKey, "user@example.com") sess.Set(srv.SessionEmailVerifiedKey, true) @@ -323,10 +323,10 @@ func Test_handleLogoutClearsPendingOAuthState(t *testing.T) { t.Fatal(resp.Status) } closeResponseBody(t, resp) - if value := sess.Get(oauth2FlowKey); value != nil { + if value := sess.Get(srv.sessionKey(oauth2FlowKey)); value != nil { t.Fatal(value) } - if value := sess.Get(oauth2AuthExpiryKey); value != nil { + if value := sess.Get(srv.sessionKey(oauth2AuthExpiryKey)); value != nil { t.Fatal(value) } } @@ -467,7 +467,7 @@ func Test_handleAuthResponseUsesPKCEVerifier(t *testing.T) { if receivedProviderErr != nil { t.Fatal(receivedProviderErr) } - if value := sess.Get(oauth2FlowKey); value != nil { + if value := sess.Get(srv.sessionKey(oauth2FlowKey)); value != nil { t.Fatal(value) } if gotEmail, _ := sess.Get(srv.SessionEmailKey).(string); gotEmail != "idtoken@example.com" { @@ -650,7 +650,7 @@ func Test_handleAuthResponseWrongStatePreservesCurrentAuth(t *testing.T) { expiry := time.Now().Add(time.Hour).Truncate(time.Second) sess.Set(srv.SessionKey, claims) sess.Set(srv.SessionTokenKey, tokenSource) - sess.Set(oauth2AuthExpiryKey, expiry) + sess.Set(srv.sessionKey(oauth2AuthExpiryKey), expiry) sess.Set(srv.SessionEmailKey, "user@example.com") sess.Set(srv.SessionEmailVerifiedKey, true) setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "legit", expiry: time.Now().Add(oauth2FlowLifetime), verifier: oauth2.GenerateVerifier(), nonce: "nonce", referrer: "/secure"}) @@ -675,7 +675,7 @@ func Test_handleAuthResponseWrongStatePreservesCurrentAuth(t *testing.T) { if got := sess.Get(srv.SessionTokenKey); got != tokenSource { t.Fatal(got) } - if got, _ := sess.Get(oauth2AuthExpiryKey).(time.Time); !got.Equal(expiry) { + if got, _ := sess.Get(srv.sessionKey(oauth2AuthExpiryKey)).(time.Time); !got.Equal(expiry) { t.Fatal(got) } if got, _ := sess.Get(srv.SessionEmailKey).(string); got != "user@example.com" { @@ -725,6 +725,11 @@ func Test_extractEmailVerified(t *testing.T) { claims: map[string]any{"email_verified": float64(1)}, want: true, }, + {name: "numeric zero", claims: map[string]any{"email_verified": json.Number("0")}}, + {name: "invalid number", claims: map[string]any{"email_verified": json.Number("x")}}, + {name: "false string", claims: map[string]any{"email_verified": "false"}}, + {name: "invalid string", claims: map[string]any{"email_verified": "junk"}}, + {name: "float zero", claims: map[string]any{"email_verified": float64(0)}}, } for _, tc := range testCases { @@ -781,26 +786,17 @@ func TestServer_fetchUserInfoRespectsRequestCancellation(t *testing.T) { oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "access"})) result <- err }() - select { - case <-started: - case <-time.After(3 * time.Second): - t.Fatal("UserInfo request did not start") - } + waitFor(t, started, "UserInfo request did not start") cancel() - select { - case err := <-result: - if !errors.Is(err, context.Canceled) { - t.Fatalf("canceled UserInfo request: %v", err) - } - case <-time.After(3 * time.Second): - t.Fatal("UserInfo request ignored cancellation") + if err := waitFor(t, result, "UserInfo request ignored cancellation"); !errors.Is(err, context.Canceled) { + t.Fatalf("canceled UserInfo request: %v", err) } } func TestServer_fetchUserInfoOversizedStatusError(t *testing.T) { provider := httptest.NewServer(http.HandlerFunc(func(hw http.ResponseWriter, _ *http.Request) { hw.WriteHeader(http.StatusServiceUnavailable) - _, _ = io.WriteString(hw, strings.Repeat("x", 32769)) + _, _ = io.WriteString(hw, strings.Repeat("x", maxUserInfoBytes+1)) })) defer provider.Close() diff --git a/oauth_flow_grace_test.go b/oauth_flow_grace_test.go index 172d953..541840d 100644 --- a/oauth_flow_grace_test.go +++ b/oauth_flow_grace_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "strings" "testing" "testing/synctest" @@ -37,14 +38,13 @@ func TestOAuthProviderVisitKeepsOnlyPendingSessionAlive(t *testing.T) { t.Fatalf("cookies: %d", len(cookies)) } callback := httptest.NewRequest(http.MethodGet, "https://app.example/auth/callback", nil) - callback.RemoteAddr = start.RemoteAddr callback.AddCookie(cookies[0]) unrelated := httptest.NewRequest(http.MethodGet, "https://app.example/other", nil) unrelated.RemoteAddr = "192.0.2.2:1234" if jw.NewSession(httptest.NewRecorder(), unrelated) == nil { t.Fatal("unrelated session missing") } - time.Sleep(61 * time.Second) + time.Sleep(time.Minute + time.Second) if jw.GetSession(callback) == nil { t.Fatal("pending preauth session expired during provider visit") } @@ -65,8 +65,7 @@ func TestPendingLoginProtectedRequestDoesNotRestartOrRenewGrace(t *testing.T) { t.Fatal(err) } defer jw.Close() - calls := 0 - srv := newProfileTestServer(t, jw, &calls) + srv := newProfileTestServer(t, jw, nil) cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) protected := httptest.NewRequest(http.MethodGet, "https://app.example/protected", nil) protected.AddCookie(cookie) @@ -75,7 +74,7 @@ func TestPendingLoginProtectedRequestDoesNotRestartOrRenewGrace(t *testing.T) { srv.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("pending login reached protected handler") })).ServeHTTP(response, protected) - if response.Code != http.StatusConflict || response.Header().Get("Location") != "" || !strings.Contains(response.Body.String(), ErrOAuth2LoginInProgress.Error()) { + if response.Code != http.StatusFound || response.Header().Get("Location") == "" { t.Fatalf("pending login: status=%d location=%q body=%q", response.Code, response.Header().Get("Location"), response.Body.String()) } stored := testOAuthFlow(jw.GetSession(protected), srv) @@ -89,6 +88,36 @@ func TestPendingLoginProtectedRequestDoesNotRestartOrRenewGrace(t *testing.T) { }) } +func TestExpiredPendingLoginStartsFreshFlow(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + srv := newProfileTestServer(t, jw, nil) + cookie, oldState := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + request := httptest.NewRequest(http.MethodGet, "https://app.example/protected", nil) + request.AddCookie(cookie) + sess := jw.GetSession(request) + if sess == nil || jw.NewRequest(httptest.NewRecorder(), request).Session() != sess { + t.Fatal("pending session not attached to live page") + } + time.Sleep(oauth2FlowLifetime + time.Second) + response := httptest.NewRecorder() + srv.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("expired pending flow reached protected handler") + })).ServeHTTP(response, request) + if response.Code != http.StatusFound || jw.GetSession(request) != sess { + t.Fatalf("fresh login: status=%d session=%v", response.Code, jw.GetSession(request)) + } + location, err := url.Parse(response.Header().Get("Location")) + if err != nil || location.Query().Get("state") == "" || location.Query().Get("state") == oldState || testOAuthFlow(sess, srv).state != location.Query().Get("state") { + t.Fatalf("expired flow was reused: location=%q err=%v", response.Header().Get("Location"), err) + } + }) +} + func TestOAuthFlowExpiresEvenWithLiveSession(t *testing.T) { synctest.Test(t, func(t *testing.T) { jw, err := jaws.New() @@ -112,7 +141,6 @@ func TestOAuthFlowExpiresEvenWithLiveSession(t *testing.T) { } cookies := response.Result().Cookies() callback := httptest.NewRequest(http.MethodGet, "https://app.example/auth/callback", nil) - callback.RemoteAddr = start.RemoteAddr callback.AddCookie(cookies[0]) sess := jw.GetSession(callback) if sess == nil { @@ -130,8 +158,8 @@ func TestOAuthFlowExpiresEvenWithLiveSession(t *testing.T) { } result := httptest.NewRecorder() srv.HandleAuthResponse(result, callback) - if result.Code != http.StatusBadRequest || !strings.Contains(result.Body.String(), ErrOAuth2ExpiredState.Error()) || sess.Get(oauth2FlowKey) != nil || providerCalls != 0 { - t.Fatalf("expired flow: status=%d state=%v provider_calls=%d body=%q", result.Code, sess.Get(oauth2FlowKey), providerCalls, result.Body.String()) + if result.Code != http.StatusBadRequest || !strings.Contains(result.Body.String(), ErrOAuth2ExpiredState.Error()) || sess.Get(srv.sessionKey(oauth2FlowKey)) != nil || providerCalls != 0 { + t.Fatalf("expired flow: status=%d state=%v provider_calls=%d body=%q", result.Code, sess.Get(srv.sessionKey(oauth2FlowKey)), providerCalls, result.Body.String()) } }) } @@ -170,9 +198,8 @@ func TestOIDCInitialCallbackAfterProviderVisit(t *testing.T) { "iss": "https://issuer.example", "aud": "app", "sub": "subject-1", "nonce": nonce, "exp": time.Now().Add(time.Hour).Unix(), "iat": time.Now().Unix(), }) - time.Sleep(61 * time.Second) + time.Sleep(time.Minute + time.Second) callback := httptest.NewRequest(http.MethodGet, "https://app.example/auth/callback?state="+state+"&code=code", nil) - callback.RemoteAddr = start.RemoteAddr callback.AddCookie(cookie) result := httptest.NewRecorder() srv.HandleAuthResponse(result, callback) @@ -184,7 +211,6 @@ func TestOIDCInitialCallbackAfterProviderVisit(t *testing.T) { t.Fatalf("rotated cookies: %d", len(rotated)) } authorized := httptest.NewRequest(http.MethodGet, "https://app.example/", nil) - authorized.RemoteAddr = start.RemoteAddr authorized.AddCookie(rotated[0]) sess := jw.GetSession(authorized) if sess == nil || sess.Get(srv.SessionKey).(map[string]any)["sub"] != "subject-1" { @@ -230,14 +256,13 @@ func TestOAuthLinkCallbackNearDeadlineCanFinishProviderExchange(t *testing.T) { } startResult := httptest.NewRecorder() srv.HandleLink(startResult, start) - if startResult.Code != http.StatusFound { + if startResult.Code != http.StatusSeeOther { t.Fatalf("link start: %d", startResult.Code) } state := testOAuthFlow(sess, nil).state cookie := created.Result().Cookies()[0] time.Sleep(oauth2FlowLifetime - time.Second) callback := httptest.NewRequest(http.MethodGet, "https://app.example/auth/callback?state="+state+"&code=code", nil) - callback.RemoteAddr = start.RemoteAddr callback.AddCookie(cookie) result := httptest.NewRecorder() srv.HandleAuthResponse(result, callback) diff --git a/profile_link_test.go b/profile_link_test.go index da06dd4..52d5d3d 100644 --- a/profile_link_test.go +++ b/profile_link_test.go @@ -40,36 +40,15 @@ func TestOAuthProfileConfigRequiresExplicitSecureEndpoints(t *testing.T) { if err := cfg.Validate(); !errors.Is(err, ErrConfigEndpointMustBeHTTPS) { t.Fatalf("insecure profile URL: %v", err) } -} - -func TestOAuthProfileConfigInsecureModeAllowsHTTPOnly(t *testing.T) { - base := Config{RedirectURL: "https://app.example/auth/callback", Issuer: "https://provider.example", - AuthURL: "https://provider.example/authorize", TokenURL: "https://provider.example/token", - UserInfoURL: "https://provider.example/user", ClientID: "app", AllowInsecureIssuer: true, - ProfileSubject: func(map[string]any) (string, error) { return "42", nil }} - for _, tc := range []struct { - name string - set func(*Config) - want error - }{ - {"issuer", func(c *Config) { c.Issuer = "ftp://provider.example" }, ErrConfigIssuerMustBeHTTPS}, - {"authorize", func(c *Config) { c.AuthURL = "ftp://provider.example/authorize" }, ErrConfigEndpointMustBeHTTPS}, - {"token", func(c *Config) { c.TokenURL = "ftp://provider.example/token" }, ErrConfigEndpointMustBeHTTPS}, - {"userinfo", func(c *Config) { c.UserInfoURL = "ftp://provider.example/user" }, ErrConfigEndpointMustBeHTTPS}, - } { - t.Run(tc.name, func(t *testing.T) { - cfg := base - tc.set(&cfg) - if err := cfg.Validate(); !errors.Is(err, tc.want) { - t.Fatalf("scheme validation: %v", err) - } - }) + cfg.AllowInsecureIssuer = true + cfg.Issuer = "ftp://provider.example" + if err := cfg.Validate(); !errors.Is(err, ErrConfigIssuerMustBeHTTPS) { + t.Fatalf("non-HTTP dev issuer: %v", err) } - base.Issuer = "http://provider.example" - base.AuthURL = "http://provider.example/authorize" - base.TokenURL = "http://provider.example/token" - base.UserInfoURL = "http://provider.example/user" - if err := base.Validate(); err != nil { + cfg.Issuer = "http://provider.example" + cfg.AuthURL = "http://provider.example/authorize" + cfg.TokenURL = "http://provider.example/token" + if err := cfg.Validate(); err != nil { t.Fatalf("HTTP dev endpoints: %v", err) } } @@ -162,8 +141,7 @@ func TestOAuthProfileRotationLockDoesNotBlockProvider(t *testing.T) { t.Fatal(err) } defer jw.Close() - calls := 0 - srv := newProfileTestServer(t, jw, &calls) + srv := newProfileTestServer(t, jw, nil) providerReached := make(chan struct{}) srv.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/token" { @@ -195,18 +173,23 @@ func TestOAuthProfileRotationLockDoesNotBlockProvider(t *testing.T) { } } -func TestOAuthFailedCallbackDoesNotEraseNewFlow(t *testing.T) { +func TestOAuthFailedCallbackDoesNotEraseNewCallback(t *testing.T) { jw, err := jaws.New() if err != nil { t.Fatal(err) } defer jw.Close() entered := make(chan struct{}) + secondEntered := make(chan struct{}) release := make(chan struct{}) + secondRelease := make(chan struct{}) var enterOnce sync.Once var releaseOnce sync.Once + var secondReleaseOnce sync.Once releaseCallback := func() { releaseOnce.Do(func() { close(release) }) } + releaseSecond := func() { secondReleaseOnce.Do(func() { close(secondRelease) }) } defer releaseCallback() + defer releaseSecond() client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/token" { if err := r.ParseForm(); err != nil { @@ -217,6 +200,8 @@ func TestOAuthFailedCallbackDoesNotEraseNewFlow(t *testing.T) { <-release return nil, errors.New("provider unavailable") } + close(secondEntered) + <-secondRelease return profileTestResponse(`{"access_token":"secret","token_type":"Bearer"}`), nil } return profileTestResponse(`{"id":42}`), nil @@ -246,18 +231,30 @@ func TestOAuthFailedCallbackDoesNotEraseNewFlow(t *testing.T) { t.Fatal(err) } secondState := location.Query().Get("state") + secondResult := make(chan *httptest.ResponseRecorder, 1) + go func() { secondResult <- profileCallback(srv, cookie, secondState, "second") }() + waitFor(t, secondEntered, "second callback did not reach provider") releaseCallback() - if code := waitFor(t, firstResult, "failed callback did not finish"); code == http.StatusFound { - t.Fatal("failed callback signed in") + if code := waitFor(t, firstResult, "failed callback did not finish"); code != http.StatusBadRequest { + t.Fatalf("failed callback status = %d", code) } sess := jw.GetSession(r) - if sess == nil || testOAuthFlow(sess, srv).state != secondState || - testOAuthFlow(sess, srv).referrer != "/second" { - t.Fatal("failed callback erased the newer flow") + if sess == nil { + t.Fatal("pending session missing") + } + srv.mu.Lock() + active := srv.oauthCallbacks[sess] + srv.mu.Unlock() + if active.canceled == nil || active.referrer != "/second" || active.revoked() { + t.Fatal("failed callback erased the newer callback") } - second := profileCallback(srv, cookie, secondState, "second") - if second.Code != http.StatusFound || second.Header().Get("Location") != "/second" { - t.Fatalf("second callback: status=%d location=%q", second.Code, second.Header().Get("Location")) + if !srv.Logout(sess, nil) || !active.revoked() { + t.Fatal("logout did not revoke the newer callback") + } + releaseSecond() + second := waitFor(t, secondResult, "revoked second callback did not finish") + if second.Code != http.StatusForbidden || second.Header().Get("Location") != "" || len(second.Result().Cookies()) != 0 { + t.Fatalf("second callback: status=%d location=%q cookies=%v", second.Code, second.Header().Get("Location"), second.Result().Cookies()) } } @@ -273,8 +270,7 @@ func TestOAuthProfileCallbackCanceledBeforeRotation(t *testing.T) { release := make(chan struct{}) var releaseOnce sync.Once defer releaseOnce.Do(func() { close(release) }) - calls := 0 - srv := newProfileTestServer(t, jw, &calls) + srv := newProfileTestServer(t, jw, nil) srv.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/token" { close(entered) @@ -338,8 +334,7 @@ func TestOAuthProfileCallbackCanceledBeforeFinalStore(t *testing.T) { t.Fatal(err) } defer jw.Close() - calls := 0 - srv := newProfileTestServer(t, jw, &calls) + srv := newProfileTestServer(t, jw, nil) factory := &testAuthTimerFactory{} srv.authTimerAfterFunc = factory.after cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) @@ -354,8 +349,10 @@ func TestOAuthProfileCallbackCanceledBeforeFinalStore(t *testing.T) { if old == nil { t.Fatal("missing original session") } - state = startProfileLoginWithCookie(t, srv, cookie) + _, state = profileStart(t, srv, http.MethodGet, "/auth/login", cookie) canceled := false + logins := 0 + srv.LoginEvent = func(*jaws.Session, *http.Request) { logins++ } srv.LogoutEvent = func(sess *jaws.Session, _ *http.Request) { if sess == old && !canceled { canceled = true @@ -364,9 +361,9 @@ func TestOAuthProfileCallbackCanceledBeforeFinalStore(t *testing.T) { } w := profileCallback(srv, cookie, state, "second") cookies := w.Result().Cookies() - if !canceled || w.Code != http.StatusForbidden || w.Header().Get("Location") != "" || + if !canceled || logins != 0 || w.Code != http.StatusForbidden || w.Header().Get("Location") != "" || len(cookies) != 2 || cookies[1].MaxAge >= 0 || cookies[1].Value != cookies[0].Value { - t.Fatalf("canceled before store: canceled=%t status=%d location=%q cookies=%v", canceled, w.Code, w.Header().Get("Location"), cookies) + t.Fatalf("canceled before store: canceled=%t logins=%d status=%d location=%q cookies=%v", canceled, logins, w.Code, w.Header().Get("Location"), cookies) } request = httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) request.AddCookie(cookies[0]) @@ -385,8 +382,7 @@ func TestOAuthProfileLinkCanceledByLogout(t *testing.T) { release := make(chan struct{}) var releaseOnce sync.Once defer releaseOnce.Do(func() { close(release) }) - calls := 0 - srv := newProfileTestServer(t, jw, &calls) + srv := newProfileTestServer(t, jw, nil) srv.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/token" { if err := r.ParseForm(); err != nil { @@ -431,6 +427,53 @@ func TestOAuthProfileLinkCanceledByLogout(t *testing.T) { } } +func TestOAuthProfileLinkRejectsReplacedSession(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + srv := newProfileTestServer(t, jw, nil) + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + login := profileCallback(srv, cookie, state, "first") + if login.Code != http.StatusFound || len(login.Result().Cookies()) != 1 { + t.Fatalf("initial login: status=%d cookies=%v", login.Code, login.Result().Cookies()) + } + cookie = login.Result().Cookies()[0] + entered := make(chan struct{}) + release := make(chan struct{}) + releaseProvider := sync.OnceFunc(func() { close(release) }) + defer releaseProvider() + srv.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/token" { + close(entered) + <-release + return profileTestResponse(`{"access_token":"proof","token_type":"Bearer"}`), nil + } + return profileTestResponse(`{"id":42}`), nil + })} + links := 0 + srv.LinkEvent = func(*jaws.Session, *http.Request, map[string]any) (int, error) { links++; return 0, nil } + _, linkState := profileStart(t, srv, http.MethodPost, "/auth/link/github", cookie) + result := make(chan *httptest.ResponseRecorder, 1) + go func() { result <- profileCallback(srv, cookie, linkState, "second") }() + waitFor(t, entered, "link callback did not reach provider") + request := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + request.AddCookie(cookie) + old := jw.GetSession(request) + if old == nil { + t.Fatal("original session missing") + } + if replacement := jw.NewSession(httptest.NewRecorder(), request); replacement == nil || replacement == old || jw.GetSession(request) == old { + t.Fatal("JaWS did not replace the session") + } + releaseProvider() + response := waitFor(t, result, "replaced link callback did not finish") + if response.Code != http.StatusForbidden || links != 0 || len(response.Result().Cookies()) != 0 { + t.Fatalf("replaced link: status=%d links=%d cookies=%v", response.Code, links, response.Result().Cookies()) + } +} + type observedRotationLock struct { sync.Mutex attempts atomic.Int32 @@ -457,8 +500,7 @@ func TestOAuthProfileLinkSerializesManagedRevocation(t *testing.T) { t.Fatal(err) } defer jw.Close() - calls := 0 - srv := newProfileTestServer(t, jw, &calls) + srv := newProfileTestServer(t, jw, nil) factory := &testAuthTimerFactory{} srv.authTimerAfterFunc = factory.after cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) @@ -510,7 +552,7 @@ func TestOAuthProfileLinkSerializesManagedRevocation(t *testing.T) { if revocationFirst { revoke() response := profileCallback(srv, cookie, linkState, "second") - if response.Code < http.StatusBadRequest || writes != 0 || logoutEvents.Load() != 1 { + if response.Code != http.StatusBadRequest || writes != 0 || logoutEvents.Load() != 1 { t.Fatalf("revocation won: status=%d writes=%d logout events=%d", response.Code, writes, logoutEvents.Load()) } return @@ -545,8 +587,7 @@ func TestOAuthProfileAuthExpiryPreservesOrdinaryLogin(t *testing.T) { t.Fatal(err) } defer jw.Close() - calls := 0 - srv := newProfileTestServer(t, jw, &calls) + srv := newProfileTestServer(t, jw, nil) factory := &testAuthTimerFactory{} srv.authTimerAfterFunc = factory.after cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) @@ -610,14 +651,22 @@ func TestOAuthProfileAuthExpiryPreservesOrdinaryLogin(t *testing.T) { } if phase == "callback" { protected := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) - protected.RemoteAddr = start.RemoteAddr protected.AddCookie(cookie) + failedCalls := 0 + srv.LoginFailed = func(_ http.ResponseWriter, _ *http.Request, status int, failure error, _ string) bool { + failedCalls++ + if status != http.StatusConflict || !errors.Is(failure, ErrOAuth2LoginInProgress) || !srv.mu.TryLock() { + t.Fatalf("LoginFailed called under lock or with wrong failure: status=%d err=%v", status, failure) + } + srv.mu.Unlock() + return false + } conflict := httptest.NewRecorder() srv.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("pending callback reached protected handler") })).ServeHTTP(conflict, protected) - if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), ErrOAuth2LoginInProgress.Error()) { - t.Fatalf("pending callback: status=%d body=%q", conflict.Code, conflict.Body.String()) + if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), ErrOAuth2LoginInProgress.Error()) || failedCalls != 1 { + t.Fatalf("pending callback: status=%d body=%q failed_calls=%d", conflict.Code, conflict.Body.String(), failedCalls) } } var response *httptest.ResponseRecorder @@ -643,7 +692,9 @@ func TestOAuthProfileAuthExpiryPreservesOrdinaryLogin(t *testing.T) { func newProfileTestServer(t *testing.T, jw *jaws.Jaws, calls *int) *Server { t.Helper() client := &http.Client{Timeout: time.Second, Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { - *calls++ + if calls != nil { + *calls++ + } switch r.URL.Path { case "/token": if err := r.ParseForm(); err != nil || r.PostForm.Get("code_verifier") == "" { @@ -698,7 +749,11 @@ func profileStart(t *testing.T, srv *Server, method, path string, cookie *http.C } else { srv.HandleLogin(w, r) } - if w.Code != http.StatusFound { + wantStatus := http.StatusFound + if method == http.MethodPost { + wantStatus = http.StatusSeeOther + } + if w.Code != wantStatus { t.Fatalf("start %s: %d %s", path, w.Code, w.Body.String()) } location, err := url.Parse(w.Header().Get("Location")) @@ -710,10 +765,14 @@ func profileStart(t *testing.T, srv *Server, method, path string, cookie *http.C t.Fatalf("profile flow sent OIDC-only parameters: %s", location) } cookies := w.Result().Cookies() - if len(cookies) != 1 { - t.Fatalf("session cookies: %d", len(cookies)) + if cookie == nil { + if len(cookies) != 1 { + t.Fatalf("new session cookies: %d", len(cookies)) + } + cookie = cookies[0] + } else if len(cookies) != 0 { + t.Fatalf("existing session cookies: %d", len(cookies)) } - cookie = cookies[0] } return cookie, location.Query().Get("state") } @@ -742,7 +801,7 @@ func TestOAuthProfileSignInAndDualProofLink(t *testing.T) { if wrong.Code != http.StatusBadRequest || calls != 0 { t.Fatalf("wrong state consumed login: status=%d calls=%d", wrong.Code, calls) } - time.Sleep(61 * time.Second) + time.Sleep(time.Minute + time.Second) first := profileCallback(srv, oldCookie, state, "first") if first.Code != http.StatusFound || logins != 1 || calls != 2 { t.Fatalf("first sign-in: status=%d logins=%d calls=%d", first.Code, logins, calls) @@ -767,7 +826,7 @@ func TestOAuthProfileSignInAndDualProofLink(t *testing.T) { return 0, nil } _, linkState := profileStart(t, srv, http.MethodPost, "/auth/link/github", cookie) - time.Sleep(61 * time.Second) + time.Sleep(time.Minute + time.Second) linked := profileCallback(srv, cookie, linkState, "second") if linked.Code != http.StatusFound || links != 1 || logins != 1 || calls != 4 || len(linked.Result().Cookies()) != 0 { t.Fatalf("link: status=%d links=%d logins=%d calls=%d", linked.Code, links, logins, calls) @@ -812,58 +871,52 @@ func TestOAuthProfileExpiredLinkStateNeverCallsProvider(t *testing.T) { } func TestOAuthLinkRequiresSessionAndSameOriginRequestAndRejectsDeclinedLink(t *testing.T) { - for _, reject := range []struct { - name string - err error - }{ - {name: "explicit error", err: errors.New("secret persistence detail")}, - {name: "status alone"}, - } { - t.Run(reject.name, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() - calls := 0 - srv := newProfileTestServer(t, jw, &calls) - missing := httptest.NewRequest(http.MethodPost, "https://app.example/auth/link/github", nil) - w := httptest.NewRecorder() - srv.LinkEvent = func(*jaws.Session, *http.Request, map[string]any) (int, error) { - return http.StatusForbidden, reject.err - } - srv.HandleLink(w, missing) - if w.Code != http.StatusForbidden || calls != 0 { - t.Fatalf("anonymous link: status=%d calls=%d", w.Code, calls) - } - oldCookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) - first := profileCallback(srv, oldCookie, state, "first") - if first.Code != http.StatusFound { - t.Fatalf("first sign-in: %d", first.Code) - } - cookie := first.Result().Cookies()[0] - cross := httptest.NewRequest(http.MethodPost, "https://app.example/auth/link/github", nil) - cross.Header.Set("Origin", "https://evil.example") - cross.AddCookie(cookie) - w = httptest.NewRecorder() - srv.HandleLink(w, cross) - if w.Code != http.StatusForbidden || calls != 2 { - t.Fatalf("cross-origin link: status=%d calls=%d", w.Code, calls) - } - _, linkState := profileStart(t, srv, http.MethodPost, "/auth/link/github", cookie) - declined := profileCallback(srv, cookie, linkState, "second") - if declined.Code != http.StatusForbidden || declined.Header().Get("Location") != "" || - !strings.Contains(declined.Body.String(), ErrOAuth2LinkRejected.Error()) || - strings.Contains(declined.Body.String(), "secret persistence detail") || len(declined.Result().Cookies()) != 0 { - t.Fatalf("declined link: status=%d body=%q", declined.Code, declined.Body.String()) - } - r := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) - r.AddCookie(cookie) - sess := jw.GetSession(r) - if sess == nil || sess.Get(srv.SessionKey).(map[string]any)["sub"] != "9007199254740993" { - t.Fatal("declined link changed original proof") - } - }) + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + calls := 0 + srv := newProfileTestServer(t, jw, &calls) + missing := httptest.NewRequest(http.MethodPost, "https://app.example/auth/link/github", nil) + w := httptest.NewRecorder() + srv.LinkEvent = func(*jaws.Session, *http.Request, map[string]any) (int, error) { + return http.StatusForbidden, errors.New("secret persistence detail") + } + srv.HandleLink(w, missing) + if w.Code != http.StatusForbidden || calls != 0 { + t.Fatalf("anonymous link: status=%d calls=%d", w.Code, calls) + } + oldCookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + first := profileCallback(srv, oldCookie, state, "first") + if first.Code != http.StatusFound { + t.Fatalf("first sign-in: %d", first.Code) + } + cookie := first.Result().Cookies()[0] + cross := httptest.NewRequest(http.MethodPost, "https://app.example/auth/link/github", nil) + cross.Header.Set("Origin", "https://evil.example") + cross.AddCookie(cookie) + w = httptest.NewRecorder() + srv.HandleLink(w, cross) + if w.Code != http.StatusForbidden || calls != 2 { + t.Fatalf("cross-origin link: status=%d calls=%d", w.Code, calls) + } + _, linkState := profileStart(t, srv, http.MethodPost, "/auth/link/github", cookie) + declined := profileCallback(srv, cookie, linkState, "second") + if declined.Code != http.StatusForbidden || declined.Header().Get("Location") != "" || + !strings.Contains(declined.Body.String(), ErrOAuth2LinkRejected.Error()) || + strings.Contains(declined.Body.String(), "secret persistence detail") || len(declined.Result().Cookies()) != 0 { + t.Fatalf("declined link: status=%d body=%q", declined.Code, declined.Body.String()) + } + r := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + r.AddCookie(cookie) + sess := jw.GetSession(r) + if sess == nil { + t.Fatal("declined link removed original session") + } + claims, ok := sess.Get(srv.SessionKey).(map[string]any) + if !ok || claims["sub"] != "9007199254740993" { + t.Fatal("declined link changed original proof") } } @@ -886,9 +939,18 @@ func TestOAuthProfileRejectsInvalidSubjectAndOversizedResponse(t *testing.T) { if sess := jw.GetSession(request); sess == nil || sess.Get(srv.SessionKey) != nil { t.Fatal("invalid profile authenticated the preauth session") } + srv.profileSubject = func(map[string]any) (string, error) { return "42", ErrOAuth2InvalidProfile } + _, state = profileStart(t, srv, http.MethodGet, "/auth/login", cookie) + response = profileCallback(srv, cookie, state, "second") + if response.Code != http.StatusUnauthorized || len(response.Result().Cookies()) != 0 { + t.Fatalf("subject with error: status=%d cookies=%v", response.Code, response.Result().Cookies()) + } + if sess := jw.GetSession(request); sess == nil || sess.Get(srv.SessionKey) != nil { + t.Fatal("subject error authenticated the preauth session") + } oversized := &Server{profileSubject: srv.profileSubject} ctx := context.WithValue(t.Context(), oauth2.HTTPClient, &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { - return profileTestResponse(`{"id":42}` + strings.Repeat(" ", 32769)), nil + return profileTestResponse(`{"id":42}` + strings.Repeat(" ", maxUserInfoBytes+1)), nil })}) _, err = oversized.fetchUserInfo(ctx, "https://provider.example/user", oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "secret"})) if !errors.Is(err, ErrUserInfoTooLarge) { @@ -898,17 +960,34 @@ func TestOAuthProfileRejectsInvalidSubjectAndOversizedResponse(t *testing.T) { func TestOAuthProfileRejectsTokenExpiredDuringUserInfo(t *testing.T) { token := &oauth2.Token{AccessToken: "access", TokenType: "Bearer", Expiry: time.Now().Add(11 * time.Second)} + called := false srv := &Server{ userinfoUrl: "https://provider.example/user", - profileSubject: func(map[string]any) (string, error) { return "42", nil }, + profileSubject: func(map[string]any) (string, error) { called = true; return "42", nil }, } ctx := context.WithValue(t.Context(), oauth2.HTTPClient, &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { token.Expiry = time.Now().Add(-time.Second) return profileTestResponse(`{"id":42}`), nil })}) claims, source, expiry, status, err := srv.verifyOAuthIdentity(ctx, token, "") - if !errors.Is(err, ErrOAuth2InvalidProfile) || claims != nil || source != nil || !expiry.IsZero() || status != http.StatusUnauthorized { - t.Fatalf("expired profile: claims=%v source=%t expiry=%v status=%d err=%v", claims, source != nil, expiry, status, err) + if !errors.Is(err, ErrOAuth2InvalidProfile) || claims != nil || source != nil || !expiry.IsZero() || status != http.StatusUnauthorized || called { + t.Fatalf("expired profile: claims=%v source=%t expiry=%v status=%d err=%v subject_called=%t", claims, source != nil, expiry, status, err, called) + } +} + +func TestOAuthProfileRejectsEmptyAccessTokenBeforeUserInfo(t *testing.T) { + calls := 0 + srv := &Server{ + userinfoUrl: "https://provider.example/user", + profileSubject: func(map[string]any) (string, error) { t.Fatal("empty token reached subject extractor"); return "", nil }, + } + ctx := context.WithValue(t.Context(), oauth2.HTTPClient, &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + calls++ + return profileTestResponse(`{"id":42}`), nil + })}) + claims, source, expiry, status, err := srv.verifyOAuthIdentity(ctx, &oauth2.Token{TokenType: "Bearer"}, "") + if !errors.Is(err, ErrOAuth2InvalidProfile) || claims != nil || source != nil || !expiry.IsZero() || status != http.StatusUnauthorized || calls != 0 { + t.Fatalf("empty access token: claims=%v source=%t expiry=%v status=%d calls=%d err=%v", claims, source != nil, expiry, status, calls, err) } } @@ -919,8 +998,7 @@ func TestOAuthProfileUserInfoCallbackDeadline(t *testing.T) { t.Fatal(err) } defer jw.Close() - calls := 0 - srv := newProfileTestServer(t, jw, &calls) + srv := newProfileTestServer(t, jw, nil) var userinfoErr error srv.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/token" { @@ -930,7 +1008,7 @@ func TestOAuthProfileUserInfoCallbackDeadline(t *testing.T) { case <-r.Context().Done(): userinfoErr = r.Context().Err() return nil, userinfoErr - case <-time.After(61 * time.Second): + case <-time.After(oauth2ProviderTimeout + time.Second): userinfoErr = errors.New("UserInfo missed callback deadline") return nil, userinfoErr } @@ -998,8 +1076,7 @@ func TestOAuthProfileExplicitZeroExpiryRejectedButOmittedExpiryCapped(t *testing t.Fatal(err) } defer jw.Close() - calls := 0 - srv := newProfileTestServer(t, jw, &calls) + srv := newProfileTestServer(t, jw, nil) srv.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/token" { if tc.form { @@ -1045,8 +1122,7 @@ func TestOAuthProfileExpiresWithoutRefresh(t *testing.T) { t.Fatal(err) } defer jw.Close() - calls := 0 - srv := newProfileTestServer(t, jw, &calls) + srv := newProfileTestServer(t, jw, nil) factory := &testAuthTimerFactory{} srv.authTimerAfterFunc = factory.after cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) @@ -1079,8 +1155,7 @@ func TestOAuthProfileTimerReleasesClosedSession(t *testing.T) { t.Fatal(err) } defer jw.Close() - calls := 0 - srv := newProfileTestServer(t, jw, &calls) + srv := newProfileTestServer(t, jw, nil) factory := &testAuthTimerFactory{} srv.authTimerAfterFunc = factory.after cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) diff --git a/server.go b/server.go index ed5d4bd..6ac529a 100644 --- a/server.go +++ b/server.go @@ -65,7 +65,8 @@ type HandleFunc func(uri string, handler http.Handler) // SessionRotationLock. type EventFunc func(sess *jaws.Session, hr *http.Request) -// FailedFunc is called when a login or link attempt fails. +// FailedFunc is called on callback failures and when a protected request meets +// a running login callback. Other flow-start failures write their own response. // // It returns true if it wrote the HTTP response itself, in which case jawsauth writes // nothing further. Errors can contain untrusted browser-supplied text. Quote errors @@ -93,11 +94,11 @@ type Server struct { SessionTokenKey string // default is "oauth2_tokensource"; an oauth2.TokenSource in OIDC mode, empty in profile mode SessionEmailKey string // default is "email", value is a parsed string address with ASCII letters lowercased SessionEmailVerifiedKey string // default is "email_verified", value will be of type bool - HandledPaths map[string]struct{} // URI paths we have registered handlers for + HandledPaths map[string]struct{} // URI paths excluded from OAuth return targets LoginEvent EventFunc // if not nil, called after claims are stored; revocation may follow LogoutEvent EventFunc // if not nil, called after auth is cleared; hr may be nil - LoginFailed FailedFunc // if not nil, called on failed login or link - LinkEvent LinkFunc // if not nil, enables HandleLink + LoginFailed FailedFunc // callback failures and protected requests during a running login callback + LinkEvent LinkFunc // required by HandleLink; register its POST route separately // SessionRotationLock is held during rotation, revocation, HandleLogout, // Logout, and LinkEvent. New supplies a private lock; share one for cross-provider links. SessionRotationLock sync.Locker @@ -349,7 +350,7 @@ func (srv *Server) wrap(h http.Handler, admin bool) (rh http.Handler) { // Unauthenticated requests are redirected into the configured login flow (HandleLogin); // authenticated users whose email is not an admin (see SetAdmins and IsAdmin) are // served the 403 handler instead of h. [Server.RequireVerifiedAdminEmail] also -// applies. A protected request during a pending login may return 409. +// applies. A protected request while a login callback runs may return 409. // If the Server is not Valid, returns h. func (srv *Server) WrapAdmin(h http.Handler) (rh http.Handler) { return srv.wrap(h, true) @@ -359,7 +360,7 @@ func (srv *Server) WrapAdmin(h http.Handler) (rh http.Handler) { // // Unauthenticated requests are redirected into the configured login flow // (HandleLogin), which stores verified claims in srv.SessionKey before the user -// returns. A protected request during a pending login may return 409. +// returns. A protected request while a login callback runs may return 409. // If the Server is not Valid, returns h. func (srv *Server) Wrap(h http.Handler) (rh http.Handler) { return srv.wrap(h, false) @@ -370,7 +371,7 @@ func (srv *Server) Wrap(h http.Handler) (rh http.Handler) { // // Unauthenticated requests are redirected into the configured login flow (HandleLogin); // authenticated non-admins (see SetAdmins and IsAdmin) are served the 403 handler. -// [Server.RequireVerifiedAdminEmail] also applies. A protected request during a pending login may return 409. +// [Server.RequireVerifiedAdminEmail] also applies. A protected request while a login callback runs may return 409. // If the Server is not Valid, the template handler is returned without the // authentication requirement. func (srv *Server) HandlerAdmin(name string, dot any) http.Handler { @@ -382,7 +383,7 @@ func (srv *Server) HandlerAdmin(name string, dot any) http.Handler { // // Unauthenticated requests are redirected into the configured login flow // (HandleLogin), which stores verified claims in srv.SessionKey before the user -// returns. A protected request during a pending login may return 409. +// returns. A protected request while a login callback runs may return 409. // If the Server is not Valid, the template // handler is returned without the authentication requirement. func (srv *Server) Handler(name string, dot any) http.Handler { diff --git a/sessionlifecycle_test.go b/sessionlifecycle_test.go index d831c2b..cd88191 100644 --- a/sessionlifecycle_test.go +++ b/sessionlifecycle_test.go @@ -112,7 +112,6 @@ func TestOIDCTimerReleasesSessionRotatedByOtherProvider(t *testing.T) { t.Fatal("live OIDC session did not schedule its next liveness check") } replace := httptest.NewRequest(http.MethodGet, "/", nil) - replace.RemoteAddr = req.RemoteAddr replace.AddCookie(first.Result().Cookies()[0]) if next := jw.NewSession(httptest.NewRecorder(), replace); next == nil || next == old || sessionAlive(old) { t.Fatal("session was not rotated") @@ -123,7 +122,7 @@ func TestOIDCTimerReleasesSessionRotatedByOtherProvider(t *testing.T) { } } -func TestOIDCRefreshTimeoutReleasesClosedSession(t *testing.T) { +func TestOIDCRefreshTimeoutClearsExpiredAuth(t *testing.T) { synctest.Test(t, func(t *testing.T) { jw, err := jaws.New() if err != nil { @@ -156,16 +155,21 @@ func TestOIDCRefreshTimeoutReleasesClosedSession(t *testing.T) { close(done) }() <-started - sess.Close() - time.Sleep(oauth2ProviderTimeout + time.Second) + time.Sleep(oauth2ProviderTimeout - time.Second) + select { + case <-done: + t.Fatal("refresh completed before the provider timeout") + default: + } + time.Sleep(2 * time.Second) synctest.Wait() select { case <-done: default: - t.Fatal("stalled refresh retained closed session") + t.Fatal("stalled refresh did not time out") } - if len(srv.authTimers) != 0 { - t.Fatal("stalled refresh left auth timer") + if len(srv.authTimers) != 0 || sess.Get(srv.SessionKey) != nil { + t.Fatal("stalled refresh retained expired auth") } }) } @@ -187,12 +191,16 @@ func TestAuthorizationWithdrawalCancelsRequests(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) sess := jw.NewSession(httptest.NewRecorder(), req) expiry := time.Now().Add(time.Hour) - if action == "expiry" { - expiry = time.Now().Add(-time.Second) - } if err = srv.storeSessionAuthClaims(sess, map[string]any{"email": "user@example.com"}, nil, expiry, nil); err != nil { t.Fatal(err) } + if action == "expiry" { + expired := time.Now().Add(-time.Second) + sess.Set(srv.sessionKey(oauth2AuthExpiryKey), expired) + srv.mu.Lock() + srv.authTimers[sess.ID()].expiry = expired + srv.mu.Unlock() + } rq := newLiveAuthRequest(t, jw, req) ctx := rq.Context() if rq.Session() != sess || len(sess.Requests()) != 1 { @@ -414,7 +422,7 @@ func TestLoginUserInfoDeadlineKeepsOIDCFallbackOptional(t *testing.T) { case <-hr.Context().Done(): userinfoErr = hr.Context().Err() return nil, userinfoErr - case <-time.After(61 * time.Second): + case <-time.After(oauth2ProviderTimeout + time.Second): userinfoErr = errors.New("UserInfo missed callback deadline") return nil, userinfoErr } diff --git a/wrapper_test.go b/wrapper_test.go index b7dae96..0aabd45 100644 --- a/wrapper_test.go +++ b/wrapper_test.go @@ -45,7 +45,7 @@ func TestWrapperServeHTTPSet403HandlerConcurrent(t *testing.T) { "exp": time.Now().Add(time.Hour).Unix(), "ok": true, }) - sess.Set(oauth2AuthExpiryKey, time.Now().Add(time.Hour)) + sess.Set(srv.sessionKey(oauth2AuthExpiryKey), time.Now().Add(time.Hour)) sess.Set(srv.SessionEmailKey, "user@example.com") w := wrapper{ @@ -92,7 +92,7 @@ func TestWrapperServeHTTPAllowsPresentAuthData(t *testing.T) { "exp": time.Now().Add(time.Hour).Unix(), "email": "user@example.com", }) - sess.Set(oauth2AuthExpiryKey, time.Now().Add(time.Hour)) + sess.Set(srv.sessionKey(oauth2AuthExpiryKey), time.Now().Add(time.Hour)) w := wrapper{ server: srv, @@ -140,7 +140,7 @@ func TestWrapperServeHTTPClearsInvalidAuthData(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) sess := jw.NewSession(httptest.NewRecorder(), req) sess.Set(srv.SessionKey, tc.authValue) - sess.Set(oauth2AuthExpiryKey, tc.expiry) + sess.Set(srv.sessionKey(oauth2AuthExpiryKey), tc.expiry) w := wrapper{ server: srv, @@ -222,7 +222,7 @@ func TestSessionAuthStatus(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) sess := jw.NewSession(httptest.NewRecorder(), req) sess.Set(srv.SessionKey, tc.authValue) - sess.Set(oauth2AuthExpiryKey, tc.expiryValue) + sess.Set(srv.sessionKey(oauth2AuthExpiryKey), tc.expiryValue) gotCurrent, gotPresent := srv.sessionAuthStatus(sess, func() time.Time { return now }) if gotCurrent != tc.wantCurrent { @@ -333,7 +333,7 @@ func assertWrapperAuthCleared(t *testing.T, srv *Server, sess *jaws.Session) { if value := sess.Get(srv.SessionTokenKey); value != nil { t.Fatal(value) } - if value := sess.Get(oauth2AuthExpiryKey); value != nil { + if value := sess.Get(srv.sessionKey(oauth2AuthExpiryKey)); value != nil { t.Fatal(value) } if value := sess.Get(srv.SessionEmailKey); value != nil { From 46dda49c56aa28a9b7a811b5f03e511dcd39684e Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Thu, 5 Nov 2026 01:44:53 +0100 Subject: [PATCH 30/43] fix: serialize OAuth starts and require verified profile admins --- README.md | 55 +++++++++++++++--------------- adminpolicy_test.go | 44 ++++++++++++++++++++++++ authsession.go | 23 +++++++++---- authsession_test.go | 65 ++++++++++++++++++++++++++++++++++++ concurrent_providers_test.go | 4 +-- config.go | 6 ++-- jawsauth.go | 1 + oauth2.go | 43 +++++++++++++++--------- oauth2_flow_guards_test.go | 53 +++++++++++++++++++++++++++++ oauth2_test.go | 6 ++++ profile_link_test.go | 24 ++++++++++--- server.go | 55 ++++++++++++++---------------- 12 files changed, 290 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index 3418337..d9f5ba0 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,11 @@ sessions. Both modes use authorization-code state and PKCE (S256). `Wrap` and overrides, discovered endpoints and JWKS URI must use HTTPS; `AllowInsecureIssuer` permits HTTP for tests and development. OIDC mode discovers provider endpoints, verifies the ID token and nonce, and attempts -refresh before expiry when the provider issues a refresh token. Providers that -issue refresh tokens only with offline access can use -`Server.Options = []oauth2.AuthCodeOption{oauth2.AccessTypeOffline}`. +refresh before expiry when the provider issues a refresh token. Request offline +access with the `offline_access` scope for OIDC providers that support it, or +`oauth2.AccessTypeOffline` in `Server.Options` for Google. Check both the error +from `New` and `Server.Valid()` before serving protected routes; invalid servers +pass wrapped handlers through without authentication. Profile mode requires `AuthURL`, `TokenURL` and `UserInfoURL`, plus a `ProfileSubject` function that extracts a stable subject from the authenticated @@ -33,7 +35,7 @@ unauthenticated requests; both limits are unlimited by default. A login attempt can retain an anonymous session for about ten minutes while the user visits the provider. Login rotates the session and discards its application data; rotation needs a free slot under both limits. -In particular, `MaxSessionsPerIP = 1` prevents login. If a slot is unavailable, +`MaxSessionsPerIP = 1` prevents login. If a slot is unavailable, login returns 503 with `Retry-After: 60` and preserves the existing session. ## Multiple providers @@ -51,40 +53,41 @@ login returns 503 with `Retry-After: 60` and preserves the existing session. Their `LogoutEvent` may run later with a nil request. - `New` gives each server a private `SessionRotationLock`. For cross-provider linking, assign one shared lock before serving. jawsauth holds it during - rotation, logout, expiry, revocation and `LinkEvent`. Do not lock around - jawsauth handlers or `Server.Logout`; do not lock or call `Server.Logout` in - `LinkEvent`. Keep `LinkEvent` brief because other sessions sharing the lock - wait. One provider's logout does not cancel another provider's pending link. + flow start, rotation, authentication clearing and `LinkEvent`. Do not take it + around jawsauth handlers or `Server.Logout`, or within `LinkEvent`; do not call + `Server.Logout` from `LinkEvent`. Keep `LinkEvent` brief because other sessions + sharing the lock wait. One provider's logout does not cancel another provider's + pending link. ## Admin access -Email verification is optional by default. With a non-empty admin list, set -`Server.RequireVerifiedAdminEmail = true` unless the provider guarantees -verified addresses. Profile providers can map their verification signal to -`email_verified` in `ProfileSubject`; providers without such a signal cannot -grant admin access in strict mode. Some providers omit `email_verified`. +Profile mode requires a verified email for a non-empty admin list by default; +OIDC mode does not. `ProfileSubject` may map a provider verification signal to +`email_verified`. Set `RequireVerifiedAdminEmail = false` only when the provider +guarantees verified addresses. Providers without a verification signal cannot +grant admin access in strict mode. [Microsoft warns against using its email claim for authorization](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference); use application policy based on a stable identifier for Entra ID. `SetAdmins` parses valid addresses and compares them ignoring ASCII case. An empty list allows every authenticated user through the admin handlers. `Server.IsAdmin(email)` checks only list membership; `JawsAuth.IsAdmin` and the -admin handlers enforce email verification when required. +admin handlers enforce email verification when required. Neither `IsAdmin` +method proves a live login; check `CurrentClaims` before protected actions. ## Session lifetime Pending OAuth flows expire after ten minutes. jawsauth extends the original JaWS session's grace while the user visits the provider. Unauthenticated -protected requests during a pending login redirect to the same provider flow -without replacing its state, return target or grace deadline. Requests during -a running callback return 409 unless `LoginFailed` handles them. Logout and -authentication expiry -clear that server's credentials and reload the session's JaWS pages. Auth expiry -preserves a pending login but cancels a pending link. Explicit logout cancels -either flow. A new login on the same server, including one started by `Wrap` or -`Handler`, cancels a pending link. Protected operations -should check `CurrentClaims`, plus `JawsAuth.IsAdmin` for administrator actions. +protected GET requests during a pending login redirect to the same provider +flow without replacing its state, return target or grace deadline. During a +running callback they return 409 unless `LoginFailed` handles them. Logout and +authentication expiry clear that server's credentials and reload its JaWS pages. +Auth expiry preserves a pending login but cancels a pending link. Explicit +logout cancels either flow. A new login on the same server, including one +started by `Wrap` or `Handler`, cancels a pending link. Check `CurrentClaims` +before protected operations, and `JawsAuth.IsAdmin` for administrator actions. -Use an HTTP endpoint for a post-logout redirect. `Server.Logout` reloads pages -and closes their sockets, so a later JaWS `Request.Redirect` cannot deliver the -redirect. +Use an HTTP endpoint for a post-logout redirect. When `Server.Logout` clears +authentication, it reloads pages and closes their sockets, so a later JaWS +`Request.Redirect` cannot deliver the redirect. diff --git a/adminpolicy_test.go b/adminpolicy_test.go index 12cd41e..25a3639 100644 --- a/adminpolicy_test.go +++ b/adminpolicy_test.go @@ -68,6 +68,50 @@ func TestAdminEmailPolicy(t *testing.T) { } } +func TestProfileAdminRequiresVerifiedEmailByDefault(t *testing.T) { + for _, tc := range []struct { + name string + optOut bool + want int + }{ + {"default", false, http.StatusForbidden}, + {"explicit opt-out", true, http.StatusOK}, + } { + t.Run(tc.name, func(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + srv := newProfileTestServer(t, jw, nil) + if !srv.RequireVerifiedAdminEmail { + t.Fatal("profile mode did not require verified admin email by default") + } + if tc.optOut { + srv.RequireVerifiedAdminEmail = false + } + srv.SetAdmins([]string{"first@example.com"}) + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + login := profileCallback(srv, cookie, state, "first") + cookies := login.Result().Cookies() + if login.Code != http.StatusFound || len(cookies) != 1 { + t.Fatalf("profile login: status=%d cookies=%v", login.Code, cookies) + } + r := httptest.NewRequest(http.MethodGet, "https://app.example/protected", nil) + r.AddCookie(cookies[0]) + sess := jw.GetSession(r) + if sess == nil || sess.Get(srv.SessionEmailVerifiedKey) != false { + t.Fatal("profile test did not authenticate an unverified email") + } + w := httptest.NewRecorder() + srv.WrapAdmin(testStatusHandler{http.StatusOK}).ServeHTTP(w, r) + if w.Code != tc.want { + t.Fatalf("unverified admin email: status=%d, want %d", w.Code, tc.want) + } + }) + } +} + func TestUserInfoClaimProvenance(t *testing.T) { for _, tc := range []struct { name string diff --git a/authsession.go b/authsession.go index 314ebd5..0838b95 100644 --- a/authsession.go +++ b/authsession.go @@ -427,9 +427,10 @@ func (srv *Server) clearSessionOAuthFlow(sess *jaws.Session) { // Logout clears this server's authentication state for sess. // // It stops the auth timer, clears verified claims, token source, email, expiry -// and any in-flight OAuth flow keys, then reloads its pages with [jaws.Request.Reload], -// calls [Server.LogoutEvent] (if set), and marks the session dirty. It returns false for a -// nil receiver or nil session, and true otherwise. The hr argument may be nil. +// and any in-flight OAuth flow keys, then marks the session dirty. If auth was +// present, it reloads its pages and calls [Server.LogoutEvent] (if set). It +// returns false for a nil receiver or nil session, and true otherwise. The hr +// argument may be nil. // // It performs no HTTP redirect. An HTTP handler can build its own post-logout // response. A JaWS event handler cannot use [jaws.Request.Redirect] after Logout @@ -447,6 +448,10 @@ func (srv *Server) clearSessionAuth(sess *jaws.Session, hr *http.Request, entry srv.SessionRotationLock.Lock() } srv.mu.Lock() + hadAuth := srv.authTimers[sess.ID()] != nil + if !hadAuth { + _, hadAuth = srv.sessionAuthStatus(sess, nil) + } cleared = srv.stopSessionAuthTimerLocked(sess, entry) var requests []*jaws.Request if cleared { @@ -460,16 +465,20 @@ func (srv *Server) clearSessionAuth(sess *jaws.Session, hr *http.Request, entry sess.Set(srv.sessionKey(oauth2AuthExpiryKey), nil) sess.Set(srv.SessionEmailKey, nil) sess.Set(srv.SessionEmailVerifiedKey, nil) - requests = sess.Requests() + if hadAuth { + requests = sess.Requests() + } } srv.mu.Unlock() if srv.SessionRotationLock != nil { srv.SessionRotationLock.Unlock() } if cleared { - reloadAuthRequests(requests) - if srv.LogoutEvent != nil { - srv.LogoutEvent(sess, hr) + if hadAuth { + reloadAuthRequests(requests) + if srv.LogoutEvent != nil { + srv.LogoutEvent(sess, hr) + } } srv.Jaws.Dirty(sess) srv.debugLog("jawsauth: cleared session auth", diff --git a/authsession_test.go b/authsession_test.go index 2fde4bf..51be5a9 100644 --- a/authsession_test.go +++ b/authsession_test.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "testing" "time" @@ -1175,6 +1176,9 @@ func TestServerLogout(t *testing.T) { if !factory.timer(0).isStopped() { t.Fatal("timer was not stopped") } + if !srv.Logout(sess, req) || logoutCount != 1 { + t.Fatalf("repeated logout: events=%d", logoutCount) + } if (*Server)(nil).Logout(sess, req) { t.Fatal("nil server should report nothing cleared") @@ -1184,6 +1188,67 @@ func TestServerLogout(t *testing.T) { } } +type delayedFirstAuthLock struct { + sync.Mutex + attempts atomic.Int32 + entered chan struct{} + release chan struct{} +} + +func (lock *delayedFirstAuthLock) Lock() { + if lock.attempts.Add(1) == 1 { + close(lock.entered) + <-lock.release + } + lock.Mutex.Lock() +} + +func TestStaleWrapperDoesNotRepeatRotationLogoutEvent(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + srv := newProfileTestServer(t, jw, nil) + srv.authTimerAfterFunc = (&testAuthTimerFactory{}).after + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + first := profileCallback(srv, cookie, state, "first") + if first.Code != http.StatusFound || len(first.Result().Cookies()) != 1 { + t.Fatalf("initial login: status=%d cookies=%v", first.Code, first.Result().Cookies()) + } + cookie = first.Result().Cookies()[0] + req := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + req.AddCookie(cookie) + old := jw.GetSession(req) + if old == nil { + t.Fatal("authenticated session missing") + } + _, state = profileStart(t, srv, http.MethodGet, "/auth/login", cookie) + old.Set(srv.sessionKey(oauth2AuthExpiryKey), time.Now().Add(-time.Second)) + lock := &delayedFirstAuthLock{entered: make(chan struct{}), release: make(chan struct{})} + srv.SessionRotationLock = lock + var oldLogouts atomic.Int32 + release := sync.OnceFunc(func() { close(lock.release) }) + defer release() + srv.LogoutEvent = func(sess *jaws.Session, _ *http.Request) { + if sess == old { + oldLogouts.Add(1) + release() + } + } + wrapped := make(chan struct{}) + go func() { + srv.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})).ServeHTTP(httptest.NewRecorder(), req) + close(wrapped) + }() + waitFor(t, lock.entered, "wrapper did not reach auth clear") + callback := profileCallback(srv, cookie, state, "second") + waitFor(t, wrapped, "stale wrapper did not finish") + if callback.Code != http.StatusFound || len(callback.Result().Cookies()) != 1 || oldLogouts.Load() != 1 { + t.Fatalf("rotation overlap: status=%d cookies=%v old_logout_events=%d", callback.Code, callback.Result().Cookies(), oldLogouts.Load()) + } +} + func TestAuthDebugSessionPrefix(t *testing.T) { jw, err := jaws.New() if err != nil { diff --git a/concurrent_providers_test.go b/concurrent_providers_test.go index e99cb2c..5107d2e 100644 --- a/concurrent_providers_test.go +++ b/concurrent_providers_test.go @@ -61,7 +61,6 @@ func TestConcurrentProviderCallbacksRotateOriginalSessionOnce(t *testing.T) { defer release() a := newConcurrentProfileProvider(t, jw, client, "a") b := newConcurrentProfileProvider(t, jw, client, "b") - a.SessionRotationLock, b.SessionRotationLock = gate, gate cookie, stateA := profileStart(t, a, http.MethodGet, "/auth/a/login", nil) startB := httptest.NewRequest(http.MethodGet, "https://app.example/auth/b/login", nil) startB.AddCookie(cookie) @@ -75,6 +74,7 @@ func TestConcurrentProviderCallbacksRotateOriginalSessionOnce(t *testing.T) { t.Fatal(err) } stateB := locB.Query().Get("state") + a.SessionRotationLock, b.SessionRotationLock = gate, gate callback := func(srv *Server, ns, state string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodGet, "https://app.example/auth/"+ns+"/callback?state="+url.QueryEscape(state)+"&code=proof", nil) req.AddCookie(cookie) @@ -161,7 +161,6 @@ func TestLinkedProviderLogoutBeforeLinkWrite(t *testing.T) { t.Fatal("missing first provider authentication") } lock := &holdFirstRotationLock{first: make(chan struct{}), releaseFirst: make(chan struct{})} - a.SessionRotationLock, b.SessionRotationLock = lock, lock var writes atomic.Int32 b.LinkEvent = func(current *jaws.Session, _ *http.Request, _ map[string]any) (int, error) { if a.CurrentClaims(current) == nil { @@ -171,6 +170,7 @@ func TestLinkedProviderLogoutBeforeLinkWrite(t *testing.T) { return 0, nil } _, linkState := profileStart(t, b, http.MethodPost, "/auth/b/link", cookie) + a.SessionRotationLock, b.SessionRotationLock = lock, lock callbackDone := make(chan *httptest.ResponseRecorder, 1) go func() { callbackRequest := httptest.NewRequest(http.MethodGet, "https://app.example/auth/b/callback?state="+url.QueryEscape(linkState)+"&code=proof", nil) diff --git a/config.go b/config.go index 0f162af..4aa29db 100644 --- a/config.go +++ b/config.go @@ -47,9 +47,9 @@ type Config struct { // Profile mode sets claims["iss"] to Issuer and claims["sub"] to this subject; // use a distinct Issuer per provider. // JSON numbers arrive as json.Number to preserve large numeric IDs. - // Profile emails can be unverified. For a non-empty admin list, set - // Server.RequireVerifiedAdminEmail unless the provider guarantees verified - // addresses; ProfileSubject may map a provider signal to claims["email_verified"]. + // ProfileSubject may adjust claims, for example by mapping the provider's + // verification signal to claims["email_verified"]. Profile mode requires + // verified email for a non-empty admin list by default. ProfileSubject func(map[string]any) (string, error) } diff --git a/jawsauth.go b/jawsauth.go index aff3dd8..938414b 100644 --- a/jawsauth.go +++ b/jawsauth.go @@ -70,6 +70,7 @@ func (a *JawsAuth) EmailVerified() (yes bool) { // // It applies [Server.RequireVerifiedAdminEmail] to non-empty admin lists. // It does not check login expiry; use [Server.CurrentClaims] for access decisions. +// With an empty admin list, it can return true without a session. // A nil or zero-value JawsAuth follows [Server.IsAdmin]'s nil-server behavior and returns true. func (a *JawsAuth) IsAdmin() (yes bool) { if a == nil || a.server == nil { diff --git a/oauth2.go b/oauth2.go index fedbe05..bf5880e 100644 --- a/oauth2.go +++ b/oauth2.go @@ -89,13 +89,12 @@ const maxRedirectTargetLen = 8192 func normalizeHost(hostport string) (normalized string) { normalized = strings.TrimSpace(hostport) if normalized != "" { - normalized = strings.TrimSuffix(normalized, ".") - normalized = strings.ToLower(normalized) if strings.Contains(normalized, ":") { if h, _, err := net.SplitHostPort(normalized); err == nil { normalized = h } } + normalized = strings.ToLower(strings.TrimSuffix(normalized, ".")) } return } @@ -140,8 +139,9 @@ func (srv *Server) begin(hr *http.Request) (oauth2cfg *oauth2.Config, location s // // For GET requests it stores the state, PKCE verifier and, for OIDC, nonce in the // session, then responds with a 302 redirect to the provider's authorization URL. +// It replaces any pending flow on this server, canceling a running callback or link. // If no session can be created, it returns 503 with Retry-After instead. -// Non-GET requests receive 405. +// Non-GET requests receive 405 with Allow: GET. func (srv *Server) HandleLogin(hw http.ResponseWriter, hr *http.Request) { srv.startOAuth(hw, hr, false, false) } @@ -179,11 +179,21 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, pr if link || hr.Method == http.MethodGet { oauth2cfg, location := srv.begin(hr) if oauth2cfg != nil { + rotationLock := srv.SessionRotationLock + if rotationLock != nil { + rotationLock.Lock() + } + unlockRotation := func() { + if rotationLock != nil { + rotationLock.Unlock() + } + } sess := srv.Jaws.GetSession(hr) if sess == nil && !link { sess = srv.Jaws.NewSession(hw, hr) } if sess == nil { + unlockRotation() missingSession() return } @@ -197,6 +207,7 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, pr flow, _ = sess.Get(srv.sessionKey(oauth2FlowKey)).(pendingOAuthFlow) if flow.state == "" { srv.mu.Unlock() + unlockRotation() if srv.LoginFailed != nil { email, _ := sess.Get(srv.SessionEmailKey).(string) if srv.LoginFailed(hw, hr, http.StatusConflict, ErrOAuth2LoginInProgress, email) { @@ -216,6 +227,7 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, pr } if !sess.ExtendGrace(oauth2FlowGrace) { srv.mu.Unlock() + unlockRotation() missingSession() return } @@ -223,6 +235,7 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, pr sess.Set(srv.sessionKey(oauth2FlowKey), flow) } srv.mu.Unlock() + unlockRotation() authOptions := append([]oauth2.AuthCodeOption{}, srv.Options...) if flow.nonce != "" { authOptions = append(authOptions, oidc.Nonce(flow.nonce)) @@ -235,6 +248,8 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, pr if link { statusCode = http.StatusSeeOther } + } else { + hw.Header().Set("Allow", http.MethodGet) } SetHeaders(hw, srv.ishttps) hw.WriteHeader(statusCode) @@ -242,8 +257,9 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, pr // HandleLogout clears this server's authentication in the session and redirects. // -// For GET requests it clears the auth (firing LogoutEvent if set) and responds with a -// 302 redirect back to the sanitized referrer (or "/"). Non-GET requests receive 405. +// For GET requests it clears the auth (firing LogoutEvent when auth was present) +// and responds with a 302 redirect to the sanitized referrer (or "/"). +// Non-GET requests receive 405 with Allow: GET. func (srv *Server) HandleLogout(hw http.ResponseWriter, hr *http.Request) { statusCode := http.StatusMethodNotAllowed if hr.Method == http.MethodGet { @@ -253,6 +269,8 @@ func (srv *Server) HandleLogout(hw http.ResponseWriter, hr *http.Request) { } hw.Header().Set("Location", location) statusCode = http.StatusFound + } else { + hw.Header().Set("Allow", http.MethodGet) } SetHeaders(hw, srv.ishttps) hw.WriteHeader(statusCode) @@ -454,21 +472,19 @@ func (srv *Server) verifyOAuthIdentity(ctx context.Context, token *oauth2.Token, if err = srv.identityExpiryError(idToken.Expiry); err != nil { return nil, nil, time.Time{}, status, err } - return claims, tokenSource, idToken.Expiry, status, nil + return claims, tokenSource, idToken.Expiry, 0, nil } if srv.profileSubject == nil || !profileTokenUnexpired(token) { return nil, nil, time.Time{}, status, ErrOAuth2InvalidProfile } - status = http.StatusBadGateway claims, err = srv.fetchUserInfo(ctx, srv.userinfoUrl, oauth2.StaticTokenSource(token)) if err != nil { - return nil, nil, time.Time{}, status, err + return nil, nil, time.Time{}, http.StatusBadGateway, err } // UserInfo can outlive the token; reject it before calling application code. if !profileTokenUnexpired(token) { return nil, nil, time.Time{}, http.StatusUnauthorized, ErrOAuth2InvalidProfile } - status = http.StatusUnauthorized if claims == nil { return nil, nil, time.Time{}, status, ErrOAuth2InvalidProfile } @@ -486,7 +502,7 @@ func (srv *Server) verifyOAuthIdentity(ctx context.Context, token *oauth2.Token, if err = srv.identityExpiryError(expiry); err != nil { return nil, nil, time.Time{}, status, err } - return claims, nil, expiry, status, nil + return claims, nil, expiry, 0, nil } func profileTokenUnexpired(token *oauth2.Token) bool { @@ -528,9 +544,6 @@ func (srv *Server) completeLink(sess *jaws.Session, hr *http.Request, claims map srv.SessionRotationLock.Lock() defer srv.SessionRotationLock.Unlock() } - if srv.LinkEvent == nil { - return http.StatusInternalServerError, ErrOAuth2NotConfigured - } if flow.revoked() || srv.Jaws.GetSession(hr) != sess { return http.StatusForbidden, ErrOAuth2MissingSession } @@ -598,9 +611,6 @@ func (srv *Server) completeOAuthCallback(ctx context.Context, hw http.ResponseWr } if flow.link { status, err = srv.completeLink(sess, hr, claims, expiry, flow) - } else if err = srv.identityExpiryError(expiry); err != nil { - // Reject expiry before rotating the session. - return sess, http.StatusUnauthorized, err } else { sess, status, err = srv.completeLogin(hw, hr, sess, flow, claims, tokenSource, expiry) } @@ -623,6 +633,7 @@ func (srv *Server) completeOAuthCallback(ctx context.Context, hw http.ResponseWr // response is 503. The user must restart login after capacity becomes available. func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) { if hr.Method != http.MethodGet { + hw.Header().Set("Allow", http.MethodGet) srv.writeResult(hw, http.StatusMethodNotAllowed, ErrOAuth2Callback, nil) return } diff --git a/oauth2_flow_guards_test.go b/oauth2_flow_guards_test.go index 1ce3113..451a6c7 100644 --- a/oauth2_flow_guards_test.go +++ b/oauth2_flow_guards_test.go @@ -21,6 +21,59 @@ func (hook infoHookLogger) Info(msg string, _ ...any) { hook(msg) } func (infoHookLogger) Warn(string, ...any) {} func (infoHookLogger) Error(string, ...any) {} +type rotationHeaderGate struct { + *httptest.ResponseRecorder + once sync.Once + entered chan struct{} + release chan struct{} +} + +func (w *rotationHeaderGate) Header() http.Header { + w.once.Do(func() { + close(w.entered) + <-w.release + }) + return w.ResponseRecorder.Header() +} + +func TestLoginStartDuringRotationKeepsFirstCallback(t *testing.T) { + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + defer jw.Close() + srv := newProfileTestServer(t, jw, nil) + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + lock := &observedRotationLock{second: make(chan struct{})} + srv.SessionRotationLock = lock + gate := &rotationHeaderGate{ResponseRecorder: httptest.NewRecorder(), entered: make(chan struct{}), release: make(chan struct{})} + callbackDone := make(chan struct{}) + go func() { + r := httptest.NewRequest(http.MethodGet, "https://app.example/auth/callback?state="+state+"&code=first", nil) + r.AddCookie(cookie) + srv.HandleAuthResponse(gate, r) + close(callbackDone) + }() + waitFor(t, gate.entered, "callback did not reach session rotation") + startDone := make(chan *httptest.ResponseRecorder, 1) + go func() { + r := httptest.NewRequest(http.MethodGet, "https://app.example/auth/login", nil) + r.AddCookie(cookie) + w := httptest.NewRecorder() + srv.HandleLogin(w, r) + startDone <- w + }() + release := sync.OnceFunc(func() { close(gate.release) }) + defer release() + waitFor(t, lock.second, "second login did not wait for rotation") + release() + waitFor(t, callbackDone, "first callback did not finish") + second := waitFor(t, startDone, "second login did not finish") + if gate.Code != http.StatusFound || len(gate.Result().Cookies()) != 1 || second.Code != http.StatusFound || len(second.Result().Cookies()) != 1 { + t.Fatalf("overlapping logins: first=%d cookies=%v second=%d cookies=%v", gate.Code, gate.Result().Cookies(), second.Code, second.Result().Cookies()) + } +} + func TestCallbackRevokedAfterStoreBalancesEvents(t *testing.T) { jw, err := jaws.New() if err != nil { diff --git a/oauth2_test.go b/oauth2_test.go index f9dcd96..8fe8ca4 100644 --- a/oauth2_test.go +++ b/oauth2_test.go @@ -101,6 +101,9 @@ func Test_sanitizeRedirectTarget(t *testing.T) { if s := sanitizeRedirectTarget("example.com:8443", "https://example.com/dashboard"); s != "/dashboard" { t.Fatal(s) } + if s := sanitizeRedirectTarget("example.com", "https://example.com.:443/dashboard"); s != "/dashboard" { + t.Fatal(s) + } if s := sanitizeRedirectTarget("example.com", "section/page"); s != "/section/page" { t.Fatal(s) } @@ -1605,6 +1608,9 @@ func Test_handlersRejectNonGet(t *testing.T) { if resp.StatusCode != http.StatusMethodNotAllowed { t.Fatal(resp.Status) } + if allow := resp.Header.Get("Allow"); allow != http.MethodGet { + t.Fatalf("Allow = %q, want GET", allow) + } if loc := resp.Header.Get("Location"); loc != "" { t.Fatalf("unexpected Location header: %q", loc) } diff --git a/profile_link_test.go b/profile_link_test.go index 52d5d3d..29367e0 100644 --- a/profile_link_test.go +++ b/profile_link_test.go @@ -62,7 +62,7 @@ func TestOAuthProfileAuthLifetimeCapsLongProviderToken(t *testing.T) { for _, duration := range []time.Duration{30 * time.Minute, 72 * time.Hour} { token := &oauth2.Token{AccessToken: "secret", TokenType: "Bearer", Expiry: time.Now().Add(duration)} _, _, expiry, status, err := srv.verifyOAuthIdentity(ctx, token, "") - if err != nil || status != http.StatusUnauthorized { + if err != nil || status != 0 { t.Fatalf("duration %s: status=%d error=%v", duration, status, err) } want := min(duration, 24*time.Hour) @@ -151,8 +151,8 @@ func TestOAuthProfileRotationLockDoesNotBlockProvider(t *testing.T) { return profileTestResponse(`{"id":42}`), nil })} lock := &rotationNoticeLock{entered: make(chan struct{})} - srv.SessionRotationLock = lock cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + srv.SessionRotationLock = lock oldRequest := httptest.NewRequest(http.MethodGet, "https://app.example/", nil) oldRequest.AddCookie(cookie) old := jw.GetSession(oldRequest) @@ -513,7 +513,6 @@ func TestOAuthProfileLinkSerializesManagedRevocation(t *testing.T) { req.AddCookie(cookie) sess := jw.GetSession(req) lock := &observedRotationLock{second: make(chan struct{})} - srv.SessionRotationLock = lock var logoutEvents atomic.Int32 srv.LogoutEvent = func(*jaws.Session, *http.Request) { lock.Lock() @@ -535,6 +534,7 @@ func TestOAuthProfileLinkSerializesManagedRevocation(t *testing.T) { return 0, nil } _, linkState := profileStart(t, srv, http.MethodPost, "/auth/link/github", cookie) + srv.SessionRotationLock = lock revoke := func() { switch revocation { case "timer": @@ -653,12 +653,18 @@ func TestOAuthProfileAuthExpiryPreservesOrdinaryLogin(t *testing.T) { protected := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) protected.AddCookie(cookie) failedCalls := 0 - srv.LoginFailed = func(_ http.ResponseWriter, _ *http.Request, status int, failure error, _ string) bool { + handleFailure := false + srv.LoginFailed = func(hw http.ResponseWriter, _ *http.Request, status int, failure error, _ string) bool { failedCalls++ if status != http.StatusConflict || !errors.Is(failure, ErrOAuth2LoginInProgress) || !srv.mu.TryLock() { t.Fatalf("LoginFailed called under lock or with wrong failure: status=%d err=%v", status, failure) } srv.mu.Unlock() + if handleFailure { + hw.WriteHeader(http.StatusAccepted) + _, _ = io.WriteString(hw, "custom response") + return true + } return false } conflict := httptest.NewRecorder() @@ -668,6 +674,14 @@ func TestOAuthProfileAuthExpiryPreservesOrdinaryLogin(t *testing.T) { if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), ErrOAuth2LoginInProgress.Error()) || failedCalls != 1 { t.Fatalf("pending callback: status=%d body=%q failed_calls=%d", conflict.Code, conflict.Body.String(), failedCalls) } + handleFailure = true + handled := httptest.NewRecorder() + srv.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("pending callback reached protected handler") + })).ServeHTTP(handled, protected) + if handled.Code != http.StatusAccepted || handled.Body.String() != "custom response" || failedCalls != 2 { + t.Fatalf("handled callback: status=%d body=%q failed_calls=%d", handled.Code, handled.Body.String(), failedCalls) + } } var response *httptest.ResponseRecorder if phase == "callback" { @@ -1051,7 +1065,7 @@ func TestOAuthProfileAcceptsShortUnexpiredToken(t *testing.T) { t.Fatal("test token should be inside oauth2's refresh skew") } claims, source, expiry, status, err := srv.verifyOAuthIdentity(ctx, token, "") - if err != nil || status != http.StatusUnauthorized || calls != 1 || source != nil || claims["sub"] != "42" || + if err != nil || status != 0 || calls != 1 || source != nil || claims["sub"] != "42" || !expiry.Equal(token.Expiry) { t.Fatalf("short token: claims=%v source=%v expiry=%v status=%d calls=%d err=%v", claims, source, expiry, status, calls, err) } diff --git a/server.go b/server.go index 6ac529a..f545645 100644 --- a/server.go +++ b/server.go @@ -99,15 +99,16 @@ type Server struct { LogoutEvent EventFunc // if not nil, called after auth is cleared; hr may be nil LoginFailed FailedFunc // callback failures and protected requests during a running login callback LinkEvent LinkFunc // required by HandleLink; register its POST route separately - // SessionRotationLock is held during rotation, revocation, HandleLogout, - // Logout, and LinkEvent. New supplies a private lock; share one for cross-provider links. + // SessionRotationLock is held during flow start, rotation, auth clearing, and LinkEvent. + // New supplies a private lock; share one for cross-provider links. SessionRotationLock sync.Locker Options []oauth2.AuthCodeOption // authorization URL options, e.g. oauth2.AccessTypeOffline; never sent to token exchange // RequireVerifiedAdminEmail requires email_verified for a non-empty admin list. // - // It defaults to false. When true, providers that omit email_verified cannot - // grant admin access through a non-empty list. Set before serving requests. + // It defaults to true in profile mode and false in OIDC mode. When true, + // providers that omit email_verified cannot grant admin access through a + // non-empty list. Set before serving requests. // Ordinary login is unaffected. RequireVerifiedAdminEmail bool @@ -169,6 +170,7 @@ func NewDebug(jw *jaws.Jaws, cfg *Config, handleFn HandleFunc, overrideUrl strin if srv.oauth2cfg, srv.userinfoUrl, srv.idTokenVerifier, err = cfg.buildContext(setupCtx, overrideUrl); err == nil { srv.httpClient = cfg.HTTPClient srv.profileSubject = cfg.ProfileSubject + srv.RequireVerifiedAdminEmail = srv.profileSubject != nil var u *url.URL if u, err = url.Parse(srv.oauth2cfg.RedirectURL); err == nil { srv.ishttps = (u.Scheme == "https") @@ -187,8 +189,9 @@ func NewDebug(jw *jaws.Jaws, cfg *Config, handleFn HandleFunc, overrideUrl strin // New creates a Server providing OIDC or OAuth2 profile authentication for JaWS sessions. // // It configures the Server from cfg and registers the login, logout and OAuth2 -// callback endpoints via handleFn. A nil jw returns ErrServerNilJaws. Use Valid to -// test whether authentication was successfully configured. +// callback endpoints via handleFn. A nil jw returns ErrServerNilJaws. Check both +// err and Valid before serving protected routes; an invalid Server leaves its +// wrapped handlers unprotected. func New(jw *jaws.Jaws, cfg *Config, handleFn HandleFunc) (srv *Server, err error) { return NewDebug(jw, cfg, handleFn, "") } @@ -331,11 +334,8 @@ func (srv *Server) Valid() bool { // wrap returns a http.Handler that requires an authenticated user before invoking h. // -// When admin is true the user must additionally be an administrator (see SetAdmins), -// otherwise the 403 handler is served. Unauthenticated requests are redirected into the -// configured login flow (HandleLogin), which verifies the provider identity and stores -// claims in srv.SessionKey before the user returns. If the -// Server is not Valid, returns h. +// When admin is true, non-admins receive 403. Unauthenticated GET begins login; +// other unauthenticated methods receive 405. An invalid Server returns h. func (srv *Server) wrap(h http.Handler, admin bool) (rh http.Handler) { rh = h if srv.Valid() { @@ -347,21 +347,19 @@ func (srv *Server) wrap(h http.Handler, admin bool) (rh http.Handler) { // WrapAdmin returns a http.Handler that requires an authenticated administrator // before invoking h. // -// Unauthenticated requests are redirected into the configured login flow (HandleLogin); -// authenticated users whose email is not an admin (see SetAdmins and IsAdmin) are -// served the 403 handler instead of h. [Server.RequireVerifiedAdminEmail] also -// applies. A protected request while a login callback runs may return 409. -// If the Server is not Valid, returns h. +// Unauthenticated GET begins login; other unauthenticated methods receive 405. +// Authenticated non-admins receive 403. [Server.RequireVerifiedAdminEmail] +// applies. A GET request during a running login callback may return 409. +// An invalid Server returns h. func (srv *Server) WrapAdmin(h http.Handler) (rh http.Handler) { return srv.wrap(h, true) } // Wrap returns a http.Handler that requires an authenticated user before invoking h. // -// Unauthenticated requests are redirected into the configured login flow -// (HandleLogin), which stores verified claims in srv.SessionKey before the user -// returns. A protected request while a login callback runs may return 409. -// If the Server is not Valid, returns h. +// Unauthenticated GET begins login; other unauthenticated methods receive 405. +// A GET request during a running login callback may return 409. +// An invalid Server returns h. func (srv *Server) Wrap(h http.Handler) (rh http.Handler) { return srv.wrap(h, false) } @@ -369,11 +367,10 @@ func (srv *Server) Wrap(h http.Handler) (rh http.Handler) { // HandlerAdmin returns a http.Handler that renders the named jaws.Template with dot // and requires an authenticated administrator. // -// Unauthenticated requests are redirected into the configured login flow (HandleLogin); -// authenticated non-admins (see SetAdmins and IsAdmin) are served the 403 handler. -// [Server.RequireVerifiedAdminEmail] also applies. A protected request while a login callback runs may return 409. -// If the Server is not Valid, the template handler is returned without the -// authentication requirement. +// Unauthenticated GET begins login; other unauthenticated methods receive 405. +// Authenticated non-admins receive 403. [Server.RequireVerifiedAdminEmail] +// applies. A GET request during a running login callback may return 409. +// An invalid Server returns the unprotected template handler. func (srv *Server) HandlerAdmin(name string, dot any) http.Handler { return srv.wrap(ui.Handler(srv.Jaws, name, dot), true) } @@ -381,11 +378,9 @@ func (srv *Server) HandlerAdmin(name string, dot any) http.Handler { // Handler returns a http.Handler that renders the named jaws.Template with dot // and requires an authenticated user. // -// Unauthenticated requests are redirected into the configured login flow -// (HandleLogin), which stores verified claims in srv.SessionKey before the user -// returns. A protected request while a login callback runs may return 409. -// If the Server is not Valid, the template -// handler is returned without the authentication requirement. +// Unauthenticated GET begins login; other unauthenticated methods receive 405. +// A GET request during a running login callback may return 409. +// An invalid Server returns the unprotected template handler. func (srv *Server) Handler(name string, dot any) http.Handler { return srv.wrap(ui.Handler(srv.Jaws, name, dot), false) } From 914c3afdac9a5a66d9342610daf589bdbaaba420 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Thu, 5 Nov 2026 02:29:59 +0100 Subject: [PATCH 31/43] fix: order auth events and simplify OAuth flow --- README.md | 12 +- adminpolicy_test.go | 22 +--- authsession.go | 57 ++++++++- authsession_test.go | 194 +++++++++++++----------------- concurrent_providers_test.go | 15 +-- config.go | 16 ++- currentclaims_test.go | 14 +-- doc.go | 2 +- erroauth2callback_test.go | 13 +- expiry_wrapper_test.go | 8 +- namespace_test.go | 6 +- oauth2.go | 222 ++++++++++++++++++----------------- oauth2_callback_edge_test.go | 109 +++++++++++++---- oauth2_flow_guards_test.go | 100 ++++++---------- oauth2_test.go | 150 +++++++---------------- oauth_flow_grace_test.go | 36 +----- profile_link_test.go | 110 +++-------------- server.go | 22 ++-- sessionlifecycle_test.go | 36 ++---- testwait_test.go | 12 ++ wrapper.go | 5 + wrapper_test.go | 49 ++++---- 22 files changed, 520 insertions(+), 690 deletions(-) diff --git a/README.md b/README.md index d9f5ba0..3b83419 100644 --- a/README.md +++ b/README.md @@ -62,10 +62,11 @@ login returns 503 with `Retry-After: 60` and preserves the existing session. ## Admin access Profile mode requires a verified email for a non-empty admin list by default; -OIDC mode does not. `ProfileSubject` may map a provider verification signal to -`email_verified`. Set `RequireVerifiedAdminEmail = false` only when the provider -guarantees verified addresses. Providers without a verification signal cannot -grant admin access in strict mode. +OIDC mode does not. Set `RequireVerifiedAdminEmail = true` in OIDC mode unless +the provider guarantees verified addresses. In profile mode, `ProfileSubject` +may map a provider verification signal to `email_verified`; disable the check +only with the same guarantee. Providers without a verification signal cannot +grant admin access while the check is enabled. [Microsoft warns against using its email claim for authorization](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference); use application policy based on a stable identifier for Entra ID. @@ -85,8 +86,7 @@ running callback they return 409 unless `LoginFailed` handles them. Logout and authentication expiry clear that server's credentials and reload its JaWS pages. Auth expiry preserves a pending login but cancels a pending link. Explicit logout cancels either flow. A new login on the same server, including one -started by `Wrap` or `Handler`, cancels a pending link. Check `CurrentClaims` -before protected operations, and `JawsAuth.IsAdmin` for administrator actions. +started by `Wrap` or `Handler`, cancels a pending link. Use an HTTP endpoint for a post-logout redirect. When `Server.Logout` clears authentication, it reloads pages and closes their sockets, so a later JaWS diff --git a/adminpolicy_test.go b/adminpolicy_test.go index 25a3639..4d84061 100644 --- a/adminpolicy_test.go +++ b/adminpolicy_test.go @@ -6,8 +6,6 @@ import ( "sync" "testing" "time" - - "github.com/linkdata/jaws" ) func TestAdminEmailPolicy(t *testing.T) { @@ -27,16 +25,12 @@ func TestAdminEmailPolicy(t *testing.T) { {"quotedLocalPart", map[string]any{"email": `"\"quoted\""@example.com`, "email_verified": true}, true, true}, } { t.Run(tc.name, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newTimerTestServer(t, jw, "https://issuer.example", &testAuthTimerFactory{}) srv.SetAdmins([]string{"admin@example.com", "kate@example.com", "Ã…sa@example.com", `"\"quoted\""@example.com`}) req := httptest.NewRequest(http.MethodGet, "/", nil) sess := jw.NewSession(httptest.NewRecorder(), req) - if err = srv.storeSessionAuthClaims(sess, tc.claims, nil, time.Now().Add(time.Hour), nil); err != nil { + if err := srv.storeSessionAuthClaims(sess, tc.claims, nil, time.Now().Add(time.Hour), nil); err != nil { t.Fatal(err) } auth := &JawsAuth{server: srv, sess: sess} @@ -78,11 +72,7 @@ func TestProfileAdminRequiresVerifiedEmailByDefault(t *testing.T) { {"explicit opt-out", true, http.StatusOK}, } { t.Run(tc.name, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) if !srv.RequireVerifiedAdminEmail { t.Fatal("profile mode did not require verified admin email by default") @@ -145,11 +135,7 @@ func TestUserInfoClaimProvenance(t *testing.T) { func TestAdminEmailPolicyConcurrentUpdates(t *testing.T) { // Exercise real concurrent updates and reads without forcing an interleaving. - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv, err := New(jw, nil, nil) if err != nil { t.Fatal(err) diff --git a/authsession.go b/authsession.go index 0838b95..8eedfb9 100644 --- a/authsession.go +++ b/authsession.go @@ -31,6 +31,13 @@ type authTimerState struct { expiry time.Time } +// pendingLoginEvent orders callbacks when authentication is cleared before login notification finishes. +type pendingLoginEvent struct { + started bool + cleared bool + logoutRequest *http.Request +} + func authTimerEntryExpiry(entry *authTimerState) (expiry time.Time) { if entry != nil { expiry = entry.expiry @@ -189,6 +196,12 @@ func (srv *Server) storeSessionAuthClaimsIfActive(sess *jaws.Session, claims map sess.Set(srv.SessionEmailKey, email) sess.Set(srv.SessionEmailVerifiedKey, verified) delay, replaced := srv.scheduleSessionAuthTimerLocked(sess, expiry) + if canceled != nil && srv.LoginEvent != nil { + if srv.loginEvents == nil { + srv.loginEvents = make(map[*jaws.Session]*pendingLoginEvent) + } + srv.loginEvents[sess] = &pendingLoginEvent{} + } srv.mu.Unlock() srv.Jaws.Dirty(sess) mode := "oidc" @@ -205,6 +218,31 @@ func (srv *Server) storeSessionAuthClaimsIfActive(sess *jaws.Session, claims map return nil } +func (srv *Server) emitLoginEvent(sess *jaws.Session, hr *http.Request) { + if srv.LoginEvent == nil { + return + } + srv.mu.Lock() + pending := srv.loginEvents[sess] + if pending == nil || pending.cleared { + delete(srv.loginEvents, sess) + srv.mu.Unlock() + return + } + pending.started = true + srv.mu.Unlock() + defer func() { + srv.mu.Lock() + delete(srv.loginEvents, sess) + cleared, request := pending.cleared, pending.logoutRequest + srv.mu.Unlock() + if cleared && srv.LogoutEvent != nil { + srv.LogoutEvent(sess, request) + } + }() + srv.LoginEvent(sess, hr) +} + func (srv *Server) setSessionAuthFromToken(ctx context.Context, sess *jaws.Session, tokenSource oauth2.TokenSource, token *oauth2.Token, minExpiry time.Time, entry *authTimerState) error { if srv == nil || srv.idTokenVerifier == nil { return ErrOAuth2NotConfigured @@ -444,10 +482,12 @@ func (srv *Server) clearSessionAuth(sess *jaws.Session, hr *http.Request, entry if srv == nil || sess == nil { return } - if srv.SessionRotationLock != nil { - srv.SessionRotationLock.Lock() + rotationLock := srv.SessionRotationLock + if rotationLock != nil { + rotationLock.Lock() } srv.mu.Lock() + pending := srv.loginEvents[sess] hadAuth := srv.authTimers[sess.ID()] != nil if !hadAuth { _, hadAuth = srv.sessionAuthStatus(sess, nil) @@ -467,16 +507,23 @@ func (srv *Server) clearSessionAuth(sess *jaws.Session, hr *http.Request, entry sess.Set(srv.SessionEmailVerifiedKey, nil) if hadAuth { requests = sess.Requests() + if pending != nil { + pending.cleared = true + pending.logoutRequest = hr + if !pending.started { + delete(srv.loginEvents, sess) + } + } } } srv.mu.Unlock() - if srv.SessionRotationLock != nil { - srv.SessionRotationLock.Unlock() + if rotationLock != nil { + rotationLock.Unlock() } if cleared { if hadAuth { reloadAuthRequests(requests) - if srv.LogoutEvent != nil { + if pending == nil && srv.LogoutEvent != nil { srv.LogoutEvent(sess, hr) } } diff --git a/authsession_test.go b/authsession_test.go index 51be5a9..828e04a 100644 --- a/authsession_test.go +++ b/authsession_test.go @@ -243,11 +243,7 @@ func newTimerTestServer(t *testing.T, jw *jaws.Jaws, issuer string, factory *tes } func TestStoreSessionAuthClaimsSchedulesTimer(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) factory := &testAuthTimerFactory{} srv := newTimerTestServer(t, jw, "https://issuer.example", factory) @@ -256,7 +252,7 @@ func TestStoreSessionAuthClaimsSchedulesTimer(t *testing.T) { expiry := time.Now().Add(time.Hour).Truncate(time.Second) tokenSource := oauth2.StaticTokenSource(makeOAuth2Token("access", "", "")) - err = srv.storeSessionAuthClaims(sess, map[string]any{ + err := srv.storeSessionAuthClaims(sess, map[string]any{ "exp": expiry.Unix(), "email": "User@Example.COM", "email_verified": "true", @@ -348,11 +344,7 @@ func TestStoreSessionAuthClaimsErrors(t *testing.T) { } func TestSetSessionAuthFromTokenErrors(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) const issuer = "https://issuer.example" factory := &testAuthTimerFactory{} @@ -360,7 +352,7 @@ func TestSetSessionAuthFromTokenErrors(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) sess := jw.NewSession(httptest.NewRecorder(), req) - err = (*Server)(nil).setSessionAuthFromToken(t.Context(), sess, nil, nil, time.Time{}, nil) + err := (*Server)(nil).setSessionAuthFromToken(t.Context(), sess, nil, nil, time.Time{}, nil) if !errors.Is(err, ErrOAuth2NotConfigured) { t.Fatal(err) } @@ -391,11 +383,7 @@ func TestSetSessionAuthFromTokenErrors(t *testing.T) { } func TestOIDCRefreshRejectsChangedSubject(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) const issuer = "https://issuer.example" factory := &testAuthTimerFactory{} srv := newTimerTestServer(t, jw, issuer, factory) @@ -433,11 +421,7 @@ func TestOIDCRefreshRejectsChangedSubject(t *testing.T) { } func TestAuthTimerRefreshesCachedTokenByForcingRefresh(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) logger := &testAuthDebugLogger{} jw.Debug = true jw.Logger = logger @@ -503,7 +487,7 @@ func TestAuthTimerRefreshesCachedTokenByForcingRefresh(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) sess := jw.NewSession(httptest.NewRecorder(), req) tokenSource := oauth2.StaticTokenSource(makeOAuth2Token("cached-access", cachedIDToken, "refresh123")) - err = srv.storeSessionAuthClaims(sess, map[string]any{ + err := srv.storeSessionAuthClaims(sess, map[string]any{ "exp": initialExpiry.Unix(), "sub": "sub-123", "email": "cached@example.com", @@ -578,11 +562,7 @@ func TestAuthTimerRefreshesCachedTokenByForcingRefresh(t *testing.T) { } func TestRefreshSessionAuthUsesStoredValidToken(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) logger := &testAuthDebugLogger{} jw.Debug = true @@ -606,7 +586,7 @@ func TestRefreshSessionAuthUsesStoredValidToken(t *testing.T) { sess := jw.NewSession(httptest.NewRecorder(), req) sess.Set(srv.SessionTokenKey, oauth2.StaticTokenSource(makeOAuth2Token("stored-access", rawIDToken, ""))) - err = srv.refreshSessionAuth(t.Context(), sess, time.Time{}, nil) + err := srv.refreshSessionAuth(t.Context(), sess, time.Time{}, nil) if err != nil { t.Fatal(err) } @@ -633,11 +613,7 @@ func TestRefreshSessionAuthUsesStoredValidToken(t *testing.T) { } func TestRefreshSessionAuthLogsForcedRefreshTokenSourceFailure(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) logger := &testAuthDebugLogger{} jw.Debug = true @@ -667,7 +643,7 @@ func TestRefreshSessionAuthLogsForcedRefreshTokenSourceFailure(t *testing.T) { sess := jw.NewSession(httptest.NewRecorder(), req) sess.Set(srv.SessionTokenKey, oauth2.StaticTokenSource(makeOAuth2Token("cached-access", "", "refresh123"))) - err = srv.refreshSessionAuth(t.Context(), sess, time.Time{}, nil) + err := srv.refreshSessionAuth(t.Context(), sess, time.Time{}, nil) if err == nil { t.Fatal("expected refresh error") } @@ -698,11 +674,7 @@ func TestRefreshSessionAuthLogsForcedRefreshTokenSourceFailure(t *testing.T) { } func TestAuthTimerRefreshFailureKeepsCurrentAuthUntilExpiry(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) logger := &testAuthDebugLogger{} jw.Debug = true @@ -718,7 +690,7 @@ func TestAuthTimerRefreshFailureKeepsCurrentAuthUntilExpiry(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) sess := jw.NewSession(httptest.NewRecorder(), req) expiry := time.Now().Add(authRefreshSkew - time.Second).Truncate(time.Second) - err = srv.storeSessionAuthClaims(sess, map[string]any{ + err := srv.storeSessionAuthClaims(sess, map[string]any{ "exp": expiry.Unix(), "email": "current@example.com", "email_verified": true, @@ -797,11 +769,7 @@ func TestAuthTimerRefreshFailureClearsExpiredAuth(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) var tokenRequests int provider := httptest.NewServer(http.HandlerFunc(func(hw http.ResponseWriter, hr *http.Request) { @@ -828,7 +796,7 @@ func TestAuthTimerRefreshFailureClearsExpiredAuth(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) sess := jw.NewSession(httptest.NewRecorder(), req) expiry := time.Now().Add(time.Minute).Truncate(time.Second) - err = srv.storeSessionAuthClaims(sess, map[string]any{ + err := srv.storeSessionAuthClaims(sess, map[string]any{ "exp": expiry.Unix(), "email": "old@example.com", "email_verified": true, @@ -866,11 +834,7 @@ func TestAuthTimerRefreshFailureClearsExpiredAuth(t *testing.T) { } func TestAuthTimerDebugLogsRefreshFailure(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) logger := &testAuthDebugLogger{} jw.Debug = true @@ -883,7 +847,7 @@ func TestAuthTimerDebugLogsRefreshFailure(t *testing.T) { sess := jw.NewSession(httptest.NewRecorder(), req) expiry := time.Now().Add(authRefreshSkew - time.Second).Truncate(time.Second) - err = srv.storeSessionAuthClaims(sess, map[string]any{ + err := srv.storeSessionAuthClaims(sess, map[string]any{ "exp": expiry.Unix(), "email": "old@example.com", "email_verified": true, @@ -911,11 +875,7 @@ func TestAuthTimerDebugLogsRefreshFailure(t *testing.T) { } func TestAuthTimerDebugLogsRequireJawsDebug(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) logger := &testAuthDebugLogger{} jw.Logger = logger @@ -927,7 +887,7 @@ func TestAuthTimerDebugLogsRequireJawsDebug(t *testing.T) { sess := jw.NewSession(httptest.NewRecorder(), req) expiry := time.Now().Add(30 * time.Second).Truncate(time.Second) - err = srv.storeSessionAuthClaims(sess, map[string]any{ + err := srv.storeSessionAuthClaims(sess, map[string]any{ "exp": expiry.Unix(), "email": "old@example.com", "email_verified": true, @@ -946,11 +906,7 @@ func TestAuthTimerDebugLogsRequireJawsDebug(t *testing.T) { } func TestAuthTimerStaleCallbackNoOp(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) const issuer = "https://issuer.example" factory := &testAuthTimerFactory{} @@ -963,7 +919,7 @@ func TestAuthTimerStaleCallbackNoOp(t *testing.T) { sess := jw.NewSession(httptest.NewRecorder(), req) expiry := time.Now().Add(time.Minute).Truncate(time.Second) - err = srv.storeSessionAuthClaims(sess, map[string]any{ + err := srv.storeSessionAuthClaims(sess, map[string]any{ "exp": expiry.Unix(), "email": "first@example.com", }, tokenSourceFunc(func() (*oauth2.Token, error) { @@ -997,11 +953,7 @@ func TestAuthTimerStaleCallbackNoOp(t *testing.T) { } func TestAuthTimerStaleAfterRefreshNoOp(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) const issuer = "https://issuer.example" factory := &testAuthTimerFactory{} @@ -1024,7 +976,7 @@ func TestAuthTimerStaleAfterRefreshNoOp(t *testing.T) { srv.mu.Unlock() return makeOAuth2Token("access", rawIDToken, ""), nil }) - err = srv.storeSessionAuthClaims(sess, map[string]any{ + err := srv.storeSessionAuthClaims(sess, map[string]any{ "exp": oldExpiry.Unix(), "email": "old@example.com", }, tokenSource, oldExpiry, nil) @@ -1044,11 +996,7 @@ func TestAuthTimerStaleAfterRefreshNoOp(t *testing.T) { } func TestExplicitLogoutStopsAuthTimer(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) factory := &testAuthTimerFactory{} srv := newTimerTestServer(t, jw, "https://issuer.example", factory) @@ -1061,7 +1009,7 @@ func TestExplicitLogoutStopsAuthTimer(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/logout", nil) rec := httptest.NewRecorder() sess := jw.NewSession(rec, req) - err = srv.storeSessionAuthClaims(sess, map[string]any{ + err := srv.storeSessionAuthClaims(sess, map[string]any{ "exp": time.Now().Add(time.Hour).Unix(), "email": "user@example.com", }, oauth2.StaticTokenSource(makeOAuth2Token("access", "", "")), time.Now().Add(time.Hour), nil) @@ -1091,11 +1039,7 @@ func TestExplicitLogoutStopsAuthTimer(t *testing.T) { } func TestRefreshSessionAuthErrors(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) const issuer = "https://issuer.example" factory := &testAuthTimerFactory{} @@ -1103,7 +1047,7 @@ func TestRefreshSessionAuthErrors(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) sess := jw.NewSession(httptest.NewRecorder(), req) - err = (*Server)(nil).refreshSessionAuth(t.Context(), sess, time.Time{}, nil) + err := (*Server)(nil).refreshSessionAuth(t.Context(), sess, time.Time{}, nil) if !errors.Is(err, ErrOAuth2NotConfigured) { t.Fatal(err) } @@ -1125,11 +1069,7 @@ func TestRefreshSessionAuthErrors(t *testing.T) { } func TestServerLogout(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) factory := &testAuthTimerFactory{} srv := newTimerTestServer(t, jw, "https://issuer.example", factory) @@ -1148,7 +1088,7 @@ func TestServerLogout(t *testing.T) { } req := httptest.NewRequest(http.MethodGet, "http://example.com/logout", nil) sess := jw.NewSession(httptest.NewRecorder(), req) - err = srv.storeSessionAuthClaims(sess, map[string]any{ + err := srv.storeSessionAuthClaims(sess, map[string]any{ "exp": time.Now().Add(time.Hour).Unix(), "email": "user@example.com", }, oauth2.StaticTokenSource(makeOAuth2Token("access", "", "")), time.Now().Add(time.Hour), nil) @@ -1188,27 +1128,59 @@ func TestServerLogout(t *testing.T) { } } -type delayedFirstAuthLock struct { - sync.Mutex - attempts atomic.Int32 - entered chan struct{} - release chan struct{} +func TestReloginAfterSessionClearReportsLogout(t *testing.T) { + jw := newTestJaws(t) + srv := newProfileTestServer(t, jw, nil) + factory := &testAuthTimerFactory{} + srv.authTimerAfterFunc = factory.after + logins, logouts := 0, 0 + srv.LoginEvent = func(*jaws.Session, *http.Request) { logins++ } + srv.LogoutEvent = func(*jaws.Session, *http.Request) { logouts++ } + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + first := profileCallback(srv, cookie, state, "first") + if first.Code != http.StatusFound || len(first.Result().Cookies()) != 1 { + t.Fatalf("first login: status=%d cookies=%v", first.Code, first.Result().Cookies()) + } + cookie = first.Result().Cookies()[0] + req := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + req.AddCookie(cookie) + old := jw.GetSession(req) + if old == nil { + t.Fatal("first login did not create a session") + } + old.Clear() + _, state = profileStart(t, srv, http.MethodGet, "/auth/login", cookie) + second := profileCallback(srv, cookie, state, "second") + if second.Code != http.StatusFound || logins != 2 || logouts != 1 || !factory.timer(0).isStopped() { + t.Fatalf("second login: status=%d logins=%d logouts=%d first_timer_stopped=%t", second.Code, logins, logouts, factory.timer(0).isStopped()) + } } -func (lock *delayedFirstAuthLock) Lock() { - if lock.attempts.Add(1) == 1 { - close(lock.entered) - <-lock.release +func TestAbandonedLoginClearDropsPendingEvent(t *testing.T) { + jw := newTestJaws(t) + srv := newProfileTestServer(t, jw, nil) + srv.authTimerAfterFunc = (&testAuthTimerFactory{}).after + events := 0 + srv.LoginEvent = func(*jaws.Session, *http.Request) { events++ } + srv.LogoutEvent = func(*jaws.Session, *http.Request) { events++ } + req := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + sess := jw.NewSession(httptest.NewRecorder(), req) + if err := srv.storeSessionAuthClaimsIfActive(sess, map[string]any{"sub": "one"}, nil, time.Now().Add(time.Hour), nil, make(chan struct{})); err != nil { + t.Fatal(err) + } + if !srv.Logout(sess, nil) { + t.Fatal("pending login was not cleared") + } + srv.mu.Lock() + pending := len(srv.loginEvents) + srv.mu.Unlock() + if pending != 0 || events != 0 || sess.Get(srv.SessionKey) != nil { + t.Fatalf("pending=%d events=%d claims=%v", pending, events, sess.Get(srv.SessionKey)) } - lock.Mutex.Lock() } func TestStaleWrapperDoesNotRepeatRotationLogoutEvent(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) srv.authTimerAfterFunc = (&testAuthTimerFactory{}).after cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) @@ -1225,10 +1197,10 @@ func TestStaleWrapperDoesNotRepeatRotationLogoutEvent(t *testing.T) { } _, state = profileStart(t, srv, http.MethodGet, "/auth/login", cookie) old.Set(srv.sessionKey(oauth2AuthExpiryKey), time.Now().Add(-time.Second)) - lock := &delayedFirstAuthLock{entered: make(chan struct{}), release: make(chan struct{})} + lock := &holdFirstRotationLock{first: make(chan struct{}), releaseFirst: make(chan struct{})} srv.SessionRotationLock = lock var oldLogouts atomic.Int32 - release := sync.OnceFunc(func() { close(lock.release) }) + release := sync.OnceFunc(func() { close(lock.releaseFirst) }) defer release() srv.LogoutEvent = func(sess *jaws.Session, _ *http.Request) { if sess == old { @@ -1241,7 +1213,7 @@ func TestStaleWrapperDoesNotRepeatRotationLogoutEvent(t *testing.T) { srv.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})).ServeHTTP(httptest.NewRecorder(), req) close(wrapped) }() - waitFor(t, lock.entered, "wrapper did not reach auth clear") + waitFor(t, lock.first, "wrapper did not reach auth clear") callback := profileCallback(srv, cookie, state, "second") waitFor(t, wrapped, "stale wrapper did not finish") if callback.Code != http.StatusFound || len(callback.Result().Cookies()) != 1 || oldLogouts.Load() != 1 { @@ -1250,11 +1222,7 @@ func TestStaleWrapperDoesNotRepeatRotationLogoutEvent(t *testing.T) { } func TestAuthDebugSessionPrefix(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) var output strings.Builder jw.Debug = true jw.Logger = slog.New(slog.NewTextHandler(&output, nil)) @@ -1263,7 +1231,7 @@ func TestAuthDebugSessionPrefix(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) sess := jw.NewSession(httptest.NewRecorder(), req) expiry := time.Now().Add(time.Hour) - if err = srv.storeSessionAuthClaims(sess, map[string]any{"email": "user@example.com"}, tokenSourceFunc(func() (*oauth2.Token, error) { + if err := srv.storeSessionAuthClaims(sess, map[string]any{"email": "user@example.com"}, tokenSourceFunc(func() (*oauth2.Token, error) { return nil, errAuthSessionTestToken }), expiry, nil); err != nil { t.Fatal(err) diff --git a/concurrent_providers_test.go b/concurrent_providers_test.go index 5107d2e..1f911e4 100644 --- a/concurrent_providers_test.go +++ b/concurrent_providers_test.go @@ -44,11 +44,7 @@ func (l *gateRotationLock) Lock() { func (l *gateRotationLock) Unlock() { l.mu.Unlock() } func TestConcurrentProviderCallbacksRotateOriginalSessionOnce(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/token" { return profileTestResponse(`{"access_token":"proof","token_type":"Bearer"}`), nil @@ -113,8 +109,7 @@ func TestConcurrentProviderCallbacksRotateOriginalSessionOnce(t *testing.T) { } } -// holdFirstRotationLock lets logout acquire the lock before a callback that -// reached Lock first, so the callback must recheck its flow after acquisition. +// holdFirstRotationLock lets a later caller acquire the lock before the first. type holdFirstRotationLock struct { mu sync.Mutex attempts atomic.Int32 @@ -135,11 +130,7 @@ func (l *holdFirstRotationLock) Unlock() { l.mu.Unlock() } func TestLinkedProviderLogoutBeforeLinkWrite(t *testing.T) { for _, logoutProvider := range []string{"linked", "original"} { t.Run(logoutProvider, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/token" { return profileTestResponse(`{"access_token":"proof","token_type":"Bearer"}`), nil diff --git a/config.go b/config.go index 4aa29db..eef9df6 100644 --- a/config.go +++ b/config.go @@ -26,9 +26,9 @@ type Config struct { // Namespace isolates providers sharing one JaWS instance. New appends it to // the four exported Server session-key fields; use a distinct value per provider. Namespace string - AuthURL string // OIDC override; required in profile mode - TokenURL string // OIDC override; required in profile mode - UserInfoURL string // OIDC override; required in profile mode + AuthURL string // OIDC override + TokenURL string // OIDC override + UserInfoURL string // OIDC override // AllowInsecureIssuer permits HTTP issuer and provider endpoints and is only for tests/dev. AllowInsecureIssuer bool // HTTPClient is used for OIDC discovery and JWKS, and by default for token @@ -42,8 +42,8 @@ type Config struct { ClientID string ClientSecret string // ProfileSubject enables OAuth2 profile authentication without an OIDC ID token. - // AuthURL, TokenURL and UserInfoURL are required in this mode. It must return - // a stable, non-empty provider subject from the authenticated profile response. + // It must return a stable, non-empty provider subject from the authenticated + // profile response. // Profile mode sets claims["iss"] to Issuer and claims["sub"] to this subject; // use a distinct Issuer per provider. // JSON numbers arrive as json.Number to preserve large numeric IDs. @@ -97,10 +97,8 @@ func validateEndpoint(field, value string, optional, allowInsecure bool) error { // Validate checks whether cfg contains usable OIDC/OAuth2 settings. // -// RedirectURL, Issuer and ClientID must be present. URL fields must be absolute -// and include a host. OIDC endpoint overrides are optional; profile endpoints -// are required and must use HTTPS, or HTTP when AllowInsecureIssuer is true. -// OIDC endpoints and Issuer must use HTTPS unless AllowInsecureIssuer is true. +// It checks required fields and absolute URLs with hosts. Configured issuer and +// provider endpoints require HTTPS unless AllowInsecureIssuer permits HTTP. // Returned failures match [ErrConfig]. func (cfg *Config) Validate() error { if _, err := validateUrl("RedirectURL", cfg.RedirectURL, false); err != nil { diff --git a/currentclaims_test.go b/currentclaims_test.go index 50e124f..d8449f0 100644 --- a/currentclaims_test.go +++ b/currentclaims_test.go @@ -5,16 +5,10 @@ import ( "net/http/httptest" "testing" "time" - - "github.com/linkdata/jaws" ) func TestServerCurrentClaimsChecksExpiryAndJaWSSession(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := &Server{Jaws: jw, SessionKey: "claims", namespace: "github"} sess := jw.NewSession(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "https://app.example/", nil)) if sess == nil { @@ -32,11 +26,7 @@ func TestServerCurrentClaimsChecksExpiryAndJaWSSession(t *testing.T) { if got := srv.CurrentClaims(sess); got["sub"] != "42" { t.Fatalf("live claims: %v", got) } - other, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer other.Close() + other := newTestJaws(t) if (&Server{Jaws: other, SessionKey: srv.SessionKey, namespace: srv.namespace}).CurrentClaims(sess) != nil { t.Fatal("another JaWS instance accepted session") } diff --git a/doc.go b/doc.go index e32e538..f7516ee 100644 --- a/doc.go +++ b/doc.go @@ -2,7 +2,7 @@ // // OIDC mode uses discovery, PKCE, nonce and ID token verification. Profile mode // uses explicit provider endpoints, PKCE and an authenticated profile response -// with an application-supplied subject extractor. The verified claims are +// with an application-supplied subject extractor. The resulting claims are // stored in a JaWS session. OIDC attempts refresh before expiry; profile auth // expires after a bounded lifetime. Both modes support admin email gates. // diff --git a/erroauth2callback_test.go b/erroauth2callback_test.go index 8df9359..72bac0d 100644 --- a/erroauth2callback_test.go +++ b/erroauth2callback_test.go @@ -10,7 +10,6 @@ import ( "testing" "time" - "github.com/linkdata/jaws" "golang.org/x/oauth2" ) @@ -96,11 +95,7 @@ func Test_oauth2CallbackErrorNilReceiver(t *testing.T) { } func Test_handleAuthResponseOAuthErrorCallback(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := &Server{ Jaws: jw, HandledPaths: map[string]struct{}{}, @@ -165,11 +160,7 @@ func Test_handleAuthResponseOAuthErrorCallback(t *testing.T) { } func Test_handleAuthResponseOAuthErrorCallbackReceivesSessionEmail(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := &Server{ Jaws: jw, SessionKey: "oauth2userinfo", diff --git a/expiry_wrapper_test.go b/expiry_wrapper_test.go index d20b434..320633b 100644 --- a/expiry_wrapper_test.go +++ b/expiry_wrapper_test.go @@ -6,16 +6,10 @@ import ( "net/url" "testing" "time" - - "github.com/linkdata/jaws" ) func TestProtectedRequestPreservesPendingLoginOnAuthExpiry(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) cookie, initialState := profileStart(t, srv, http.MethodGet, "/auth/login", nil) first := profileCallback(srv, cookie, initialState, "first") diff --git a/namespace_test.go b/namespace_test.go index 6d42458..d231047 100644 --- a/namespace_test.go +++ b/namespace_test.go @@ -28,11 +28,7 @@ func setTestOAuthFlow(sess *jaws.Session, srv *Server, flow pendingOAuthFlow) { } func TestOAuthFlowNamespacesShareSessionWithoutSharingState(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) newServer := func(namespace string) *Server { srv, err := New(jw, &Config{Namespace: namespace}, nil) if err != nil { diff --git a/oauth2.go b/oauth2.go index bf5880e..769151f 100644 --- a/oauth2.go +++ b/oauth2.go @@ -13,6 +13,7 @@ import ( "net/http" "net/mail" "net/url" + "path" "strconv" "strings" "time" @@ -89,10 +90,15 @@ const maxRedirectTargetLen = 8192 func normalizeHost(hostport string) (normalized string) { normalized = strings.TrimSpace(hostport) if normalized != "" { + if strings.HasPrefix(normalized, "[") && strings.HasSuffix(normalized, "]") { + normalized += ":0" + } if strings.Contains(normalized, ":") { - if h, _, err := net.SplitHostPort(normalized); err == nil { - normalized = h + h, _, err := net.SplitHostPort(normalized) + if err != nil { + return "" } + normalized = h } normalized = strings.ToLower(strings.TrimSuffix(normalized, ".")) } @@ -128,7 +134,7 @@ func (srv *Server) begin(hr *http.Request) (oauth2cfg *oauth2.Config, location s } location = sanitizeRedirectTarget(hr.Host, location) if target, err := url.Parse(location); err == nil { - if _, handled := srv.HandledPaths[target.Path]; handled { + if _, handled := srv.HandledPaths[path.Clean(target.Path)]; handled { location = "/" } } @@ -140,16 +146,24 @@ func (srv *Server) begin(hr *http.Request) (oauth2cfg *oauth2.Config, location s // For GET requests it stores the state, PKCE verifier and, for OIDC, nonce in the // session, then responds with a 302 redirect to the provider's authorization URL. // It replaces any pending flow on this server, canceling a running callback or link. -// If no session can be created, it returns 503 with Retry-After instead. +// If no usable session is available, it returns 503 with Retry-After instead. // Non-GET requests receive 405 with Allow: GET. func (srv *Server) HandleLogin(hw http.ResponseWriter, hr *http.Request) { + if hr.Method != http.MethodGet { + hw.Header().Set("Allow", http.MethodGet) + SetHeaders(hw, srv.ishttps) + hw.WriteHeader(http.StatusMethodNotAllowed) + return + } srv.startOAuth(hw, hr, false, false) } // HandleLink begins a provider flow for linking to the caller's existing // identity. The application must authorize this POST before calling it and // recheck that identity in LinkEvent when the provider returns. On success it -// responds with a 303 redirect to the provider. +// responds with a 303 redirect to the provider. It returns 405 with Allow: POST +// for other methods, 403 for a cross-origin or missing session, and 500 when +// linking is not configured. func (srv *Server) HandleLink(hw http.ResponseWriter, hr *http.Request) { switch { case hr.Method != http.MethodPost: @@ -167,94 +181,91 @@ func (srv *Server) HandleLink(hw http.ResponseWriter, hr *http.Request) { var linkOriginCheck = http.NewCrossOriginProtection() func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, preservePendingLogin bool) { - missingSession := func() { - status := http.StatusForbidden - if !link { - status = http.StatusServiceUnavailable - hw.Header().Set("Retry-After", "60") + oauth2cfg, location := srv.begin(hr) + if oauth2cfg != nil { + if link && strings.TrimSpace(hr.Referer()) == "" { + location = "/" } - srv.writeResult(hw, status, ErrOAuth2MissingSession, nil) - } - statusCode := http.StatusMethodNotAllowed - if link || hr.Method == http.MethodGet { - oauth2cfg, location := srv.begin(hr) - if oauth2cfg != nil { - rotationLock := srv.SessionRotationLock - if rotationLock != nil { - rotationLock.Lock() - } - unlockRotation := func() { - if rotationLock != nil { - rotationLock.Unlock() - } - } - sess := srv.Jaws.GetSession(hr) - if sess == nil && !link { - sess = srv.Jaws.NewSession(hw, hr) - } - if sess == nil { - unlockRotation() - missingSession() - return - } - if link && strings.TrimSpace(hr.Referer()) == "" { - location = "/" - } - var flow pendingOAuthFlow - srv.mu.Lock() - if preservePendingLogin && srv.pendingOrdinaryLoginLocked(sess) { - // Reissue one pending transaction without replacing the first tab's state. - flow, _ = sess.Get(srv.sessionKey(oauth2FlowKey)).(pendingOAuthFlow) - if flow.state == "" { - srv.mu.Unlock() - unlockRotation() - if srv.LoginFailed != nil { - email, _ := sess.Get(srv.SessionEmailKey).(string) - if srv.LoginFailed(hw, hr, http.StatusConflict, ErrOAuth2LoginInProgress, email) { - return - } + flow, sess, status := srv.prepareOAuthFlow(hw, hr, link, preservePendingLogin, location) + if status != 0 { + if status == http.StatusConflict { + if srv.LoginFailed != nil { + email, _ := sess.Get(srv.SessionEmailKey).(string) + if srv.LoginFailed(hw, hr, status, ErrOAuth2LoginInProgress, email) { + return } - srv.writeResult(hw, http.StatusConflict, ErrOAuth2LoginInProgress, nil) - return } + srv.writeResult(hw, status, ErrOAuth2LoginInProgress, nil) } else { - flow = pendingOAuthFlow{ - state: rand.Text(), expiry: time.Now().Add(oauth2FlowLifetime), - link: link, verifier: oauth2.GenerateVerifier(), referrer: location, - } - if srv.profileSubject == nil { - flow.nonce = rand.Text() + if status == http.StatusServiceUnavailable { + hw.Header().Set("Retry-After", "60") } - if !sess.ExtendGrace(oauth2FlowGrace) { - srv.mu.Unlock() - unlockRotation() - missingSession() - return - } - srv.clearSessionOAuthFlow(sess) - sess.Set(srv.sessionKey(oauth2FlowKey), flow) - } - srv.mu.Unlock() - unlockRotation() - authOptions := append([]oauth2.AuthCodeOption{}, srv.Options...) - if flow.nonce != "" { - authOptions = append(authOptions, oidc.Nonce(flow.nonce)) + srv.writeResult(hw, status, ErrOAuth2MissingSession, nil) } - authOptions = append(authOptions, oauth2.S256ChallengeOption(flow.verifier)) - location = oauth2cfg.AuthCodeURL(flow.state, authOptions...) + return } - hw.Header().Set("Location", location) - statusCode = http.StatusFound - if link { - statusCode = http.StatusSeeOther + authOptions := append([]oauth2.AuthCodeOption{}, srv.Options...) + if flow.nonce != "" { + authOptions = append(authOptions, oidc.Nonce(flow.nonce)) } - } else { - hw.Header().Set("Allow", http.MethodGet) + authOptions = append(authOptions, oauth2.S256ChallengeOption(flow.verifier)) + location = oauth2cfg.AuthCodeURL(flow.state, authOptions...) + } + hw.Header().Set("Location", location) + statusCode := http.StatusFound + if link { + statusCode = http.StatusSeeOther } SetHeaders(hw, srv.ishttps) hw.WriteHeader(statusCode) } +func (srv *Server) prepareOAuthFlow(hw http.ResponseWriter, hr *http.Request, link, preservePendingLogin bool, location string) (flow pendingOAuthFlow, sess *jaws.Session, status int) { + rotationLock := srv.SessionRotationLock + if rotationLock != nil { + rotationLock.Lock() + defer rotationLock.Unlock() + } + sess = srv.Jaws.GetSession(hr) + if sess == nil && !link { + sess = srv.Jaws.NewSession(hw, hr) + } + if sess == nil { + if link { + return flow, nil, http.StatusForbidden + } + return flow, nil, http.StatusServiceUnavailable + } + srv.mu.Lock() + defer srv.mu.Unlock() + if preservePendingLogin && srv.pendingOrdinaryLoginLocked(sess) { + // Reissue one pending transaction without replacing the first tab's state. + flow, _ = sess.Get(srv.sessionKey(oauth2FlowKey)).(pendingOAuthFlow) + if flow.state == "" { + status = http.StatusConflict + } + return + } + flow = pendingOAuthFlow{ + state: rand.Text(), expiry: time.Now().Add(oauth2FlowLifetime), + link: link, verifier: oauth2.GenerateVerifier(), referrer: location, + } + if srv.profileSubject == nil { + flow.nonce = rand.Text() + } + if !sess.ExtendGrace(oauth2FlowGrace) { + if link { + status = http.StatusForbidden + } else { + status = http.StatusServiceUnavailable + } + return + } + srv.clearSessionOAuthFlow(sess) + sess.Set(srv.sessionKey(oauth2FlowKey), flow) + return +} + // HandleLogout clears this server's authentication in the session and redirects. // // For GET requests it clears the auth (firing LogoutEvent when auth was present) @@ -334,7 +345,7 @@ var ErrUserInfoStatus = errors.New("userinfo status") // ErrUserInfoTooLarge means the authenticated profile exceeded the response limit. var ErrUserInfoTooLarge = errors.New("userinfo response too large") -// ErrUserInfoInvalidResponse means the UserInfo response was not one valid JSON value. +// ErrUserInfoInvalidResponse means the UserInfo response was not one JSON object. var ErrUserInfoInvalidResponse = errors.New("userinfo invalid response") // ErrOAuth2InvalidProfile means the profile or access token could not prove a stable subject. @@ -436,6 +447,9 @@ func (srv *Server) fetchUserInfo(ctx context.Context, userinfoURL string, tokenS decoder.UseNumber() } err = decoder.Decode(&userinfo) + if err != nil || userinfo == nil { + userinfo, err = nil, ErrUserInfoInvalidResponse + } } } } @@ -485,9 +499,6 @@ func (srv *Server) verifyOAuthIdentity(ctx context.Context, token *oauth2.Token, if !profileTokenUnexpired(token) { return nil, nil, time.Time{}, http.StatusUnauthorized, ErrOAuth2InvalidProfile } - if claims == nil { - return nil, nil, time.Time{}, status, ErrOAuth2InvalidProfile - } var subject string subject, err = srv.profileSubject(claims) if srv.Jaws.Log(err) != nil || subject == "" { @@ -523,9 +534,10 @@ func profileTokenUnexpired(token *oauth2.Token) bool { } func (srv *Server) rotateOAuthSession(hw http.ResponseWriter, hr *http.Request, old *jaws.Session, flow pendingOAuthFlow) (rotated *jaws.Session, stale bool) { - if srv.SessionRotationLock != nil { - srv.SessionRotationLock.Lock() - defer srv.SessionRotationLock.Unlock() + rotationLock := srv.SessionRotationLock + if rotationLock != nil { + rotationLock.Lock() + defer rotationLock.Unlock() } if flow.revoked() || srv.Jaws.GetSession(hr) != old { return nil, true @@ -540,9 +552,10 @@ func (srv *Server) completeLink(sess *jaws.Session, hr *http.Request, claims map _ = srv.Jaws.Log(linkErr) } }() - if srv.SessionRotationLock != nil { - srv.SessionRotationLock.Lock() - defer srv.SessionRotationLock.Unlock() + rotationLock := srv.SessionRotationLock + if rotationLock != nil { + rotationLock.Lock() + defer rotationLock.Unlock() } if flow.revoked() || srv.Jaws.GetSession(hr) != sess { return http.StatusForbidden, ErrOAuth2MissingSession @@ -562,7 +575,6 @@ func (srv *Server) completeLink(sess *jaws.Session, hr *http.Request, claims map func (srv *Server) completeLogin(hw http.ResponseWriter, hr *http.Request, old *jaws.Session, flow pendingOAuthFlow, claims map[string]any, tokenSource oauth2.TokenSource, expiry time.Time) (sess *jaws.Session, status int, err error) { sess, status, err = old, http.StatusServiceUnavailable, ErrOAuth2MissingSession - _, hadAuth := srv.sessionAuthStatus(old, nil) rotated, stale := srv.rotateOAuthSession(hw, hr, old, flow) if rotated == nil { if stale { @@ -576,7 +588,7 @@ func (srv *Server) completeLogin(hw http.ResponseWriter, hr *http.Request, old * hadTimer := srv.authTimers[old.ID()] != nil srv.stopSessionAuthTimerLocked(old, nil) srv.mu.Unlock() - if hadAuth && hadTimer && srv.LogoutEvent != nil { + if hadTimer && srv.LogoutEvent != nil { srv.LogoutEvent(old, hr) } sess = rotated @@ -660,23 +672,15 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) } } } - if sess != nil { - if err != nil { - srv.Jaws.Dirty(sess) - } else if !flow.link { - if srv.LoginEvent != nil { - srv.LoginEvent(sess, hr) - } - current, present := srv.sessionAuthStatus(sess, time.Now) - if flow.revoked() || !current || !sessionAlive(sess) { - if present { - srv.clearSessionAuth(sess, hr, nil, false) - } - http.SetCookie(hw, sess.Close()) - hw.Header().Del("Location") - err = ErrOAuth2MissingSession - statusCode = http.StatusForbidden - } + if sess != nil && err == nil && !flow.link { + srv.emitLoginEvent(sess, hr) + current, _ := srv.sessionAuthStatus(sess, time.Now) + if flow.revoked() || !current || !sessionAlive(sess) { + srv.clearSessionAuth(sess, hr, nil, false) + http.SetCookie(hw, sess.Close()) + hw.Header().Del("Location") + err = ErrOAuth2MissingSession + statusCode = http.StatusForbidden } } if err != nil && srv.LoginFailed != nil { diff --git a/oauth2_callback_edge_test.go b/oauth2_callback_edge_test.go index e697a95..0ab2efd 100644 --- a/oauth2_callback_edge_test.go +++ b/oauth2_callback_edge_test.go @@ -22,11 +22,7 @@ func TestOIDCCallbackRejectsEmptySubject(t *testing.T) { name = "link" } t.Run(name, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newWrapperTestServer(jw, "https://issuer.example") idToken := makeIDToken(t, map[string]any{ "iss": "https://issuer.example", "aud": "client", "sub": "", "nonce": "nonce", @@ -77,11 +73,7 @@ func TestOIDCCallbackRejectsEmptySubject(t *testing.T) { func TestOIDCExpiryDuringUserInfoRejected(t *testing.T) { synctest.Test(t, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newWrapperTestServer(jw, "https://issuer.example") srv.userinfoUrl = "https://provider.example/userinfo" idToken := makeIDToken(t, map[string]any{ @@ -115,7 +107,7 @@ func TestOIDCExpiryDuringUserInfoRejected(t *testing.T) { refreshReq := httptest.NewRequest(http.MethodGet, "http://example.com/refresh", nil) refreshSess := jw.NewSession(httptest.NewRecorder(), refreshReq) refreshToken := makeOAuth2Token("access", idToken, "") - err = srv.setSessionAuthFromToken(srv.oauth2Context(t.Context()), refreshSess, + err := srv.setSessionAuthFromToken(srv.oauth2Context(t.Context()), refreshSess, oauth2.StaticTokenSource(refreshToken), refreshToken, time.Time{}, nil) if !errors.Is(err, ErrOIDCInvalidIDToken) || refreshSess.Get(srv.SessionKey) != nil { t.Fatalf("expired refresh during UserInfo: claims=%v err=%v", refreshSess.Get(srv.SessionKey), err) @@ -125,11 +117,7 @@ func TestOIDCExpiryDuringUserInfoRejected(t *testing.T) { func TestProfileExpiryDuringSubjectRejected(t *testing.T) { synctest.Test(t, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := &Server{Jaws: jw, userinfoUrl: "https://provider.example/user", profileSubject: func(map[string]any) (string, error) { time.Sleep(12 * time.Second) @@ -147,11 +135,7 @@ func TestProfileExpiryDuringSubjectRejected(t *testing.T) { } func TestOAuthLogoutDuringLoginEventClosesRotatedSession(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) calls := 0 srv := newProfileTestServer(t, jw, &calls) entered := make(chan *jaws.Session, 1) @@ -187,3 +171,86 @@ func TestOAuthLogoutDuringLoginEventClosesRotatedSession(t *testing.T) { t.Fatalf("superseded login: status=%d location=%q cookies=%v claims=%v", w.Code, w.Header().Get("Location"), cookies, rotated.Get(srv.SessionKey)) } } + +func TestLoginClearedBeforeEventSkipsLifecycleEvents(t *testing.T) { + jw := newTestJaws(t) + srv := newProfileTestServer(t, jw, nil) + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + req := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + req.AddCookie(cookie) + preauth := jw.GetSession(req) + var events []string + srv.LoginEvent = func(*jaws.Session, *http.Request) { events = append(events, "login") } + srv.LogoutEvent = func(*jaws.Session, *http.Request) { events = append(events, "logout") } + cleared := false + jw.Debug = true + jw.Logger = infoHookLogger(func(msg string) { + if msg == "jawsauth: scheduled auth timer" { + for _, sess := range jw.Sessions() { + if sess != preauth { + cleared = srv.Logout(sess, nil) + } + } + } + }) + response := profileCallback(srv, cookie, state, "first") + if !cleared || response.Code != http.StatusForbidden || len(events) != 0 { + t.Fatalf("cleared=%t status=%d events=%v", cleared, response.Code, events) + } + srv.mu.Lock() + pending := len(srv.loginEvents) + srv.mu.Unlock() + if pending != 0 { + t.Fatalf("pending login events = %d", pending) + } +} + +func TestLoginEventCanLogoutItsSession(t *testing.T) { + jw := newTestJaws(t) + srv := newProfileTestServer(t, jw, nil) + var events []string + srv.LoginEvent = func(sess *jaws.Session, _ *http.Request) { + events = append(events, "login") + if !srv.Logout(sess, nil) { + t.Fatal("logout failed") + } + } + srv.LogoutEvent = func(*jaws.Session, *http.Request) { events = append(events, "logout") } + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + response := profileCallback(srv, cookie, state, "first") + if response.Code != http.StatusForbidden || strings.Join(events, ",") != "login,logout" { + t.Fatalf("status=%d events=%v", response.Code, events) + } +} + +func TestConcurrentLogoutWaitsForLoginEvent(t *testing.T) { + jw := newTestJaws(t) + srv := newProfileTestServer(t, jw, nil) + entered := make(chan *jaws.Session, 1) + release := make(chan struct{}) + releaseOnce := sync.OnceFunc(func() { close(release) }) + defer releaseOnce() + events := make(chan string, 2) + srv.LoginEvent = func(sess *jaws.Session, _ *http.Request) { + events <- "login" + entered <- sess + <-release + } + srv.LogoutEvent = func(*jaws.Session, *http.Request) { events <- "logout" } + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + result := make(chan *httptest.ResponseRecorder, 1) + go func() { result <- profileCallback(srv, cookie, state, "first") }() + rotated := waitFor(t, entered, "LoginEvent did not start") + loggedOut := make(chan bool, 1) + go func() { loggedOut <- srv.Logout(rotated, nil) }() + if !waitFor(t, loggedOut, "concurrent logout blocked") || len(events) != 1 { + t.Fatalf("logout before LoginEvent returned: events=%d", len(events)) + } + releaseOnce() + response := waitFor(t, result, "callback did not finish") + first := waitFor(t, events, "missing LoginEvent") + second := waitFor(t, events, "missing LogoutEvent") + if response.Code != http.StatusForbidden || first != "login" || second != "logout" || srv.CurrentClaims(rotated) != nil { + t.Fatalf("status=%d events=%s,%s claims=%v", response.Code, first, second, srv.CurrentClaims(rotated)) + } +} diff --git a/oauth2_flow_guards_test.go b/oauth2_flow_guards_test.go index 451a6c7..5ba44ee 100644 --- a/oauth2_flow_guards_test.go +++ b/oauth2_flow_guards_test.go @@ -37,11 +37,7 @@ func (w *rotationHeaderGate) Header() http.Header { } func TestLoginStartDuringRotationKeepsFirstCallback(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) lock := &observedRotationLock{second: make(chan struct{})} @@ -75,11 +71,7 @@ func TestLoginStartDuringRotationKeepsFirstCallback(t *testing.T) { } func TestCallbackRevokedAfterStoreBalancesEvents(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) r := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) @@ -112,11 +104,7 @@ func TestCallbackRevokedAfterStoreBalancesEvents(t *testing.T) { func TestCallbackRejectsAuthInvalidatedByLoginEvent(t *testing.T) { for _, action := range []string{"expired auth", "closed session"} { t.Run(action, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) called := false @@ -139,11 +127,7 @@ func TestCallbackRejectsAuthInvalidatedByLoginEvent(t *testing.T) { func TestProfileTokenExpiresBetweenRotationAndStore(t *testing.T) { synctest.Test(t, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) first := profileCallback(srv, cookie, state, "first") @@ -182,11 +166,7 @@ func TestProfileTokenExpiresBetweenRotationAndStore(t *testing.T) { } func TestLinkWithoutEventDoesNotStartFlow(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) calls := 0 srv := newProfileTestServer(t, jw, &calls) srv.LinkEvent = nil @@ -203,11 +183,7 @@ func TestLinkWithoutEventDoesNotStartFlow(t *testing.T) { } func TestLinkRejectsGETBeforeStartingFlow(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) calls := 0 srv := newProfileTestServer(t, jw, &calls) srv.LinkEvent = func(*jaws.Session, *http.Request, map[string]any) (int, error) { return 0, nil } @@ -235,11 +211,7 @@ func TestLinkRejectsInvalidEventResults(t *testing.T) { {"conflict status", http.StatusConflict, errors.New("secret persistence detail"), http.StatusConflict}, } { t.Run(tc.name, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) first := profileCallback(srv, cookie, state, "first") @@ -263,11 +235,7 @@ func TestLinkRejectsInvalidEventResults(t *testing.T) { func TestLinkRejectsIdentityThatExpiresWhileWaitingForLock(t *testing.T) { synctest.Test(t, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) lock := &holdFirstRotationLock{first: make(chan struct{}), releaseFirst: make(chan struct{})} srv.SessionRotationLock = lock @@ -302,30 +270,36 @@ func TestLinkRejectsIdentityThatExpiresWhileWaitingForLock(t *testing.T) { }) } -func TestUserInfoRejectsTrailingJSON(t *testing.T) { +func TestUserInfoRequiresOneJSONObject(t *testing.T) { for _, profile := range []bool{false, true} { t.Run(map[bool]string{false: "OIDC", true: "profile"}[profile], func(t *testing.T) { - srv := &Server{} - if profile { - srv.profileSubject = func(map[string]any) (string, error) { return "42", nil } - } - ctx := context.WithValue(t.Context(), oauth2.HTTPClient, &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { - return profileTestResponse(`{"id":42} {"id":43}`), nil - })}) - claims, err := srv.fetchUserInfo(ctx, "https://provider.example/user", oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "proof"})) - if !errors.Is(err, ErrUserInfoInvalidResponse) || claims != nil { - t.Fatalf("trailing JSON accepted: claims=%v err=%v", claims, err) + for _, tc := range []struct{ name, body string }{ + {"trailing", `{"id":42} {"id":43}`}, + {"null", `null`}, + {"array", `[1]`}, + {"string", `"x"`}, + {"number", `1`}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := &Server{} + if profile { + srv.profileSubject = func(map[string]any) (string, error) { return "42", nil } + } + ctx := context.WithValue(t.Context(), oauth2.HTTPClient, &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return profileTestResponse(tc.body), nil + })}) + claims, err := srv.fetchUserInfo(ctx, "https://provider.example/user", oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "proof"})) + if !errors.Is(err, ErrUserInfoInvalidResponse) || claims != nil { + t.Fatalf("invalid UserInfo accepted: claims=%v err=%v", claims, err) + } + }) } }) } } func TestLinkWithoutRefererReturnsHome(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) first := profileCallback(srv, cookie, state, "first") @@ -354,17 +328,13 @@ func TestProfileUserInfoNullDoesNotCallSubject(t *testing.T) { return profileTestResponse(`null`), nil })}) claims, source, expiry, status, err := srv.verifyOAuthIdentity(ctx, &oauth2.Token{AccessToken: "proof", TokenType: "Bearer"}, "") - if !errors.Is(err, ErrOAuth2InvalidProfile) || claims != nil || source != nil || !expiry.IsZero() || status != http.StatusUnauthorized || called { + if !errors.Is(err, ErrUserInfoInvalidResponse) || claims != nil || source != nil || !expiry.IsZero() || status != http.StatusBadGateway || called { t.Fatalf("null profile: claims=%v source=%t expiry=%v status=%d err=%v subject_called=%t", claims, source != nil, expiry, status, err, called) } } func TestCallbackClosesRotatedSessionOnStoreFailure(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) first := profileCallback(srv, cookie, state, "first") @@ -403,11 +373,7 @@ func (lock *revokeAfterRotationLock) Unlock() { } func TestCallbackRevokedAfterRotationClosesNewSession(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) first := profileCallback(srv, cookie, state, "first") diff --git a/oauth2_test.go b/oauth2_test.go index 8fe8ca4..8b5b05a 100644 --- a/oauth2_test.go +++ b/oauth2_test.go @@ -104,6 +104,15 @@ func Test_sanitizeRedirectTarget(t *testing.T) { if s := sanitizeRedirectTarget("example.com", "https://example.com.:443/dashboard"); s != "/dashboard" { t.Fatal(s) } + if s := sanitizeRedirectTarget("[::1]", "https://[::1]:443/dashboard"); s != "/dashboard" { + t.Fatal(s) + } + if s := sanitizeRedirectTarget("[::1]:443", "https://[::1]/dashboard"); s != "/dashboard" { + t.Fatal(s) + } + if s := sanitizeRedirectTarget("::1:443", "https://[::1]/dashboard"); s != "/" { + t.Fatalf("malformed host accepted: %q", s) + } if s := sanitizeRedirectTarget("example.com", "section/page"); s != "/section/page" { t.Fatal(s) } @@ -153,6 +162,15 @@ func Test_beginReferrerHandling(t *testing.T) { if location != "/app/home?next=x" { t.Fatalf("unhandled target changed to %q", location) } + for _, referer := range []string{ + "https://example.com/oauth2//login", "https://example.com/oauth2/./login", + } { + hr.Header.Set("Referer", referer) + _, location = srv.begin(hr) + if location != "/" { + t.Fatalf("handled path %q returned to %q", referer, location) + } + } } // Test_beginPathBoundary verifies that handled paths only rewrite exact @@ -178,11 +196,7 @@ func Test_beginPathBoundary(t *testing.T) { } func Test_handleLoginGeneratesOpaqueState(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := &Server{ Jaws: jw, HandledPaths: map[string]struct{}{"/oauth2/login": {}}, @@ -245,11 +259,7 @@ func Test_handleLoginGeneratesOpaqueState(t *testing.T) { } func Test_handleLoginCreatesSessionAndRotatesState(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := &Server{ Jaws: jw, HandledPaths: map[string]struct{}{"/oauth2/login": {}}, @@ -295,11 +305,7 @@ func Test_handleLoginCreatesSessionAndRotatesState(t *testing.T) { } func Test_handleLogoutClearsPendingOAuthState(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := &Server{ Jaws: jw, SessionKey: "oauth2userinfo", @@ -335,11 +341,7 @@ func Test_handleLogoutClearsPendingOAuthState(t *testing.T) { } func Test_handleAuthResponseUsesPKCEVerifier(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) var mu sync.Mutex var gotVerifier string @@ -497,11 +499,7 @@ func Test_handleAuthResponseUsesPKCEVerifier(t *testing.T) { // the TokenSource stored in the session uses a request-independent context. // Scheduled refreshes can run after the callback request finishes. func Test_handleAuthResponseStoredTokenSourceSurvivesRequestCancel(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) const issuer = "https://issuer.example" const wantNonce = "nonce123" @@ -577,11 +575,7 @@ func Test_handleAuthResponseStoredTokenSourceSurvivesRequestCancel(t *testing.T) } func Test_handleAuthResponseMissingPKCEVerifier(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) var tokenRequests int provider := httptest.NewServer(http.HandlerFunc(func(hw http.ResponseWriter, hr *http.Request) { @@ -636,11 +630,7 @@ func Test_handleAuthResponseMissingPKCEVerifier(t *testing.T) { } func Test_handleAuthResponseWrongStatePreservesCurrentAuth(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newWrapperTestServer(jw, "https://issuer.example") rec := httptest.NewRecorder() @@ -831,11 +821,7 @@ func TestServer_fetchUserInfoDoesNotFollowBearerRedirect(t *testing.T) { } func TestOAuthOfflineOptionUsesAuthorizationOnly(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newWrapperTestServer(jw, "https://issuer.example") srv.Options = []oauth2.AuthCodeOption{oauth2.AccessTypeOffline} var idToken string @@ -880,11 +866,7 @@ func TestOAuthOfflineOptionUsesAuthorizationOnly(t *testing.T) { } func TestVerifyOIDCUserInfoDoesNotRefreshDuringCallback(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newWrapperTestServer(jw, "https://issuer.example") srv.userinfoUrl = "https://provider.example/userinfo" userinfoCalls, refreshCalls := 0, 0 @@ -940,11 +922,7 @@ func TestServer_fetchUserInfoCloseError(t *testing.T) { } func Test_handleAuthResponseMissingIDToken(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) const issuer = "https://issuer.example" provider := httptest.NewServer(http.HandlerFunc(func(hw http.ResponseWriter, hr *http.Request) { @@ -995,11 +973,7 @@ func Test_handleAuthResponseMissingIDToken(t *testing.T) { } func Test_handleAuthResponseInvalidIDToken(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) const issuer = "https://issuer.example" idToken := makeIDToken(t, map[string]any{ @@ -1059,11 +1033,7 @@ func Test_handleAuthResponseInvalidIDToken(t *testing.T) { } func Test_handleAuthResponseAudienceMismatch(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) const issuer = "https://issuer.example" idToken := makeIDToken(t, map[string]any{ @@ -1123,11 +1093,7 @@ func Test_handleAuthResponseAudienceMismatch(t *testing.T) { } func Test_handleAuthResponseNonceMismatch(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) const issuer = "https://issuer.example" idToken := makeIDToken(t, map[string]any{ @@ -1187,11 +1153,7 @@ func Test_handleAuthResponseNonceMismatch(t *testing.T) { } func Test_handleAuthResponseWithoutSession(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := &Server{ Jaws: jw, HandledPaths: map[string]struct{}{}, @@ -1221,11 +1183,7 @@ func Test_handleAuthResponseWithoutSession(t *testing.T) { } func Test_handleAuthResponseLoginFailedFallsBack(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := &Server{Jaws: jw} var callbackCount int var callbackStatus int @@ -1269,11 +1227,7 @@ func Test_handleAuthResponseLoginFailedFallsBack(t *testing.T) { } func Test_handleAuthResponseLoginFailedHandlesResponse(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := &Server{Jaws: jw} const customBody = "handled by callback" srv.LoginFailed = func(hw http.ResponseWriter, hr *http.Request, httpCode int, callErr error, email string) bool { @@ -1311,11 +1265,7 @@ func Test_handleAuthResponseLoginFailedHandlesResponse(t *testing.T) { } func TestServerExtractEmail(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) testCases := []struct { name string @@ -1446,11 +1396,7 @@ func (h *recordingHandler) WithGroup(string) slog.Handler { } func Test_handleAuthResponseMissingState(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := &Server{ Jaws: jw, HandledPaths: map[string]struct{}{}, @@ -1481,11 +1427,7 @@ func Test_handleAuthResponseMissingState(t *testing.T) { } func Test_handleAuthResponseMissingNonce(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) const issuer = "https://issuer.example" idToken := makeIDToken(t, map[string]any{ @@ -1546,11 +1488,7 @@ func Test_handleAuthResponseMissingNonce(t *testing.T) { } func TestHandleLinkWithoutOAuthConfigReturnsError(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := &Server{Jaws: jw, LinkEvent: func(*jaws.Session, *http.Request, map[string]any) (int, error) { return 0, nil }} req := httptest.NewRequest(http.MethodPost, "https://app.example/auth/link/provider", nil) if jw.NewSession(httptest.NewRecorder(), req) == nil { @@ -1565,11 +1503,7 @@ func TestHandleLinkWithoutOAuthConfigReturnsError(t *testing.T) { } func Test_handlersRejectNonGet(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) newServer := func() *Server { return &Server{ @@ -1690,11 +1624,7 @@ func TestRedirectTargetBounds(t *testing.T) { func TestLoginSessionUnavailable(t *testing.T) { for _, limit := range []string{"global", "perIP", "closed"} { t.Run(limit, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) switch limit { case "global": jw.MaxSessions = 1 diff --git a/oauth_flow_grace_test.go b/oauth_flow_grace_test.go index 541840d..7b50814 100644 --- a/oauth_flow_grace_test.go +++ b/oauth_flow_grace_test.go @@ -18,11 +18,7 @@ import ( func TestOAuthProviderVisitKeepsOnlyPendingSessionAlive(t *testing.T) { synctest.Test(t, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := &Server{Jaws: jw, oauth2cfg: &oauth2.Config{ ClientID: "test", RedirectURL: "https://app.example/auth/callback", Endpoint: oauth2.Endpoint{AuthURL: "https://provider.example/auth", TokenURL: "https://provider.example/token"}, @@ -60,11 +56,7 @@ func TestOAuthProviderVisitKeepsOnlyPendingSessionAlive(t *testing.T) { func TestPendingLoginProtectedRequestDoesNotRestartOrRenewGrace(t *testing.T) { synctest.Test(t, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) protected := httptest.NewRequest(http.MethodGet, "https://app.example/protected", nil) @@ -90,11 +82,7 @@ func TestPendingLoginProtectedRequestDoesNotRestartOrRenewGrace(t *testing.T) { func TestExpiredPendingLoginStartsFreshFlow(t *testing.T) { synctest.Test(t, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) cookie, oldState := profileStart(t, srv, http.MethodGet, "/auth/login", nil) request := httptest.NewRequest(http.MethodGet, "https://app.example/protected", nil) @@ -120,11 +108,7 @@ func TestExpiredPendingLoginStartsFreshFlow(t *testing.T) { func TestOAuthFlowExpiresEvenWithLiveSession(t *testing.T) { synctest.Test(t, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) providerCalls := 0 srv := &Server{Jaws: jw, oauth2cfg: &oauth2.Config{ ClientID: "test", RedirectURL: "https://app.example/auth/callback", @@ -166,11 +150,7 @@ func TestOAuthFlowExpiresEvenWithLiveSession(t *testing.T) { func TestOIDCInitialCallbackAfterProviderVisit(t *testing.T) { synctest.Test(t, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) var idToken string exchanges := 0 srv := &Server{Jaws: jw, @@ -221,11 +201,7 @@ func TestOIDCInitialCallbackAfterProviderVisit(t *testing.T) { func TestOAuthLinkCallbackNearDeadlineCanFinishProviderExchange(t *testing.T) { synctest.Test(t, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) linked := false srv := &Server{Jaws: jw, SessionKey: "claims", issuer: "https://provider.example", profileSubject: func(map[string]any) (string, error) { return "42", nil }, diff --git a/profile_link_test.go b/profile_link_test.go index 29367e0..83e85b9 100644 --- a/profile_link_test.go +++ b/profile_link_test.go @@ -73,11 +73,7 @@ func TestOAuthProfileAuthLifetimeCapsLongProviderToken(t *testing.T) { } func TestOAuthProfileCallbackStateIsSingleUseConcurrently(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) var calls atomic.Int32 client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { calls.Add(1) @@ -136,11 +132,7 @@ func (l *rotationNoticeLock) Lock() { func (l *rotationNoticeLock) Unlock() { l.mu.Unlock() } func TestOAuthProfileRotationLockDoesNotBlockProvider(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) providerReached := make(chan struct{}) srv.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { @@ -174,11 +166,7 @@ func TestOAuthProfileRotationLockDoesNotBlockProvider(t *testing.T) { } func TestOAuthFailedCallbackDoesNotEraseNewCallback(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) entered := make(chan struct{}) secondEntered := make(chan struct{}) release := make(chan struct{}) @@ -261,11 +249,7 @@ func TestOAuthFailedCallbackDoesNotEraseNewCallback(t *testing.T) { func TestOAuthProfileCallbackCanceledBeforeRotation(t *testing.T) { for _, action := range []string{"logout", "new flow"} { t.Run(action, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) entered := make(chan struct{}) release := make(chan struct{}) var releaseOnce sync.Once @@ -329,11 +313,7 @@ func TestOAuthProfileCallbackCanceledBeforeRotation(t *testing.T) { } func TestOAuthProfileCallbackCanceledBeforeFinalStore(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) factory := &testAuthTimerFactory{} srv.authTimerAfterFunc = factory.after @@ -373,11 +353,7 @@ func TestOAuthProfileCallbackCanceledBeforeFinalStore(t *testing.T) { } func TestOAuthProfileLinkCanceledByLogout(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) entered := make(chan struct{}) release := make(chan struct{}) var releaseOnce sync.Once @@ -428,11 +404,7 @@ func TestOAuthProfileLinkCanceledByLogout(t *testing.T) { } func TestOAuthProfileLinkRejectsReplacedSession(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) login := profileCallback(srv, cookie, state, "first") @@ -495,11 +467,7 @@ func TestOAuthProfileLinkSerializesManagedRevocation(t *testing.T) { name = revocation + "/revocation first" } t.Run(name, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) factory := &testAuthTimerFactory{} srv.authTimerAfterFunc = factory.after @@ -582,11 +550,7 @@ func TestOAuthProfileLinkSerializesManagedRevocation(t *testing.T) { func TestOAuthProfileAuthExpiryPreservesOrdinaryLogin(t *testing.T) { for _, phase := range []string{"pending", "callback"} { t.Run(phase, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) factory := &testAuthTimerFactory{} srv.authTimerAfterFunc = factory.after @@ -801,11 +765,7 @@ func profileCallback(srv *Server, cookie *http.Cookie, state, code string) *http func TestOAuthProfileSignInAndDualProofLink(t *testing.T) { synctest.Test(t, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) calls := 0 srv := newProfileTestServer(t, jw, &calls) logins, links := 0, 0 @@ -856,11 +816,7 @@ func TestOAuthProfileSignInAndDualProofLink(t *testing.T) { } func TestOAuthProfileExpiredLinkStateNeverCallsProvider(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) calls := 0 srv := newProfileTestServer(t, jw, &calls) oldCookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) @@ -885,11 +841,7 @@ func TestOAuthProfileExpiredLinkStateNeverCallsProvider(t *testing.T) { } func TestOAuthLinkRequiresSessionAndSameOriginRequestAndRejectsDeclinedLink(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) calls := 0 srv := newProfileTestServer(t, jw, &calls) missing := httptest.NewRequest(http.MethodPost, "https://app.example/auth/link/github", nil) @@ -935,11 +887,7 @@ func TestOAuthLinkRequiresSessionAndSameOriginRequestAndRejectsDeclinedLink(t *t } func TestOAuthProfileRejectsInvalidSubjectAndOversizedResponse(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) calls := 0 srv := newProfileTestServer(t, jw, &calls) srv.profileSubject = func(map[string]any) (string, error) { return "", ErrOAuth2InvalidProfile } @@ -966,7 +914,7 @@ func TestOAuthProfileRejectsInvalidSubjectAndOversizedResponse(t *testing.T) { ctx := context.WithValue(t.Context(), oauth2.HTTPClient, &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return profileTestResponse(`{"id":42}` + strings.Repeat(" ", maxUserInfoBytes+1)), nil })}) - _, err = oversized.fetchUserInfo(ctx, "https://provider.example/user", oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "secret"})) + _, err := oversized.fetchUserInfo(ctx, "https://provider.example/user", oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "secret"})) if !errors.Is(err, ErrUserInfoTooLarge) { t.Fatalf("oversized profile: %v", err) } @@ -1007,11 +955,7 @@ func TestOAuthProfileRejectsEmptyAccessTokenBeforeUserInfo(t *testing.T) { func TestOAuthProfileUserInfoCallbackDeadline(t *testing.T) { synctest.Test(t, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) var userinfoErr error srv.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { @@ -1045,11 +989,7 @@ func TestOAuthProfileUserInfoCallbackDeadline(t *testing.T) { func TestOAuthProfileAcceptsShortUnexpiredToken(t *testing.T) { synctest.Test(t, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := &Server{Jaws: jw, issuer: "https://provider.example", userinfoUrl: "https://provider.example/user", profileSubject: func(map[string]any) (string, error) { return "42", nil }} calls := 0 @@ -1085,11 +1025,7 @@ func TestOAuthProfileExplicitZeroExpiryRejectedButOmittedExpiryCapped(t *testing {"form zero", "&expires_in=0", true, http.StatusUnauthorized}, } { t.Run(tc.name, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) srv.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/token" { @@ -1131,11 +1067,7 @@ func TestOAuthProfileExplicitZeroExpiryRejectedButOmittedExpiryCapped(t *testing } func TestOAuthProfileExpiresWithoutRefresh(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) factory := &testAuthTimerFactory{} srv.authTimerAfterFunc = factory.after @@ -1164,11 +1096,7 @@ func TestOAuthProfileExpiresWithoutRefresh(t *testing.T) { } func TestOAuthProfileTimerReleasesClosedSession(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) factory := &testAuthTimerFactory{} srv.authTimerAfterFunc = factory.after diff --git a/server.go b/server.go index f545645..93d8277 100644 --- a/server.go +++ b/server.go @@ -62,7 +62,8 @@ type HandleFunc func(uri string, handler http.Handler) // EventFunc is called for login and logout lifecycle events. // // For [Server.LogoutEvent], hr may be nil. jawsauth calls it after releasing -// SessionRotationLock. +// SessionRotationLock. When LoginEvent is set, clearing auth before it begins +// suppresses both events; clearing auth during it defers LogoutEvent until it returns. type EventFunc func(sess *jaws.Session, hr *http.Request) // FailedFunc is called on callback failures and when a protected request meets @@ -73,7 +74,7 @@ type EventFunc func(sess *jaws.Session, hr *http.Request) // when logging them and escape them for the output format when rendering them. type FailedFunc func(hw http.ResponseWriter, hr *http.Request, httpCode int, err error, email string) (wroteresponse bool) -// LinkFunc receives the current session and a newly verified identity. Recheck +// LinkFunc receives the current session and a newly authenticated identity. Recheck // the existing authentication before a durable link write; with multiple // providers, use the original provider's CurrentClaims. Logout and flow // cancellation affect only their own Server. jawsauth holds @@ -83,7 +84,7 @@ type FailedFunc func(hw http.ResponseWriter, hr *http.Request, httpCode int, err // Return zero and nil on success, or an HTTP error status. type LinkFunc func(sess *jaws.Session, hr *http.Request, claims map[string]any) (statusCode int, err error) -// Server provides OIDC or verified OAuth2 profile authentication for JaWS sessions. +// Server provides OIDC or OAuth2 profile authentication for JaWS sessions. // // Create one with New or NewDebug. Its methods are safe for concurrent use; the // exported configuration fields and callbacks should be set before serving requests. @@ -95,7 +96,7 @@ type Server struct { SessionEmailKey string // default is "email", value is a parsed string address with ASCII letters lowercased SessionEmailVerifiedKey string // default is "email_verified", value will be of type bool HandledPaths map[string]struct{} // URI paths excluded from OAuth return targets - LoginEvent EventFunc // if not nil, called after claims are stored; revocation may follow + LoginEvent EventFunc // called after claims are stored; revocation may suppress or follow it LogoutEvent EventFunc // if not nil, called after auth is cleared; hr may be nil LoginFailed FailedFunc // callback failures and protected requests during a running login callback LinkEvent LinkFunc // required by HandleLink; register its POST route separately @@ -123,6 +124,7 @@ type Server struct { admins map[string]struct{} // if not empty, emails of admins handle403 http.Handler // handler for 403 Forbidden authTimers map[uint64]*authTimerState + loginEvents map[*jaws.Session]*pendingLoginEvent oauthCallbacks map[*jaws.Session]pendingOAuthFlow authTimerAfterFunc authTimerAfterFunc namespace string @@ -137,7 +139,7 @@ type Server struct { // A nil jw returns ErrServerNilJaws and a nil Server. Otherwise a non-nil Server is // always returned. Authentication is configured, and the login, logout and callback // handlers registered via handleFn, only when cfg, handleFn and cfg.RedirectURL are -// all provided; setup errors are returned alongside the not-yet-Valid Server. +// all provided; setup errors are returned with the Server. func NewDebug(jw *jaws.Jaws, cfg *Config, handleFn HandleFunc, overrideUrl string) (srv *Server, err error) { if jw == nil { err = ErrServerNilJaws @@ -335,7 +337,7 @@ func (srv *Server) Valid() bool { // wrap returns a http.Handler that requires an authenticated user before invoking h. // // When admin is true, non-admins receive 403. Unauthenticated GET begins login; -// other unauthenticated methods receive 405. An invalid Server returns h. +// other unauthenticated methods receive 403. An invalid Server returns h. func (srv *Server) wrap(h http.Handler, admin bool) (rh http.Handler) { rh = h if srv.Valid() { @@ -347,7 +349,7 @@ func (srv *Server) wrap(h http.Handler, admin bool) (rh http.Handler) { // WrapAdmin returns a http.Handler that requires an authenticated administrator // before invoking h. // -// Unauthenticated GET begins login; other unauthenticated methods receive 405. +// Unauthenticated GET begins login; other unauthenticated methods receive 403. // Authenticated non-admins receive 403. [Server.RequireVerifiedAdminEmail] // applies. A GET request during a running login callback may return 409. // An invalid Server returns h. @@ -357,7 +359,7 @@ func (srv *Server) WrapAdmin(h http.Handler) (rh http.Handler) { // Wrap returns a http.Handler that requires an authenticated user before invoking h. // -// Unauthenticated GET begins login; other unauthenticated methods receive 405. +// Unauthenticated GET begins login; other unauthenticated methods receive 403. // A GET request during a running login callback may return 409. // An invalid Server returns h. func (srv *Server) Wrap(h http.Handler) (rh http.Handler) { @@ -367,7 +369,7 @@ func (srv *Server) Wrap(h http.Handler) (rh http.Handler) { // HandlerAdmin returns a http.Handler that renders the named jaws.Template with dot // and requires an authenticated administrator. // -// Unauthenticated GET begins login; other unauthenticated methods receive 405. +// Unauthenticated GET begins login; other unauthenticated methods receive 403. // Authenticated non-admins receive 403. [Server.RequireVerifiedAdminEmail] // applies. A GET request during a running login callback may return 409. // An invalid Server returns the unprotected template handler. @@ -378,7 +380,7 @@ func (srv *Server) HandlerAdmin(name string, dot any) http.Handler { // Handler returns a http.Handler that renders the named jaws.Template with dot // and requires an authenticated user. // -// Unauthenticated GET begins login; other unauthenticated methods receive 405. +// Unauthenticated GET begins login; other unauthenticated methods receive 403. // A GET request during a running login callback may return 409. // An invalid Server returns the unprotected template handler. func (srv *Server) Handler(name string, dot any) http.Handler { diff --git a/sessionlifecycle_test.go b/sessionlifecycle_test.go index cd88191..6beee74 100644 --- a/sessionlifecycle_test.go +++ b/sessionlifecycle_test.go @@ -24,23 +24,19 @@ func (l logoutOnWarnLogger) Error(string, ...any) {} func (l logoutOnWarnLogger) Warn(string, ...any) { l.onWarn() } func TestLogoutDuringClaimPreparation(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) factory := &testAuthTimerFactory{} srv := newTimerTestServer(t, jw, "https://issuer.example", factory) req := httptest.NewRequest(http.MethodGet, "/", nil) sess := jw.NewSession(httptest.NewRecorder(), req) expiry := time.Now().Add(time.Hour) - if err = srv.storeSessionAuthClaims(sess, map[string]any{"email": "user@example.com"}, nil, expiry, nil); err != nil { + if err := srv.storeSessionAuthClaims(sess, map[string]any{"email": "user@example.com"}, nil, expiry, nil); err != nil { t.Fatal(err) } entry := srv.authTimers[sess.ID()] jw.Logger = logoutOnWarnLogger{onWarn: func() { srv.Logout(sess, req) }} // Claim preparation may invoke application logging while logout completes. - err = srv.storeSessionAuthClaims(sess, map[string]any{"sub": "one"}, nil, expiry, entry) + err := srv.storeSessionAuthClaims(sess, map[string]any{"sub": "one"}, nil, expiry, entry) if !errors.Is(err, errAuthTimerStale) { t.Fatalf("got %v, want stale refresh", err) } @@ -88,11 +84,7 @@ func TestAuthTimerDiscardsClosedSession(t *testing.T) { } func TestOIDCTimerReleasesSessionRotatedByOtherProvider(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) factory := &testAuthTimerFactory{} srv := newTimerTestServer(t, jw, "https://issuer.example", factory) req := httptest.NewRequest(http.MethodGet, "/", nil) @@ -124,11 +116,7 @@ func TestOIDCTimerReleasesSessionRotatedByOtherProvider(t *testing.T) { func TestOIDCRefreshTimeoutClearsExpiredAuth(t *testing.T) { synctest.Test(t, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) factory := &testAuthTimerFactory{} srv := newTimerTestServer(t, jw, "https://issuer.example", factory) started := make(chan struct{}, 1) @@ -289,11 +277,7 @@ func TestLoginRotationExistingAuth(t *testing.T) { } func TestSetAdminsLeavesAnonymousRequestsOpen(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newWrapperTestServer(jw, "https://issuer.example") req := httptest.NewRequest(http.MethodGet, "/public", nil) jw.NewSession(httptest.NewRecorder(), req) @@ -392,17 +376,13 @@ func TestSetAdminsUsesSessionPolicy(t *testing.T) { func TestLoginUserInfoDeadlineKeepsOIDCFallbackOptional(t *testing.T) { // A slow UserInfo fallback must stop at the callback deadline before rotation. synctest.Test(t, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } + jw := newTestJaws(t) go jw.ServeWithTimeout(2 * time.Minute) - t.Cleanup(jw.Close) srv := newWrapperTestServer(jw, "https://issuer.example") expiry := time.Now().Add(time.Hour) hr := httptest.NewRequest(http.MethodGet, "/callback?state=state&code=code", nil) sess := jw.NewSession(httptest.NewRecorder(), hr) - if err = srv.storeSessionAuthClaims(sess, map[string]any{"sub": "user", "email": "user@example.com"}, nil, expiry, nil); err != nil { + if err := srv.storeSessionAuthClaims(sess, map[string]any{"sub": "user", "email": "user@example.com"}, nil, expiry, nil); err != nil { t.Fatal(err) } defer srv.Logout(sess, nil) diff --git a/testwait_test.go b/testwait_test.go index 8e597d3..e8c345b 100644 --- a/testwait_test.go +++ b/testwait_test.go @@ -3,8 +3,20 @@ package jawsauth import ( "testing" "time" + + "github.com/linkdata/jaws" ) +func newTestJaws(t *testing.T) *jaws.Jaws { + t.Helper() + jw, err := jaws.New() + if err != nil { + t.Fatal(err) + } + t.Cleanup(jw.Close) + return jw +} + func waitFor[T any](t *testing.T, ch <-chan T, msg string) T { t.Helper() select { diff --git a/wrapper.go b/wrapper.go index c84a2db..063fd97 100644 --- a/wrapper.go +++ b/wrapper.go @@ -17,6 +17,11 @@ func (w wrapper) ServeHTTP(hw http.ResponseWriter, hr *http.Request) { if present { w.server.clearSessionAuth(sess, hr, nil, true) } + if hr.Method != http.MethodGet { + SetHeaders(hw, w.server.ishttps) + hw.WriteHeader(http.StatusForbidden) + return + } w.server.startOAuth(hw, hr, false, true) return } diff --git a/wrapper_test.go b/wrapper_test.go index 0aabd45..49ae748 100644 --- a/wrapper_test.go +++ b/wrapper_test.go @@ -24,11 +24,7 @@ func (h testStatusHandler) ServeHTTP(hw http.ResponseWriter, _ *http.Request) { // TestWrapperServeHTTPSet403HandlerConcurrent verifies that Set403Handler and // ServeHTTP can run concurrently under the race detector. func TestWrapperServeHTTPSet403HandlerConcurrent(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := &Server{ Jaws: jw, @@ -79,11 +75,7 @@ func TestWrapperServeHTTPSet403HandlerConcurrent(t *testing.T) { } func TestWrapperServeHTTPAllowsPresentAuthData(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newWrapperTestServer(jw, "https://issuer.example") req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) @@ -107,6 +99,25 @@ func TestWrapperServeHTTPAllowsPresentAuthData(t *testing.T) { } } +func TestWrapperUnauthenticatedPostReturnsForbidden(t *testing.T) { + jw := newTestJaws(t) + srv := newWrapperTestServer(jw, "https://issuer.example") + req := httptest.NewRequest(http.MethodPost, "http://example.com/protected", nil) + rec := httptest.NewRecorder() + (wrapper{server: srv, handler: testStatusHandler{statusCode: http.StatusOK}}).ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden || rec.Header().Get("Allow") != "" { + t.Fatalf("protected POST: status=%d Allow=%q", rec.Code, rec.Header().Get("Allow")) + } + sess := jw.NewSession(httptest.NewRecorder(), req) + sess.Set(srv.SessionKey, map[string]any{"sub": "user"}) + sess.Set(srv.sessionKey(oauth2AuthExpiryKey), time.Now().Add(time.Hour)) + rec = httptest.NewRecorder() + (wrapper{server: srv, handler: testStatusHandler{statusCode: http.StatusOK}}).ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("authenticated POST: status=%d", rec.Code) + } +} + func TestWrapperServeHTTPClearsInvalidAuthData(t *testing.T) { testCases := []struct { name string @@ -130,11 +141,7 @@ func TestWrapperServeHTTPClearsInvalidAuthData(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newWrapperTestServer(jw, "https://issuer.example") req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) @@ -212,11 +219,7 @@ func TestSessionAuthStatus(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := &Server{SessionKey: "claims"} req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) @@ -236,11 +239,7 @@ func TestSessionAuthStatus(t *testing.T) { } func TestWrapperServeHTTPRedirectsWhenAuthDataMissing(t *testing.T) { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } - defer jw.Close() + jw := newTestJaws(t) srv := newWrapperTestServer(jw, "https://issuer.example") tests := []struct { From 351cefd0d475b464c8ee906d25f68801c8ecefbb Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Thu, 5 Nov 2026 02:55:52 +0100 Subject: [PATCH 32/43] fix: preserve callback paths and defer logout safely --- README.md | 2 ++ authsession.go | 28 +++++++++++++--------------- config.go | 8 ++++---- oauth2.go | 36 ++++++++++++++++++------------------ oauth2_callback_edge_test.go | 17 +++++++++++++---- oauth2_test.go | 16 ++++++++++++++++ server.go | 9 +++++---- 7 files changed, 71 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 3b83419..c25285a 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ login returns 503 with `Retry-After: 60` and preserves the existing session. the original provider's `CurrentClaims` before writing the link. Linking keeps the session; an ordinary login rotates it and signs out other providers. Their `LogoutEvent` may run later with a nil request. + Starting a link replaces this server's pending flow, including a running + login callback. - `New` gives each server a private `SessionRotationLock`. For cross-provider linking, assign one shared lock before serving. jawsauth holds it during flow start, rotation, authentication clearing and `LinkEvent`. Do not take it diff --git a/authsession.go b/authsession.go index 8eedfb9..aad9cb0 100644 --- a/authsession.go +++ b/authsession.go @@ -33,9 +33,8 @@ type authTimerState struct { // pendingLoginEvent orders callbacks when authentication is cleared before login notification finishes. type pendingLoginEvent struct { - started bool - cleared bool - logoutRequest *http.Request + started bool + cleared bool } func authTimerEntryExpiry(entry *authTimerState) (expiry time.Time) { @@ -146,7 +145,7 @@ func (srv *Server) storeSessionAuthClaims(sess *jaws.Session, claims map[string] return srv.storeSessionAuthClaimsIfActive(sess, claims, tokenSource, expiry, entry, nil) } -func (srv *Server) storeSessionAuthClaimsIfActive(sess *jaws.Session, claims map[string]any, tokenSource oauth2.TokenSource, expiry time.Time, entry *authTimerState, canceled <-chan struct{}) error { +func (srv *Server) storeSessionAuthClaimsIfActive(sess *jaws.Session, claims map[string]any, tokenSource oauth2.TokenSource, expiry time.Time, entry *authTimerState, loginCanceled <-chan struct{}) error { if srv == nil { return ErrOAuth2NotConfigured } @@ -160,7 +159,7 @@ func (srv *Server) storeSessionAuthClaimsIfActive(sess *jaws.Session, claims map claims["email_verified"] = verified srv.mu.Lock() select { - case <-canceled: + case <-loginCanceled: srv.mu.Unlock() return ErrOAuth2MissingSession default: @@ -196,7 +195,7 @@ func (srv *Server) storeSessionAuthClaimsIfActive(sess *jaws.Session, claims map sess.Set(srv.SessionEmailKey, email) sess.Set(srv.SessionEmailVerifiedKey, verified) delay, replaced := srv.scheduleSessionAuthTimerLocked(sess, expiry) - if canceled != nil && srv.LoginEvent != nil { + if loginCanceled != nil && srv.LoginEvent != nil { if srv.loginEvents == nil { srv.loginEvents = make(map[*jaws.Session]*pendingLoginEvent) } @@ -234,10 +233,10 @@ func (srv *Server) emitLoginEvent(sess *jaws.Session, hr *http.Request) { defer func() { srv.mu.Lock() delete(srv.loginEvents, sess) - cleared, request := pending.cleared, pending.logoutRequest + cleared := pending.cleared srv.mu.Unlock() if cleared && srv.LogoutEvent != nil { - srv.LogoutEvent(sess, request) + srv.LogoutEvent(sess, nil) } }() srv.LoginEvent(sess, hr) @@ -454,7 +453,7 @@ func (srv *Server) pendingOrdinaryLoginLocked(sess *jaws.Session) bool { return flow.state != "" && !flow.link && time.Now().Before(flow.expiry) } -func (srv *Server) clearSessionOAuthFlow(sess *jaws.Session) { +func (srv *Server) clearSessionOAuthFlowLocked(sess *jaws.Session) { if flow := srv.oauthCallbacks[sess]; flow.canceled != nil { close(flow.canceled) delete(srv.oauthCallbacks, sess) @@ -464,11 +463,11 @@ func (srv *Server) clearSessionOAuthFlow(sess *jaws.Session) { // Logout clears this server's authentication state for sess. // -// It stops the auth timer, clears verified claims, token source, email, expiry +// It stops the auth timer, clears stored claims, token source, email, expiry // and any in-flight OAuth flow keys, then marks the session dirty. If auth was -// present, it reloads its pages and calls [Server.LogoutEvent] (if set). It -// returns false for a nil receiver or nil session, and true otherwise. The hr -// argument may be nil. +// present, it reloads its pages. [Server.LogoutEvent] follows [EventFunc]'s +// ordering rules. It returns false for a nil receiver or session, and true +// otherwise. The hr argument may be nil. // // It performs no HTTP redirect. An HTTP handler can build its own post-logout // response. A JaWS event handler cannot use [jaws.Request.Redirect] after Logout @@ -498,7 +497,7 @@ func (srv *Server) clearSessionAuth(sess *jaws.Session, hr *http.Request, entry // Auth expiry must not cancel an ordinary login already in progress. // Explicit logout and link flows still revoke their pending callbacks. if !preservePendingLogin || !srv.pendingOrdinaryLoginLocked(sess) { - srv.clearSessionOAuthFlow(sess) + srv.clearSessionOAuthFlowLocked(sess) } sess.Set(srv.SessionKey, nil) sess.Set(srv.SessionTokenKey, nil) @@ -509,7 +508,6 @@ func (srv *Server) clearSessionAuth(sess *jaws.Session, hr *http.Request, entry requests = sess.Requests() if pending != nil { pending.cleared = true - pending.logoutRequest = hr if !pending.started { delete(srv.loginEvents, sess) } diff --git a/config.go b/config.go index eef9df6..0c1833d 100644 --- a/config.go +++ b/config.go @@ -19,16 +19,16 @@ import ( // // RedirectURL, Issuer and ClientID are required. Setting ProfileSubject selects // profile mode and requires AuthURL, TokenURL and UserInfoURL. Otherwise OIDC -// mode discovers endpoints; URL fields may override them. +// mode discovers endpoints; those three URLs may override them. type Config struct { RedirectURL string // required. e.g. "https://application.example.com/oauth2/callback" Issuer string // required. e.g. "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0" // Namespace isolates providers sharing one JaWS instance. New appends it to // the four exported Server session-key fields; use a distinct value per provider. Namespace string - AuthURL string // OIDC override - TokenURL string // OIDC override - UserInfoURL string // OIDC override + AuthURL string + TokenURL string + UserInfoURL string // AllowInsecureIssuer permits HTTP issuer and provider endpoints and is only for tests/dev. AllowInsecureIssuer bool // HTTPClient is used for OIDC discovery and JWKS, and by default for token diff --git a/oauth2.go b/oauth2.go index 769151f..9a0aa66 100644 --- a/oauth2.go +++ b/oauth2.go @@ -13,7 +13,6 @@ import ( "net/http" "net/mail" "net/url" - "path" "strconv" "strings" "time" @@ -32,7 +31,7 @@ const oauth2ProviderTimeout = 15 * time.Second const profileAuthLifetime = 24 * time.Hour const maxUserInfoBytes = 32 << 10 -// Leave one second after the provider deadline for the callback to finish. +// Allow provider I/O after the flow lifetime, with one second of extra grace. const oauth2FlowGrace = oauth2FlowLifetime + oauth2ProviderTimeout + time.Second type pendingOAuthFlow struct { @@ -64,7 +63,7 @@ func (srv *Server) takeOAuthFlow(sess *jaws.Session, gotState string) (flow pend if gotState != flow.state { return flow, ErrOAuth2WrongState } - srv.clearSessionOAuthFlow(sess) + srv.clearSessionOAuthFlowLocked(sess) if !time.Now().Before(flow.expiry) { return flow, ErrOAuth2ExpiredState } @@ -134,7 +133,7 @@ func (srv *Server) begin(hr *http.Request) (oauth2cfg *oauth2.Config, location s } location = sanitizeRedirectTarget(hr.Host, location) if target, err := url.Parse(location); err == nil { - if _, handled := srv.HandledPaths[path.Clean(target.Path)]; handled { + if _, handled := srv.HandledPaths[callbackPathFromURL(target)]; handled { location = "/" } } @@ -151,8 +150,7 @@ func (srv *Server) begin(hr *http.Request) (oauth2cfg *oauth2.Config, location s func (srv *Server) HandleLogin(hw http.ResponseWriter, hr *http.Request) { if hr.Method != http.MethodGet { hw.Header().Set("Allow", http.MethodGet) - SetHeaders(hw, srv.ishttps) - hw.WriteHeader(http.StatusMethodNotAllowed) + srv.writeResult(hw, http.StatusMethodNotAllowed, nil, nil) return } srv.startOAuth(hw, hr, false, false) @@ -163,7 +161,8 @@ func (srv *Server) HandleLogin(hw http.ResponseWriter, hr *http.Request) { // recheck that identity in LinkEvent when the provider returns. On success it // responds with a 303 redirect to the provider. It returns 405 with Allow: POST // for other methods, 403 for a cross-origin or missing session, and 500 when -// linking is not configured. +// linking is not configured. Starting a link replaces any pending flow on this +// server, including a running login callback. func (srv *Server) HandleLink(hw http.ResponseWriter, hr *http.Request) { switch { case hr.Method != http.MethodPost: @@ -188,20 +187,20 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, pr } flow, sess, status := srv.prepareOAuthFlow(hw, hr, link, preservePendingLogin, location) if status != 0 { - if status == http.StatusConflict { + failure := ErrOAuth2MissingSession + switch status { + case http.StatusConflict: + failure = ErrOAuth2LoginInProgress if srv.LoginFailed != nil { email, _ := sess.Get(srv.SessionEmailKey).(string) - if srv.LoginFailed(hw, hr, status, ErrOAuth2LoginInProgress, email) { + if srv.LoginFailed(hw, hr, status, failure, email) { return } } - srv.writeResult(hw, status, ErrOAuth2LoginInProgress, nil) - } else { - if status == http.StatusServiceUnavailable { - hw.Header().Set("Retry-After", "60") - } - srv.writeResult(hw, status, ErrOAuth2MissingSession, nil) + case http.StatusServiceUnavailable: + hw.Header().Set("Retry-After", "60") } + srv.writeResult(hw, status, failure, nil) return } authOptions := append([]oauth2.AuthCodeOption{}, srv.Options...) @@ -261,15 +260,15 @@ func (srv *Server) prepareOAuthFlow(hw http.ResponseWriter, hr *http.Request, li } return } - srv.clearSessionOAuthFlow(sess) + srv.clearSessionOAuthFlowLocked(sess) sess.Set(srv.sessionKey(oauth2FlowKey), flow) return } // HandleLogout clears this server's authentication in the session and redirects. // -// For GET requests it clears the auth (firing LogoutEvent when auth was present) -// and responds with a 302 redirect to the sanitized referrer (or "/"). +// For GET requests it clears auth and responds with a 302 redirect to the +// sanitized referrer (or "/"). LogoutEvent follows [EventFunc]'s ordering rules. // Non-GET requests receive 405 with Allow: GET. func (srv *Server) HandleLogout(hw http.ResponseWriter, hr *http.Request) { statusCode := http.StatusMethodNotAllowed @@ -547,6 +546,7 @@ func (srv *Server) rotateOAuthSession(hw http.ResponseWriter, hr *http.Request, func (srv *Server) completeLink(sess *jaws.Session, hr *http.Request, claims map[string]any, expiry time.Time, flow pendingOAuthFlow) (status int, err error) { var linkErr error + // Log after releasing SessionRotationLock. defer func() { if linkErr != nil { _ = srv.Jaws.Log(linkErr) diff --git a/oauth2_callback_edge_test.go b/oauth2_callback_edge_test.go index 0ab2efd..16624dc 100644 --- a/oauth2_callback_edge_test.go +++ b/oauth2_callback_edge_test.go @@ -231,26 +231,35 @@ func TestConcurrentLogoutWaitsForLoginEvent(t *testing.T) { releaseOnce := sync.OnceFunc(func() { close(release) }) defer releaseOnce() events := make(chan string, 2) + logoutRequests := make(chan *http.Request, 1) srv.LoginEvent = func(sess *jaws.Session, _ *http.Request) { events <- "login" entered <- sess <-release } - srv.LogoutEvent = func(*jaws.Session, *http.Request) { events <- "logout" } + srv.LogoutEvent = func(_ *jaws.Session, request *http.Request) { + events <- "logout" + logoutRequests <- request + } cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) result := make(chan *httptest.ResponseRecorder, 1) go func() { result <- profileCallback(srv, cookie, state, "first") }() rotated := waitFor(t, entered, "LoginEvent did not start") loggedOut := make(chan bool, 1) - go func() { loggedOut <- srv.Logout(rotated, nil) }() + logoutContext, cancelLogout := context.WithCancel(t.Context()) + defer cancelLogout() + logoutRequest := httptest.NewRequest(http.MethodGet, "https://app.example/logout", nil).WithContext(logoutContext) + go func() { loggedOut <- srv.Logout(rotated, logoutRequest) }() if !waitFor(t, loggedOut, "concurrent logout blocked") || len(events) != 1 { t.Fatalf("logout before LoginEvent returned: events=%d", len(events)) } + cancelLogout() releaseOnce() response := waitFor(t, result, "callback did not finish") first := waitFor(t, events, "missing LoginEvent") second := waitFor(t, events, "missing LogoutEvent") - if response.Code != http.StatusForbidden || first != "login" || second != "logout" || srv.CurrentClaims(rotated) != nil { - t.Fatalf("status=%d events=%s,%s claims=%v", response.Code, first, second, srv.CurrentClaims(rotated)) + deferredRequest := waitFor(t, logoutRequests, "missing LogoutEvent request") + if response.Code != http.StatusForbidden || first != "login" || second != "logout" || deferredRequest != nil || srv.CurrentClaims(rotated) != nil { + t.Fatalf("status=%d events=%s,%s request=%v claims=%v", response.Code, first, second, deferredRequest, srv.CurrentClaims(rotated)) } } diff --git a/oauth2_test.go b/oauth2_test.go index 8b5b05a..8a3067f 100644 --- a/oauth2_test.go +++ b/oauth2_test.go @@ -173,6 +173,22 @@ func Test_beginReferrerHandling(t *testing.T) { } } +func TestBeginExcludesTrailingSlashCallback(t *testing.T) { + srv := &Server{HandledPaths: map[string]struct{}{"/oauth2/callback/": {}}} + hr := httptest.NewRequest(http.MethodGet, "https://example.com/oauth2/login", nil) + for _, referer := range []string{ + "https://example.com/oauth2/callback/", + "https://example.com/oauth2//callback/", + "https://example.com/oauth2/./callback/", + } { + hr.Header.Set("Referer", referer) + _, location := srv.begin(hr) + if location != "/" { + t.Fatalf("callback return target %q kept as %q", referer, location) + } + } +} + // Test_beginPathBoundary verifies that handled paths only rewrite exact // registered endpoints. Referrers that merely share a suffix remain valid // redirect targets. diff --git a/server.go b/server.go index 93d8277..5b9a6f5 100644 --- a/server.go +++ b/server.go @@ -61,9 +61,10 @@ type HandleFunc func(uri string, handler http.Handler) // EventFunc is called for login and logout lifecycle events. // -// For [Server.LogoutEvent], hr may be nil. jawsauth calls it after releasing -// SessionRotationLock. When LoginEvent is set, clearing auth before it begins -// suppresses both events; clearing auth during it defers LogoutEvent until it returns. +// jawsauth calls [Server.LogoutEvent] after releasing SessionRotationLock. +// When LoginEvent is set, clearing auth before it begins suppresses both +// events; clearing auth during it defers LogoutEvent until it returns. A +// deferred LogoutEvent receives a nil request. type EventFunc func(sess *jaws.Session, hr *http.Request) // FailedFunc is called on callback failures and when a protected request meets @@ -97,7 +98,7 @@ type Server struct { SessionEmailVerifiedKey string // default is "email_verified", value will be of type bool HandledPaths map[string]struct{} // URI paths excluded from OAuth return targets LoginEvent EventFunc // called after claims are stored; revocation may suppress or follow it - LogoutEvent EventFunc // if not nil, called after auth is cleared; hr may be nil + LogoutEvent EventFunc // auth-clear callback; see EventFunc for ordering and nil requests LoginFailed FailedFunc // callback failures and protected requests during a running login callback LinkEvent LinkFunc // required by HandleLink; register its POST route separately // SessionRotationLock is held during flow start, rotation, auth clearing, and LinkEvent. From 254569a3f1130dac687f7df557d5c5053983dcf3 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Thu, 5 Nov 2026 03:26:12 +0100 Subject: [PATCH 33/43] fix(auth): tighten return targets and provider timeouts Clarify shared JaWS locking, validate discovered metadata by its provider keys, and stabilize auth lifecycle tests. Pin gosec to an immutable revision. --- .github/workflows/go.yml | 2 +- README.md | 22 ++++++++++--------- adminpolicy_test.go | 27 +++++++++++++++++------- authsession_test.go | 19 ++++++++++++++++- concurrent_providers_test.go | 2 +- config.go | 32 ++++++++++++---------------- config_test.go | 20 +++++++++--------- oauth2.go | 35 ++++++++++++++++++++---------- oauth2_callback_edge_test.go | 7 ++---- oauth2_test.go | 41 ++++++++++++++++++++++++++++++++++++ profile_link_test.go | 18 +++++++++++----- server.go | 7 +++--- sessionlifecycle_test.go | 4 ++++ wrapper.go | 3 +-- 14 files changed, 163 insertions(+), 76 deletions(-) diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 9d39bbc..fb8ca16 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -75,7 +75,7 @@ jobs: working-directory: cmd - name: Install gosec - run: go install github.com/securego/gosec/v2/cmd/gosec@v2.30.0 + run: go install github.com/securego/gosec/v2/cmd/gosec@5eb1d104ef85ff2c905c877eb600e8c1dee33a22 # v2.30.0 - name: Run Gosec Security Scanner run: gosec ./... diff --git a/README.md b/README.md index c25285a..d79c0c8 100644 --- a/README.md +++ b/README.md @@ -53,13 +53,13 @@ login returns 503 with `Retry-After: 60` and preserves the existing session. Their `LogoutEvent` may run later with a nil request. Starting a link replaces this server's pending flow, including a running login callback. -- `New` gives each server a private `SessionRotationLock`. For cross-provider - linking, assign one shared lock before serving. jawsauth holds it during - flow start, rotation, authentication clearing and `LinkEvent`. Do not take it - around jawsauth handlers or `Server.Logout`, or within `LinkEvent`; do not call - `Server.Logout` from `LinkEvent`. Keep `LinkEvent` brief because other sessions - sharing the lock wait. One provider's logout does not cancel another provider's - pending link. +- `New` gives each server a private `SessionRotationLock`. When several servers + share one JaWS instance, assign them one shared lock before serving. jawsauth + holds it during flow start, rotation, authentication clearing and `LinkEvent`. + Do not take the lock around jawsauth handlers or `Server.Logout`, or within + `LinkEvent`; do not call `Server.Logout` from `LinkEvent`. Keep `LinkEvent` brief + because other sessions sharing the lock wait. One provider's logout does not + cancel another provider's pending link. ## Admin access @@ -72,8 +72,9 @@ grant admin access while the check is enabled. [Microsoft warns against using its email claim for authorization](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference); use application policy based on a stable identifier for Entra ID. -`SetAdmins` parses valid addresses and compares them ignoring ASCII case. An -empty list allows every authenticated user through the admin handlers. +`SetAdmins` parses valid addresses and retains other nonempty entries. It trims +whitespace and ignores ASCII case. An empty list allows every authenticated user +through the admin handlers. `Server.IsAdmin(email)` checks only list membership; `JawsAuth.IsAdmin` and the admin handlers enforce email verification when required. Neither `IsAdmin` method proves a live login; check `CurrentClaims` before protected actions. @@ -85,7 +86,8 @@ JaWS session's grace while the user visits the provider. Unauthenticated protected GET requests during a pending login redirect to the same provider flow without replacing its state, return target or grace deadline. During a running callback they return 409 unless `LoginFailed` handles them. Logout and -authentication expiry clear that server's credentials and reload its JaWS pages. +authentication expiry clear that server's credentials and reload every JaWS page +in the session. Auth expiry preserves a pending login but cancels a pending link. Explicit logout cancels either flow. A new login on the same server, including one started by `Wrap` or `Handler`, cancels a pending link. diff --git a/adminpolicy_test.go b/adminpolicy_test.go index 4d84061..ab9d7fa 100644 --- a/adminpolicy_test.go +++ b/adminpolicy_test.go @@ -64,16 +64,24 @@ func TestAdminEmailPolicy(t *testing.T) { func TestProfileAdminRequiresVerifiedEmailByDefault(t *testing.T) { for _, tc := range []struct { - name string - optOut bool - want int + name string + verified, optOut bool + want int }{ - {"default", false, http.StatusForbidden}, - {"explicit opt-out", true, http.StatusOK}, + {"default", false, false, http.StatusForbidden}, + {"verified by profile", true, false, http.StatusOK}, + {"explicit opt-out", false, true, http.StatusOK}, } { t.Run(tc.name, func(t *testing.T) { jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) + originalSubject := srv.profileSubject + srv.profileSubject = func(claims map[string]any) (string, error) { + if tc.verified { + claims["email_verified"] = true + } + return originalSubject(claims) + } if !srv.RequireVerifiedAdminEmail { t.Fatal("profile mode did not require verified admin email by default") } @@ -90,13 +98,16 @@ func TestProfileAdminRequiresVerifiedEmailByDefault(t *testing.T) { r := httptest.NewRequest(http.MethodGet, "https://app.example/protected", nil) r.AddCookie(cookies[0]) sess := jw.GetSession(r) - if sess == nil || sess.Get(srv.SessionEmailVerifiedKey) != false { - t.Fatal("profile test did not authenticate an unverified email") + if sess == nil { + t.Fatal("missing profile session") + } + if sess.Get(srv.SessionEmailVerifiedKey) != tc.verified { + t.Fatalf("profile verification = %v, want %t", sess.Get(srv.SessionEmailVerifiedKey), tc.verified) } w := httptest.NewRecorder() srv.WrapAdmin(testStatusHandler{http.StatusOK}).ServeHTTP(w, r) if w.Code != tc.want { - t.Fatalf("unverified admin email: status=%d, want %d", w.Code, tc.want) + t.Fatalf("profile admin email: status=%d, want %d", w.Code, tc.want) } }) } diff --git a/authsession_test.go b/authsession_test.go index 828e04a..424d3c9 100644 --- a/authsession_test.go +++ b/authsession_test.go @@ -10,6 +10,7 @@ import ( "sync" "sync/atomic" "testing" + "testing/synctest" "time" "github.com/coreos/go-oidc/v3/oidc" @@ -181,6 +182,8 @@ func TestErrorDebugAttrsIncludeOIDCAndOAuth2Details(t *testing.T) { body := strings.Repeat("x", debugErrorBodyLimit+1) err := errors.Join( errOIDC{kind: ErrOIDCInvalidIDToken, cause: errOIDCStaleIDToken}, + errOIDCMissingSubject, + errOIDCSubjectMismatch, &OAuth2CallbackError{ Code: "access_denied", Description: "user cancelled", @@ -206,6 +209,8 @@ func TestErrorDebugAttrsIncludeOIDCAndOAuth2Details(t *testing.T) { for _, want := range []string{ "oidc_invalid_id_token", "oidc_stale_id_token", + "oidc_missing_subject", + "oidc_subject_mismatch", "oauth2_callback", } { if !testStringSliceContains(classes, want) { @@ -267,7 +272,7 @@ func TestStoreSessionAuthClaimsSchedulesTimer(t *testing.T) { if gotVerified, _ := sess.Get(srv.SessionEmailVerifiedKey).(bool); !gotVerified { t.Fatal(gotVerified) } - if gotExpiry, _ := sess.Get(oauth2AuthExpiryKey).(time.Time); !gotExpiry.Equal(expiry) { + if gotExpiry, _ := sess.Get(srv.sessionKey(oauth2AuthExpiryKey)).(time.Time); !gotExpiry.Equal(expiry) { t.Fatal(gotExpiry) } if factory.len() != 1 { @@ -421,6 +426,10 @@ func TestOIDCRefreshRejectsChangedSubject(t *testing.T) { } func TestAuthTimerRefreshesCachedTokenByForcingRefresh(t *testing.T) { + synctest.Test(t, testAuthTimerRefreshesCachedTokenByForcingRefresh) +} + +func testAuthTimerRefreshesCachedTokenByForcingRefresh(t *testing.T) { jw := newTestJaws(t) logger := &testAuthDebugLogger{} jw.Debug = true @@ -674,6 +683,10 @@ func TestRefreshSessionAuthLogsForcedRefreshTokenSourceFailure(t *testing.T) { } func TestAuthTimerRefreshFailureKeepsCurrentAuthUntilExpiry(t *testing.T) { + synctest.Test(t, testAuthTimerRefreshFailureKeepsCurrentAuthUntilExpiry) +} + +func testAuthTimerRefreshFailureKeepsCurrentAuthUntilExpiry(t *testing.T) { jw := newTestJaws(t) logger := &testAuthDebugLogger{} @@ -834,6 +847,10 @@ func TestAuthTimerRefreshFailureClearsExpiredAuth(t *testing.T) { } func TestAuthTimerDebugLogsRefreshFailure(t *testing.T) { + synctest.Test(t, testAuthTimerDebugLogsRefreshFailure) +} + +func testAuthTimerDebugLogsRefreshFailure(t *testing.T) { jw := newTestJaws(t) logger := &testAuthDebugLogger{} diff --git a/concurrent_providers_test.go b/concurrent_providers_test.go index 1f911e4..84cc964 100644 --- a/concurrent_providers_test.go +++ b/concurrent_providers_test.go @@ -16,7 +16,7 @@ func newConcurrentProfileProvider(t *testing.T, jw *jaws.Jaws, client *http.Clie t.Helper() srv, err := New(jw, &Config{ Namespace: namespace, RedirectURL: "https://app.example/auth/" + namespace + "/callback", - Issuer: "https://provider.example", AuthURL: "https://provider.example/authorize", + Issuer: "https://" + namespace + ".provider.example", AuthURL: "https://provider.example/authorize", TokenURL: "https://provider.example/token", UserInfoURL: "https://provider.example/user", ClientID: "app", HTTPClient: client, ProfileSubject: func(profile map[string]any) (string, error) { return string(profile["id"].(json.Number)), nil }, diff --git a/config.go b/config.go index 0c1833d..e6d0cc4 100644 --- a/config.go +++ b/config.go @@ -31,25 +31,19 @@ type Config struct { UserInfoURL string // AllowInsecureIssuer permits HTTP issuer and provider endpoints and is only for tests/dev. AllowInsecureIssuer bool - // HTTPClient is used for OIDC discovery and JWKS, and by default for token - // exchange/refresh and UserInfo. All provider requests cap its timeout at 15 - // seconds. Discovery/JWKS follow only HTTPS redirects unless AllowInsecureIssuer - // is set; token and UserInfo requests follow none. A request context may supply - // a different oauth2.HTTPClient for code exchange and UserInfo requests; - // background refresh uses this configured client. + // HTTPClient handles discovery, JWKS, token exchange/refresh and UserInfo. + // Requests cap its timeout at 15 seconds. Discovery/JWKS follow only HTTPS + // redirects unless AllowInsecureIssuer is set; token and UserInfo follow none. + // A request's oauth2.HTTPClient can override code exchange and UserInfo; + // background refresh uses HTTPClient. HTTPClient *http.Client Scopes []string // optional; OIDC adds "openid" and "email"; profile mode adds none ClientID string ClientSecret string - // ProfileSubject enables OAuth2 profile authentication without an OIDC ID token. - // It must return a stable, non-empty provider subject from the authenticated - // profile response. - // Profile mode sets claims["iss"] to Issuer and claims["sub"] to this subject; - // use a distinct Issuer per provider. - // JSON numbers arrive as json.Number to preserve large numeric IDs. - // ProfileSubject may adjust claims, for example by mapping the provider's - // verification signal to claims["email_verified"]. Profile mode requires - // verified email for a non-empty admin list by default. + // ProfileSubject enables OAuth2 profile mode and returns a stable, non-empty + // subject from the authenticated profile. It may map provider verification + // data to claims["email_verified"] for admin checks. Profile JSON numbers + // arrive as json.Number. ProfileSubject func(map[string]any) (string, error) } @@ -200,10 +194,10 @@ func (cfg *Config) buildContext(ctx context.Context, overrideUrl string) (oauth2 name, value string optional bool }{ - {"AuthURL", authURL, false}, - {"TokenURL", tokenURL, false}, - {"UserInfoURL", userInfoURL, true}, - {"JWKSURI", metadata.JWKSURI, false}, + {"authorization_endpoint", authURL, false}, + {"token_endpoint", tokenURL, false}, + {"userinfo_endpoint", userInfoURL, true}, + {"jwks_uri", metadata.JWKSURI, false}, } { if err = validateEndpoint(field.name, field.value, field.optional, cfg.AllowInsecureIssuer); wrapOIDC(ErrOIDCProviderMetadata, &err) != nil { return diff --git a/config_test.go b/config_test.go index 06c65aa..430a50b 100644 --- a/config_test.go +++ b/config_test.go @@ -260,16 +260,16 @@ func TestConfigValidateRejectsInsecureOIDCOverrides(t *testing.T) { func TestConfigBuildContextRejectsInsecureOIDCMetadata(t *testing.T) { for _, tc := range []struct { - key, field, value string - allowInsecure bool + key, value string + allowInsecure bool }{ - {"authorization_endpoint", "AuthURL", "http://provider.example/auth", false}, - {"token_endpoint", "TokenURL", "http://provider.example/token", false}, - {"userinfo_endpoint", "UserInfoURL", "http://provider.example/userinfo", false}, - {"jwks_uri", "JWKSURI", "http://provider.example/jwks", false}, - {"jwks_uri", "JWKSURI", "", false}, - {"jwks_uri", "JWKSURI", "", true}, - {"jwks_uri", "JWKSURI", "ftp://provider.example/jwks", true}, + {"authorization_endpoint", "http://provider.example/auth", false}, + {"token_endpoint", "http://provider.example/token", false}, + {"userinfo_endpoint", "http://provider.example/userinfo", false}, + {"jwks_uri", "http://provider.example/jwks", false}, + {"jwks_uri", "", false}, + {"jwks_uri", "", true}, + {"jwks_uri", "ftp://provider.example/jwks", true}, } { name := tc.key + "/" + tc.value if tc.allowInsecure { @@ -291,7 +291,7 @@ func TestConfigBuildContextRejectsInsecureOIDCMetadata(t *testing.T) { AllowInsecureIssuer: tc.allowInsecure} _, _, _, err := cfg.buildContext(t.Context(), "") var configErr errConfig - if !errors.Is(err, ErrOIDCProviderMetadata) || !errors.As(err, &configErr) || configErr.field != tc.field { + if !errors.Is(err, ErrOIDCProviderMetadata) || !errors.As(err, &configErr) || configErr.field != tc.key { t.Fatalf("%s metadata validation: %v", tc.key, err) } if tc.value != "" && !errors.Is(err, ErrConfigEndpointMustBeHTTPS) { diff --git a/oauth2.go b/oauth2.go index 9a0aa66..718597c 100644 --- a/oauth2.go +++ b/oauth2.go @@ -131,13 +131,18 @@ func (srv *Server) begin(hr *http.Request) (oauth2cfg *oauth2.Config, location s if location = strings.TrimSpace(hr.Referer()); location == "" { location = hr.RequestURI } + location = srv.returnTarget(hr, location) + return +} + +func (srv *Server) returnTarget(hr *http.Request, location string) string { location = sanitizeRedirectTarget(hr.Host, location) if target, err := url.Parse(location); err == nil { if _, handled := srv.HandledPaths[callbackPathFromURL(target)]; handled { location = "/" } } - return + return location } // HandleLogin begins the configured OIDC or OAuth2 profile login flow. @@ -182,6 +187,9 @@ var linkOriginCheck = http.NewCrossOriginProtection() func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, preservePendingLogin bool) { oauth2cfg, location := srv.begin(hr) if oauth2cfg != nil { + if preservePendingLogin { + location = srv.returnTarget(hr, hr.RequestURI) + } if link && strings.TrimSpace(hr.Referer()) == "" { location = "/" } @@ -220,6 +228,10 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, pr } func (srv *Server) prepareOAuthFlow(hw http.ResponseWriter, hr *http.Request, link, preservePendingLogin bool, location string) (flow pendingOAuthFlow, sess *jaws.Session, status int) { + failStatus := http.StatusServiceUnavailable + if link { + failStatus = http.StatusForbidden + } rotationLock := srv.SessionRotationLock if rotationLock != nil { rotationLock.Lock() @@ -230,10 +242,7 @@ func (srv *Server) prepareOAuthFlow(hw http.ResponseWriter, hr *http.Request, li sess = srv.Jaws.NewSession(hw, hr) } if sess == nil { - if link { - return flow, nil, http.StatusForbidden - } - return flow, nil, http.StatusServiceUnavailable + return flow, nil, failStatus } srv.mu.Lock() defer srv.mu.Unlock() @@ -253,11 +262,7 @@ func (srv *Server) prepareOAuthFlow(hw http.ResponseWriter, hr *http.Request, li flow.nonce = rand.Text() } if !sess.ExtendGrace(oauth2FlowGrace) { - if link { - status = http.StatusForbidden - } else { - status = http.StatusServiceUnavailable - } + status = failStatus return } srv.clearSessionOAuthFlowLocked(sess) @@ -420,8 +425,16 @@ func (srv *Server) fetchUserInfo(ctx context.Context, userinfoURL string, tokenS if userinfoURL != "" && tokenSource != nil { client := oauth2.NewClient(ctx, tokenSource) client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + timeout := client.Timeout + if timeout <= 0 || timeout > oauth2ProviderTimeout { + timeout = oauth2ProviderTimeout + } + requestCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + // oauth2.Transport logs from its deprecated CancelRequest; use the request context. + client.Timeout = 0 var request *http.Request - if request, err = http.NewRequestWithContext(ctx, http.MethodGet, userinfoURL, nil); err != nil { + if request, err = http.NewRequestWithContext(requestCtx, http.MethodGet, userinfoURL, nil); err != nil { return nil, err } var resp *http.Response diff --git a/oauth2_callback_edge_test.go b/oauth2_callback_edge_test.go index 16624dc..562777f 100644 --- a/oauth2_callback_edge_test.go +++ b/oauth2_callback_edge_test.go @@ -223,7 +223,7 @@ func TestLoginEventCanLogoutItsSession(t *testing.T) { } } -func TestConcurrentLogoutWaitsForLoginEvent(t *testing.T) { +func TestLogoutDuringLoginEventDefersLogoutEvent(t *testing.T) { jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) entered := make(chan *jaws.Session, 1) @@ -246,14 +246,11 @@ func TestConcurrentLogoutWaitsForLoginEvent(t *testing.T) { go func() { result <- profileCallback(srv, cookie, state, "first") }() rotated := waitFor(t, entered, "LoginEvent did not start") loggedOut := make(chan bool, 1) - logoutContext, cancelLogout := context.WithCancel(t.Context()) - defer cancelLogout() - logoutRequest := httptest.NewRequest(http.MethodGet, "https://app.example/logout", nil).WithContext(logoutContext) + logoutRequest := httptest.NewRequest(http.MethodGet, "https://app.example/logout", nil) go func() { loggedOut <- srv.Logout(rotated, logoutRequest) }() if !waitFor(t, loggedOut, "concurrent logout blocked") || len(events) != 1 { t.Fatalf("logout before LoginEvent returned: events=%d", len(events)) } - cancelLogout() releaseOnce() response := waitFor(t, result, "callback did not finish") first := waitFor(t, events, "missing LoginEvent") diff --git a/oauth2_test.go b/oauth2_test.go index 8a3067f..f158d37 100644 --- a/oauth2_test.go +++ b/oauth2_test.go @@ -134,6 +134,21 @@ func Test_beginSanitizesReferrer(t *testing.T) { } } +func TestProtectedRequestReturnsToRequestedPage(t *testing.T) { + jw := newTestJaws(t) + srv := newWrapperTestServer(jw, "https://issuer.example") + hr := httptest.NewRequest(http.MethodGet, "http://example.com/protected?tab=one", nil) + hr.Header.Set("Referer", "http://example.com/home") + hw := httptest.NewRecorder() + srv.Wrap(testStatusHandler{statusCode: http.StatusOK}).ServeHTTP(hw, hr) + if hw.Code != http.StatusFound { + t.Fatalf("protected request status = %d", hw.Code) + } + if got := testOAuthFlow(jw.GetSession(hr), srv).referrer; got != "/protected?tab=one" { + t.Fatalf("OAuth return target = %q", got) + } +} + func Test_beginReferrerHandling(t *testing.T) { srv := &Server{HandledPaths: map[string]struct{}{"/oauth2/login": {}}} hr := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/login", nil) @@ -802,6 +817,32 @@ func TestServer_fetchUserInfoRespectsRequestCancellation(t *testing.T) { } } +func TestServer_fetchUserInfoUsesClientTimeoutAsRequestDeadline(t *testing.T) { + const timeout = 20 * time.Millisecond + var requestDeadline time.Time + client := &http.Client{ + Timeout: timeout, + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + requestDeadline, _ = r.Context().Deadline() + <-r.Context().Done() + return nil, r.Context().Err() + }), + } + ctx := context.WithValue(t.Context(), oauth2.HTTPClient, client) + started := time.Now() + _, err := (&Server{}).fetchUserInfo(ctx, "https://provider.example/userinfo", + oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "access"})) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("UserInfo timeout: %v", err) + } + if requestDeadline.Before(started) || requestDeadline.After(started.Add(time.Second)) { + t.Fatalf("UserInfo request deadline %s, want a short bounded deadline", requestDeadline) + } + if client.Timeout != timeout { + t.Fatalf("configured client timeout changed to %s", client.Timeout) + } +} + func TestServer_fetchUserInfoOversizedStatusError(t *testing.T) { provider := httptest.NewServer(http.HandlerFunc(func(hw http.ResponseWriter, _ *http.Request) { hw.WriteHeader(http.StatusServiceUnavailable) diff --git a/profile_link_test.go b/profile_link_test.go index 83e85b9..61b4a1e 100644 --- a/profile_link_test.go +++ b/profile_link_test.go @@ -61,13 +61,18 @@ func TestOAuthProfileAuthLifetimeCapsLongProviderToken(t *testing.T) { })}) for _, duration := range []time.Duration{30 * time.Minute, 72 * time.Hour} { token := &oauth2.Token{AccessToken: "secret", TokenType: "Bearer", Expiry: time.Now().Add(duration)} + before := time.Now() _, _, expiry, status, err := srv.verifyOAuthIdentity(ctx, token, "") + after := time.Now() if err != nil || status != 0 { t.Fatalf("duration %s: status=%d error=%v", duration, status, err) } - want := min(duration, 24*time.Hour) - if diff := time.Until(expiry) - want; diff < -time.Second || diff > time.Second { - t.Fatalf("duration %s: expiry offset %s", duration, diff) + if duration < 24*time.Hour { + if !expiry.Equal(token.Expiry) { + t.Fatalf("duration %s: expiry=%s token expiry=%s", duration, expiry, token.Expiry) + } + } else if expiry.Before(before.Add(24*time.Hour)) || expiry.After(after.Add(24*time.Hour)) { + t.Fatalf("duration %s: cap expiry=%s, callback bounds=%s..%s", duration, expiry, before, after) } } } @@ -775,6 +780,7 @@ func TestOAuthProfileSignInAndDualProofLink(t *testing.T) { if wrong.Code != http.StatusBadRequest || calls != 0 { t.Fatalf("wrong state consumed login: status=%d calls=%d", wrong.Code, calls) } + // Let the one-minute initial JaWS grace period pass before callback. time.Sleep(time.Minute + time.Second) first := profileCallback(srv, oldCookie, state, "first") if first.Code != http.StatusFound || logins != 1 || calls != 2 { @@ -800,6 +806,7 @@ func TestOAuthProfileSignInAndDualProofLink(t *testing.T) { return 0, nil } _, linkState := profileStart(t, srv, http.MethodPost, "/auth/link/github", cookie) + // Check that a pending link also survives the initial grace period. time.Sleep(time.Minute + time.Second) linked := profileCallback(srv, cookie, linkState, "second") if linked.Code != http.StatusFound || links != 1 || logins != 1 || calls != 4 || len(linked.Result().Cookies()) != 0 { @@ -1012,13 +1019,14 @@ func TestOAuthProfileAcceptsShortUnexpiredToken(t *testing.T) { }) } -func TestOAuthProfileExplicitZeroExpiryRejectedButOmittedExpiryCapped(t *testing.T) { +func TestOAuthProfileExplicitZeroExpiryRejectedButMissingExpiryCapped(t *testing.T) { for _, tc := range []struct { name, expiryField string form bool wantStatus int }{ {"omitted", "", false, http.StatusFound}, + {"null", `,"expires_in":null`, false, http.StatusFound}, {"zero", `,"expires_in":0`, false, http.StatusUnauthorized}, {"zero string", `,"expires_in":"0"`, false, http.StatusUnauthorized}, {"form omitted", "", true, http.StatusFound}, @@ -1056,7 +1064,7 @@ func TestOAuthProfileExplicitZeroExpiryRejectedButOmittedExpiryCapped(t *testing r.AddCookie(cookies[0]) sess := jw.GetSession(r) if sess == nil || sess.Get(srv.SessionKey) == nil { - t.Fatal("omitted expiry did not authenticate") + t.Fatal("missing expiry did not authenticate") } expiry, _ := sess.Get(srv.sessionKey(oauth2AuthExpiryKey)).(time.Time) if remaining := time.Until(expiry); remaining < 23*time.Hour || remaining > 24*time.Hour { diff --git a/server.go b/server.go index 5b9a6f5..06c9764 100644 --- a/server.go +++ b/server.go @@ -93,7 +93,7 @@ type Server struct { Jaws *jaws.Jaws // New appends Config.Namespace to these four session keys when configured. SessionKey string // default is "oidc_claims", value will be of type map[string]any - SessionTokenKey string // default is "oauth2_tokensource"; an oauth2.TokenSource in OIDC mode, empty in profile mode + SessionTokenKey string // default is "oauth2_tokensource"; an oauth2.TokenSource in OIDC mode, unset in profile mode SessionEmailKey string // default is "email", value is a parsed string address with ASCII letters lowercased SessionEmailVerifiedKey string // default is "email_verified", value will be of type bool HandledPaths map[string]struct{} // URI paths excluded from OAuth return targets @@ -102,7 +102,7 @@ type Server struct { LoginFailed FailedFunc // callback failures and protected requests during a running login callback LinkEvent LinkFunc // required by HandleLink; register its POST route separately // SessionRotationLock is held during flow start, rotation, auth clearing, and LinkEvent. - // New supplies a private lock; share one for cross-provider links. + // New supplies a private lock; share one when servers use the same JaWS instance. SessionRotationLock sync.Locker Options []oauth2.AuthCodeOption // authorization URL options, e.g. oauth2.AccessTypeOffline; never sent to token exchange @@ -260,7 +260,8 @@ func (srv *Server) sessionIsAdminLocked(sess *jaws.Session, admins map[string]st // SetAdmins sets the emails of administrators. // -// Addresses are parsed with [mail.ParseAddress] and ASCII letters are lowercased. +// Valid addresses are parsed with [mail.ParseAddress]; other nonempty entries +// are retained. ASCII letters are lowercased. // Non-ASCII characters are matched exactly. If empty, everyone is considered an // administrator. Session checks also apply [Server.RequireVerifiedAdminEmail]. // diff --git a/sessionlifecycle_test.go b/sessionlifecycle_test.go index 6beee74..0b715ed 100644 --- a/sessionlifecycle_test.go +++ b/sessionlifecycle_test.go @@ -47,6 +47,10 @@ func TestLogoutDuringClaimPreparation(t *testing.T) { } func TestAuthTimerDiscardsClosedSession(t *testing.T) { + synctest.Test(t, testAuthTimerDiscardsClosedSession) +} + +func testAuthTimerDiscardsClosedSession(t *testing.T) { for _, duringRefresh := range []bool{false, true} { jw, err := jaws.New() if err != nil { diff --git a/wrapper.go b/wrapper.go index 063fd97..bf8a808 100644 --- a/wrapper.go +++ b/wrapper.go @@ -18,8 +18,7 @@ func (w wrapper) ServeHTTP(hw http.ResponseWriter, hr *http.Request) { w.server.clearSessionAuth(sess, hr, nil, true) } if hr.Method != http.MethodGet { - SetHeaders(hw, w.server.ishttps) - hw.WriteHeader(http.StatusForbidden) + w.server.writeResult(hw, http.StatusForbidden, nil, nil) return } w.server.startOAuth(hw, hr, false, true) From 36b1a65e62d31571c84a566d6ecde38fb657fac5 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Thu, 5 Nov 2026 03:52:17 +0100 Subject: [PATCH 34/43] fix(auth): renew nearly expired login flows Return gateway errors for token exchange failures, clarify multi-provider setup and logout events, and simplify OAuth return handling. --- README.md | 26 ++++++++------- authsession.go | 6 ++-- concurrent_providers_test.go | 8 ++--- config_test.go | 2 +- jawsauth.go | 1 + oauth2.go | 43 +++++++++++++----------- oauth2_callback_edge_test.go | 8 ++--- oauth2_flow_guards_test.go | 8 ++--- oauth2_test.go | 64 ++++++++++++++++++++++++++++-------- oauth_flow_grace_test.go | 38 ++++++++++++++++++--- profile_link_test.go | 52 ++++++++++++++--------------- server.go | 9 ++--- 12 files changed, 170 insertions(+), 95 deletions(-) diff --git a/README.md b/README.md index d79c0c8..41052dd 100644 --- a/README.md +++ b/README.md @@ -11,15 +11,15 @@ sessions. Both modes use authorization-code state and PKCE (S256). `Wrap` and ## Setup -`Config` requires `RedirectURL`, `Issuer` and `ClientID`. The issuer, endpoint -overrides, discovered endpoints and JWKS URI must use HTTPS; +`Config` requires `RedirectURL`, `Issuer` and `ClientID`. The issuer, effective +provider endpoints and JWKS URI must use HTTPS; `AllowInsecureIssuer` permits HTTP for tests and development. OIDC mode discovers provider endpoints, verifies the ID token and nonce, and attempts refresh before expiry when the provider issues a refresh token. Request offline access with the `offline_access` scope for OIDC providers that support it, or `oauth2.AccessTypeOffline` in `Server.Options` for Google. Check both the error -from `New` and `Server.Valid()` before serving protected routes; invalid servers -pass wrapped handlers through without authentication. +from `New` and `Server.Valid()` before serving protected routes; an invalid server +leaves `Wrap` and `WrapAdmin` handlers unprotected. Profile mode requires `AuthURL`, `TokenURL` and `UserInfoURL`, plus a `ProfileSubject` function that extracts a stable subject from the authenticated @@ -43,9 +43,11 @@ login returns 503 with `Retry-After: 60` and preserves the existing session. - Give each `Config` a distinct `Namespace` and callback directory. Add each provider's registered paths to the other servers' `HandledPaths` so return targets cannot enter another provider's auth endpoints. -- `New` sets `Jaws.MakeAuth`. Install a composite `MakeAuth` if JaWS pages need - data from several providers. Each server's `Wrap`, `Handler` and - `CurrentClaims` check that provider's authentication. +- `New` replaces `Jaws.MakeAuth` when it configures authentication. When servers + share one JaWS, install a composite `MakeAuth` after constructing them so + templates use the intended provider's authentication and admin policy. Each + server's `Wrap`, `Handler` and `CurrentClaims` check that provider's + authentication. - Register `HandleLink` as a POST handler to enable linking; `New` does not register it. Authorize the session before calling it. In `LinkEvent`, recheck the original provider's `CurrentClaims` before writing the link. Linking keeps @@ -83,11 +85,11 @@ method proves a live login; check `CurrentClaims` before protected actions. Pending OAuth flows expire after ten minutes. jawsauth extends the original JaWS session's grace while the user visits the provider. Unauthenticated -protected GET requests during a pending login redirect to the same provider -flow without replacing its state, return target or grace deadline. During a -running callback they return 409 unless `LoginFailed` handles them. Logout and -authentication expiry clear that server's credentials and reload every JaWS page -in the session. +protected GETs reuse a pending login's state, return target and grace deadline +while more than two minutes remain; near expiry, they start a new flow and +extend grace. During a running callback they return 409 unless `LoginFailed` +handles them. Logout and auth expiry clear this server's credentials and reload +every JaWS page in the session. Auth expiry preserves a pending login but cancels a pending link. Explicit logout cancels either flow. A new login on the same server, including one started by `Wrap` or `Handler`, cancels a pending link. diff --git a/authsession.go b/authsession.go index aad9cb0..f4da14e 100644 --- a/authsession.go +++ b/authsession.go @@ -445,12 +445,12 @@ func (srv *Server) authTimerDue(expiry time.Time) time.Time { } // pendingOrdinaryLoginLocked requires srv.mu. -func (srv *Server) pendingOrdinaryLoginLocked(sess *jaws.Session) bool { +func (srv *Server) pendingOrdinaryLoginLocked(sess *jaws.Session, minimumRemaining time.Duration) bool { if flow := srv.oauthCallbacks[sess]; flow.canceled != nil { return !flow.link } flow, _ := sess.Get(srv.sessionKey(oauth2FlowKey)).(pendingOAuthFlow) - return flow.state != "" && !flow.link && time.Now().Before(flow.expiry) + return flow.state != "" && !flow.link && time.Until(flow.expiry) > minimumRemaining } func (srv *Server) clearSessionOAuthFlowLocked(sess *jaws.Session) { @@ -496,7 +496,7 @@ func (srv *Server) clearSessionAuth(sess *jaws.Session, hr *http.Request, entry if cleared { // Auth expiry must not cancel an ordinary login already in progress. // Explicit logout and link flows still revoke their pending callbacks. - if !preservePendingLogin || !srv.pendingOrdinaryLoginLocked(sess) { + if !preservePendingLogin || !srv.pendingOrdinaryLoginLocked(sess, 0) { srv.clearSessionOAuthFlowLocked(sess) } sess.Set(srv.SessionKey, nil) diff --git a/concurrent_providers_test.go b/concurrent_providers_test.go index 84cc964..e194a7e 100644 --- a/concurrent_providers_test.go +++ b/concurrent_providers_test.go @@ -47,9 +47,9 @@ func TestConcurrentProviderCallbacksRotateOriginalSessionOnce(t *testing.T) { jw := newTestJaws(t) client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/token" { - return profileTestResponse(`{"access_token":"proof","token_type":"Bearer"}`), nil + return jsonResponse(`{"access_token":"proof","token_type":"Bearer"}`), nil } - return profileTestResponse(`{"id":42}`), nil + return jsonResponse(`{"id":42}`), nil })} gate := &gateRotationLock{both: make(chan struct{}), release: make(chan struct{})} var releaseOnce sync.Once @@ -133,9 +133,9 @@ func TestLinkedProviderLogoutBeforeLinkWrite(t *testing.T) { jw := newTestJaws(t) client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/token" { - return profileTestResponse(`{"access_token":"proof","token_type":"Bearer"}`), nil + return jsonResponse(`{"access_token":"proof","token_type":"Bearer"}`), nil } - return profileTestResponse(`{"id":42}`), nil + return jsonResponse(`{"id":42}`), nil })} a := newConcurrentProfileProvider(t, jw, client, "a") b := newConcurrentProfileProvider(t, jw, client, "b") diff --git a/config_test.go b/config_test.go index 430a50b..0c4f3a7 100644 --- a/config_test.go +++ b/config_test.go @@ -471,7 +471,7 @@ func TestConfigBuildContextJWKSTimeout(t *testing.T) { client := &http.Client{Timeout: configuredTimeout, Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { switch req.URL.Path { case "/.well-known/openid-configuration": - return profileTestResponse(`{"issuer":"https://issuer.example","authorization_endpoint":"https://issuer.example/auth","token_endpoint":"https://issuer.example/token","jwks_uri":"https://issuer.example/jwks"}`), nil + return jsonResponse(`{"issuer":"https://issuer.example","authorization_endpoint":"https://issuer.example/auth","token_endpoint":"https://issuer.example/token","jwks_uri":"https://issuer.example/jwks"}`), nil case "/jwks": jwksHits.Add(1) deadline, ok := req.Context().Deadline() diff --git a/jawsauth.go b/jawsauth.go index 938414b..842fb97 100644 --- a/jawsauth.go +++ b/jawsauth.go @@ -58,6 +58,7 @@ func (a *JawsAuth) Email() (s string) { // EmailVerified reports whether the session address came from a verified email claim. // // Addresses taken from mail or public_email claims are unverified. +// It does not check expiry; use [Server.CurrentClaims] for access decisions. // It is safe to call on a nil or zero-value JawsAuth. func (a *JawsAuth) EmailVerified() (yes bool) { if a != nil && a.server != nil && a.sess != nil { diff --git a/oauth2.go b/oauth2.go index 718597c..1fd5cff 100644 --- a/oauth2.go +++ b/oauth2.go @@ -126,8 +126,7 @@ func sanitizeRedirectTarget(requestHost, location string) (sanitized string) { return } -func (srv *Server) begin(hr *http.Request) (oauth2cfg *oauth2.Config, location string) { - oauth2cfg = srv.oauth2cfg +func (srv *Server) begin(hr *http.Request) (location string) { if location = strings.TrimSpace(hr.Referer()); location == "" { location = hr.RequestURI } @@ -184,16 +183,19 @@ func (srv *Server) HandleLink(hw http.ResponseWriter, hr *http.Request) { var linkOriginCheck = http.NewCrossOriginProtection() -func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, preservePendingLogin bool) { - oauth2cfg, location := srv.begin(hr) +func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, fromWrapper bool) { + oauth2cfg := srv.oauth2cfg + var location string + switch { + case oauth2cfg != nil && fromWrapper: + location = srv.returnTarget(hr, hr.RequestURI) + case oauth2cfg != nil && link && strings.TrimSpace(hr.Referer()) == "": + location = "/" + default: + location = srv.begin(hr) + } if oauth2cfg != nil { - if preservePendingLogin { - location = srv.returnTarget(hr, hr.RequestURI) - } - if link && strings.TrimSpace(hr.Referer()) == "" { - location = "/" - } - flow, sess, status := srv.prepareOAuthFlow(hw, hr, link, preservePendingLogin, location) + flow, sess, status := srv.prepareOAuthFlow(hw, hr, link, fromWrapper, location) if status != 0 { failure := ErrOAuth2MissingSession switch status { @@ -227,7 +229,7 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, pr hw.WriteHeader(statusCode) } -func (srv *Server) prepareOAuthFlow(hw http.ResponseWriter, hr *http.Request, link, preservePendingLogin bool, location string) (flow pendingOAuthFlow, sess *jaws.Session, status int) { +func (srv *Server) prepareOAuthFlow(hw http.ResponseWriter, hr *http.Request, link, fromWrapper bool, location string) (flow pendingOAuthFlow, sess *jaws.Session, status int) { failStatus := http.StatusServiceUnavailable if link { failStatus = http.StatusForbidden @@ -246,7 +248,7 @@ func (srv *Server) prepareOAuthFlow(hw http.ResponseWriter, hr *http.Request, li } srv.mu.Lock() defer srv.mu.Unlock() - if preservePendingLogin && srv.pendingOrdinaryLoginLocked(sess) { + if fromWrapper && srv.pendingOrdinaryLoginLocked(sess, 2*time.Minute) { // Reissue one pending transaction without replacing the first tab's state. flow, _ = sess.Get(srv.sessionKey(oauth2FlowKey)).(pendingOAuthFlow) if flow.state == "" { @@ -278,7 +280,7 @@ func (srv *Server) prepareOAuthFlow(hw http.ResponseWriter, hr *http.Request, li func (srv *Server) HandleLogout(hw http.ResponseWriter, hr *http.Request) { statusCode := http.StatusMethodNotAllowed if hr.Method == http.MethodGet { - _, location := srv.begin(hr) + location := srv.begin(hr) if sess := srv.Jaws.GetSession(hr); sess != nil { srv.clearSessionAuth(sess, hr, nil, false) } @@ -425,10 +427,7 @@ func (srv *Server) fetchUserInfo(ctx context.Context, userinfoURL string, tokenS if userinfoURL != "" && tokenSource != nil { client := oauth2.NewClient(ctx, tokenSource) client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } - timeout := client.Timeout - if timeout <= 0 || timeout > oauth2ProviderTimeout { - timeout = oauth2ProviderTimeout - } + timeout := boundedProviderClient(client).Timeout requestCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() // oauth2.Transport logs from its deprecated CancelRequest; use the request context. @@ -628,7 +627,13 @@ func (srv *Server) completeOAuthCallback(ctx context.Context, hw http.ResponseWr } token, err := srv.oauth2cfg.Exchange(ctx, hr.FormValue("code") /* #nosec G120 */, oauth2.VerifierOption(flow.verifier)) if srv.Jaws.Log(err) != nil { - return sess, http.StatusBadRequest, err + status := http.StatusBadGateway + var retrieveErr *oauth2.RetrieveError + if errors.As(err, &retrieveErr) && retrieveErr.Response != nil && + retrieveErr.Response.StatusCode >= 400 && retrieveErr.Response.StatusCode < 500 { + status = http.StatusBadRequest + } + return sess, status, err } claims, tokenSource, expiry, status, err := srv.verifyOAuthIdentity(ctx, token, flow.nonce) if err != nil { diff --git a/oauth2_callback_edge_test.go b/oauth2_callback_edge_test.go index 562777f..b255b40 100644 --- a/oauth2_callback_edge_test.go +++ b/oauth2_callback_edge_test.go @@ -29,7 +29,7 @@ func TestOIDCCallbackRejectsEmptySubject(t *testing.T) { "exp": time.Now().Add(time.Hour).Unix(), }) srv.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { - return profileTestResponse(`{"access_token":"access","token_type":"Bearer","id_token":"` + idToken + `"}`), nil + return jsonResponse(`{"access_token":"access","token_type":"Bearer","id_token":"` + idToken + `"}`), nil })} logins, links := 0, 0 srv.LoginEvent = func(*jaws.Session, *http.Request) { logins++ } @@ -83,9 +83,9 @@ func TestOIDCExpiryDuringUserInfoRejected(t *testing.T) { srv.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/userinfo" { time.Sleep(6 * time.Second) - return profileTestResponse(`{"sub":"person","email":"user@example.com"}`), nil + return jsonResponse(`{"sub":"person","email":"user@example.com"}`), nil } - return profileTestResponse(`{"access_token":"access","token_type":"Bearer","id_token":"` + idToken + `"}`), nil + return jsonResponse(`{"access_token":"access","token_type":"Bearer","id_token":"` + idToken + `"}`), nil })} req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state&code=code", nil) w := httptest.NewRecorder() @@ -124,7 +124,7 @@ func TestProfileExpiryDuringSubjectRejected(t *testing.T) { return "person", nil }} ctx := context.WithValue(t.Context(), oauth2.HTTPClient, &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { - return profileTestResponse(`{"id":42}`), nil + return jsonResponse(`{"id":42}`), nil })}) token := &oauth2.Token{AccessToken: "access", TokenType: "Bearer", Expiry: time.Now().Add(11 * time.Second)} claims, _, _, status, err := srv.verifyOAuthIdentity(ctx, token, "") diff --git a/oauth2_flow_guards_test.go b/oauth2_flow_guards_test.go index 5ba44ee..a0b7ba7 100644 --- a/oauth2_flow_guards_test.go +++ b/oauth2_flow_guards_test.go @@ -144,9 +144,9 @@ func TestProfileTokenExpiresBetweenRotationAndStore(t *testing.T) { _, state = profileStart(t, srv, http.MethodGet, "/auth/login", cookie) srv.httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { if req.URL.Path == "/token" { - return profileTestResponse(`{"access_token":"short","token_type":"Bearer","expires_in":30}`), nil + return jsonResponse(`{"access_token":"short","token_type":"Bearer","expires_in":30}`), nil } - return profileTestResponse(`{"id":42}`), nil + return jsonResponse(`{"id":42}`), nil })} logins := 0 srv.LoginEvent = func(*jaws.Session, *http.Request) { logins++ } @@ -286,7 +286,7 @@ func TestUserInfoRequiresOneJSONObject(t *testing.T) { srv.profileSubject = func(map[string]any) (string, error) { return "42", nil } } ctx := context.WithValue(t.Context(), oauth2.HTTPClient, &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { - return profileTestResponse(tc.body), nil + return jsonResponse(tc.body), nil })}) claims, err := srv.fetchUserInfo(ctx, "https://provider.example/user", oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "proof"})) if !errors.Is(err, ErrUserInfoInvalidResponse) || claims != nil { @@ -325,7 +325,7 @@ func TestProfileUserInfoNullDoesNotCallSubject(t *testing.T) { }, } ctx := context.WithValue(t.Context(), oauth2.HTTPClient, &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { - return profileTestResponse(`null`), nil + return jsonResponse(`null`), nil })}) claims, source, expiry, status, err := srv.verifyOAuthIdentity(ctx, &oauth2.Token{AccessToken: "proof", TokenType: "Bearer"}, "") if !errors.Is(err, ErrUserInfoInvalidResponse) || claims != nil || source != nil || !expiry.IsZero() || status != http.StatusBadGateway || called { diff --git a/oauth2_test.go b/oauth2_test.go index f158d37..0f8832b 100644 --- a/oauth2_test.go +++ b/oauth2_test.go @@ -128,7 +128,7 @@ func Test_beginSanitizesReferrer(t *testing.T) { srv := &Server{HandledPaths: make(map[string]struct{})} hr := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/login", nil) hr.Header.Set("Referer", "https://evil.com/wrong") - _, location := srv.begin(hr) + location := srv.begin(hr) if location != "/" { t.Fatal(location) } @@ -153,27 +153,27 @@ func Test_beginReferrerHandling(t *testing.T) { srv := &Server{HandledPaths: map[string]struct{}{"/oauth2/login": {}}} hr := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/login", nil) hr.Header.Set("Referer", "https://example.com/oauth2/login") - _, location := srv.begin(hr) + location := srv.begin(hr) if location != "/" { t.Fatal(location) } hr.Header.Set("Referer", "https://example.com/app/home") - _, location = srv.begin(hr) + location = srv.begin(hr) if location != "/app/home" { t.Fatal(location) } hr = httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/login?next=x", nil) - _, location = srv.begin(hr) + location = srv.begin(hr) if location != "/" { t.Fatalf("query-bearing login loops to %q", location) } hr.Header.Set("Referer", "https://example.com/oauth2/login?next=x") - _, location = srv.begin(hr) + location = srv.begin(hr) if location != "/" { t.Fatalf("query-bearing referrer loops to %q", location) } hr.Header.Set("Referer", "https://example.com/app/home?next=x") - _, location = srv.begin(hr) + location = srv.begin(hr) if location != "/app/home?next=x" { t.Fatalf("unhandled target changed to %q", location) } @@ -181,7 +181,7 @@ func Test_beginReferrerHandling(t *testing.T) { "https://example.com/oauth2//login", "https://example.com/oauth2/./login", } { hr.Header.Set("Referer", referer) - _, location = srv.begin(hr) + location = srv.begin(hr) if location != "/" { t.Fatalf("handled path %q returned to %q", referer, location) } @@ -197,7 +197,7 @@ func TestBeginExcludesTrailingSlashCallback(t *testing.T) { "https://example.com/oauth2/./callback/", } { hr.Header.Set("Referer", referer) - _, location := srv.begin(hr) + location := srv.begin(hr) if location != "/" { t.Fatalf("callback return target %q kept as %q", referer, location) } @@ -212,7 +212,7 @@ func Test_beginPathBoundary(t *testing.T) { hr := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/login", nil) hr.Header.Set("Referer", "https://example.com/admin/login") for range 50 { - _, location := srv.begin(hr) + location := srv.begin(hr) if location != "/" { t.Fatal(location) } @@ -220,7 +220,7 @@ func Test_beginPathBoundary(t *testing.T) { srv = &Server{HandledPaths: map[string]struct{}{"/login": {}}} hr.Header.Set("Referer", "https://example.com/admin/login") - _, location := srv.begin(hr) + location := srv.begin(hr) if location != "/admin/login" { t.Fatal(location) } @@ -789,6 +789,44 @@ func TestServer_fetchUserInfoStatusError(t *testing.T) { } } +func TestTokenExchangeFailureStatus(t *testing.T) { + for _, tc := range []struct { + name string + providerStatus int + transportError error + want int + }{ + {"network failure", 0, errors.New("provider unavailable"), http.StatusBadGateway}, + {"timeout", 0, context.DeadlineExceeded, http.StatusBadGateway}, + {"provider bad request", http.StatusBadRequest, nil, http.StatusBadRequest}, + {"provider unavailable", http.StatusServiceUnavailable, nil, http.StatusBadGateway}, + } { + t.Run(tc.name, func(t *testing.T) { + jw := newTestJaws(t) + srv := newProfileTestServer(t, jw, nil) + srv.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path != "/token" { + t.Errorf("unexpected provider request %s", r.URL.Path) + return nil, errors.New("unexpected provider request") + } + if tc.transportError != nil { + return nil, tc.transportError + } + w := httptest.NewRecorder() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tc.providerStatus) + _, _ = io.WriteString(w, `{"error":"invalid_grant"}`) + return w.Result(), nil + })} + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + response := profileCallback(srv, cookie, state, "code") + if response.Code != tc.want { + t.Fatalf("token exchange status = %d, want %d", response.Code, tc.want) + } + }) + } +} + func TestServer_fetchUserInfoRespectsRequestCancellation(t *testing.T) { started := make(chan struct{}) release := make(chan struct{}) @@ -893,7 +931,7 @@ func TestOAuthOfflineOptionUsesAuthorizationOnly(t *testing.T) { tokenAccessType = r.PostForm.Get("access_type") tokenVerifier = r.PostForm.Get("code_verifier") tokenCode = r.PostForm.Get("code") - return profileTestResponse(`{"access_token":"access","token_type":"Bearer","id_token":"` + idToken + `"}`), nil + return jsonResponse(`{"access_token":"access","token_type":"Bearer","id_token":"` + idToken + `"}`), nil })} start := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/login", nil) startResult := httptest.NewRecorder() @@ -931,10 +969,10 @@ func TestVerifyOIDCUserInfoDoesNotRefreshDuringCallback(t *testing.T) { switch req.URL.Path { case "/userinfo": userinfoCalls++ - return profileTestResponse(`{"sub":"one","email":"user@example.com"}`), nil + return jsonResponse(`{"sub":"one","email":"user@example.com"}`), nil case "/token": refreshCalls++ - return profileTestResponse(`{"access_token":"refreshed","token_type":"Bearer"}`), nil + return jsonResponse(`{"access_token":"refreshed","token_type":"Bearer"}`), nil default: t.Fatalf("unexpected provider request %s", req.URL) return nil, nil diff --git a/oauth_flow_grace_test.go b/oauth_flow_grace_test.go index 7b50814..d6cbdc1 100644 --- a/oauth_flow_grace_test.go +++ b/oauth_flow_grace_test.go @@ -61,7 +61,7 @@ func TestPendingLoginProtectedRequestDoesNotRestartOrRenewGrace(t *testing.T) { cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) protected := httptest.NewRequest(http.MethodGet, "https://app.example/protected", nil) protected.AddCookie(cookie) - time.Sleep(9 * time.Minute) + time.Sleep(7 * time.Minute) response := httptest.NewRecorder() srv.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("pending login reached protected handler") @@ -73,9 +73,37 @@ func TestPendingLoginProtectedRequestDoesNotRestartOrRenewGrace(t *testing.T) { if stored.state != state { t.Fatalf("pending login replaced state: want %q got %q", state, stored.state) } - time.Sleep(time.Minute + oauth2ProviderTimeout + 2*time.Second) + time.Sleep(3*time.Minute + oauth2ProviderTimeout + 2*time.Second) if jw.GetSession(protected) != nil { - t.Fatal("rejected start renewed pending session grace") + t.Fatal("protected request renewed pending session grace") + } + }) +} + +func TestPendingLoginProtectedRequestRenewsNearExpiry(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + jw := newTestJaws(t) + srv := newProfileTestServer(t, jw, nil) + cookie, oldState := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + protected := httptest.NewRequest(http.MethodGet, "https://app.example/protected", nil) + protected.AddCookie(cookie) + time.Sleep(oauth2FlowLifetime - time.Second) + response := httptest.NewRecorder() + srv.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("pending login reached protected handler") + })).ServeHTTP(response, protected) + location, err := url.Parse(response.Header().Get("Location")) + if err != nil || response.Code != http.StatusFound { + t.Fatalf("new login: status=%d location=%q err=%v", response.Code, response.Header().Get("Location"), err) + } + newState := location.Query().Get("state") + if newState == "" || newState == oldState || testOAuthFlow(jw.GetSession(protected), srv).state != newState { + t.Fatalf("near-expiry flow was reused: old=%q new=%q", oldState, newState) + } + // Complete the renewed flow after the original session's grace deadline. + time.Sleep(oauth2ProviderTimeout + 3*time.Second) + if result := profileCallback(srv, cookie, newState, "first"); result.Code != http.StatusFound { + t.Fatalf("renewed login callback: status=%d body=%q", result.Code, result.Body.String()) } }) } @@ -211,9 +239,9 @@ func TestOAuthLinkCallbackNearDeadlineCanFinishProviderExchange(t *testing.T) { httpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/token" { time.Sleep(10 * time.Second) - return profileTestResponse(`{"access_token":"access","token_type":"Bearer"}`), nil + return jsonResponse(`{"access_token":"access","token_type":"Bearer"}`), nil } - return profileTestResponse(`{"id":42}`), nil + return jsonResponse(`{"id":42}`), nil })}, } start := httptest.NewRequest(http.MethodPost, "https://app.example/auth/link", nil) diff --git a/profile_link_test.go b/profile_link_test.go index 61b4a1e..e330b95 100644 --- a/profile_link_test.go +++ b/profile_link_test.go @@ -20,7 +20,7 @@ import ( "golang.org/x/oauth2" ) -func profileTestResponse(body string) *http.Response { +func jsonResponse(body string) *http.Response { return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(strings.NewReader(body))} } @@ -57,7 +57,7 @@ func TestOAuthProfileAuthLifetimeCapsLongProviderToken(t *testing.T) { srv := &Server{issuer: "https://provider.example", userinfoUrl: "https://provider.example/user", profileSubject: func(map[string]any) (string, error) { return "42", nil }} ctx := context.WithValue(t.Context(), oauth2.HTTPClient, &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { - return profileTestResponse(`{"id":42}`), nil + return jsonResponse(`{"id":42}`), nil })}) for _, duration := range []time.Duration{30 * time.Minute, 72 * time.Hour} { token := &oauth2.Token{AccessToken: "secret", TokenType: "Bearer", Expiry: time.Now().Add(duration)} @@ -83,9 +83,9 @@ func TestOAuthProfileCallbackStateIsSingleUseConcurrently(t *testing.T) { client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { calls.Add(1) if r.URL.Path == "/token" { - return profileTestResponse(`{"access_token":"secret","token_type":"Bearer"}`), nil + return jsonResponse(`{"access_token":"secret","token_type":"Bearer"}`), nil } - return profileTestResponse(`{"id":42}`), nil + return jsonResponse(`{"id":42}`), nil })} srv, err := New(jw, &Config{Namespace: "one", RedirectURL: "https://app.example/auth/callback", Issuer: "https://provider.example", AuthURL: "https://provider.example/authorize", @@ -143,9 +143,9 @@ func TestOAuthProfileRotationLockDoesNotBlockProvider(t *testing.T) { srv.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/token" { close(providerReached) - return profileTestResponse(`{"access_token":"proof","token_type":"Bearer"}`), nil + return jsonResponse(`{"access_token":"proof","token_type":"Bearer"}`), nil } - return profileTestResponse(`{"id":42}`), nil + return jsonResponse(`{"id":42}`), nil })} lock := &rotationNoticeLock{entered: make(chan struct{})} cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) @@ -195,9 +195,9 @@ func TestOAuthFailedCallbackDoesNotEraseNewCallback(t *testing.T) { } close(secondEntered) <-secondRelease - return profileTestResponse(`{"access_token":"secret","token_type":"Bearer"}`), nil + return jsonResponse(`{"access_token":"secret","token_type":"Bearer"}`), nil } - return profileTestResponse(`{"id":42}`), nil + return jsonResponse(`{"id":42}`), nil })} srv, err := New(jw, &Config{Namespace: "one", RedirectURL: "https://app.example/auth/callback", Issuer: "https://provider.example", AuthURL: "https://provider.example/authorize", @@ -228,7 +228,7 @@ func TestOAuthFailedCallbackDoesNotEraseNewCallback(t *testing.T) { go func() { secondResult <- profileCallback(srv, cookie, secondState, "second") }() waitFor(t, secondEntered, "second callback did not reach provider") releaseCallback() - if code := waitFor(t, firstResult, "failed callback did not finish"); code != http.StatusBadRequest { + if code := waitFor(t, firstResult, "failed callback did not finish"); code != http.StatusBadGateway { t.Fatalf("failed callback status = %d", code) } sess := jw.GetSession(r) @@ -264,9 +264,9 @@ func TestOAuthProfileCallbackCanceledBeforeRotation(t *testing.T) { if r.URL.Path == "/token" { close(entered) <-release - return profileTestResponse(`{"access_token":"first-token","token_type":"Bearer"}`), nil + return jsonResponse(`{"access_token":"first-token","token_type":"Bearer"}`), nil } - return profileTestResponse(`{"id":42,"email":"first@example.com"}`), nil + return jsonResponse(`{"id":42,"email":"first@example.com"}`), nil })} logins := 0 srv.LoginEvent = func(*jaws.Session, *http.Request) { logins++ } @@ -373,9 +373,9 @@ func TestOAuthProfileLinkCanceledByLogout(t *testing.T) { close(entered) <-release } - return profileTestResponse(`{"access_token":"first-token","token_type":"Bearer"}`), nil + return jsonResponse(`{"access_token":"first-token","token_type":"Bearer"}`), nil } - return profileTestResponse(`{"id":42,"email":"first@example.com"}`), nil + return jsonResponse(`{"id":42,"email":"first@example.com"}`), nil })} cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) login := profileCallback(srv, cookie, state, "first") @@ -425,9 +425,9 @@ func TestOAuthProfileLinkRejectsReplacedSession(t *testing.T) { if r.URL.Path == "/token" { close(entered) <-release - return profileTestResponse(`{"access_token":"proof","token_type":"Bearer"}`), nil + return jsonResponse(`{"access_token":"proof","token_type":"Bearer"}`), nil } - return profileTestResponse(`{"id":42}`), nil + return jsonResponse(`{"id":42}`), nil })} links := 0 srv.LinkEvent = func(*jaws.Session, *http.Request, map[string]any) (int, error) { links++; return 0, nil } @@ -684,15 +684,15 @@ func newProfileTestServer(t *testing.T, jw *jaws.Jaws, calls *int) *Server { return nil, errors.New("missing PKCE verifier") } if r.PostForm.Get("code") == "second" { - return profileTestResponse(`{"access_token":"second-token","token_type":"Bearer"}`), nil + return jsonResponse(`{"access_token":"second-token","token_type":"Bearer"}`), nil } - return profileTestResponse(`{"access_token":"first-token","token_type":"Bearer"}`), nil + return jsonResponse(`{"access_token":"first-token","token_type":"Bearer"}`), nil case "/user": switch r.Header.Get("Authorization") { case "Bearer first-token": - return profileTestResponse(`{"id":9007199254740993,"login":"first","email":"first@example.com"}`), nil + return jsonResponse(`{"id":9007199254740993,"login":"first","email":"first@example.com"}`), nil case "Bearer second-token": - return profileTestResponse(`{"id":99,"login":"second"}`), nil + return jsonResponse(`{"id":99,"login":"second"}`), nil } } return nil, errors.New("unexpected provider request") @@ -919,7 +919,7 @@ func TestOAuthProfileRejectsInvalidSubjectAndOversizedResponse(t *testing.T) { } oversized := &Server{profileSubject: srv.profileSubject} ctx := context.WithValue(t.Context(), oauth2.HTTPClient, &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { - return profileTestResponse(`{"id":42}` + strings.Repeat(" ", maxUserInfoBytes+1)), nil + return jsonResponse(`{"id":42}` + strings.Repeat(" ", maxUserInfoBytes+1)), nil })}) _, err := oversized.fetchUserInfo(ctx, "https://provider.example/user", oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "secret"})) if !errors.Is(err, ErrUserInfoTooLarge) { @@ -936,7 +936,7 @@ func TestOAuthProfileRejectsTokenExpiredDuringUserInfo(t *testing.T) { } ctx := context.WithValue(t.Context(), oauth2.HTTPClient, &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { token.Expiry = time.Now().Add(-time.Second) - return profileTestResponse(`{"id":42}`), nil + return jsonResponse(`{"id":42}`), nil })}) claims, source, expiry, status, err := srv.verifyOAuthIdentity(ctx, token, "") if !errors.Is(err, ErrOAuth2InvalidProfile) || claims != nil || source != nil || !expiry.IsZero() || status != http.StatusUnauthorized || called { @@ -952,7 +952,7 @@ func TestOAuthProfileRejectsEmptyAccessTokenBeforeUserInfo(t *testing.T) { } ctx := context.WithValue(t.Context(), oauth2.HTTPClient, &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { calls++ - return profileTestResponse(`{"id":42}`), nil + return jsonResponse(`{"id":42}`), nil })}) claims, source, expiry, status, err := srv.verifyOAuthIdentity(ctx, &oauth2.Token{TokenType: "Bearer"}, "") if !errors.Is(err, ErrOAuth2InvalidProfile) || claims != nil || source != nil || !expiry.IsZero() || status != http.StatusUnauthorized || calls != 0 { @@ -967,7 +967,7 @@ func TestOAuthProfileUserInfoCallbackDeadline(t *testing.T) { var userinfoErr error srv.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/token" { - return profileTestResponse(`{"access_token":"access","token_type":"Bearer"}`), nil + return jsonResponse(`{"access_token":"access","token_type":"Bearer"}`), nil } select { case <-r.Context().Done(): @@ -1005,7 +1005,7 @@ func TestOAuthProfileAcceptsShortUnexpiredToken(t *testing.T) { if got := r.Header.Get("Authorization"); got != "Bearer access" { t.Fatalf("userinfo authorization = %q", got) } - return profileTestResponse(`{"id":42}`), nil + return jsonResponse(`{"id":42}`), nil })}) token := &oauth2.Token{AccessToken: "access", TokenType: "Bearer", Expiry: time.Now().Add(5 * time.Second)} if token.Valid() { @@ -1041,9 +1041,9 @@ func TestOAuthProfileExplicitZeroExpiryRejectedButMissingExpiryCapped(t *testing return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/x-www-form-urlencoded"}}, Body: io.NopCloser(strings.NewReader("access_token=access&token_type=Bearer" + tc.expiryField))}, nil } - return profileTestResponse(`{"access_token":"access","token_type":"Bearer"` + tc.expiryField + `}`), nil + return jsonResponse(`{"access_token":"access","token_type":"Bearer"` + tc.expiryField + `}`), nil } - return profileTestResponse(`{"id":42}`), nil + return jsonResponse(`{"id":42}`), nil })} cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) response := profileCallback(srv, cookie, state, "code") diff --git a/server.go b/server.go index 06c9764..d0c76ee 100644 --- a/server.go +++ b/server.go @@ -63,8 +63,9 @@ type HandleFunc func(uri string, handler http.Handler) // // jawsauth calls [Server.LogoutEvent] after releasing SessionRotationLock. // When LoginEvent is set, clearing auth before it begins suppresses both -// events; clearing auth during it defers LogoutEvent until it returns. A -// deferred LogoutEvent receives a nil request. +// events; clearing auth during it defers LogoutEvent until it returns. +// LogoutEvent receives a nil request for timer cleanup, deferred delivery, or +// a call to [Server.Logout] with a nil request. type EventFunc func(sess *jaws.Session, hr *http.Request) // FailedFunc is called on callback failures and when a protected request meets @@ -374,7 +375,7 @@ func (srv *Server) Wrap(h http.Handler) (rh http.Handler) { // Unauthenticated GET begins login; other unauthenticated methods receive 403. // Authenticated non-admins receive 403. [Server.RequireVerifiedAdminEmail] // applies. A GET request during a running login callback may return 409. -// An invalid Server returns the unprotected template handler. +// An invalid, non-nil Server returns the unprotected template handler. func (srv *Server) HandlerAdmin(name string, dot any) http.Handler { return srv.wrap(ui.Handler(srv.Jaws, name, dot), true) } @@ -384,7 +385,7 @@ func (srv *Server) HandlerAdmin(name string, dot any) http.Handler { // // Unauthenticated GET begins login; other unauthenticated methods receive 403. // A GET request during a running login callback may return 409. -// An invalid Server returns the unprotected template handler. +// An invalid, non-nil Server returns the unprotected template handler. func (srv *Server) Handler(name string, dot any) http.Handler { return srv.wrap(ui.Handler(srv.Jaws, name, dot), false) } From e5256aae38979ed944b6e9c382f0dbb2bbb97f89 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Thu, 5 Nov 2026 04:29:55 +0100 Subject: [PATCH 35/43] fix(auth): share default rotation lock across providers --- README.md | 14 ++++--- adminpolicy_test.go | 9 +--- authsession.go | 6 +-- authsession_test.go | 9 +--- concurrent_providers_test.go | 79 ++++++++++++------------------------ expiry_wrapper_test.go | 7 +--- oauth2.go | 4 +- profile_link_test.go | 61 +++++++++------------------- server.go | 9 +++- 9 files changed, 72 insertions(+), 126 deletions(-) diff --git a/README.md b/README.md index 41052dd..508990d 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ refresh before expiry when the provider issues a refresh token. Request offline access with the `offline_access` scope for OIDC providers that support it, or `oauth2.AccessTypeOffline` in `Server.Options` for Google. Check both the error from `New` and `Server.Valid()` before serving protected routes; an invalid server -leaves `Wrap` and `WrapAdmin` handlers unprotected. +can return handlers without authentication. Profile mode requires `AuthURL`, `TokenURL` and `UserInfoURL`, plus a `ProfileSubject` function that extracts a stable subject from the authenticated @@ -52,12 +52,16 @@ login returns 503 with `Retry-After: 60` and preserves the existing session. register it. Authorize the session before calling it. In `LinkEvent`, recheck the original provider's `CurrentClaims` before writing the link. Linking keeps the session; an ordinary login rotates it and signs out other providers. - Their `LogoutEvent` may run later with a nil request. + A re-login calls its `LogoutEvent` before storing the new sign-in; calling + `Logout` on that event session cancels the new sign-in. Other providers' + `LogoutEvent` may run later with a nil request. Starting a link replaces this server's pending flow, including a running login callback. -- `New` gives each server a private `SessionRotationLock`. When several servers - share one JaWS instance, assign them one shared lock before serving. jawsauth - holds it during flow start, rotation, authentication clearing and `LinkEvent`. +- `New` shares one process-wide `SessionRotationLock` by default. To coordinate + with other authentication methods or isolate independent JaWS instances, set + a custom lock before serving; servers sharing a JaWS must use the same lock. + jawsauth holds it during flow start, rotation, authentication clearing and + `LinkEvent`. Do not take the lock around jawsauth handlers or `Server.Logout`, or within `LinkEvent`; do not call `Server.Logout` from `LinkEvent`. Keep `LinkEvent` brief because other sessions sharing the lock wait. One provider's logout does not diff --git a/adminpolicy_test.go b/adminpolicy_test.go index ab9d7fa..775a18f 100644 --- a/adminpolicy_test.go +++ b/adminpolicy_test.go @@ -89,14 +89,9 @@ func TestProfileAdminRequiresVerifiedEmailByDefault(t *testing.T) { srv.RequireVerifiedAdminEmail = false } srv.SetAdmins([]string{"first@example.com"}) - cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) - login := profileCallback(srv, cookie, state, "first") - cookies := login.Result().Cookies() - if login.Code != http.StatusFound || len(cookies) != 1 { - t.Fatalf("profile login: status=%d cookies=%v", login.Code, cookies) - } + cookie := profileLogin(t, srv) r := httptest.NewRequest(http.MethodGet, "https://app.example/protected", nil) - r.AddCookie(cookies[0]) + r.AddCookie(cookie) sess := jw.GetSession(r) if sess == nil { t.Fatal("missing profile session") diff --git a/authsession.go b/authsession.go index f4da14e..33b4e34 100644 --- a/authsession.go +++ b/authsession.go @@ -465,9 +465,9 @@ func (srv *Server) clearSessionOAuthFlowLocked(sess *jaws.Session) { // // It stops the auth timer, clears stored claims, token source, email, expiry // and any in-flight OAuth flow keys, then marks the session dirty. If auth was -// present, it reloads its pages. [Server.LogoutEvent] follows [EventFunc]'s -// ordering rules. It returns false for a nil receiver or session, and true -// otherwise. The hr argument may be nil. +// present, it reloads its pages and may call [Server.LogoutEvent] under +// [EventFunc]'s ordering rules. It returns false for a nil receiver or session, +// and true otherwise. The hr argument may be nil. // // It performs no HTTP redirect. An HTTP handler can build its own post-logout // response. A JaWS event handler cannot use [jaws.Request.Redirect] after Logout diff --git a/authsession_test.go b/authsession_test.go index 424d3c9..ac81a45 100644 --- a/authsession_test.go +++ b/authsession_test.go @@ -1200,19 +1200,14 @@ func TestStaleWrapperDoesNotRepeatRotationLogoutEvent(t *testing.T) { jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) srv.authTimerAfterFunc = (&testAuthTimerFactory{}).after - cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) - first := profileCallback(srv, cookie, state, "first") - if first.Code != http.StatusFound || len(first.Result().Cookies()) != 1 { - t.Fatalf("initial login: status=%d cookies=%v", first.Code, first.Result().Cookies()) - } - cookie = first.Result().Cookies()[0] + cookie := profileLogin(t, srv) req := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) req.AddCookie(cookie) old := jw.GetSession(req) if old == nil { t.Fatal("authenticated session missing") } - _, state = profileStart(t, srv, http.MethodGet, "/auth/login", cookie) + _, state := profileStart(t, srv, http.MethodGet, "/auth/login", cookie) old.Set(srv.sessionKey(oauth2AuthExpiryKey), time.Now().Add(-time.Second)) lock := &holdFirstRotationLock{first: make(chan struct{}), releaseFirst: make(chan struct{})} srv.SessionRotationLock = lock diff --git a/concurrent_providers_test.go b/concurrent_providers_test.go index e194a7e..e40a8a2 100644 --- a/concurrent_providers_test.go +++ b/concurrent_providers_test.go @@ -27,68 +27,41 @@ func newConcurrentProfileProvider(t *testing.T, jw *jaws.Jaws, client *http.Clie return srv } -type gateRotationLock struct { - mu sync.Mutex - entrants atomic.Int32 - both chan struct{} - release chan struct{} -} - -func (l *gateRotationLock) Lock() { - if l.entrants.Add(1) == 2 { - close(l.both) - } - <-l.release - l.mu.Lock() -} -func (l *gateRotationLock) Unlock() { l.mu.Unlock() } - -func TestConcurrentProviderCallbacksRotateOriginalSessionOnce(t *testing.T) { +func TestDefaultRotationLockSerializesProvidersSharingJaws(t *testing.T) { jw := newTestJaws(t) + providerReached := make(chan struct{}, 2) client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/token" { return jsonResponse(`{"access_token":"proof","token_type":"Bearer"}`), nil } + providerReached <- struct{}{} return jsonResponse(`{"id":42}`), nil })} - gate := &gateRotationLock{both: make(chan struct{}), release: make(chan struct{})} - var releaseOnce sync.Once - release := func() { releaseOnce.Do(func() { close(gate.release) }) } - defer release() a := newConcurrentProfileProvider(t, jw, client, "a") b := newConcurrentProfileProvider(t, jw, client, "b") - cookie, stateA := profileStart(t, a, http.MethodGet, "/auth/a/login", nil) - startB := httptest.NewRequest(http.MethodGet, "https://app.example/auth/b/login", nil) - startB.AddCookie(cookie) - wB := httptest.NewRecorder() - b.HandleLogin(wB, startB) - if wB.Code != http.StatusFound { - t.Fatalf("second start %d", wB.Code) - } - locB, err := url.Parse(wB.Header().Get("Location")) - if err != nil { - t.Fatal(err) - } - stateB := locB.Query().Get("state") - a.SessionRotationLock, b.SessionRotationLock = gate, gate - callback := func(srv *Server, ns, state string) *httptest.ResponseRecorder { - req := httptest.NewRequest(http.MethodGet, "https://app.example/auth/"+ns+"/callback?state="+url.QueryEscape(state)+"&code=proof", nil) - req.AddCookie(cookie) - w := httptest.NewRecorder() - srv.HandleAuthResponse(w, req) - return w - } - out := make(chan *httptest.ResponseRecorder, 2) - go func() { out <- callback(a, "a", stateA) }() - go func() { out <- callback(b, "b", stateB) }() - waitFor(t, gate.both, "provider callbacks did not reach rotation") - release() - responses := make([]*httptest.ResponseRecorder, 0, 2) - for range 2 { - responses = append(responses, waitFor(t, out, "provider callback did not finish")) + if a.SessionRotationLock == nil || a.SessionRotationLock != b.SessionRotationLock { + t.Fatal("providers sharing JaWS do not share the default rotation lock") } + cookie, stateA := profileStart(t, a, http.MethodGet, "/auth/a/login", nil) + _, stateB := profileStart(t, b, http.MethodGet, "/auth/b/login", cookie) + var logins atomic.Int32 + a.LoginEvent = func(*jaws.Session, *http.Request) { logins.Add(1) } + b.LoginEvent = func(*jaws.Session, *http.Request) { logins.Add(1) } + + lock := a.SessionRotationLock + lock.Lock() + unlock := sync.OnceFunc(lock.Unlock) + defer unlock() + results := make(chan *httptest.ResponseRecorder, 2) + go func() { results <- profileCallback(a, cookie, stateA, "proof") }() + go func() { results <- profileCallback(b, cookie, stateB, "proof") }() + waitFor(t, providerReached, "first callback did not finish UserInfo") + waitFor(t, providerReached, "second callback did not finish UserInfo") + unlock() + success, rejected := 0, 0 - for _, response := range responses { + for range 2 { + response := waitFor(t, results, "provider callback did not finish") switch response.Code { case http.StatusFound: success++ @@ -104,8 +77,8 @@ func TestConcurrentProviderCallbacksRotateOriginalSessionOnce(t *testing.T) { t.Fatalf("callback status=%d", response.Code) } } - if success != 1 || rejected != 1 || jw.SessionCount() != 1 { - t.Fatalf("callbacks: success=%d rejected=%d sessions=%d", success, rejected, jw.SessionCount()) + if success != 1 || rejected != 1 || logins.Load() != 1 || jw.SessionCount() != 1 { + t.Fatalf("callbacks: success=%d rejected=%d logins=%d sessions=%d", success, rejected, logins.Load(), jw.SessionCount()) } } diff --git a/expiry_wrapper_test.go b/expiry_wrapper_test.go index 320633b..53c219e 100644 --- a/expiry_wrapper_test.go +++ b/expiry_wrapper_test.go @@ -11,12 +11,7 @@ import ( func TestProtectedRequestPreservesPendingLoginOnAuthExpiry(t *testing.T) { jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) - cookie, initialState := profileStart(t, srv, http.MethodGet, "/auth/login", nil) - first := profileCallback(srv, cookie, initialState, "first") - if first.Code != http.StatusFound { - t.Fatalf("initial sign-in %d", first.Code) - } - cookie = first.Result().Cookies()[0] + cookie := profileLogin(t, srv) start := httptest.NewRequest(http.MethodGet, "https://app.example/auth/login", nil) start.AddCookie(cookie) w := httptest.NewRecorder() diff --git a/oauth2.go b/oauth2.go index 1fd5cff..1554413 100644 --- a/oauth2.go +++ b/oauth2.go @@ -187,9 +187,9 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, fr oauth2cfg := srv.oauth2cfg var location string switch { - case oauth2cfg != nil && fromWrapper: + case fromWrapper: location = srv.returnTarget(hr, hr.RequestURI) - case oauth2cfg != nil && link && strings.TrimSpace(hr.Referer()) == "": + case link && strings.TrimSpace(hr.Referer()) == "": location = "/" default: location = srv.begin(hr) diff --git a/profile_link_test.go b/profile_link_test.go index e330b95..762d830 100644 --- a/profile_link_test.go +++ b/profile_link_test.go @@ -322,19 +322,14 @@ func TestOAuthProfileCallbackCanceledBeforeFinalStore(t *testing.T) { srv := newProfileTestServer(t, jw, nil) factory := &testAuthTimerFactory{} srv.authTimerAfterFunc = factory.after - cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) - first := profileCallback(srv, cookie, state, "first") - if first.Code != http.StatusFound { - t.Fatalf("first login: %d", first.Code) - } - cookie = first.Result().Cookies()[0] + cookie := profileLogin(t, srv) request := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) request.AddCookie(cookie) old := jw.GetSession(request) if old == nil { t.Fatal("missing original session") } - _, state = profileStart(t, srv, http.MethodGet, "/auth/login", cookie) + _, state := profileStart(t, srv, http.MethodGet, "/auth/login", cookie) canceled := false logins := 0 srv.LoginEvent = func(*jaws.Session, *http.Request) { logins++ } @@ -377,12 +372,7 @@ func TestOAuthProfileLinkCanceledByLogout(t *testing.T) { } return jsonResponse(`{"id":42,"email":"first@example.com"}`), nil })} - cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) - login := profileCallback(srv, cookie, state, "first") - if login.Code != http.StatusFound || len(login.Result().Cookies()) != 1 { - t.Fatalf("initial login: status=%d cookies=%v", login.Code, login.Result().Cookies()) - } - cookie = login.Result().Cookies()[0] + cookie := profileLogin(t, srv) request := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) request.AddCookie(cookie) sess := jw.GetSession(request) @@ -411,12 +401,7 @@ func TestOAuthProfileLinkCanceledByLogout(t *testing.T) { func TestOAuthProfileLinkRejectsReplacedSession(t *testing.T) { jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) - cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) - login := profileCallback(srv, cookie, state, "first") - if login.Code != http.StatusFound || len(login.Result().Cookies()) != 1 { - t.Fatalf("initial login: status=%d cookies=%v", login.Code, login.Result().Cookies()) - } - cookie = login.Result().Cookies()[0] + cookie := profileLogin(t, srv) entered := make(chan struct{}) release := make(chan struct{}) releaseProvider := sync.OnceFunc(func() { close(release) }) @@ -476,12 +461,7 @@ func TestOAuthProfileLinkSerializesManagedRevocation(t *testing.T) { srv := newProfileTestServer(t, jw, nil) factory := &testAuthTimerFactory{} srv.authTimerAfterFunc = factory.after - cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) - first := profileCallback(srv, cookie, state, "first") - if first.Code != http.StatusFound { - t.Fatalf("initial login: %d", first.Code) - } - cookie = first.Result().Cookies()[0] + cookie := profileLogin(t, srv) req := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) req.AddCookie(cookie) sess := jw.GetSession(req) @@ -559,12 +539,7 @@ func TestOAuthProfileAuthExpiryPreservesOrdinaryLogin(t *testing.T) { srv := newProfileTestServer(t, jw, nil) factory := &testAuthTimerFactory{} srv.authTimerAfterFunc = factory.after - cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) - first := profileCallback(srv, cookie, state, "first") - if first.Code != http.StatusFound { - t.Fatalf("initial login: %d", first.Code) - } - cookie = first.Result().Cookies()[0] + cookie := profileLogin(t, srv) start := httptest.NewRequest(http.MethodGet, "https://app.example/auth/login", nil) start.AddCookie(cookie) sess := jw.GetSession(start) @@ -580,7 +555,7 @@ func TestOAuthProfileAuthExpiryPreservesOrdinaryLogin(t *testing.T) { if err != nil { t.Fatal(err) } - state = location.Query().Get("state") + state := location.Query().Get("state") if state == "" { t.Fatal("missing new login state") } @@ -768,6 +743,17 @@ func profileCallback(srv *Server, cookie *http.Cookie, state, code string) *http return w } +func profileLogin(t *testing.T, srv *Server) *http.Cookie { + t.Helper() + cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) + response := profileCallback(srv, cookie, state, "first") + cookies := response.Result().Cookies() + if response.Code != http.StatusFound || len(cookies) != 1 { + t.Fatalf("profile login: status=%d cookies=%v", response.Code, cookies) + } + return cookies[0] +} + func TestOAuthProfileSignInAndDualProofLink(t *testing.T) { synctest.Test(t, func(t *testing.T) { jw := newTestJaws(t) @@ -826,9 +812,7 @@ func TestOAuthProfileExpiredLinkStateNeverCallsProvider(t *testing.T) { jw := newTestJaws(t) calls := 0 srv := newProfileTestServer(t, jw, &calls) - oldCookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) - first := profileCallback(srv, oldCookie, state, "first") - cookie := first.Result().Cookies()[0] + cookie := profileLogin(t, srv) srv.LinkEvent = func(*jaws.Session, *http.Request, map[string]any) (int, error) { t.Fatal("expired link reached link event") return 0, nil @@ -860,12 +844,7 @@ func TestOAuthLinkRequiresSessionAndSameOriginRequestAndRejectsDeclinedLink(t *t if w.Code != http.StatusForbidden || calls != 0 { t.Fatalf("anonymous link: status=%d calls=%d", w.Code, calls) } - oldCookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) - first := profileCallback(srv, oldCookie, state, "first") - if first.Code != http.StatusFound { - t.Fatalf("first sign-in: %d", first.Code) - } - cookie := first.Result().Cookies()[0] + cookie := profileLogin(t, srv) cross := httptest.NewRequest(http.MethodPost, "https://app.example/auth/link/github", nil) cross.Header.Set("Origin", "https://evil.example") cross.AddCookie(cookie) diff --git a/server.go b/server.go index d0c76ee..a2e3b59 100644 --- a/server.go +++ b/server.go @@ -66,6 +66,8 @@ type HandleFunc func(uri string, handler http.Handler) // events; clearing auth during it defers LogoutEvent until it returns. // LogoutEvent receives a nil request for timer cleanup, deferred delivery, or // a call to [Server.Logout] with a nil request. +// During re-login, LogoutEvent runs on the old session before the new sign-in +// is stored; calling Logout on that session cancels the new sign-in. type EventFunc func(sess *jaws.Session, hr *http.Request) // FailedFunc is called on callback failures and when a protected request meets @@ -103,7 +105,8 @@ type Server struct { LoginFailed FailedFunc // callback failures and protected requests during a running login callback LinkEvent LinkFunc // required by HandleLink; register its POST route separately // SessionRotationLock is held during flow start, rotation, auth clearing, and LinkEvent. - // New supplies a private lock; share one when servers use the same JaWS instance. + // New supplies a shared package lock; set the same custom lock on servers using + // one JaWS instance when coordinating with other authentication methods. SessionRotationLock sync.Locker Options []oauth2.AuthCodeOption // authorization URL options, e.g. oauth2.AccessTypeOffline; never sent to token exchange @@ -132,6 +135,8 @@ type Server struct { namespace string } +var defaultSessionRotationLock sync.Mutex + // NewDebug behaves like New but can override the scheme and host of cfg.RedirectURL. // // overrideUrl supplies the replacement scheme and host (an empty overrideUrl disables @@ -154,7 +159,7 @@ func NewDebug(jw *jaws.Jaws, cfg *Config, handleFn HandleFunc, overrideUrl strin SessionEmailKey: "email", SessionEmailVerifiedKey: "email_verified", HandledPaths: make(map[string]struct{}), - SessionRotationLock: &sync.Mutex{}, + SessionRotationLock: &defaultSessionRotationLock, admins: make(map[string]struct{}), handle403: default403handler{}, authTimers: make(map[uint64]*authTimerState), From c763eb4504f43a72965b0e221d4a77d0f69c59f7 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Thu, 5 Nov 2026 05:03:06 +0100 Subject: [PATCH 36/43] fix(auth): bind session claims to provider issuer --- authsession.go | 18 +++++++++++++----- authsession_test.go | 4 ++++ currentclaims_test.go | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/authsession.go b/authsession.go index 33b4e34..00e7c5f 100644 --- a/authsession.go +++ b/authsession.go @@ -102,6 +102,14 @@ func (srv *Server) sessionAuthStatus(sess *jaws.Session, now func() time.Time) ( authValue := sess.Get(srv.SessionKey) present = authValue != nil || expiryValue != nil if authValue != nil && now != nil { + if srv.issuer != "" { + claims, ok := authValue.(map[string]any) + issuer, _ := claims["iss"].(string) + // go-oidc accepts Google's scheme-less issuer. + if !ok || (issuer != srv.issuer && (srv.issuer != "https://accounts.google.com" || issuer != "accounts.google.com")) { + return + } + } if expiry, _ := expiryValue.(time.Time); !expiry.IsZero() { current = expiry.After(now()) } @@ -463,11 +471,11 @@ func (srv *Server) clearSessionOAuthFlowLocked(sess *jaws.Session) { // Logout clears this server's authentication state for sess. // -// It stops the auth timer, clears stored claims, token source, email, expiry -// and any in-flight OAuth flow keys, then marks the session dirty. If auth was -// present, it reloads its pages and may call [Server.LogoutEvent] under -// [EventFunc]'s ordering rules. It returns false for a nil receiver or session, -// and true otherwise. The hr argument may be nil. +// It stops the auth timer and clears claims, token source, email and its +// verification flag, expiry, and in-flight OAuth flow keys, then marks the +// session dirty. If auth was present, it reloads its pages and may call +// [Server.LogoutEvent] under [EventFunc]'s ordering rules. It returns false for +// a nil receiver or session, and true otherwise. The hr argument may be nil. // // It performs no HTTP redirect. An HTTP handler can build its own post-logout // response. A JaWS event handler cannot use [jaws.Request.Redirect] after Logout diff --git a/authsession_test.go b/authsession_test.go index ac81a45..b7a8fe1 100644 --- a/authsession_test.go +++ b/authsession_test.go @@ -947,6 +947,7 @@ func TestAuthTimerStaleCallbackNoOp(t *testing.T) { t.Fatal(err) } firstTimer := factory.timer(0) + firstEntry := srv.authTimers[sess.ID()] err = srv.storeSessionAuthClaims(sess, map[string]any{ "exp": time.Now().Add(time.Hour).Unix(), "email": "second@example.com", @@ -954,6 +955,9 @@ func TestAuthTimerStaleCallbackNoOp(t *testing.T) { if err != nil { t.Fatal(err) } + if srv.clearSessionAuth(sess, nil, firstEntry, true) { + t.Fatal("stale timer cleared newer authentication") + } firstTimer.fire() diff --git a/currentclaims_test.go b/currentclaims_test.go index d8449f0..ea785bb 100644 --- a/currentclaims_test.go +++ b/currentclaims_test.go @@ -40,3 +40,38 @@ func TestServerCurrentClaimsChecksExpiryAndJaWSSession(t *testing.T) { t.Fatal("closed session returned claims") } } + +func TestCurrentClaimsRejectsOtherIssuerWithSameNamespace(t *testing.T) { + jw := newTestJaws(t) + social, err := New(jw, &Config{Namespace: "shared", Issuer: "https://social.example"}, nil) + if err != nil { + t.Fatal(err) + } + corp, err := New(jw, &Config{Namespace: "shared", Issuer: "https://corp.example"}, nil) + if err != nil { + t.Fatal(err) + } + if social.SessionKey != corp.SessionKey { + t.Fatal("test providers do not share session keys") + } + sess := jw.NewSession(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "https://app.example/", nil)) + sess.Set(social.SessionKey, map[string]any{"iss": social.issuer, "sub": "person"}) + sess.Set(social.sessionKey(oauth2AuthExpiryKey), time.Now().Add(time.Hour)) + if social.CurrentClaims(sess) == nil || corp.CurrentClaims(sess) != nil { + t.Fatal("another provider accepted claims from the shared namespace") + } +} + +func TestCurrentClaimsAcceptsGoogleIssuerAlias(t *testing.T) { + jw := newTestJaws(t) + srv, err := New(jw, &Config{Issuer: "https://accounts.google.com"}, nil) + if err != nil { + t.Fatal(err) + } + sess := jw.NewSession(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "https://app.example/", nil)) + sess.Set(srv.SessionKey, map[string]any{"iss": "accounts.google.com", "sub": "person"}) + sess.Set(srv.sessionKey(oauth2AuthExpiryKey), time.Now().Add(time.Hour)) + if srv.CurrentClaims(sess) == nil { + t.Fatal("go-oidc Google issuer alias was rejected") + } +} From 6e5c988db806b69105c355c185983702a87a93ea Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Thu, 5 Nov 2026 05:36:09 +0100 Subject: [PATCH 37/43] fix(auth): refresh early and simplify callback handling --- README.md | 3 +++ authsession.go | 12 +++++++++--- authsession_test.go | 9 +++++---- oauth2.go | 15 ++++----------- oauth2_redirect_test.go | 1 - oauth2_test.go | 7 ++++++- 6 files changed, 27 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 508990d..e904093 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,9 @@ only with the same guarantee. Providers without a verification signal cannot grant admin access while the check is enabled. [Microsoft warns against using its email claim for authorization](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference); use application policy based on a stable identifier for Entra ID. +OIDC UserInfo fallback requires its `sub` to match the ID token's nonempty `sub`. Its +`email_verified` applies only to an email it supplies or one matching the ID +token email exactly. `mail` and `public_email` never count as verified. `SetAdmins` parses valid addresses and retains other nonempty entries. It trims whitespace and ignores ASCII case. An empty list allows every authenticated user diff --git a/authsession.go b/authsession.go index 00e7c5f..ce20b52 100644 --- a/authsession.go +++ b/authsession.go @@ -11,7 +11,7 @@ import ( "golang.org/x/oauth2" ) -const authRefreshSkew = 10 * time.Second +const authRefreshSkew = time.Minute const authSessionCheckInterval = time.Minute var errOIDCStaleIDToken = errors.New("oidc stale id_token") @@ -231,7 +231,7 @@ func (srv *Server) emitLoginEvent(sess *jaws.Session, hr *http.Request) { } srv.mu.Lock() pending := srv.loginEvents[sess] - if pending == nil || pending.cleared { + if pending == nil { delete(srv.loginEvents, sess) srv.mu.Unlock() return @@ -330,7 +330,13 @@ func (srv *Server) refreshSessionAuth(ctx context.Context, sess *jaws.Session, m // Caller holds srv.mu across the auth writes and timer replacement. func (srv *Server) scheduleSessionAuthTimerLocked(sess *jaws.Session, expiry time.Time) (delay time.Duration, replaced bool) { - delay = min(max(time.Until(srv.authTimerDue(expiry)), 0), authSessionCheckInterval) + remaining := time.Until(expiry) + delay = remaining + if srv.profileSubject == nil { + // Short-lived tokens wait half their remaining lifetime to avoid refresh loops. + delay = max(remaining-authRefreshSkew, remaining/2) + } + delay = min(max(delay, 0), authSessionCheckInterval) entry := &authTimerState{expiry: expiry} if srv.authTimers == nil { srv.authTimers = make(map[uint64]*authTimerState) diff --git a/authsession_test.go b/authsession_test.go index b7a8fe1..a14ec09 100644 --- a/authsession_test.go +++ b/authsession_test.go @@ -254,7 +254,7 @@ func TestStoreSessionAuthClaimsSchedulesTimer(t *testing.T) { srv := newTimerTestServer(t, jw, "https://issuer.example", factory) req := httptest.NewRequest(http.MethodGet, "http://example.com/protected", nil) sess := jw.NewSession(httptest.NewRecorder(), req) - expiry := time.Now().Add(time.Hour).Truncate(time.Second) + expiry := time.Now().Add(90 * time.Second) tokenSource := oauth2.StaticTokenSource(makeOAuth2Token("access", "", "")) err := srv.storeSessionAuthClaims(sess, map[string]any{ @@ -279,15 +279,16 @@ func TestStoreSessionAuthClaimsSchedulesTimer(t *testing.T) { t.Fatal(factory.len()) } timer := factory.timer(0) - if timer.delay <= 0 || timer.delay > time.Hour-authRefreshSkew { + if timer.delay < 40*time.Second || timer.delay > 50*time.Second { t.Fatal(timer.delay) } if !srv.sessionAuthTimerCurrent(sess, srv.authTimers[sess.ID()]) { t.Fatal("timer was not current") } + shortExpiry := time.Now().Add(time.Second) srv.mu.Lock() - srv.scheduleSessionAuthTimerLocked(sess, time.Now().Add(time.Second)) + srv.scheduleSessionAuthTimerLocked(sess, shortExpiry) srv.mu.Unlock() if factory.len() != 2 { t.Fatal(factory.len()) @@ -295,7 +296,7 @@ func TestStoreSessionAuthClaimsSchedulesTimer(t *testing.T) { if !timer.isStopped() { t.Fatal("old timer was not stopped") } - if delay := factory.timer(1).delay; delay != 0 { + if delay := factory.timer(1).delay; delay < time.Second/4 || delay > 3*time.Second/4 { t.Fatal(delay) } } diff --git a/oauth2.go b/oauth2.go index 1554413..69fcfd2 100644 --- a/oauth2.go +++ b/oauth2.go @@ -557,13 +557,6 @@ func (srv *Server) rotateOAuthSession(hw http.ResponseWriter, hr *http.Request, } func (srv *Server) completeLink(sess *jaws.Session, hr *http.Request, claims map[string]any, expiry time.Time, flow pendingOAuthFlow) (status int, err error) { - var linkErr error - // Log after releasing SessionRotationLock. - defer func() { - if linkErr != nil { - _ = srv.Jaws.Log(linkErr) - } - }() rotationLock := srv.SessionRotationLock if rotationLock != nil { rotationLock.Lock() @@ -575,8 +568,8 @@ func (srv *Server) completeLink(sess *jaws.Session, hr *http.Request, claims map if err = srv.identityExpiryError(expiry); err != nil { return http.StatusUnauthorized, err } - status, linkErr = srv.LinkEvent(sess, hr, claims) - if linkErr != nil || status != 0 { + status, err = srv.LinkEvent(sess, hr, claims) + if srv.Jaws.Log(err) != nil || status != 0 { if status < 400 || status > 599 { status = http.StatusForbidden } @@ -630,7 +623,7 @@ func (srv *Server) completeOAuthCallback(ctx context.Context, hw http.ResponseWr status := http.StatusBadGateway var retrieveErr *oauth2.RetrieveError if errors.As(err, &retrieveErr) && retrieveErr.Response != nil && - retrieveErr.Response.StatusCode >= 400 && retrieveErr.Response.StatusCode < 500 { + retrieveErr.Response.StatusCode == http.StatusBadRequest { status = http.StatusBadRequest } return sess, status, err @@ -690,7 +683,7 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) } } } - if sess != nil && err == nil && !flow.link { + if err == nil && !flow.link { srv.emitLoginEvent(sess, hr) current, _ := srv.sessionAuthStatus(sess, time.Now) if flow.revoked() || !current || !sessionAlive(sess) { diff --git a/oauth2_redirect_test.go b/oauth2_redirect_test.go index a300708..2c5bf58 100644 --- a/oauth2_redirect_test.go +++ b/oauth2_redirect_test.go @@ -62,7 +62,6 @@ func TestOAuthTokenRequestsDoNotFollowRedirect(t *testing.T) { ctx, cancel := context.WithCancel(t.Context()) defer cancel() if grant == "exchange" { - // The request context's client takes precedence over Server.httpClient. srv.httpClient = nil ctx = context.WithValue(ctx, oauth2.HTTPClient, client) } diff --git a/oauth2_test.go b/oauth2_test.go index 0f8832b..7a072db 100644 --- a/oauth2_test.go +++ b/oauth2_test.go @@ -799,6 +799,7 @@ func TestTokenExchangeFailureStatus(t *testing.T) { {"network failure", 0, errors.New("provider unavailable"), http.StatusBadGateway}, {"timeout", 0, context.DeadlineExceeded, http.StatusBadGateway}, {"provider bad request", http.StatusBadRequest, nil, http.StatusBadRequest}, + {"invalid client", http.StatusUnauthorized, nil, http.StatusBadGateway}, {"provider unavailable", http.StatusServiceUnavailable, nil, http.StatusBadGateway}, } { t.Run(tc.name, func(t *testing.T) { @@ -815,7 +816,11 @@ func TestTokenExchangeFailureStatus(t *testing.T) { w := httptest.NewRecorder() w.Header().Set("Content-Type", "application/json") w.WriteHeader(tc.providerStatus) - _, _ = io.WriteString(w, `{"error":"invalid_grant"}`) + errorCode := "invalid_grant" + if tc.providerStatus == http.StatusUnauthorized { + errorCode = "invalid_client" + } + _, _ = fmt.Fprintf(w, "{\"error\":%q}", errorCode) return w.Result(), nil })} cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) From 476fc8d1ad3776f58425f682a7d6835aae45ec8a Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Thu, 5 Nov 2026 06:14:15 +0100 Subject: [PATCH 38/43] fix(auth): stop old timer during login rotation --- README.md | 6 +++--- oauth2.go | 25 +++++++++++++------------ oauth2_flow_guards_test.go | 29 +++++++++++++++++++++++++++++ server.go | 4 ++-- 4 files changed, 47 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index e904093..894af9a 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,9 @@ login returns 503 with `Retry-After: 60` and preserves the existing session. register it. Authorize the session before calling it. In `LinkEvent`, recheck the original provider's `CurrentClaims` before writing the link. Linking keeps the session; an ordinary login rotates it and signs out other providers. - A re-login calls its `LogoutEvent` before storing the new sign-in; calling - `Logout` on that event session cancels the new sign-in. Other providers' - `LogoutEvent` may run later with a nil request. + A re-login replacing existing auth calls its `LogoutEvent` before storing + the new sign-in. Calling `Logout` on that event session cancels it. Other + providers' `LogoutEvent` may run later with a nil request. Starting a link replaces this server's pending flow, including a running login callback. - `New` shares one process-wide `SessionRotationLock` by default. To coordinate diff --git a/oauth2.go b/oauth2.go index 69fcfd2..650e0fe 100644 --- a/oauth2.go +++ b/oauth2.go @@ -544,16 +544,22 @@ func profileTokenUnexpired(token *oauth2.Token) bool { return token.Expiry.After(time.Now()) } -func (srv *Server) rotateOAuthSession(hw http.ResponseWriter, hr *http.Request, old *jaws.Session, flow pendingOAuthFlow) (rotated *jaws.Session, stale bool) { +func (srv *Server) rotateOAuthSession(hw http.ResponseWriter, hr *http.Request, old *jaws.Session, flow pendingOAuthFlow) (rotated *jaws.Session, hadTimer, stale bool) { rotationLock := srv.SessionRotationLock if rotationLock != nil { rotationLock.Lock() defer rotationLock.Unlock() } if flow.revoked() || srv.Jaws.GetSession(hr) != old { - return nil, true + return nil, false, true } - return srv.Jaws.NewSession(hw, hr), false + if rotated = srv.Jaws.NewSession(hw, hr); rotated != nil { + srv.mu.Lock() + hadTimer = srv.authTimers[old.ID()] != nil + srv.stopSessionAuthTimerLocked(old, nil) + srv.mu.Unlock() + } + return } func (srv *Server) completeLink(sess *jaws.Session, hr *http.Request, claims map[string]any, expiry time.Time, flow pendingOAuthFlow) (status int, err error) { @@ -580,7 +586,7 @@ func (srv *Server) completeLink(sess *jaws.Session, hr *http.Request, claims map func (srv *Server) completeLogin(hw http.ResponseWriter, hr *http.Request, old *jaws.Session, flow pendingOAuthFlow, claims map[string]any, tokenSource oauth2.TokenSource, expiry time.Time) (sess *jaws.Session, status int, err error) { sess, status, err = old, http.StatusServiceUnavailable, ErrOAuth2MissingSession - rotated, stale := srv.rotateOAuthSession(hw, hr, old, flow) + rotated, hadTimer, stale := srv.rotateOAuthSession(hw, hr, old, flow) if rotated == nil { if stale { status = http.StatusForbidden @@ -589,10 +595,6 @@ func (srv *Server) completeLogin(hw http.ResponseWriter, hr *http.Request, old * } return } - srv.mu.Lock() - hadTimer := srv.authTimers[old.ID()] != nil - srv.stopSessionAuthTimerLocked(old, nil) - srv.mu.Unlock() if hadTimer && srv.LogoutEvent != nil { srv.LogoutEvent(old, hr) } @@ -637,9 +639,6 @@ func (srv *Server) completeOAuthCallback(ctx context.Context, hw http.ResponseWr } else { sess, status, err = srv.completeLogin(hw, hr, sess, flow, claims, tokenSource, expiry) } - if err == nil { - hw.Header().Set("Location", flow.referrer) - } return sess, status, err } @@ -689,11 +688,13 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) if flow.revoked() || !current || !sessionAlive(sess) { srv.clearSessionAuth(sess, hr, nil, false) http.SetCookie(hw, sess.Close()) - hw.Header().Del("Location") err = ErrOAuth2MissingSession statusCode = http.StatusForbidden } } + if err == nil { + hw.Header().Set("Location", flow.referrer) + } if err != nil && srv.LoginFailed != nil { if srv.LoginFailed(hw, hr, statusCode, err, sessEmail) { return diff --git a/oauth2_flow_guards_test.go b/oauth2_flow_guards_test.go index a0b7ba7..dd31270 100644 --- a/oauth2_flow_guards_test.go +++ b/oauth2_flow_guards_test.go @@ -372,6 +372,35 @@ func (lock *revokeAfterRotationLock) Unlock() { } } +func TestReloginStopsOldTimerBeforeRotationUnlock(t *testing.T) { + jw := newTestJaws(t) + srv := newProfileTestServer(t, jw, nil) + factory := &testAuthTimerFactory{} + srv.authTimerAfterFunc = factory.after + cookie := profileLogin(t, srv) + oldRequest := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + oldRequest.AddCookie(cookie) + old := jw.GetSession(oldRequest) + if old == nil { + t.Fatal("old session missing") + } + _, state := profileStart(t, srv, http.MethodGet, "/auth/login", cookie) + timer := factory.timer(0) + var logoutRequest *http.Request + logouts := 0 + srv.LogoutEvent = func(sess *jaws.Session, hr *http.Request) { + if sess == old { + logouts++ + logoutRequest = hr + } + } + srv.SessionRotationLock = &revokeAfterRotationLock{logout: timer.fire} + response := profileCallback(srv, cookie, state, "second") + if response.Code != http.StatusFound || logouts != 1 || logoutRequest == nil || !timer.isStopped() { + t.Fatalf("re-login: status=%d logouts=%d request=%v timer_stopped=%t", response.Code, logouts, logoutRequest != nil, timer.isStopped()) + } +} + func TestCallbackRevokedAfterRotationClosesNewSession(t *testing.T) { jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) diff --git a/server.go b/server.go index a2e3b59..918a859 100644 --- a/server.go +++ b/server.go @@ -66,8 +66,8 @@ type HandleFunc func(uri string, handler http.Handler) // events; clearing auth during it defers LogoutEvent until it returns. // LogoutEvent receives a nil request for timer cleanup, deferred delivery, or // a call to [Server.Logout] with a nil request. -// During re-login, LogoutEvent runs on the old session before the new sign-in -// is stored; calling Logout on that session cancels the new sign-in. +// When re-login replaces existing auth, its LogoutEvent runs on the old session +// before the new sign-in is stored; calling Logout there cancels the new sign-in. type EventFunc func(sess *jaws.Session, hr *http.Request) // FailedFunc is called on callback failures and when a protected request meets From df67b3da57e5ce1b27a95a0604328fb9f9721dec Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Thu, 5 Nov 2026 06:58:55 +0100 Subject: [PATCH 39/43] refactor: simplify auth timer and clarify provider guidance --- README.md | 30 ++++++++++++++++-------------- authsession.go | 35 ++++++++++++++--------------------- authsession_test.go | 1 + doc.go | 2 +- oauth2.go | 29 +++++++++++++---------------- oauth2_test.go | 2 +- wrapper.go | 2 +- 7 files changed, 47 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 894af9a..d269bb8 100644 --- a/README.md +++ b/README.md @@ -31,21 +31,22 @@ authentication expires after 24 hours or the access token's earlier expiry. An explicit `expires_in: 0` in the token response is rejected. Set `Jaws.MaxSessions` and `Jaws.MaxSessionsPerIP` to bound sessions created by -unauthenticated requests; both limits are unlimited by default. A login attempt -can retain an anonymous session for about ten minutes while the user visits the -provider. Login rotates the session and discards its application data; rotation -needs a free slot under both limits. +unauthenticated requests; both limits are unlimited by default. An unauthenticated +protected GET starts a login flow and can retain its session for about ten minutes +while the user visits the provider. Login rotates the session and discards its +application data; rotation needs a free slot under both limits. `MaxSessionsPerIP = 1` prevents login. If a slot is unavailable, login returns 503 with `Retry-After: 60` and preserves the existing session. ## Multiple providers -- Give each `Config` a distinct `Namespace` and callback directory. Add each +- Providers sharing JaWS must have distinct `Namespace` values and callback + directories; a shared namespace can clear another provider's login. Add each provider's registered paths to the other servers' `HandledPaths` so return targets cannot enter another provider's auth endpoints. -- `New` replaces `Jaws.MakeAuth` when it configures authentication. When servers - share one JaWS, install a composite `MakeAuth` after constructing them so - templates use the intended provider's authentication and admin policy. Each +- `New` replaces `Jaws.MakeAuth`. When servers share JaWS, capture `jw.MakeAuth` + immediately after each `New`, then install a composite function so templates + use the intended provider's authentication and admin policy. Each server's `Wrap`, `Handler` and `CurrentClaims` check that provider's authentication. - Register `HandleLink` as a POST handler to enable linking; `New` does not @@ -63,9 +64,9 @@ login returns 503 with `Retry-After: 60` and preserves the existing session. jawsauth holds it during flow start, rotation, authentication clearing and `LinkEvent`. Do not take the lock around jawsauth handlers or `Server.Logout`, or within - `LinkEvent`; do not call `Server.Logout` from `LinkEvent`. Keep `LinkEvent` brief - because other sessions sharing the lock wait. One provider's logout does not - cancel another provider's pending link. + `LinkEvent`; do not call `Server.Logout` from `LinkEvent`. Bound `LinkEvent` I/O + with a timeout; other sessions sharing the lock wait. One provider's logout + does not cancel another provider's pending link. ## Admin access @@ -94,9 +95,10 @@ Pending OAuth flows expire after ten minutes. jawsauth extends the original JaWS session's grace while the user visits the provider. Unauthenticated protected GETs reuse a pending login's state, return target and grace deadline while more than two minutes remain; near expiry, they start a new flow and -extend grace. During a running callback they return 409 unless `LoginFailed` -handles them. Logout and auth expiry clear this server's credentials and reload -every JaWS page in the session. +extend grace. While a callback runs and before session rotation, protected GETs +with its session cookie return 409 unless `LoginFailed` handles them. After +rotation, the old cookie starts a new flow. Logout and auth expiry clear this +server's credentials and reload every JaWS page in the session. Auth expiry preserves a pending login but cancels a pending link. Explicit logout cancels either flow. A new login on the same server, including one started by `Wrap` or `Handler`, cancels a pending link. diff --git a/authsession.go b/authsession.go index ce20b52..c2f26f9 100644 --- a/authsession.go +++ b/authsession.go @@ -232,7 +232,6 @@ func (srv *Server) emitLoginEvent(sess *jaws.Session, hr *http.Request) { srv.mu.Lock() pending := srv.loginEvents[sess] if pending == nil { - delete(srv.loginEvents, sess) srv.mu.Unlock() return } @@ -376,6 +375,18 @@ func (srv *Server) stopSessionAuthTimerLocked(sess *jaws.Session, entry *authTim return true } +func (srv *Server) rearmSessionAuthTimer(sess *jaws.Session, entry *authTimerState, delay time.Duration) bool { + srv.mu.Lock() + defer srv.mu.Unlock() + if srv.authTimers[sess.ID()] != entry { + return false + } + entry.timer = srv.authTimerAfterFunc(delay, func() { + srv.handleSessionAuthTimer(sess, entry) + }) + return true +} + func (srv *Server) handleSessionAuthTimer(sess *jaws.Session, entry *authTimerState) { if srv.sessionAuthTimerCurrent(sess, entry) { remaining := time.Until(srv.authTimerDue(entry.expiry)) @@ -384,13 +395,7 @@ func (srv *Server) handleSessionAuthTimer(sess *jaws.Session, entry *authTimerSt return } if remaining > 0 { - srv.mu.Lock() - if srv.authTimers[sess.ID()] == entry { - entry.timer = srv.authTimerAfterFunc(min(remaining, authSessionCheckInterval), func() { - srv.handleSessionAuthTimer(sess, entry) - }) - } - srv.mu.Unlock() + srv.rearmSessionAuthTimer(sess, entry, min(remaining, authSessionCheckInterval)) return } current, present := srv.sessionAuthStatus(sess, time.Now) @@ -409,19 +414,7 @@ func (srv *Server) handleSessionAuthTimer(sess *jaws.Session, entry *authTimerSt current, present = srv.sessionAuthStatus(sess, time.Now) if current && present { retryDelay := max(time.Until(entry.expiry), 0) - retryScheduled := false - srv.mu.Lock() - if srv.authTimers[sess.ID()] == entry { - if entry.timer != nil { - entry.timer.Stop() - } - entry.timer = srv.authTimerAfterFunc(retryDelay, func() { - srv.handleSessionAuthTimer(sess, entry) - }) - retryScheduled = true - } - srv.mu.Unlock() - if retryScheduled { + if srv.rearmSessionAuthTimer(sess, entry, retryDelay) { srv.debugErrorLog("jawsauth: auth refresh timer failed; keeping current auth", err, "session_prefix", debugSessionPrefix(sess), "entry_expiry", authTimerEntryExpiry(entry), diff --git a/authsession_test.go b/authsession_test.go index a14ec09..ee10e99 100644 --- a/authsession_test.go +++ b/authsession_test.go @@ -37,6 +37,7 @@ func (timer *testAuthTimer) Stop() bool { func (timer *testAuthTimer) fire() { timer.mu.Lock() + timer.stopped = true callback := timer.callback timer.mu.Unlock() callback() diff --git a/doc.go b/doc.go index f7516ee..27de459 100644 --- a/doc.go +++ b/doc.go @@ -1,4 +1,4 @@ -// Package jawsauth provides verified OIDC and OAuth2 profile authentication for JaWS sessions. +// Package jawsauth provides OIDC and OAuth2 profile authentication for JaWS sessions. // // OIDC mode uses discovery, PKCE, nonce and ID token verification. Profile mode // uses explicit provider endpoints, PKCE and an authenticated profile response diff --git a/oauth2.go b/oauth2.go index 650e0fe..fd17c3a 100644 --- a/oauth2.go +++ b/oauth2.go @@ -154,7 +154,7 @@ func (srv *Server) returnTarget(hr *http.Request, location string) string { func (srv *Server) HandleLogin(hw http.ResponseWriter, hr *http.Request) { if hr.Method != http.MethodGet { hw.Header().Set("Allow", http.MethodGet) - srv.writeResult(hw, http.StatusMethodNotAllowed, nil, nil) + srv.writeResult(hw, http.StatusMethodNotAllowed, nil) return } srv.startOAuth(hw, hr, false, false) @@ -171,11 +171,11 @@ func (srv *Server) HandleLink(hw http.ResponseWriter, hr *http.Request) { switch { case hr.Method != http.MethodPost: hw.Header().Set("Allow", http.MethodPost) - srv.writeResult(hw, http.StatusMethodNotAllowed, nil, nil) + srv.writeResult(hw, http.StatusMethodNotAllowed, nil) case linkOriginCheck.Check(hr) != nil: - srv.writeResult(hw, http.StatusForbidden, ErrOAuth2InvalidOrigin, nil) + srv.writeResult(hw, http.StatusForbidden, ErrOAuth2InvalidOrigin) case srv.oauth2cfg == nil || srv.LinkEvent == nil: - srv.writeResult(hw, http.StatusInternalServerError, ErrOAuth2NotConfigured, nil) + srv.writeResult(hw, http.StatusInternalServerError, ErrOAuth2NotConfigured) default: srv.startOAuth(hw, hr, true, false) } @@ -210,7 +210,7 @@ func (srv *Server) startOAuth(hw http.ResponseWriter, hr *http.Request, link, fr case http.StatusServiceUnavailable: hw.Header().Set("Retry-After", "60") } - srv.writeResult(hw, status, failure, nil) + srv.writeResult(hw, status, failure) return } authOptions := append([]oauth2.AuthCodeOption{}, srv.Options...) @@ -293,14 +293,11 @@ func (srv *Server) HandleLogout(hw http.ResponseWriter, hr *http.Request) { hw.WriteHeader(statusCode) } -func writeBody(w io.Writer, statusCode int, err error, body []byte) { - const tmpl = `

%03d %s

%s

` - if body == nil { - if err != nil { - body = fmt.Appendf(nil, tmpl, statusCode, http.StatusText(statusCode), html.EscapeString(err.Error())) - } +func writeBody(w io.Writer, statusCode int, err error) { + if err != nil { + const tmpl = `

%03d %s

%s

` + _, _ = fmt.Fprintf(w, tmpl, statusCode, http.StatusText(statusCode), html.EscapeString(err.Error())) } - _, _ = w.Write(body) } // SetHeaders writes the HTTP response headers for all OAuth endpoint responses. @@ -315,10 +312,10 @@ func DefaultSetHeaders(hw http.ResponseWriter, ishttps bool) { hw.Header().Set("Cache-Control", "no-store") } -func (srv *Server) writeResult(hw http.ResponseWriter, statusCode int, err error, body []byte) { +func (srv *Server) writeResult(hw http.ResponseWriter, statusCode int, err error) { SetHeaders(hw, srv.ishttps) hw.WriteHeader(statusCode) - writeBody(hw, statusCode, err, body) + writeBody(hw, statusCode, err) } // ErrOAuth2NotConfigured means OAuth2/OIDC is not configured on the Server. @@ -656,7 +653,7 @@ func (srv *Server) completeOAuthCallback(ctx context.Context, hw http.ResponseWr func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) { if hr.Method != http.MethodGet { hw.Header().Set("Allow", http.MethodGet) - srv.writeResult(hw, http.StatusMethodNotAllowed, ErrOAuth2Callback, nil) + srv.writeResult(hw, http.StatusMethodNotAllowed, ErrOAuth2Callback) return } providerCtx, cancel := context.WithTimeout(hr.Context(), oauth2ProviderTimeout) @@ -700,5 +697,5 @@ func (srv *Server) HandleAuthResponse(hw http.ResponseWriter, hr *http.Request) return } } - srv.writeResult(hw, statusCode, err, nil) + srv.writeResult(hw, statusCode, err) } diff --git a/oauth2_test.go b/oauth2_test.go index 7a072db..5b7781c 100644 --- a/oauth2_test.go +++ b/oauth2_test.go @@ -70,7 +70,7 @@ func makeIDToken(t *testing.T, claims map[string]any) string { func Test_errtext(t *testing.T) { var sb strings.Builder - writeBody(&sb, http.StatusForbidden, ErrOAuth2WrongState, nil) + writeBody(&sb, http.StatusForbidden, ErrOAuth2WrongState) s := sb.String() if !strings.Contains(s, "403 Forbidden") { t.Fatal() diff --git a/wrapper.go b/wrapper.go index bf8a808..005573b 100644 --- a/wrapper.go +++ b/wrapper.go @@ -18,7 +18,7 @@ func (w wrapper) ServeHTTP(hw http.ResponseWriter, hr *http.Request) { w.server.clearSessionAuth(sess, hr, nil, true) } if hr.Method != http.MethodGet { - w.server.writeResult(hw, http.StatusForbidden, nil, nil) + w.server.writeResult(hw, http.StatusForbidden, nil) return } w.server.startOAuth(hw, hr, false, true) From 05fb2cc1aefd595489499aaa32f60ba91c2cb489 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Thu, 5 Nov 2026 07:06:48 +0100 Subject: [PATCH 40/43] test: cover stale auth timer and clarify session guidance --- README.md | 31 ++++++++++++++++--------------- authsession_test.go | 38 ++++++++++++++++++++++++++++++-------- oauth2.go | 12 ++++-------- oauth2_flow_guards_test.go | 10 +++++++--- oauth2_test.go | 8 ++++---- profile_link_test.go | 4 ++-- server.go | 5 +++-- 7 files changed, 66 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index d269bb8..5263cd5 100644 --- a/README.md +++ b/README.md @@ -31,10 +31,10 @@ authentication expires after 24 hours or the access token's earlier expiry. An explicit `expires_in: 0` in the token response is rejected. Set `Jaws.MaxSessions` and `Jaws.MaxSessionsPerIP` to bound sessions created by -unauthenticated requests; both limits are unlimited by default. An unauthenticated -protected GET starts a login flow and can retain its session for about ten minutes -while the user visits the provider. Login rotates the session and discards its -application data; rotation needs a free slot under both limits. +unauthenticated requests; both limits are unlimited by default. A login start, +including an unauthenticated protected GET, can retain its session for about ten +minutes even if the client never reaches the provider. Login rotates the session +and discards its application data; rotation needs a free slot under both limits. `MaxSessionsPerIP = 1` prevents login. If a slot is unavailable, login returns 503 with `Retry-After: 60` and preserves the existing session. @@ -44,9 +44,10 @@ login returns 503 with `Retry-After: 60` and preserves the existing session. directories; a shared namespace can clear another provider's login. Add each provider's registered paths to the other servers' `HandledPaths` so return targets cannot enter another provider's auth endpoints. -- `New` replaces `Jaws.MakeAuth`. When servers share JaWS, capture `jw.MakeAuth` - immediately after each `New`, then install a composite function so templates - use the intended provider's authentication and admin policy. Each +- `New` replaces `Jaws.MakeAuth` when it configures authentication. When servers + share JaWS, capture `jw.MakeAuth` immediately after each configured `New`, then + install a composite function so templates use the intended provider's + authentication and admin policy. Each server's `Wrap`, `Handler` and `CurrentClaims` check that provider's authentication. - Register `HandleLink` as a POST handler to enable linking; `New` does not @@ -92,16 +93,16 @@ method proves a live login; check `CurrentClaims` before protected actions. ## Session lifetime Pending OAuth flows expire after ten minutes. jawsauth extends the original -JaWS session's grace while the user visits the provider. Unauthenticated +JaWS session's grace for the pending flow. Unauthenticated protected GETs reuse a pending login's state, return target and grace deadline while more than two minutes remain; near expiry, they start a new flow and -extend grace. While a callback runs and before session rotation, protected GETs -with its session cookie return 409 unless `LoginFailed` handles them. After -rotation, the old cookie starts a new flow. Logout and auth expiry clear this -server's credentials and reload every JaWS page in the session. -Auth expiry preserves a pending login but cancels a pending link. Explicit -logout cancels either flow. A new login on the same server, including one -started by `Wrap` or `Handler`, cancels a pending link. +extend grace. While a login callback runs before session rotation, protected GETs +with that session cookie return 409 unless `LoginFailed` handles them. After +rotation, the old cookie starts a new flow and cannot cancel that sign-in. Logout +and auth expiry clear this server's credentials and reload every JaWS page in the +session. Auth expiry preserves a pending login but cancels a pending link. Explicit +logout on the live session cancels either flow. A new login on the same server, +including one started by `Wrap` or `Handler`, cancels a pending link. Use an HTTP endpoint for a post-logout redirect. When `Server.Logout` clears authentication, it reloads pages and closes their sockets, so a later JaWS diff --git a/authsession_test.go b/authsession_test.go index ee10e99..1fdcf2c 100644 --- a/authsession_test.go +++ b/authsession_test.go @@ -716,8 +716,7 @@ func testAuthTimerRefreshFailureKeepsCurrentAuthUntilExpiry(t *testing.T) { t.Fatal(err) } - firstTimer := factory.timer(0) - firstTimer.fire() + factory.timer(0).fire() if logoutCount != 0 { t.Fatal(logoutCount) @@ -732,9 +731,6 @@ func testAuthTimerRefreshFailureKeepsCurrentAuthUntilExpiry(t *testing.T) { if !srv.sessionAuthTimerCurrent(sess, srv.authTimers[sess.ID()]) { t.Fatal("current timer missing") } - if !firstTimer.isStopped() { - t.Fatal("old timer was not stopped") - } if factory.len() != 2 { t.Fatal(factory.len()) } @@ -835,9 +831,6 @@ func TestAuthTimerRefreshFailureClearsExpiredAuth(t *testing.T) { if srv.authTimers[sess.ID()] != nil { t.Fatal("timer was not removed") } - if !factory.timer(0).isStopped() { - t.Fatal("timer was not stopped") - } if tc.tokenBody == "" && tokenRequests != 0 { t.Fatal(tokenRequests) } @@ -1018,6 +1011,35 @@ func TestAuthTimerStaleAfterRefreshNoOp(t *testing.T) { } } +func TestAuthTimerFailedRefreshDoesNotRearmReplacedAuth(t *testing.T) { + jw := newTestJaws(t) + factory := &testAuthTimerFactory{} + srv := newTimerTestServer(t, jw, "https://issuer.example", factory) + sess := jw.NewSession(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) + newExpiry := time.Now().Add(time.Hour) + source := tokenSourceFunc(func() (*oauth2.Token, error) { + if err := srv.storeSessionAuthClaims(sess, map[string]any{"email": "new@example.com"}, nil, newExpiry, nil); err != nil { + t.Fatal(err) + } + return nil, errAuthSessionTestToken + }) + if err := srv.storeSessionAuthClaims(sess, map[string]any{"email": "old@example.com"}, source, time.Now().Add(authRefreshSkew-time.Second), nil); err != nil { + t.Fatal(err) + } + old := srv.authTimers[sess.ID()] + factory.timer(0).fire() + current := srv.authTimers[sess.ID()] + if factory.len() != 2 { + t.Fatal(factory.len()) + } + if current == nil || current == old || current.timer != factory.timer(1) || factory.timer(1).isStopped() { + t.Fatalf("stale refresh changed timers: current=%p old=%p count=%d", current, old, factory.len()) + } + if got := sess.Get(srv.SessionEmailKey); got != "new@example.com" { + t.Fatal(got) + } +} + func TestExplicitLogoutStopsAuthTimer(t *testing.T) { jw := newTestJaws(t) diff --git a/oauth2.go b/oauth2.go index fd17c3a..05e10e4 100644 --- a/oauth2.go +++ b/oauth2.go @@ -293,13 +293,6 @@ func (srv *Server) HandleLogout(hw http.ResponseWriter, hr *http.Request) { hw.WriteHeader(statusCode) } -func writeBody(w io.Writer, statusCode int, err error) { - if err != nil { - const tmpl = `

%03d %s

%s

` - _, _ = fmt.Fprintf(w, tmpl, statusCode, http.StatusText(statusCode), html.EscapeString(err.Error())) - } -} - // SetHeaders writes the HTTP response headers for all OAuth endpoint responses. // // It defaults to DefaultSetHeaders and may be reassigned at startup to customize @@ -315,7 +308,10 @@ func DefaultSetHeaders(hw http.ResponseWriter, ishttps bool) { func (srv *Server) writeResult(hw http.ResponseWriter, statusCode int, err error) { SetHeaders(hw, srv.ishttps) hw.WriteHeader(statusCode) - writeBody(hw, statusCode, err) + if err != nil { + const tmpl = `

%03d %s

%s

` + _, _ = fmt.Fprintf(hw, tmpl, statusCode, http.StatusText(statusCode), html.EscapeString(err.Error())) + } } // ErrOAuth2NotConfigured means OAuth2/OIDC is not configured on the Server. diff --git a/oauth2_flow_guards_test.go b/oauth2_flow_guards_test.go index dd31270..fd25e0b 100644 --- a/oauth2_flow_guards_test.go +++ b/oauth2_flow_guards_test.go @@ -394,10 +394,14 @@ func TestReloginStopsOldTimerBeforeRotationUnlock(t *testing.T) { logoutRequest = hr } } - srv.SessionRotationLock = &revokeAfterRotationLock{logout: timer.fire} + var stoppedBeforeFire bool + srv.SessionRotationLock = &revokeAfterRotationLock{logout: func() { + stoppedBeforeFire = timer.isStopped() + timer.fire() + }} response := profileCallback(srv, cookie, state, "second") - if response.Code != http.StatusFound || logouts != 1 || logoutRequest == nil || !timer.isStopped() { - t.Fatalf("re-login: status=%d logouts=%d request=%v timer_stopped=%t", response.Code, logouts, logoutRequest != nil, timer.isStopped()) + if response.Code != http.StatusFound || logouts != 1 || logoutRequest == nil || !stoppedBeforeFire { + t.Fatalf("re-login: status=%d logouts=%d request=%v timer_stopped_before_fire=%t", response.Code, logouts, logoutRequest != nil, stoppedBeforeFire) } } diff --git a/oauth2_test.go b/oauth2_test.go index 5b7781c..f81fba3 100644 --- a/oauth2_test.go +++ b/oauth2_test.go @@ -68,10 +68,10 @@ func makeIDToken(t *testing.T, claims map[string]any) string { return header + "." + payload + "." + signature } -func Test_errtext(t *testing.T) { - var sb strings.Builder - writeBody(&sb, http.StatusForbidden, ErrOAuth2WrongState) - s := sb.String() +func TestWriteResultError(t *testing.T) { + hw := httptest.NewRecorder() + (&Server{}).writeResult(hw, http.StatusForbidden, ErrOAuth2WrongState) + s := hw.Body.String() if !strings.Contains(s, "403 Forbidden") { t.Fatal() } diff --git a/profile_link_test.go b/profile_link_test.go index 762d830..7625e87 100644 --- a/profile_link_test.go +++ b/profile_link_test.go @@ -1077,7 +1077,7 @@ func TestOAuthProfileExpiresWithoutRefresh(t *testing.T) { srv.authTimers[sess.ID()].expiry = time.Now().Add(-time.Second) srv.mu.Unlock() factory.timer(1).fire() - if sess.Get(srv.SessionKey) != nil || !factory.timer(1).isStopped() { + if sess.Get(srv.SessionKey) != nil { t.Fatal("profile auth remained after expiry timer") } } @@ -1097,7 +1097,7 @@ func TestOAuthProfileTimerReleasesClosedSession(t *testing.T) { sess := jw.GetSession(r) sess.Close() factory.timer(0).fire() - if sess.Get(srv.SessionKey) != nil || len(srv.authTimers) != 0 || !factory.timer(0).isStopped() { + if sess.Get(srv.SessionKey) != nil || len(srv.authTimers) != 0 { t.Fatal("closed profile session remained pinned by auth timer") } } diff --git a/server.go b/server.go index 918a859..199fd65 100644 --- a/server.go +++ b/server.go @@ -71,7 +71,8 @@ type HandleFunc func(uri string, handler http.Handler) type EventFunc func(sess *jaws.Session, hr *http.Request) // FailedFunc is called on callback failures and when a protected request meets -// a running login callback. Other flow-start failures write their own response. +// a running login callback before session rotation. Other flow-start failures +// write their own response. // // It returns true if it wrote the HTTP response itself, in which case jawsauth writes // nothing further. Errors can contain untrusted browser-supplied text. Quote errors @@ -102,7 +103,7 @@ type Server struct { HandledPaths map[string]struct{} // URI paths excluded from OAuth return targets LoginEvent EventFunc // called after claims are stored; revocation may suppress or follow it LogoutEvent EventFunc // auth-clear callback; see EventFunc for ordering and nil requests - LoginFailed FailedFunc // callback failures and protected requests during a running login callback + LoginFailed FailedFunc // callback failures and protected requests during a login callback before rotation LinkEvent LinkFunc // required by HandleLink; register its POST route separately // SessionRotationLock is held during flow start, rotation, auth clearing, and LinkEvent. // New supplies a shared package lock; set the same custom lock on servers using From e54221370151ed81dc84effbaa9532fd02e0f36f Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Thu, 5 Nov 2026 08:05:43 +0100 Subject: [PATCH 41/43] test: cover linking revocation and callback limits --- README.md | 3 ++- authsession_test.go | 37 ++++++++++++++++++++++++++ currentclaims_test.go | 4 +++ oauth2.go | 3 ++- oauth2_flow_guards_test.go | 7 +++-- oauth2_test.go | 6 ++++- profile_link_test.go | 53 +++++++++++++++++++++++++++++++++++++- server.go | 2 ++ sessionlifecycle_test.go | 14 ++++------ 9 files changed, 114 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 5263cd5..7325eac 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,8 @@ login returns 503 with `Retry-After: 60` and preserves the existing session. the session; an ordinary login rotates it and signs out other providers. A re-login replacing existing auth calls its `LogoutEvent` before storing the new sign-in. Calling `Logout` on that event session cancels it. Other - providers' `LogoutEvent` may run later with a nil request. + providers' `LogoutEvent` may run later with a nil request. Before using one to + log out another provider, check its `CurrentClaims`; the session may have rotated. Starting a link replaces this server's pending flow, including a running login callback. - `New` shares one process-wide `SessionRotationLock` by default. To coordinate diff --git a/authsession_test.go b/authsession_test.go index 1fdcf2c..d3dd8c5 100644 --- a/authsession_test.go +++ b/authsession_test.go @@ -249,6 +249,10 @@ func newTimerTestServer(t *testing.T, jw *jaws.Jaws, issuer string, factory *tes } func TestStoreSessionAuthClaimsSchedulesTimer(t *testing.T) { + synctest.Test(t, testStoreSessionAuthClaimsSchedulesTimer) +} + +func testStoreSessionAuthClaimsSchedulesTimer(t *testing.T) { jw := newTestJaws(t) factory := &testAuthTimerFactory{} @@ -350,6 +354,26 @@ func TestStoreSessionAuthClaimsErrors(t *testing.T) { } } +func TestRefreshDoesNotQueueLoginEvent(t *testing.T) { + jw := newTestJaws(t) + srv := newTimerTestServer(t, jw, "https://issuer.example", &testAuthTimerFactory{}) + sess := jw.NewSession(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) + expiry := time.Now().Add(time.Hour) + if err := srv.storeSessionAuthClaims(sess, map[string]any{"sub": "one"}, nil, expiry, nil); err != nil { + t.Fatal(err) + } + entry := srv.authTimers[sess.ID()] + logins, logouts := 0, 0 + srv.LoginEvent = func(*jaws.Session, *http.Request) { logins++ } + srv.LogoutEvent = func(*jaws.Session, *http.Request) { logouts++ } + if err := srv.storeSessionAuthClaims(sess, map[string]any{"sub": "one"}, nil, expiry, entry); err != nil { + t.Fatal(err) + } + if !srv.Logout(sess, nil) || logins != 0 || logouts != 1 { + t.Fatalf("refresh and logout: logins=%d logouts=%d", logins, logouts) + } +} + func TestSetSessionAuthFromTokenErrors(t *testing.T) { jw := newTestJaws(t) @@ -1173,6 +1197,19 @@ func TestServerLogout(t *testing.T) { } } +func TestLogoutWithoutTimerReportsEvent(t *testing.T) { + jw := newTestJaws(t) + srv := newTimerTestServer(t, jw, "https://issuer.example", &testAuthTimerFactory{}) + sess := jw.NewSession(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) + sess.Set(srv.SessionKey, map[string]any{"iss": srv.issuer, "sub": "one"}) + sess.Set(srv.sessionKey(oauth2AuthExpiryKey), time.Now().Add(time.Hour)) + logouts := 0 + srv.LogoutEvent = func(*jaws.Session, *http.Request) { logouts++ } + if !srv.Logout(sess, nil) || logouts != 1 || sess.Get(srv.SessionKey) != nil { + t.Fatalf("logout without timer: events=%d claims=%v", logouts, sess.Get(srv.SessionKey)) + } +} + func TestReloginAfterSessionClearReportsLogout(t *testing.T) { jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) diff --git a/currentclaims_test.go b/currentclaims_test.go index ea785bb..83d16d9 100644 --- a/currentclaims_test.go +++ b/currentclaims_test.go @@ -60,6 +60,10 @@ func TestCurrentClaimsRejectsOtherIssuerWithSameNamespace(t *testing.T) { if social.CurrentClaims(sess) == nil || corp.CurrentClaims(sess) != nil { t.Fatal("another provider accepted claims from the shared namespace") } + sess.Set(social.SessionKey, "not claims") + if social.CurrentClaims(sess) != nil { + t.Fatal("non-map claims were accepted") + } } func TestCurrentClaimsAcceptsGoogleIssuerAlias(t *testing.T) { diff --git a/oauth2.go b/oauth2.go index 05e10e4..5483b4d 100644 --- a/oauth2.go +++ b/oauth2.go @@ -310,7 +310,8 @@ func (srv *Server) writeResult(hw http.ResponseWriter, statusCode int, err error hw.WriteHeader(statusCode) if err != nil { const tmpl = `

%03d %s

%s

` - _, _ = fmt.Fprintf(hw, tmpl, statusCode, http.StatusText(statusCode), html.EscapeString(err.Error())) + body := fmt.Appendf(nil, tmpl, statusCode, http.StatusText(statusCode), html.EscapeString(err.Error())) + _, _ = hw.Write(body) } } diff --git a/oauth2_flow_guards_test.go b/oauth2_flow_guards_test.go index fd25e0b..a8394b0 100644 --- a/oauth2_flow_guards_test.go +++ b/oauth2_flow_guards_test.go @@ -209,6 +209,7 @@ func TestLinkRejectsInvalidEventResults(t *testing.T) { {"error without status", 0, errors.New("secret persistence detail"), http.StatusForbidden}, {"success status without acceptance", http.StatusOK, nil, http.StatusForbidden}, {"conflict status", http.StatusConflict, errors.New("secret persistence detail"), http.StatusConflict}, + {"out of range status", 600, errors.New("secret persistence detail"), http.StatusForbidden}, } { t.Run(tc.name, func(t *testing.T) { jw := newTestJaws(t) @@ -421,14 +422,16 @@ func TestCallbackRevokedAfterRotationClosesNewSession(t *testing.T) { t.Fatal("old session missing") } _, state = profileStart(t, srv, http.MethodGet, "/auth/login", cookie) + logins := 0 + srv.LoginEvent = func(*jaws.Session, *http.Request) { logins++ } revoked := false srv.SessionRotationLock = &revokeAfterRotationLock{logout: func() { revoked = srv.Logout(old, nil) }} w := profileCallback(srv, cookie, state, "second") cookies := w.Result().Cookies() - if !revoked || w.Code != http.StatusForbidden || w.Header().Get("Location") != "" || + if !revoked || logins != 0 || w.Code != http.StatusForbidden || w.Header().Get("Location") != "" || len(cookies) != 2 || cookies[1].MaxAge >= 0 || cookies[1].Value != cookies[0].Value { - t.Fatalf("revoked after rotation: revoked=%t status=%d location=%q cookies=%v", revoked, w.Code, w.Header().Get("Location"), cookies) + t.Fatalf("revoked after rotation: revoked=%t logins=%d status=%d location=%q cookies=%v", revoked, logins, w.Code, w.Header().Get("Location"), cookies) } } diff --git a/oauth2_test.go b/oauth2_test.go index f81fba3..eaaf333 100644 --- a/oauth2_test.go +++ b/oauth2_test.go @@ -867,7 +867,11 @@ func TestServer_fetchUserInfoUsesClientTimeoutAsRequestDeadline(t *testing.T) { Timeout: timeout, Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { requestDeadline, _ = r.Context().Deadline() - <-r.Context().Done() + select { + case <-r.Context().Done(): + case <-time.After(time.Second): + return nil, errors.New("UserInfo request did not time out") + } return nil, r.Context().Err() }), } diff --git a/profile_link_test.go b/profile_link_test.go index 7625e87..03b317f 100644 --- a/profile_link_test.go +++ b/profile_link_test.go @@ -398,6 +398,47 @@ func TestOAuthProfileLinkCanceledByLogout(t *testing.T) { } } +func TestOAuthProfileAuthExpiryCancelsRunningLink(t *testing.T) { + jw := newTestJaws(t) + srv := newProfileTestServer(t, jw, nil) + factory := &testAuthTimerFactory{} + srv.authTimerAfterFunc = factory.after + cookie := profileLogin(t, srv) + entered := make(chan struct{}) + release := make(chan struct{}) + releaseProvider := sync.OnceFunc(func() { close(release) }) + defer releaseProvider() + transport := srv.httpClient.Transport + srv.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Path == "/token" { + close(entered) + <-release + } + return transport.RoundTrip(r) + })} + links := 0 + srv.LinkEvent = func(*jaws.Session, *http.Request, map[string]any) (int, error) { links++; return 0, nil } + _, state := profileStart(t, srv, http.MethodPost, "/auth/link/github", cookie) + result := make(chan *httptest.ResponseRecorder, 1) + go func() { result <- profileCallback(srv, cookie, state, "second") }() + waitFor(t, entered, "link callback did not reach provider") + req := httptest.NewRequest(http.MethodGet, "https://app.example/account", nil) + req.AddCookie(cookie) + sess := jw.GetSession(req) + if sess == nil { + t.Fatal("authenticated session missing") + } + srv.mu.Lock() + srv.authTimers[sess.ID()].expiry = time.Now().Add(-time.Second) + srv.mu.Unlock() + factory.timer(0).fire() + releaseProvider() + response := waitFor(t, result, "expired link callback did not finish") + if response.Code != http.StatusForbidden || links != 0 || sess.Get(srv.SessionKey) != nil { + t.Fatalf("expired link: status=%d links=%d claims=%v", response.Code, links, sess.Get(srv.SessionKey)) + } +} + func TestOAuthProfileLinkRejectsReplacedSession(t *testing.T) { jw := newTestJaws(t) srv := newProfileTestServer(t, jw, nil) @@ -483,6 +524,9 @@ func TestOAuthProfileLinkSerializesManagedRevocation(t *testing.T) { } close(checked) <-release + if sess.Get(srv.SessionKey) == nil { + return http.StatusForbidden, ErrOAuth2MissingSession + } writes++ // durable application write return 0, nil } @@ -504,6 +548,7 @@ func TestOAuthProfileLinkSerializesManagedRevocation(t *testing.T) { } if revocationFirst { revoke() + releaseEvent() response := profileCallback(srv, cookie, linkState, "second") if response.Code != http.StatusBadRequest || writes != 0 || logoutEvents.Load() != 1 { t.Fatalf("revocation won: status=%d writes=%d logout events=%d", response.Code, writes, logoutEvents.Load()) @@ -611,6 +656,7 @@ func TestOAuthProfileAuthExpiryPreservesOrdinaryLogin(t *testing.T) { } return false } + defer func() { srv.LoginFailed = nil }() conflict := httptest.NewRecorder() srv.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("pending callback reached protected handler") @@ -626,6 +672,7 @@ func TestOAuthProfileAuthExpiryPreservesOrdinaryLogin(t *testing.T) { if handled.Code != http.StatusAccepted || handled.Body.String() != "custom response" || failedCalls != 2 { t.Fatalf("handled callback: status=%d body=%q failed_calls=%d", handled.Code, handled.Body.String(), failedCalls) } + srv.LoginFailed = nil } var response *httptest.ResponseRecorder if phase == "callback" { @@ -946,13 +993,14 @@ func TestOAuthProfileUserInfoCallbackDeadline(t *testing.T) { var userinfoErr error srv.httpClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { if r.URL.Path == "/token" { + time.Sleep(10 * time.Second) return jsonResponse(`{"access_token":"access","token_type":"Bearer"}`), nil } select { case <-r.Context().Done(): userinfoErr = r.Context().Err() return nil, userinfoErr - case <-time.After(oauth2ProviderTimeout + time.Second): + case <-time.After(6 * time.Second): userinfoErr = errors.New("UserInfo missed callback deadline") return nil, userinfoErr } @@ -1073,6 +1121,9 @@ func TestOAuthProfileExpiresWithoutRefresh(t *testing.T) { if sess.Get(srv.SessionKey) == nil || factory.len() != 2 { t.Fatal("profile auth cleared before its expiry") } + if delay := factory.timer(1).delay; delay <= 0 || delay > authSessionCheckInterval { + t.Fatalf("profile auth rearm interval: %s", delay) + } srv.mu.Lock() srv.authTimers[sess.ID()].expiry = time.Now().Add(-time.Second) srv.mu.Unlock() diff --git a/server.go b/server.go index 199fd65..d40fa6e 100644 --- a/server.go +++ b/server.go @@ -68,6 +68,8 @@ type HandleFunc func(uri string, handler http.Handler) // a call to [Server.Logout] with a nil request. // When re-login replaces existing auth, its LogoutEvent runs on the old session // before the new sign-in is stored; calling Logout there cancels the new sign-in. +// For cross-provider cleanup, check the other Server's CurrentClaims before +// calling Logout on the old session; that call can revoke a running sign-in. type EventFunc func(sess *jaws.Session, hr *http.Request) // FailedFunc is called on callback failures and when a protected request meets diff --git a/sessionlifecycle_test.go b/sessionlifecycle_test.go index 0b715ed..c2b4d89 100644 --- a/sessionlifecycle_test.go +++ b/sessionlifecycle_test.go @@ -52,10 +52,7 @@ func TestAuthTimerDiscardsClosedSession(t *testing.T) { func testAuthTimerDiscardsClosedSession(t *testing.T) { for _, duringRefresh := range []bool{false, true} { - jw, err := jaws.New() - if err != nil { - t.Fatal(err) - } + jw := newTestJaws(t) factory := &testAuthTimerFactory{} srv := newTimerTestServer(t, jw, "https://issuer.example", factory) req := httptest.NewRequest(http.MethodGet, "/", nil) @@ -68,7 +65,7 @@ func testAuthTimerDiscardsClosedSession(t *testing.T) { sess.Close() return makeOAuth2Token("access", raw, "refresh"), nil }) - if err = srv.storeSessionAuthClaims(sess, map[string]any{"email": "user@example.com"}, source, expiry, nil); err != nil { + if err := srv.storeSessionAuthClaims(sess, map[string]any{"email": "user@example.com"}, source, expiry, nil); err != nil { t.Fatal(err) } if !duringRefresh { @@ -83,11 +80,10 @@ func testAuthTimerDiscardsClosedSession(t *testing.T) { t.Fatalf("calls=%d timers=%d scheduled=%d", calls, len(srv.authTimers), factory.len()) } assertWrapperAuthCleared(t, srv, sess) - jw.Close() } } -func TestOIDCTimerReleasesSessionRotatedByOtherProvider(t *testing.T) { +func TestOIDCTimerReleasesRotatedSession(t *testing.T) { jw := newTestJaws(t) factory := &testAuthTimerFactory{} srv := newTimerTestServer(t, jw, "https://issuer.example", factory) @@ -114,7 +110,7 @@ func TestOIDCTimerReleasesSessionRotatedByOtherProvider(t *testing.T) { } factory.timer(1).fire() if len(srv.authTimers) != 0 { - t.Fatal("other provider retained the rotated session") + t.Fatal("OIDC timer retained the rotated session") } } @@ -259,7 +255,7 @@ func TestLoginRotationExistingAuth(t *testing.T) { rec := httptest.NewRecorder() srv.HandleAuthResponse(rec, req) if limit == 1 { - if rec.Code != http.StatusServiceUnavailable || rec.Header().Get("Location") != "" { + if rec.Code != http.StatusServiceUnavailable || rec.Header().Get("Location") != "" || rec.Header().Get("Retry-After") != "60" { t.Fatalf("status=%d headers=%v", rec.Code, rec.Header()) } if jw.GetSession(req) != sess || !srv.sessionAuthTimerCurrent(sess, entry) || sess.Get(srv.SessionEmailKey) != "existing@example.com" { From fc41b6837654d18cb2b5a76d34e9f2d508c56769 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Thu, 5 Nov 2026 08:40:23 +0100 Subject: [PATCH 42/43] fix: bind OAuth flows to originating server --- authsession_test.go | 1 + namespace_test.go | 58 ++++++++++++++++++++++++++++++++---- oauth2.go | 6 ++-- oauth2_callback_edge_test.go | 4 +-- oauth2_test.go | 23 +++++++------- profile_link_test.go | 2 +- sessionlifecycle_test.go | 4 +-- 7 files changed, 75 insertions(+), 23 deletions(-) diff --git a/authsession_test.go b/authsession_test.go index d3dd8c5..8ab0acd 100644 --- a/authsession_test.go +++ b/authsession_test.go @@ -1018,6 +1018,7 @@ func TestAuthTimerStaleAfterRefreshNoOp(t *testing.T) { }) err := srv.storeSessionAuthClaims(sess, map[string]any{ "exp": oldExpiry.Unix(), + "sub": "sub-123", "email": "old@example.com", }, tokenSource, oldExpiry, nil) if err != nil { diff --git a/namespace_test.go b/namespace_test.go index d231047..c463019 100644 --- a/namespace_test.go +++ b/namespace_test.go @@ -20,11 +20,8 @@ func testOAuthFlow(sess *jaws.Session, srv *Server) pendingOAuthFlow { } func setTestOAuthFlow(sess *jaws.Session, srv *Server, flow pendingOAuthFlow) { - key := oauth2FlowKey - if srv != nil { - key = srv.sessionKey(key) - } - sess.Set(key, flow) + flow.owner = srv + sess.Set(srv.sessionKey(oauth2FlowKey), flow) } func TestOAuthFlowNamespacesShareSessionWithoutSharingState(t *testing.T) { @@ -95,3 +92,54 @@ func TestOAuthFlowNamespacesShareSessionWithoutSharingState(t *testing.T) { t.Fatal("github flow remained after consumption") } } + +func TestOAuthFlowDefaultNamespaceRejectsForeignServer(t *testing.T) { + jw := newTestJaws(t) + newServer := func(clientID string) *Server { + srv, err := New(jw, &Config{}, nil) + if err != nil { + t.Fatal(err) + } + srv.oauth2cfg = &oauth2.Config{ + ClientID: clientID, + RedirectURL: "https://app.example/auth/" + clientID + "/callback", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://" + clientID + ".example/authorize", + TokenURL: "https://" + clientID + ".example/token", + }, + } + return srv + } + github, gitlab := newServer("github"), newServer("gitlab") + loginRequest := httptest.NewRequest(http.MethodGet, "https://app.example/auth/gitlab/login", nil) + login := httptest.NewRecorder() + gitlab.HandleLogin(login, loginRequest) + if login.Code != http.StatusFound { + t.Fatalf("gitlab start: %d", login.Code) + } + sess := jw.GetSession(loginRequest) + flow := testOAuthFlow(sess, gitlab) + if flow.state == "" { + t.Fatal("gitlab state missing") + } + exchanges := 0 + github.httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + exchanges++ + return nil, errors.New("unexpected token exchange") + })} + callbackRequest := httptest.NewRequest(http.MethodGet, "https://app.example/auth/github/callback?code=foreign&state="+flow.state, nil) + for _, cookie := range login.Result().Cookies() { + callbackRequest.AddCookie(cookie) + } + callback := httptest.NewRecorder() + github.HandleAuthResponse(callback, callbackRequest) + if callback.Code != http.StatusBadRequest || exchanges != 0 { + t.Fatalf("foreign callback: status %d, token exchanges %d", callback.Code, exchanges) + } + if got := testOAuthFlow(sess, gitlab); got != flow { + t.Fatal("foreign callback changed gitlab flow") + } + if _, err := gitlab.takeOAuthFlow(sess, flow.state); err != nil { + t.Fatalf("gitlab lost its own state: %v", err) + } +} diff --git a/oauth2.go b/oauth2.go index 5483b4d..bfe3c3d 100644 --- a/oauth2.go +++ b/oauth2.go @@ -35,6 +35,7 @@ const maxUserInfoBytes = 32 << 10 const oauth2FlowGrace = oauth2FlowLifetime + oauth2ProviderTimeout + time.Second type pendingOAuthFlow struct { + owner *Server state string expiry time.Time verifier string @@ -60,7 +61,7 @@ func (srv *Server) takeOAuthFlow(sess *jaws.Session, gotState string) (flow pend if flow.state == "" { return flow, ErrOAuth2MissingState } - if gotState != flow.state { + if gotState != flow.state || flow.owner != srv { return flow, ErrOAuth2WrongState } srv.clearSessionOAuthFlowLocked(sess) @@ -257,6 +258,7 @@ func (srv *Server) prepareOAuthFlow(hw http.ResponseWriter, hr *http.Request, li return } flow = pendingOAuthFlow{ + owner: srv, state: rand.Text(), expiry: time.Now().Add(oauth2FlowLifetime), link: link, verifier: oauth2.GenerateVerifier(), referrer: location, } @@ -311,7 +313,7 @@ func (srv *Server) writeResult(hw http.ResponseWriter, statusCode int, err error if err != nil { const tmpl = `

%03d %s

%s

` body := fmt.Appendf(nil, tmpl, statusCode, http.StatusText(statusCode), html.EscapeString(err.Error())) - _, _ = hw.Write(body) + _, _ = hw.Write(body) // #nosec G705 -- error text is HTML-escaped } } diff --git a/oauth2_callback_edge_test.go b/oauth2_callback_edge_test.go index b255b40..fbc1ecf 100644 --- a/oauth2_callback_edge_test.go +++ b/oauth2_callback_edge_test.go @@ -40,7 +40,7 @@ func TestOIDCCallbackRejectsEmptySubject(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state&code=code", nil) w := httptest.NewRecorder() sess := jw.NewSession(w, req) - setTestOAuthFlow(sess, nil, pendingOAuthFlow{ + setTestOAuthFlow(sess, srv, pendingOAuthFlow{ state: "state", expiry: time.Now().Add(oauth2FlowLifetime), verifier: oauth2.GenerateVerifier(), nonce: "nonce", link: link, }) @@ -90,7 +90,7 @@ func TestOIDCExpiryDuringUserInfoRejected(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state&code=code", nil) w := httptest.NewRecorder() sess := jw.NewSession(w, req) - setTestOAuthFlow(sess, nil, pendingOAuthFlow{ + setTestOAuthFlow(sess, srv, pendingOAuthFlow{ state: "state", expiry: time.Now().Add(oauth2FlowLifetime), verifier: oauth2.GenerateVerifier(), nonce: "nonce", }) diff --git a/oauth2_test.go b/oauth2_test.go index eaaf333..162102f 100644 --- a/oauth2_test.go +++ b/oauth2_test.go @@ -347,7 +347,7 @@ func Test_handleLogoutClearsPendingOAuthState(t *testing.T) { } req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/logout", nil) sess := jw.NewSession(httptest.NewRecorder(), req) - setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime), verifier: "verifier123", nonce: "nonce123", referrer: "/protected"}) + setTestOAuthFlow(sess, srv, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime), verifier: "verifier123", nonce: "nonce123", referrer: "/protected"}) sess.Set(srv.SessionKey, map[string]any{"email": "user@example.com"}) sess.Set(srv.SessionTokenKey, oauth2.StaticTokenSource(makeOAuth2Token("access", "", ""))) sess.Set(srv.sessionKey(oauth2AuthExpiryKey), time.Now().Add(time.Hour)) @@ -461,7 +461,7 @@ func Test_handleAuthResponseUsesPKCEVerifier(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state="+wantState+"&code="+wantCode, nil) sess := jw.NewSession(rec, req) - setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: wantState, expiry: time.Now().Add(oauth2FlowLifetime), verifier: verifier, nonce: wantNonce, referrer: "/secure"}) + setTestOAuthFlow(sess, srv, pendingOAuthFlow{state: wantState, expiry: time.Now().Add(oauth2FlowLifetime), verifier: verifier, nonce: wantNonce, referrer: "/secure"}) oldSession := sess srv.HandleAuthResponse(rec, req) @@ -578,7 +578,7 @@ func Test_handleAuthResponseStoredTokenSourceSurvivesRequestCancel(t *testing.T) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state&code=code", nil).WithContext(ctx) sess := jw.NewSession(rec, req) - setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state", expiry: time.Now().Add(oauth2FlowLifetime), verifier: oauth2.GenerateVerifier(), nonce: wantNonce}) + setTestOAuthFlow(sess, srv, pendingOAuthFlow{state: "state", expiry: time.Now().Add(oauth2FlowLifetime), verifier: oauth2.GenerateVerifier(), nonce: wantNonce}) oldSession := sess srv.HandleAuthResponse(rec, req) @@ -639,7 +639,7 @@ func Test_handleAuthResponseMissingPKCEVerifier(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state123&code=authcode123", nil) sess := jw.NewSession(rec, req) - setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime), nonce: "nonce123"}) + setTestOAuthFlow(sess, srv, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime), nonce: "nonce123"}) srv.HandleAuthResponse(rec, req) @@ -677,7 +677,7 @@ func Test_handleAuthResponseWrongStatePreservesCurrentAuth(t *testing.T) { sess.Set(srv.sessionKey(oauth2AuthExpiryKey), expiry) sess.Set(srv.SessionEmailKey, "user@example.com") sess.Set(srv.SessionEmailVerifiedKey, true) - setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "legit", expiry: time.Now().Add(oauth2FlowLifetime), verifier: oauth2.GenerateVerifier(), nonce: "nonce", referrer: "/secure"}) + setTestOAuthFlow(sess, srv, pendingOAuthFlow{state: "legit", expiry: time.Now().Add(oauth2FlowLifetime), verifier: oauth2.GenerateVerifier(), nonce: "nonce", referrer: "/secure"}) srv.HandleAuthResponse(rec, req) @@ -987,6 +987,7 @@ func TestVerifyOIDCUserInfoDoesNotRefreshDuringCallback(t *testing.T) { return nil, nil } })} + srv.httpClient = client ctx := context.WithValue(t.Context(), oauth2.HTTPClient, client) raw := makeIDToken(t, map[string]any{ "iss": "https://issuer.example", "aud": "client", "sub": "one", "nonce": "nonce", @@ -1058,7 +1059,7 @@ func Test_handleAuthResponseMissingIDToken(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state123&code=authcode123", nil) sess := jw.NewSession(rec, req) - setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime), verifier: oauth2.GenerateVerifier(), nonce: "nonce123"}) + setTestOAuthFlow(sess, srv, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime), verifier: oauth2.GenerateVerifier(), nonce: "nonce123"}) srv.HandleAuthResponse(rec, req) @@ -1118,7 +1119,7 @@ func Test_handleAuthResponseInvalidIDToken(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state123&code=authcode123", nil) sess := jw.NewSession(rec, req) - setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime), verifier: oauth2.GenerateVerifier(), nonce: "nonce123"}) + setTestOAuthFlow(sess, srv, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime), verifier: oauth2.GenerateVerifier(), nonce: "nonce123"}) srv.HandleAuthResponse(rec, req) @@ -1178,7 +1179,7 @@ func Test_handleAuthResponseAudienceMismatch(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state123&code=authcode123", nil) sess := jw.NewSession(rec, req) - setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime), verifier: oauth2.GenerateVerifier(), nonce: "nonce123"}) + setTestOAuthFlow(sess, srv, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime), verifier: oauth2.GenerateVerifier(), nonce: "nonce123"}) srv.HandleAuthResponse(rec, req) @@ -1238,7 +1239,7 @@ func Test_handleAuthResponseNonceMismatch(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state123&code=authcode123", nil) sess := jw.NewSession(rec, req) - setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime), verifier: oauth2.GenerateVerifier(), nonce: "nonce123"}) + setTestOAuthFlow(sess, srv, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime), verifier: oauth2.GenerateVerifier(), nonce: "nonce123"}) srv.HandleAuthResponse(rec, req) @@ -1572,7 +1573,7 @@ func Test_handleAuthResponseMissingNonce(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "http://example.com/oauth2/callback?state=state123&code=authcode123", nil) sess := jw.NewSession(rec, req) - setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime), verifier: oauth2.GenerateVerifier()}) + setTestOAuthFlow(sess, srv, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime), verifier: oauth2.GenerateVerifier()}) // deliberately leave nonce unset to exercise the missing-nonce branch srv.HandleAuthResponse(rec, req) @@ -1637,7 +1638,7 @@ func Test_handlersRejectNonGet(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(method, "http://example.com/oauth2/callback", nil) sess := jw.NewSession(rec, req) - setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime), verifier: "verifier123", nonce: "nonce123"}) + setTestOAuthFlow(sess, srv, pendingOAuthFlow{state: "state123", expiry: time.Now().Add(oauth2FlowLifetime), verifier: "verifier123", nonce: "nonce123"}) h.handler(srv)(rec, req) diff --git a/profile_link_test.go b/profile_link_test.go index 03b317f..8ce42cd 100644 --- a/profile_link_test.go +++ b/profile_link_test.go @@ -923,7 +923,7 @@ func TestOAuthProfileRejectsInvalidSubjectAndOversizedResponse(t *testing.T) { jw := newTestJaws(t) calls := 0 srv := newProfileTestServer(t, jw, &calls) - srv.profileSubject = func(map[string]any) (string, error) { return "", ErrOAuth2InvalidProfile } + srv.profileSubject = func(map[string]any) (string, error) { return "", nil } cookie, state := profileStart(t, srv, http.MethodGet, "/auth/login", nil) response := profileCallback(srv, cookie, state, "first") if response.Code != http.StatusUnauthorized || calls != 2 || len(response.Result().Cookies()) != 0 { diff --git a/sessionlifecycle_test.go b/sessionlifecycle_test.go index c2b4d89..ade716a 100644 --- a/sessionlifecycle_test.go +++ b/sessionlifecycle_test.go @@ -251,7 +251,7 @@ func TestLoginRotationExistingAuth(t *testing.T) { t.Fatal("login reused old session") } } - setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state", expiry: time.Now().Add(oauth2FlowLifetime), nonce: "nonce", verifier: oauth2.GenerateVerifier()}) + setTestOAuthFlow(sess, srv, pendingOAuthFlow{state: "state", expiry: time.Now().Add(oauth2FlowLifetime), nonce: "nonce", verifier: oauth2.GenerateVerifier()}) rec := httptest.NewRecorder() srv.HandleAuthResponse(rec, req) if limit == 1 { @@ -412,7 +412,7 @@ func TestLoginUserInfoDeadlineKeepsOIDCFallbackOptional(t *testing.T) { logins, logouts := 0, 0 srv.LoginEvent = func(*jaws.Session, *http.Request) { logins++ } srv.LogoutEvent = func(*jaws.Session, *http.Request) { logouts++ } - setTestOAuthFlow(sess, nil, pendingOAuthFlow{state: "state", expiry: time.Now().Add(oauth2FlowLifetime), nonce: "nonce", verifier: oauth2.GenerateVerifier()}) + setTestOAuthFlow(sess, srv, pendingOAuthFlow{state: "state", expiry: time.Now().Add(oauth2FlowLifetime), nonce: "nonce", verifier: oauth2.GenerateVerifier()}) hw := httptest.NewRecorder() srv.HandleAuthResponse(hw, hr) current := jw.GetSession(hr) From b65e0badb9e66ec08199a0c56b6ce2be9dd86882 Mon Sep 17 00:00:00 2001 From: Johan Lindh Date: Thu, 5 Nov 2026 09:05:36 +0100 Subject: [PATCH 43/43] fix: prevent cross-provider OAuth redirect reuse --- namespace_test.go | 73 +++++++++++++++++++++++++++++++++++++++++++++++ oauth2.go | 2 +- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/namespace_test.go b/namespace_test.go index c463019..4adc63a 100644 --- a/namespace_test.go +++ b/namespace_test.go @@ -4,8 +4,11 @@ import ( "errors" "net/http" "net/http/httptest" + "net/url" + "strings" "testing" + "github.com/coreos/go-oidc/v3/oidc" "github.com/linkdata/jaws" "golang.org/x/oauth2" ) @@ -143,3 +146,73 @@ func TestOAuthFlowDefaultNamespaceRejectsForeignServer(t *testing.T) { t.Fatalf("gitlab lost its own state: %v", err) } } + +func TestOAuthFlowDefaultNamespaceWrapperDoesNotReissueForeignFlow(t *testing.T) { + for _, inFlight := range []bool{false, true} { + name := "pending" + if inFlight { + name = "callback_in_flight" + } + t.Run(name, func(t *testing.T) { + jw := newTestJaws(t) + newServer := func(clientID string) *Server { + srv, err := New(jw, &Config{}, nil) + if err != nil { + t.Fatal(err) + } + srv.oauth2cfg = &oauth2.Config{ + ClientID: clientID, RedirectURL: "https://app.example/auth/" + clientID + "/callback", + Endpoint: oauth2.Endpoint{AuthURL: "https://" + clientID + ".example/authorize", TokenURL: "https://" + clientID + ".example/token"}, + } + srv.idTokenVerifier = oidc.NewVerifier("https://"+clientID+".example", passthroughKeySet{}, &oidc.Config{ClientID: clientID}) + return srv + } + github, gitlab := newServer("github"), newServer("gitlab") + sessionResponse := httptest.NewRecorder() + sess := jw.NewSession(sessionResponse, httptest.NewRequest(http.MethodGet, "https://app.example/", nil)) + cookie := sessionResponse.Result().Cookies()[0] + start := func(srv *Server, path string) *httptest.ResponseRecorder { + request := httptest.NewRequest(http.MethodGet, "https://app.example"+path, nil) + request.AddCookie(cookie) + response := httptest.NewRecorder() + srv.HandleLogin(response, request) + if response.Code != http.StatusFound { + t.Fatalf("%s login: %d", path, response.Code) + } + return response + } + if inFlight { + start(github, "/auth/github/login") + flow := testOAuthFlow(sess, github) + if _, err := github.takeOAuthFlow(sess, flow.state); err != nil { + t.Fatalf("github callback start: %v", err) + } + } + gitlabLogin := start(gitlab, "/auth/gitlab/login") + gitlabFlow := testOAuthFlow(sess, gitlab) + gitlabURL, err := url.Parse(gitlabLogin.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "https://app.example/protected", nil) + request.AddCookie(cookie) + response := httptest.NewRecorder() + github.Wrap(testStatusHandler{statusCode: http.StatusOK}).ServeHTTP(response, request) + if response.Code != http.StatusConflict || response.Header().Get("Location") != "" { + t.Fatalf("foreign flow reissued: status %d, location %q", response.Code, response.Header().Get("Location")) + } + for _, key := range []string{"state", "nonce", "code_challenge"} { + value := gitlabURL.Query().Get(key) + if value == "" || strings.Contains(response.Body.String(), value) { + t.Fatalf("foreign %s missing or exposed in response body", key) + } + } + if got := testOAuthFlow(sess, gitlab); got != gitlabFlow { + t.Fatal("foreign wrapper changed gitlab flow") + } + if _, err := gitlab.takeOAuthFlow(sess, gitlabFlow.state); err != nil { + t.Fatalf("gitlab lost its own state: %v", err) + } + }) + } +} diff --git a/oauth2.go b/oauth2.go index bfe3c3d..bffa3ab 100644 --- a/oauth2.go +++ b/oauth2.go @@ -252,7 +252,7 @@ func (srv *Server) prepareOAuthFlow(hw http.ResponseWriter, hr *http.Request, li if fromWrapper && srv.pendingOrdinaryLoginLocked(sess, 2*time.Minute) { // Reissue one pending transaction without replacing the first tab's state. flow, _ = sess.Get(srv.sessionKey(oauth2FlowKey)).(pendingOAuthFlow) - if flow.state == "" { + if flow.state == "" || flow.owner != srv { status = http.StatusConflict } return