// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial // SPDX-FileCopyrightText: 2025-2026 Christian Galo package auth import ( "context" "crypto/rand" "crypto/sha256" "crypto/subtle" "database/sql" "encoding/base64" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "net/url" "slices" "strings" "time" "git.coopcloud.tech/wiki-cafe/member-console/internal/config" "git.coopcloud.tech/wiki-cafe/member-console/internal/identity" "git.coopcloud.tech/wiki-cafe/member-console/internal/logging" "git.coopcloud.tech/wiki-cafe/member-console/internal/organization" "git.coopcloud.tech/wiki-cafe/member-console/internal/provisioning" "github.com/alexedwards/scs/redisstore" "github.com/alexedwards/scs/v2" "github.com/coreos/go-oidc/v3/oidc" "github.com/gomodule/redigo/redis" "github.com/spf13/viper" "golang.org/x/oauth2" "golang.org/x/sync/singleflight" ) // FailurePage renders the page a person lands on when an authentication flow // refuses: a heading naming what failed, and the one link that leads out. // // The interface is declared here, and internal/server's template set // satisfies it, because the server imports this package -- so this package // cannot import the server to reach the renderer directly (design.md, D1). type FailurePage interface { RenderAuthFailure(w http.ResponseWriter, r *http.Request, status int, heading, actionLabel, actionHref string) } // authFailure is what a person is told when a flow refuses. Every refusal in // this package is reached by a browser following a redirect, so every one of // them ends on a page rather than in plain text. type authFailure struct { heading string actionLabel string actionHref string } // The four ways an authentication flow can dead-end. Each names what the // person was doing and links back to the flow they were in, because the // dashboard -- the ordinary error page's way out -- is the page they could // not reach. // // Sign-in splits in two. The difference is the one thing the person cannot // work out for themselves: whether trying again is likely to work. Expired // is the flow going stale or being replayed, where a fresh attempt succeeds. // Failed is the token exchange, the identity provider's answer, or this // console's own provisioning breaking, where it may not; the log line says // which. // // Sign-out refuses only when the provider's logout URL cannot be built or the // store refuses to end the session; in both the person is still signed in, so // retrying the sign-out is the honest way out. The retry is the account // menu's Sign out (a POST), so the page's link leads back to the console. var ( signInExpired = authFailure{"Sign-in expired", "Sign in again", "/login"} signInFailed = authFailure{"Sign-in failed", "Sign in again", "/login"} registrationFailed = authFailure{"Registration failed", "Start again", "/register"} signOutFailed = authFailure{"Sign-out failed", "Back to the console", "/"} ) // retryableAuthorizationErrors are the identity provider's error codes that a // fresh attempt clears: the provider was briefly unavailable, or it wants the // person to interact with it again. Every other code names a configuration or // authorization problem that a second attempt reproduces. var retryableAuthorizationErrors = map[string]bool{ // RFC 6749 section 4.1.2.1 "temporarily_unavailable": true, "server_error": true, // OIDC Core section 3.1.2.6 "login_required": true, "interaction_required": true, "consent_required": true, "account_selection_required": true, } // authorizationFailure chooses what a person is told about an error the // identity provider returned, on the one axis they can act on. func authorizationFailure(code string) authFailure { if retryableAuthorizationErrors[code] { return signInExpired } return signInFailed } // Config holds all auth-related configuration type Config struct { SessionManager *scs.SessionManager OAuthConfig *oauth2.Config Verifier *oidc.IDTokenVerifier Provider *oidc.Provider Database *sql.DB // Raw DB for transactions (auto-provisioning) IdentityQ identity.Querier // Identity module queries OrgQ organization.Querier // Organization module queries // Failures renders a refused flow. The server assigns it once the // template set exists. A Config built without one (every test in this // package) answers with plain text, so a refusal is never a nil // dereference. Failures FailurePage // EndSessionEndpoint is the provider's RP-initiated logout endpoint // (OpenID Connect RP-Initiated Logout 1.0, section 2.1), read from // discovery at Setup. A provider that publishes none cannot end its // session on the person's behalf, and Setup refuses to start rather // than guess a path. EndSessionEndpoint string // RevocationEndpoint is the provider's token revocation endpoint (RFC // 7009), read from discovery at Setup. Empty when the provider // publishes none; the refresh token is then not revoked at sign-out, // which Setup says once at boot. RevocationEndpoint string // refreshes serialises identity re-derivation per session, keyed by // session token (refreshIdentity). Its zero value is ready to use. refreshes singleflight.Group } // providerEndpoints reads the endpoints sign-out needs beyond the OAuth 2.0 // pair from the provider's discovery document. func providerEndpoints(provider *oidc.Provider) (endSession, revocation string, err error) { var meta struct { EndSessionEndpoint string `json:"end_session_endpoint"` RevocationEndpoint string `json:"revocation_endpoint"` } if err := provider.Claims(&meta); err != nil { return "", "", fmt.Errorf("reading the identity provider's discovery document: %w", err) } if meta.EndSessionEndpoint == "" { return "", "", errors.New("the identity provider publishes no end_session_endpoint; sign-out needs OpenID Connect RP-Initiated Logout 1.0") } return meta.EndSessionEndpoint, meta.RevocationEndpoint, nil } // showFailure answers a refused authentication flow with the styled page, or // with the heading as plain text when no renderer is wired. func (c *Config) showFailure(w http.ResponseWriter, r *http.Request, status int, f authFailure) { if c.Failures != nil { c.Failures.RenderAuthFailure(w, r, status, f.heading, f.actionLabel, f.actionHref) return } http.Error(w, f.heading, status) } // Setup initializes the auth configuration func Setup(database *sql.DB, identityQ identity.Querier, orgQ organization.Querier) (*Config, error) { ctx := context.Background() logger := logging.FromContext(ctx) // Create Redis pool for Valkey dialOptions := valkeyDialOptions(ctx) pool := &redis.Pool{ MaxIdle: 10, IdleTimeout: 240 * time.Second, Dial: func() (redis.Conn, error) { return redis.Dial("tcp", viper.GetString("valkey-addr"), dialOptions...) }, TestOnBorrow: func(c redis.Conn, t time.Time) error { if time.Since(t) < time.Minute { return nil } _, err := c.Do("PING") return err }, } // Test connection conn := pool.Get() defer conn.Close() if _, err := conn.Do("PING"); err != nil { return nil, fmt.Errorf("failed to connect to Valkey: %w", err) } logger.Info("session store connected", slog.String("addr", viper.GetString("valkey-addr"))) sessionManager := newSessionManager(redisstore.New(pool)) // Initialize OIDC provider provider, err := oidc.NewProvider(ctx, viper.GetString("oidc-idp-issuer-url")) if err != nil { return nil, fmt.Errorf("failed to initialize OIDC provider: %w", err) } endSession, revocation, err := providerEndpoints(provider) if err != nil { return nil, err } if revocation == "" { logger.Warn("the identity provider publishes no revocation_endpoint; refresh tokens will not be revoked at sign-out") } // Create OAuth2 config oauthConfig := &oauth2.Config{ ClientID: viper.GetString("oidc-sp-client-id"), ClientSecret: viper.GetString("oidc-sp-client-secret"), RedirectURL: viper.GetString("base-url") + "/callback", Endpoint: provider.Endpoint(), Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, } // Create auth config config := &Config{ SessionManager: sessionManager, OAuthConfig: oauthConfig, Provider: provider, Verifier: provider.Verifier(&oidc.Config{ClientID: oauthConfig.ClientID}), Database: database, IdentityQ: identityQ, OrgQ: orgQ, EndSessionEndpoint: endSession, RevocationEndpoint: revocation, } return config, nil } // newSessionManager builds the session manager over store. // // The cookie is Secure exactly when base-url is https (config.ServesHTTPS): // that is the transport the browser sees, and the flag tells the browser to // send the cookie over nothing less. Until the 2026-09 security audit // (finding 5, decision D3) the flag followed the env key being exactly // "production", so any other label, the default included, shipped the session // cookie over plain HTTP wherever a plain-HTTP path existed. func newSessionManager(store scs.Store) *scs.SessionManager { sessionManager := scs.New() sessionManager.Store = store sessionManager.Lifetime = 7 * 24 * time.Hour // 1 week sessionManager.Cookie.Name = "session" sessionManager.Cookie.Path = "/" sessionManager.Cookie.HttpOnly = true sessionManager.Cookie.Secure = config.ServesHTTPS() sessionManager.Cookie.SameSite = http.SameSiteLaxMode return sessionManager } // RegisterHandlers adds all auth-related handlers to the router func (c *Config) RegisterHandlers(mux *http.ServeMux) { mux.HandleFunc("/login", c.LoginHandler) mux.HandleFunc("/callback", c.CallbackHandler) mux.HandleFunc("POST /logout", c.LogoutHandler) mux.HandleFunc("GET /logout", c.logoutGet) mux.HandleFunc("/logout-callback", c.LogoutCallbackHandler) mux.HandleFunc("/register", c.RegistrationHandler) } // Middleware returns an auth middleware function. selfAuthenticatedPaths // are integration-declared endpoints that verify their own caller (provider // webhooks checking an HMAC signature) — they must bypass session auth or // the provider's delivery bounces off the /login redirect. The server // passes the paths collected from RouteMount.CSRFExemptPaths: a path that // authenticates by signature is exempt from both protections for the same // reason. func (c *Config) Middleware(selfAuthenticatedPaths ...string) func(http.Handler) http.Handler { publicPaths := map[string]bool{ "/login": true, "/callback": true, "/logout": true, "/logout-callback": true, "/register": true, "/favicon.ico": true, // Browser-fired in parallel with page loads; must not bounce through /login } for _, p := range selfAuthenticatedPaths { publicPaths[p] = true } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if publicPaths[r.URL.Path] { next.ServeHTTP(w, r) return } // Skip authentication for static assets (public, no session needed) if strings.HasPrefix(r.URL.Path, "/static/") { next.ServeHTTP(w, r) return } // Check if authenticated if !c.SessionManager.GetBool(r.Context(), sessionKeyAuthenticated) { c.bounceToLogin(w, r) return } // An authenticated session can outlive the person it names: // the row behind the session's person id is gone after an // identity merge or purge, or after the database was rebuilt // from a snapshot (the demo reset). Every write such a session // makes then attributes itself to a person that does not // exist and fails on a foreign key deep in a handler // (fk_grants_granted_by_person_id on a ladder reorder, // 2026-09-03) instead of at sign-in. So the person is looked // up on every authenticated request (one primary-key read, // static assets excluded above); a session whose person is // gone ends here and the request bounces to /login exactly as // an unauthenticated one does. if !c.sessionPersonExists(r.Context()) { c.endSession(r.Context()) c.bounceToLogin(w, r) return } // The identity provider is asked, once per interval, whether // this session's person is still who they were with the roles // they had (refreshIdentity). A definitive no ends the session // here, exactly as a missing person does. if !c.refreshIdentity(r.Context()) { c.endSession(r.Context()) c.bounceToLogin(w, r) return } next.ServeHTTP(w, r) }) } } // bounceToLogin sends a request that has no usable session to /login. // // Where the request was headed is remembered first so CallbackHandler can // send the person back there after sign-in instead of always landing on // "/" (ACC-9, design D2). Only a same-origin relative path from a GET // request is safe to store: a POST's target is not a page to land on, and // an HTMX request's own URL is a fetch endpoint (a partial), not a page // either — its same-origin Referer, the document that made the fetch, is // recorded instead. The value rides in the ordinary session (the same // store LoginHandler's state/nonce/codeVerifier use), so it survives the // five-minute pending-flow reuse window untouched. // // An HTMX request can't follow a 302 to the cross-origin Keycloak // authorize URL — the XHR fails with status 0 and error-handler.js shows a // misleading "Network error" toast with no way back to /login (finding // #15). Such a request gets a 401 with HX-Redirect so htmx performs a // same-origin client-side redirect to /login instead, matching the // HX-Request convention used elsewhere (billing.go, operator.go). A page // load gets the plain 302. func (c *Config) bounceToLogin(w http.ResponseWriter, r *http.Request) { // The default return path ("/") carries no information CallbackHandler's // fallback doesn't already give it, so storing it bought nothing but a // session-store write and a seven-day cookie on every anonymous request // (design D3). A bare request that would only store "/" now modifies // nothing, and scs writes nothing to the store or the response. if r.Method == http.MethodGet { if r.Header.Get("HX-Request") == "true" { if dest, ok := sameOriginReturnTo(r.Referer(), r); ok && dest != "/" { c.SessionManager.Put(r.Context(), sessionKeyReturnTo, dest) } } else if dest, ok := validReturnTo(r.URL.RequestURI()); ok && dest != "/" { c.SessionManager.Put(r.Context(), sessionKeyReturnTo, dest) } } w.Header().Add("Vary", "HX-Request") if r.Header.Get("HX-Request") == "true" { w.Header().Set("HX-Redirect", "/login") w.WriteHeader(http.StatusUnauthorized) return } http.Redirect(w, r, "/login", http.StatusFound) } // sessionPersonExists reports whether the person an authenticated session // names still has a row. Two answers count as gone: a definite "no such // row" from the lookup (sql.ErrNoRows, what a sqlc :one query returns), and // an authenticated session with no person id at all. Any other lookup // error is logged and treated as "cannot tell", and the request proceeds: // the handler reads the same database and fails on its own terms, and a // blip must not sign everyone out. A Config built without IdentityQ (tests // build one by hand; Setup always wires it) cannot look anyone up and // proceeds the same way. func (c *Config) sessionPersonExists(ctx context.Context) bool { personID := c.SessionManager.GetString(ctx, sessionKeyPersonID) if personID == "" { logging.FromContext(ctx).Warn("authenticated session names no person; ending the session") return false } if c.IdentityQ == nil { return true } _, err := c.IdentityQ.GetPersonByID(ctx, personID) switch { case err == nil: return true case errors.Is(err, sql.ErrNoRows): logging.FromContext(ctx).Warn("person behind an authenticated session no longer exists; ending the session", slog.String("person_id", personID)) return false default: logging.FromContext(ctx).Warn("could not verify the person behind the session; letting the request through", slog.String("person_id", personID), slog.Any("error", err)) return true } } // endSession drops everything the session holds and retires its token, so // the cookie the browser still carries names a session that no longer // exists in the store: Clear, then RenewToken, which deletes the old token // and issues a new, empty one (bounceToLogin may still record a return-to // in it). If the store refuses either step, Destroy is the fallback; it // deletes the token and expires the cookie outright. func (c *Config) endSession(ctx context.Context) { if err := c.SessionManager.Clear(ctx); err == nil { if err := c.SessionManager.RenewToken(ctx); err == nil { return } } if err := c.SessionManager.Destroy(ctx); err != nil { logging.FromContext(ctx).Error("could not end the session", slog.Any("error", err)) } } // LoginHandler initiates the OIDC authentication flow func (c *Config) LoginHandler(w http.ResponseWriter, r *http.Request) { // Rotate the session token before writing OIDC flow data. // // Defends against two things: // 1. Session fixation (OAuth 2.1 BCP §4.5 — start every authn from a // fresh session ID). // 2. Stale browser cookies pointing at a session that no longer exists // in the store (e.g. after a Valkey teardown). Without this, scs // silently creates an empty session on the store miss, /login writes // state into it, but the browser keeps sending the original cookie // to /callback — savedState comes back empty and the user is stuck. // RenewToken issues a new session ID and Set-Cookie on the redirect // to the IdP, guaranteeing the cookie that returns to /callback maps // to the session we just wrote state into. if err := c.SessionManager.RenewToken(r.Context()); err != nil { logging.FromContext(r.Context()).Error("sign-in could not renew the session token", slog.Any("error", err)) c.showFailure(w, r, http.StatusInternalServerError, signInFailed) return } // Reuse a pending, recent OIDC flow instead of overwriting it. Parallel // /login hits in one session (a page load fanning out into a favicon // fetch, or a second tab) each used to generate fresh state and clobber // the other's; the IdP then returned a state the session no longer held, // producing "State mismatch" 400s at /callback. The state stays // single-use (CallbackHandler clears it on success) and a stale flow is // replaced wholesale. state := c.SessionManager.GetString(r.Context(), sessionKeyState) nonce := c.SessionManager.GetString(r.Context(), sessionKeyNonce) codeVerifier := c.SessionManager.GetString(r.Context(), sessionKeyCodeVerifier) issuedAt := c.SessionManager.GetInt64(r.Context(), sessionKeyStateIssuedAt) reusable := state != "" && nonce != "" && codeVerifier != "" && issuedAt > 0 && time.Since(time.Unix(issuedAt, 0)) < loginStateReuseWindow if !reusable { state = generateRandomString(32) nonce = generateRandomString(32) codeVerifier = generateRandomString(32) // Store OIDC flow data in session c.SessionManager.Put(r.Context(), sessionKeyState, state) c.SessionManager.Put(r.Context(), sessionKeyNonce, nonce) c.SessionManager.Put(r.Context(), sessionKeyCodeVerifier, codeVerifier) c.SessionManager.Put(r.Context(), sessionKeyStateIssuedAt, time.Now().Unix()) } // A session /login writes to lives only long enough to be worth // attacking: the five-minute state freshness window (loginStateReuseWindow) // plus headroom for a second factor at the provider (design D3). A // successful callback restores the full lifetime; one that never // completes leaves nothing behind past this deadline. Set on the reuse // path too, so a second /login hit within the window extends the // deadline exactly as it extends the flow's freshness. c.SessionManager.SetDeadline(r.Context(), time.Now().Add(loginSessionDeadline)) hashVal := sha256.Sum256([]byte(codeVerifier)) codeChallenge := base64.RawURLEncoding.EncodeToString(hashVal[:]) authURL := c.OAuthConfig.AuthCodeURL( state, oidc.Nonce(nonce), oauth2.SetAuthURLParam("code_challenge", codeChallenge), oauth2.SetAuthURLParam("code_challenge_method", "S256"), ) http.Redirect(w, r, authURL, http.StatusFound) } // CallbackHandler processes the OIDC callback func (c *Config) CallbackHandler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() logger := logging.FromContext(ctx) // Validate state. // // A genuine mismatch (savedState present but different) stays a hard 400 — // it's a CSRF/replay signal. But savedState == "" means the session we // wrote state into at /login is gone by the time the IdP redirected back // (lost cookie, expired session, store wiped). That's a UX failure, not an // attack: send the user back through /login so they get a fresh session // and try once more transparently. state := r.URL.Query().Get("state") savedState := c.SessionManager.GetString(ctx, sessionKeyState) if savedState == "" { logger.Info("callback has no saved state; restarting sign-in") http.Redirect(w, r, "/login", http.StatusFound) return } if state != savedState { // %q escapes newlines and control characters, so a crafted state // cannot forge additional log records. The saved state is not logged: // it is a CSRF-grade secret and a mismatch is diagnostic on its own. logger.Warn("callback state does not match the session", slog.String("state", state)) c.showFailure(w, r, http.StatusBadRequest, signInExpired) return } // The identity provider may answer the authorization request with an // error instead of a code (RFC 6749 section 4.1.2.1, OIDC Core section // 3.1.2.6). The console used to ignore the parameter and carry on to the // token exchange with an empty code, which failed as an internal error // and told the person nothing about what had actually happened. That is // the shape a Keycloak page left open long enough to answer // "temporarily_unavailable / authentication_expired" comes back in; // reproduced live on 2026-09-08. // // The state is validated above first, deliberately: an error response // carries the state too, so an unsolicited one must not be able to end a // flow the person is still in. if authErr := r.URL.Query().Get("error"); authErr != "" { logger.Warn("the identity provider refused the authorization request", slog.String("error", authErr), slog.String("error_description", r.URL.Query().Get("error_description"))) c.showFailure(w, r, http.StatusBadRequest, authorizationFailure(authErr)) return } // Exchange code code := r.URL.Query().Get("code") codeVerifier := c.SessionManager.GetString(ctx, sessionKeyCodeVerifier) if codeVerifier == "" { logger.Warn("callback has no code verifier in the session") c.showFailure(w, r, http.StatusBadRequest, signInExpired) return } token, err := c.OAuthConfig.Exchange( ctx, code, oauth2.VerifierOption(codeVerifier), ) if err != nil { logger.Error("token exchange failed", slog.Any("error", err)) c.showFailure(w, r, http.StatusInternalServerError, signInFailed) return } // Verify ID token rawIDToken, ok := token.Extra("id_token").(string) if !ok { logger.Error("token response carries no ID token") c.showFailure(w, r, http.StatusInternalServerError, signInFailed) return } idToken, err := c.Verifier.Verify(ctx, rawIDToken) if err != nil { logger.Warn("ID token failed verification", slog.Any("error", err)) c.showFailure(w, r, http.StatusInternalServerError, signInFailed) return } // Verify nonce var nonceClaims struct { Nonce string `json:"nonce"` } if err := idToken.Claims(&nonceClaims); err != nil { logger.Error("could not parse the ID token's nonce claim", slog.Any("error", err)) c.showFailure(w, r, http.StatusInternalServerError, signInFailed) return } savedNonce := c.SessionManager.GetString(ctx, sessionKeyNonce) if nonceClaims.Nonce != savedNonce { logger.Warn("ID token nonce does not match the session") c.showFailure(w, r, http.StatusBadRequest, signInExpired) return } // ---- Database Interaction & User Info Extraction ---- var userInfoClaims struct { Subject string `json:"sub"` Email string `json:"email"` EmailVerified bool `json:"email_verified"` Name string `json:"name"` PreferredUsername string `json:"preferred_username"` GivenName string `json:"given_name"` FamilyName string `json:"family_name"` } if err := idToken.Claims(&userInfoClaims); err != nil { logger.Error("could not parse the ID token's user claims", slog.Any("error", err)) c.showFailure(w, r, http.StatusInternalServerError, signInFailed) return } // Session variables to populate var personID, orgID, workspaceID string user, err := c.IdentityQ.GetUserByOIDCSubject(ctx, userInfoClaims.Subject) if err != nil { if err == sql.ErrNoRows { // New user — auto-provision all governance structures result, errProv := provisioning.AutoProvision(ctx, c.Database, provisioning.OIDCClaims{ Subject: userInfoClaims.Subject, Email: userInfoClaims.Email, EmailVerified: userInfoClaims.EmailVerified, Name: userInfoClaims.Name, PreferredUsername: userInfoClaims.PreferredUsername, }) if errProv != nil { logger.Error("auto-provisioning a new person failed", slog.Any("error", errProv)) c.showFailure(w, r, http.StatusInternalServerError, signInFailed) return } user = result.User personID = result.Person.PersonID orgID = result.Org.OrgID workspaceID = result.Workspace.WorkspaceID logger.Info("new person provisioned at sign-in", slog.String("email", userInfoClaims.Email), slog.String("person_id", personID), slog.String("org_id", orgID)) } else { logger.Error("could not look up the user by OIDC subject", slog.String("oidc_subject", userInfoClaims.Subject), slog.Any("error", err)) c.showFailure(w, r, http.StatusInternalServerError, signInFailed) return } } else { // Returning user — load existing governance records and update login _, errLogin := c.IdentityQ.UpdateUserLogin(ctx, identity.UpdateUserLoginParams{ LastLoginAt: sql.NullTime{Time: time.Now(), Valid: true}, LastLoginIp: sql.NullString{String: r.RemoteAddr, Valid: true}, UserID: user.UserID, }) if errLogin != nil { logger.Warn("could not record the login timestamp", slog.String("user_id", user.UserID), slog.Any("error", errLogin)) } // Load person person, errPerson := c.IdentityQ.GetPersonByUserID(ctx, user.UserID) if errPerson != nil { logger.Error("could not load the person behind the user", slog.String("user_id", user.UserID), slog.Any("error", errPerson)) c.showFailure(w, r, http.StatusInternalServerError, signInFailed) return } personID = person.PersonID // Update person if OIDC claims changed. The display name falls back // exactly as first-login provisioning does (name, then // preferred_username), and never to an empty string: an IdP that // omits `name` used to overwrite a good display name with "" here // (status/issues.md, "Returning-login resync overwrites..."). resyncName := provisioning.DisplayNameFromClaims(userInfoClaims.Name, userInfoClaims.PreferredUsername) if resyncName == "" { resyncName = person.DisplayName } if person.DisplayName != resyncName || person.PrimaryEmail != userInfoClaims.Email { _, errUpdate := c.IdentityQ.UpdatePerson(ctx, identity.UpdatePersonParams{ DisplayName: resyncName, PrimaryEmail: userInfoClaims.Email, PrimaryEmailVerified: userInfoClaims.EmailVerified, PersonID: person.PersonID, }) if errUpdate != nil { logger.Warn("could not resync the person from the ID token claims", slog.String("person_id", person.PersonID), slog.Any("error", errUpdate)) } } // Load personal org (first org owned by this person) orgs, errOrgs := c.OrgQ.GetOrganizationsByOwner(ctx, personID) if errOrgs != nil || len(orgs) == 0 { logger.Error("could not load an organization for the person", slog.String("person_id", personID), slog.Any("error", errOrgs)) c.showFailure(w, r, http.StatusInternalServerError, signInFailed) return } orgID = orgs[0].OrgID // Load default workspace workspaces, errWS := c.OrgQ.GetWorkspacesByOrgID(ctx, orgID) if errWS != nil || len(workspaces) == 0 { logger.Error("could not load a workspace for the organization", slog.String("org_id", orgID), slog.Any("error", errWS)) c.showFailure(w, r, http.StatusInternalServerError, signInFailed) return } workspaceID = workspaces[0].WorkspaceID logger.Info("returning person signed in", slog.String("email", userInfoClaims.Email), slog.String("person_id", personID), slog.String("org_id", orgID)) } // ---- Extract roles from the verified ID token (IdP-agnostic) ---- roles := rolesFromIDToken(ctx, idToken, c.OAuthConfig.ClientID, userInfoClaims.Email) logger.Info("roles read from the ID token", slog.String("email", userInfoClaims.Email), slog.Any("roles", roles)) // Clear OIDC flow data and set authenticated session data c.SessionManager.Remove(ctx, sessionKeyState) c.SessionManager.Remove(ctx, sessionKeyNonce) c.SessionManager.Remove(ctx, sessionKeyCodeVerifier) c.SessionManager.Remove(ctx, sessionKeyStateIssuedAt) // Rotate the session token again, now that the state, nonce and PKCE // checks and the code exchange have all passed, and before the identity // is written (design D2). LoginHandler's rotation only defeats fixation // planted before /login; a session id fixed between /login and /callback // still bound the matching state and would otherwise carry the identity // this handler is about to write. scs's own guidance is to renew on a // privilege change, and this is that change; RenewToken keeps the // session data (the OIDC keys just cleared are already gone, nothing // else is lost) and issues a fresh token under which the identity below // is the first thing written. if err := c.SessionManager.RenewToken(ctx); err != nil { logger.Error("callback could not renew the session token", slog.Any("error", err)) c.showFailure(w, r, http.StatusInternalServerError, signInFailed) return } // A pre-authentication session lives fifteen minutes (LoginHandler); a // successful sign-in restores the full configured lifetime (design D3). c.SessionManager.SetDeadline(ctx, time.Now().Add(c.SessionManager.Lifetime)) c.SessionManager.Put(ctx, sessionKeyAuthenticated, true) c.SessionManager.Put(ctx, sessionKeyIDToken, rawIDToken) if token.RefreshToken != "" { c.SessionManager.Put(ctx, sessionKeyRefreshToken, token.RefreshToken) } c.SessionManager.Put(ctx, sessionKeyPersonID, personID) c.SessionManager.Put(ctx, sessionKeyOrgID, orgID) c.SessionManager.Put(ctx, sessionKeyWorkspaceID, workspaceID) c.SessionManager.Put(ctx, sessionKeyOIDCSubject, user.OidcSubject) c.SessionManager.Put(ctx, sessionKeyEmail, userInfoClaims.Email) c.SessionManager.Put(ctx, sessionKeyName, userInfoClaims.Name) c.SessionManager.Put(ctx, sessionKeyUsername, userInfoClaims.PreferredUsername) c.SessionManager.Put(ctx, sessionKeyRoles, roles) c.SessionManager.Put(ctx, sessionKeyIdentityRefreshedAt, time.Now().Unix()) dest := c.resolveReturnTo(ctx) logger.Info("sign-in complete", slog.String("email", userInfoClaims.Email), slog.String("destination", dest)) http.Redirect(w, r, dest, http.StatusFound) } // resolveReturnTo consumes the return-to path the middleware recorded // before redirecting to /login (ACC-9), one-time and re-validated since it // round-tripped through session storage: a value that fails validReturnTo // (a foreign origin, or nothing stored — including a direct /login visit, // which never sets the key) falls back to "/". func (c *Config) resolveReturnTo(ctx context.Context) string { returnTo := c.SessionManager.GetString(ctx, sessionKeyReturnTo) c.SessionManager.Remove(ctx, sessionKeyReturnTo) if dest, ok := validReturnTo(returnTo); ok { return dest } return "/" } // providerCallTimeout bounds each call made to the identity provider outside // the browser's own redirects: the sign-out hint refresh, the revocation, and // the interval re-derivation of a session's identity. Each failure costs only // its own effect, so no request waits longer than this per call. const providerCallTimeout = 5 * time.Second // LogoutHandler ends the session here, then sends the browser to the identity // provider to end its own. // // Until the 2026-09 security audit (finding 11, decision D5) the session was // destroyed only in LogoutCallbackHandler, once the identity provider had // redirected back and a logout state had matched. A person who never completed // that round trip -- who closed the tab at Keycloak's "Do you want to log // out?" prompt -- kept a working session for the rest of its week. The session // now ends before the redirect, unconditionally. // // The second (2026-09, run two) audit found a forged GET straight at // /logout-callback could still end an unrelated, live session, because that // handler acted on nothing but its own arrival. The state generated below, // held in the logout_state cookie and echoed back by the provider, is what // lets the callback tell a genuine round trip apart from that forgery (design // D4; see LogoutCallbackHandler). // // The prompt is avoided where it can be. The provider skips confirmation when // the request carries a valid id_token_hint, and the stored ID token is valid // only for its first few minutes, so an aged session mints a fresh one from the // refresh token first (idTokenHint). When that is not possible the hint is // omitted and the provider asks, exactly as before; the console session is gone // either way. // // The refresh token is then revoked at the provider (revokeRefreshToken), so // that no copy of it -- in a backup of the session store, in a log nobody // meant to keep -- can mint tokens after the person signed out. func (c *Config) LogoutHandler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() logger := logging.FromContext(ctx) // Sign-out changes state, so it is a POST and the cross-origin // protection covers it: a page on another origin cannot end a person's // session by sending the browser here (2026-09 audit candidate "Logout // CSRF"). RegisterHandlers routes only POST here; the check stands for a // caller that wires the handler by hand. if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) return } endSessionURL, err := url.Parse(c.EndSessionEndpoint) if err != nil || c.EndSessionEndpoint == "" { logger.Error("no usable end_session_endpoint; Setup guarantees one, so this Config was built by hand", slog.String("endpoint", c.EndSessionEndpoint), slog.Any("error", err)) c.showFailure(w, r, http.StatusInternalServerError, signOutFailed) return } // Everything the request needs from the session is read here, before // the session goes. storedRefreshToken := c.SessionManager.GetString(ctx, sessionKeyRefreshToken) hint, currentRefreshToken := c.idTokenHint(ctx, storedRefreshToken) // An unguessable state, echoed back to /logout-callback by the provider // (OpenID Connect RP-Initiated Logout 1.0, section 2), is what lets that // callback tell a genuine round trip from a forged visit once the // session it would have checked is already gone (design D4). It is held // in a cookie scoped to the callback path rather than the session, since // the session is destroyed below before the redirect; five minutes // covers the round trip to the provider and back. logoutState := generateRandomString(32) http.SetCookie(w, &http.Cookie{ Name: "logout_state", Value: logoutState, Path: "/logout-callback", HttpOnly: true, Secure: config.ServesHTTPS(), SameSite: http.SameSiteLaxMode, MaxAge: 300, }) q := endSessionURL.Query() q.Set("post_logout_redirect_uri", viper.GetString("base-url")+"/logout-callback") q.Set("client_id", viper.GetString("oidc-sp-client-id")) q.Set("state", logoutState) if hint != "" { q.Set("id_token_hint", hint) } endSessionURL.RawQuery = q.Encode() if err := c.SessionManager.Destroy(ctx); err != nil { logger.Error("could not end the session at sign-out", slog.Any("error", err)) c.showFailure(w, r, http.StatusInternalServerError, signOutFailed) return } c.revokeRefreshTokens(ctx, storedRefreshToken, currentRefreshToken) // Log the destination without its query string. The query carries // id_token_hint, a live token; logging the whole URL wrote it to the log // (2026-09 security audit). Redacting one named parameter would mean // keeping a list of secret parameter names in sync by hand; the // destination is the only part worth logging. loggableLogoutURL := *endSessionURL loggableLogoutURL.RawQuery = "" logger.Info("session ended; redirecting to the identity provider to sign out", slog.String("url", loggableLogoutURL.String())) // The account menu's Sign out is an htmx request, and fetch cannot // follow a redirect to another origin; HX-Redirect makes the browser // navigate to the provider instead. Anything else posting here gets the // redirect itself, 303 because the request was a POST (RFC 9110 // section 15.4.4). w.Header().Add("Vary", "HX-Request") if r.Header.Get("HX-Request") == "true" { w.Header().Set("HX-Redirect", endSessionURL.String()) w.WriteHeader(http.StatusOK) return } http.Redirect(w, r, endSessionURL.String(), http.StatusSeeOther) } // logoutGet answers a GET at /logout, which is never the account menu: it is // an old bookmark, a typed address, or a link on some other page. None of // those may end a session, so the browser is sent to the console's root, // where the account menu is. func (c *Config) logoutGet(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusSeeOther) } // idTokenHint returns an ID token fit to send as id_token_hint, or "" when // none can be had, together with the refresh token that is current afterwards. // // The stored token is used while it is still valid. Past that, a fresh one is // minted from the refresh token: one call to the provider's token endpoint, // bounded by providerCallTimeout. Its failure, or a provider that answers a // refresh without an ID token (OIDC Core section 12.2 makes that optional), // costs the hint and nothing else: the sign-out proceeds and the provider asks // for confirmation, as it did before the refresh token was kept. // // A provider may rotate the refresh token on use (RFC 6749 section 6). The // one returned is whichever is live afterwards, so the caller revokes the // right one. func (c *Config) idTokenHint(ctx context.Context, refreshToken string) (hint, currentRefreshToken string) { if stored := c.SessionManager.GetString(ctx, sessionKeyIDToken); stored != "" && idTokenUsableAsHint(stored, time.Now()) { return stored, refreshToken } if refreshToken == "" || c.OAuthConfig == nil { return "", refreshToken } logger := logging.FromContext(ctx) refreshCtx, cancel := context.WithTimeout(ctx, providerCallTimeout) defer cancel() // A token with no access token is not Valid, so the source refreshes on // the first call rather than returning what it was given. fresh, err := c.OAuthConfig.TokenSource(refreshCtx, &oauth2.Token{RefreshToken: refreshToken}).Token() if err != nil { logger.Warn("could not refresh the ID token for the sign-out hint; the identity provider will ask for confirmation", slog.Any("error", err)) return "", refreshToken } if fresh.RefreshToken != "" { refreshToken = fresh.RefreshToken } idToken, _ := fresh.Extra("id_token").(string) if idToken == "" { logger.Warn("the identity provider's refresh response carries no ID token; the identity provider will ask for confirmation") return "", refreshToken } return idToken, refreshToken } // revokeRefreshTokens tells the identity provider the refresh tokens are no // longer in use (RFC 7009, section 2.1). Best effort and bounded: a failure is // logged at warning and costs nothing here, because the session that held the // tokens is already gone and the provider's own logout, which the browser is // about to request, invalidates the session's tokens as well. // // What it buys is the case where that logout does not happen -- no valid hint // could be had and the person walked away from the confirmation page -- and, // more generally, that a copy of the session store taken before sign-out // holds a token that no longer works. // // Every token that was live is revoked: the stored one, and the one a refresh // handed back when it differs. RFC 7009 obliges a provider to invalidate the // token named and its access tokens, not other refresh tokens of the same // grant, so a provider that hands out a new refresh token without retiring // the old one would otherwise leave the old one working. func (c *Config) revokeRefreshTokens(ctx context.Context, refreshTokens ...string) { if c.RevocationEndpoint == "" || c.OAuthConfig == nil { return } seen := map[string]bool{"": true} for _, token := range refreshTokens { if !seen[token] { seen[token] = true c.revokeRefreshToken(ctx, token) } } } // revokeRefreshToken is one revocation call. The client authenticates with // HTTP Basic, the form RFC 6749 section 2.3.1 says a confidential client // SHOULD use, with the id and secret form-encoded as that section requires. A // provider answers 200 whether or not the token was live (RFC 7009 section // 2.2), so anything else is worth a line. func (c *Config) revokeRefreshToken(ctx context.Context, refreshToken string) { logger := logging.FromContext(ctx) revokeCtx, cancel := context.WithTimeout(ctx, providerCallTimeout) defer cancel() form := url.Values{"token": {refreshToken}, "token_type_hint": {"refresh_token"}} req, err := http.NewRequestWithContext(revokeCtx, http.MethodPost, c.RevocationEndpoint, strings.NewReader(form.Encode())) if err != nil { logger.Warn("could not build the refresh token revocation request", slog.Any("error", err)) return } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.SetBasicAuth(url.QueryEscape(c.OAuthConfig.ClientID), url.QueryEscape(c.OAuthConfig.ClientSecret)) resp, err := http.DefaultClient.Do(req) if err != nil { logger.Warn("could not revoke the refresh token at sign-out", slog.Any("error", err)) return } defer resp.Body.Close() _, _ = io.Copy(io.Discard, resp.Body) if resp.StatusCode != http.StatusOK { logger.Warn("the identity provider did not accept the refresh token revocation", slog.Int("status", resp.StatusCode)) return } logger.Debug("refresh token revoked at the identity provider") } // LogoutCallbackHandler is where the identity provider sends the browser once // its own sign-out is done. // // The console session ended in LogoutHandler, so there is normally nothing // here to verify. But the 2026-09 audit's second run found this handler // acted on its own arrival alone: a forged GET straight at this path, from // another origin, with no round trip through /logout at all, ended whatever // session the browser happened to be carrying (design D4). The state // LogoutHandler put in the logout_state cookie is what distinguishes the two: // it lives only across a genuine round trip to the provider and back, scoped // to this path, so a forged visit cannot present it. The comparison is // constant-time because a state is exactly the kind of secret a timing // side-channel could otherwise narrow down. // // A match ends whatever session the browser presents (normally none; the // destroy at /logout already ran, but a store that refused that first // delete, or a session that predates it, does not outlive this) and clears // the cookie, landing on /login. Anything else -- no cookie, a state that // does not match, no state at all -- leaves the session untouched: unlike a // match, there is nothing here establishing the browser actually came from a // sign-out this console started, and / is the page that knows whether to // bounce a session that may still be live. The handler never refuses either // way; it always redirects. func (c *Config) LogoutCallbackHandler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() state := r.URL.Query().Get("state") cookieState := "" if cookie, err := r.Cookie("logout_state"); err == nil { cookieState = cookie.Value } matched := state != "" && cookieState != "" && subtle.ConstantTimeCompare([]byte(state), []byte(cookieState)) == 1 if !matched { http.Redirect(w, r, "/", http.StatusFound) return } if err := c.SessionManager.Destroy(ctx); err != nil { logging.FromContext(ctx).Warn("could not clear a session at the logout callback", slog.Any("error", err)) } http.SetCookie(w, &http.Cookie{ Name: "logout_state", Value: "", Path: "/logout-callback", HttpOnly: true, Secure: config.ServesHTTPS(), SameSite: http.SameSiteLaxMode, MaxAge: -1, }) http.Redirect(w, r, "/login", http.StatusFound) } // RegistrationHandler redirects to the OIDC registration page func (c *Config) RegistrationHandler(w http.ResponseWriter, r *http.Request) { ctx := r.Context() logger := logging.FromContext(ctx) // Rotate the session id before the flow starts, exactly as LoginHandler // does. Registration is the other path that ends in an authenticated // session, and it was the only one that did not rotate: a session id // planted in the browser beforehand survived the person's sign-in, which // is session fixation. Found by the 2026-09 security audit. This also // gives registration the same guarantee LoginHandler documents, that the // cookie returning to /callback maps to the session the state was written // into. if err := c.SessionManager.RenewToken(ctx); err != nil { logger.Error("registration could not renew the session token", slog.Any("error", err)) c.showFailure(w, r, http.StatusInternalServerError, registrationFailed) return } state := generateRandomString(32) nonce := generateRandomString(32) codeVerifier := generateRandomString(32) hashVal := sha256.Sum256([]byte(codeVerifier)) codeChallenge := base64.RawURLEncoding.EncodeToString(hashVal[:]) c.SessionManager.Put(ctx, sessionKeyState, state) c.SessionManager.Put(ctx, sessionKeyNonce, nonce) c.SessionManager.Put(ctx, sessionKeyCodeVerifier, codeVerifier) registrationEndpoint := viper.GetString("oidc-idp-issuer-url") + "/protocol/openid-connect/registrations" parsedRegistrationURL, err := url.Parse(registrationEndpoint) if err != nil { logger.Error("could not build the identity provider's registration URL", slog.String("endpoint", registrationEndpoint), slog.Any("error", err)) c.showFailure(w, r, http.StatusInternalServerError, registrationFailed) return } q := parsedRegistrationURL.Query() q.Set("client_id", viper.GetString("oidc-sp-client-id")) q.Set("response_type", "code") q.Set("scope", "openid email profile") q.Set("redirect_uri", viper.GetString("base-url")+"/callback") q.Set("state", state) q.Set("nonce", nonce) q.Set("code_challenge", codeChallenge) q.Set("code_challenge_method", "S256") parsedRegistrationURL.RawQuery = q.Encode() loggableRegistrationURL := *parsedRegistrationURL loggableRegistrationURL.RawQuery = "" logger.Info("redirecting to the identity provider to register", slog.String("url", loggableRegistrationURL.String())) http.Redirect(w, r, parsedRegistrationURL.String(), http.StatusFound) } // Identity re-derivation ------------------------------------------------------ // identityRefreshInterval bounds how long a change at the identity provider // -- a role removed, an account disabled, a session ended by an administrator // -- can go unnoticed by a signed-in session. Finding 3 of the 2026-09 // security audit (decision D2): roles were copied into the session at sign-in // and read from that copy for the session's week-long life. const identityRefreshInterval = 5 * time.Minute // identityRefreshBackoff is how long a session waits before asking again // after the provider gave no answer at all. const identityRefreshBackoff = time.Minute // identityRefresh is one answer from the provider, shared between requests // that asked together. type identityRefresh struct { idToken string refreshToken string subject string roles []string refused bool // the provider said the grant is invalid: definitive err error // no usable answer, or the definitive refusal's detail } // refreshIdentity re-derives a signed-in session's identity and roles from // the identity provider once identityRefreshInterval has passed since the last // time, and reports whether the session may continue. // // The question is put with the session's refresh token (OAuth 2.0 section 6), // and the answer is a new ID token whose claims are current, so this needs // nothing beyond what sign-in already needs: refresh tokens, and roles in the // ID token. The answer is read the way sign-in read the original. // // Three outcomes. The provider refuses the grant (invalid_grant, RFC 6749 // section 5.2): the session is gone at the provider, the account is disabled, // or the token was revoked, and the session ends now. The provider answers: // roles, ID token and refresh token are replaced and the clock restarts. The // provider gives no answer, a timeout, a 5xx, a verification failure: the // session keeps its last known state and asks again after a short backoff, // as the per-request person check treats a database blip. An outage at the // provider must not sign everyone out, and a revoked operator cannot cause // one. // // A session without a refresh token (a provider that issues none) stays on // its sign-in snapshot for its lifetime, which is what every session did // before this existed. func (c *Config) refreshIdentity(ctx context.Context) bool { now := time.Now() if now.Unix()-c.SessionManager.GetInt64(ctx, sessionKeyIdentityRefreshedAt) < int64(identityRefreshInterval/time.Second) { return true } if c.SessionManager.GetInt64(ctx, sessionKeyIdentityRetryAt) > now.Unix() { return true } refreshToken := c.SessionManager.GetString(ctx, sessionKeyRefreshToken) if refreshToken == "" || c.OAuthConfig == nil || c.Verifier == nil { return true } logger := logging.FromContext(ctx) // One refresh per session at a time. Two requests crossing the interval // together would otherwise both present the same refresh token, and a // provider that retires a refresh token on use would refuse the second // as a replay, which reads as a definitive refusal below. Requests that // arrive while one is in flight share its answer. shared, _, _ := c.refreshes.Do(c.SessionManager.Token(ctx), func() (any, error) { return c.rederive(ctx, refreshToken), nil }) answer := shared.(identityRefresh) switch { case answer.refused: logger.Warn("the identity provider refused the session's refresh token; ending the session", slog.Any("error", answer.err)) return false case answer.err != nil: logger.Warn("could not re-derive the session's identity; keeping it and asking again later", slog.Any("error", answer.err)) c.SessionManager.Put(ctx, sessionKeyIdentityRetryAt, now.Add(identityRefreshBackoff).Unix()) return true } if subject := c.SessionManager.GetString(ctx, sessionKeyOIDCSubject); subject != "" && answer.subject != subject { logger.Error("the refreshed ID token names a different subject than the session; ending the session", slog.String("session_subject", subject), slog.String("token_subject", answer.subject)) return false } before := c.getRoles(ctx) c.SessionManager.Put(ctx, sessionKeyIDToken, answer.idToken) if answer.refreshToken != "" { c.SessionManager.Put(ctx, sessionKeyRefreshToken, answer.refreshToken) } c.SessionManager.Put(ctx, sessionKeyRoles, answer.roles) c.SessionManager.Put(ctx, sessionKeyIdentityRefreshedAt, now.Unix()) c.SessionManager.Remove(ctx, sessionKeyIdentityRetryAt) if !slices.Equal(before, answer.roles) { logger.Info("roles changed at the identity provider; session updated", slog.Any("before", before), slog.Any("after", answer.roles)) } return true } // rederive is the one call to the provider: a refresh-token grant, the new ID // token verified exactly as sign-in verifies one, and the roles read from it. func (c *Config) rederive(ctx context.Context, refreshToken string) identityRefresh { callCtx, cancel := context.WithTimeout(ctx, providerCallTimeout) defer cancel() fresh, err := c.OAuthConfig.TokenSource(callCtx, &oauth2.Token{RefreshToken: refreshToken}).Token() if err != nil { var refusal *oauth2.RetrieveError if errors.As(err, &refusal) && refusal.ErrorCode == "invalid_grant" { return identityRefresh{refused: true, err: err} } return identityRefresh{err: err} } rawIDToken, _ := fresh.Extra("id_token").(string) if rawIDToken == "" { return identityRefresh{err: errors.New("the refresh response carries no ID token")} } idToken, err := c.Verifier.Verify(callCtx, rawIDToken) if err != nil { return identityRefresh{err: fmt.Errorf("verifying the refreshed ID token: %w", err)} } var claims struct { Email string `json:"email"` } _ = idToken.Claims(&claims) return identityRefresh{ idToken: rawIDToken, refreshToken: fresh.RefreshToken, subject: idToken.Subject, roles: rolesFromIDToken(ctx, idToken, c.OAuthConfig.ClientID, claims.Email), } } // Session key constants - single source of truth for session data const ( sessionKeyAuthenticated = "authenticated" sessionKeyIDToken = "id_token" // The refresh token is kept for one purpose today: minting a fresh ID // token at sign-out so the identity provider gets a valid id_token_hint // and ends its session without asking for confirmation (LogoutHandler). // It lives only in the server-side store, never in the cookie. sessionKeyRefreshToken = "refresh_token" // Identity re-derivation (refreshIdentity): when the provider last // confirmed this session's identity and roles, and, after it gave no // answer, when to ask again. sessionKeyIdentityRefreshedAt = "identity_refreshed_at" sessionKeyIdentityRetryAt = "identity_retry_at" sessionKeyPersonID = "person_id" sessionKeyOrgID = "org_id" sessionKeyWorkspaceID = "workspace_id" sessionKeyOIDCSubject = "oidc_subject" sessionKeyEmail = "email" sessionKeyName = "name" sessionKeyUsername = "username" sessionKeyRoles = "roles" // OIDC flow keys (temporary) sessionKeyState = "state" sessionKeyNonce = "nonce" sessionKeyCodeVerifier = "code_verifier" sessionKeyStateIssuedAt = "state_issued_at" // sessionKeyReturnTo is the same-origin path the auth middleware // recorded before bouncing an unauthenticated request to /login // (ACC-9); CallbackHandler consumes and clears it via resolveReturnTo. sessionKeyReturnTo = "return_to" ) // loginStateReuseWindow bounds how long a pending (unconsumed) OIDC flow is // reused by subsequent /login hits instead of being regenerated. Long enough // to cover parallel redirects from one navigation plus a user pausing at the // IdP form; short enough that an abandoned flow's state has a bounded life. const loginStateReuseWindow = 5 * time.Minute // loginSessionDeadline is how long a session /login creates lives before a // callback completes: the state freshness window above plus headroom for a // person to get through a second factor at the identity provider (design // D3). A successful callback replaces this with the full session lifetime. const loginSessionDeadline = 15 * time.Minute // UserSession contains the authenticated user's session data. // This provides type-safe access to session values. type UserSession struct { PersonID string OrgID string WorkspaceID string OIDCSubject string Email string Name string Username string Roles []string } // GetUserSession retrieves the authenticated user's session data. // Returns nil if the user is not authenticated. func (c *Config) GetUserSession(ctx context.Context) *UserSession { if !c.SessionManager.GetBool(ctx, sessionKeyAuthenticated) { return nil } return &UserSession{ PersonID: c.SessionManager.GetString(ctx, sessionKeyPersonID), OrgID: c.SessionManager.GetString(ctx, sessionKeyOrgID), WorkspaceID: c.SessionManager.GetString(ctx, sessionKeyWorkspaceID), OIDCSubject: c.SessionManager.GetString(ctx, sessionKeyOIDCSubject), Email: c.SessionManager.GetString(ctx, sessionKeyEmail), Name: c.SessionManager.GetString(ctx, sessionKeyName), Username: c.SessionManager.GetString(ctx, sessionKeyUsername), Roles: c.getRoles(ctx), } } // GetPersonID returns the person UUID of the authenticated user. // Returns empty string if the user is not authenticated. func (c *Config) GetPersonID(ctx context.Context) string { return c.SessionManager.GetString(ctx, sessionKeyPersonID) } // GetOrgID returns the active organization UUID. func (c *Config) GetOrgID(ctx context.Context) string { return c.SessionManager.GetString(ctx, sessionKeyOrgID) } // GetWorkspaceID returns the active workspace UUID. func (c *Config) GetWorkspaceID(ctx context.Context) string { return c.SessionManager.GetString(ctx, sessionKeyWorkspaceID) } // GetUserEmail returns the email of the authenticated user. func (c *Config) GetUserEmail(ctx context.Context) string { return c.SessionManager.GetString(ctx, sessionKeyEmail) } // GetUserName returns the display name of the authenticated user. func (c *Config) GetUserName(ctx context.Context) string { return c.SessionManager.GetString(ctx, sessionKeyName) } // GetUsername returns the username of the authenticated user. func (c *Config) GetUsername(ctx context.Context) string { return c.SessionManager.GetString(ctx, sessionKeyUsername) } // IsAuthenticated returns true if the user has an active session. func (c *Config) IsAuthenticated(ctx context.Context) bool { return c.SessionManager.GetBool(ctx, sessionKeyAuthenticated) } // HasRole checks if the current user has the specified role. func (c *Config) HasRole(r *http.Request, role string) bool { roles := c.getRoles(r.Context()) for _, r := range roles { if r == role { return true } } return false } // getRoles retrieves the roles from session. func (c *Config) getRoles(ctx context.Context) []string { roles, ok := c.SessionManager.Get(ctx, sessionKeyRoles).([]string) if !ok { return nil } return roles } // validReturnTo reports whether raw is safe to store, and later redirect // to, as the page to return to after sign-in (ACC-9): a same-origin // relative reference starting with a single "/" and carrying no scheme or // host of its own. A leading "//" is rejected outright — a browser // resolves it as protocol-relative to whatever host follows, the same // open-redirect shape as an absolute URL — even though url.Parse would // also catch it via a non-empty Host. func validReturnTo(raw string) (string, bool) { if raw == "" || raw[0] != '/' || strings.HasPrefix(raw, "//") { return "", false } // Browsers parse a backslash in an http(s) URL as a slash (WHATWG URL, // "special scheme"), so "/\evil.example" is "//evil.example" to the // browser and a same-origin path to net/url. No console path contains // one; refuse them all rather than model the browser's parser (2026-09 // audit candidate "Open redirect via backslash"). if strings.Contains(raw, "\\") { return "", false } u, err := url.Parse(raw) if err != nil || u.IsAbs() || u.Host != "" { return "", false } return raw, true } // sameOriginReturnTo validates referer (an HTMX request's own Referer // header — the document that issued the fetch, since the fetch's own URL // is a partial endpoint, not a page to land on) as a same-origin URL // matching the incoming request's Host, and returns its path plus query // the way validReturnTo treats a direct GET request's own URL. func sameOriginReturnTo(referer string, r *http.Request) (string, bool) { if referer == "" { return "", false } u, err := url.Parse(referer) if err != nil || !u.IsAbs() || u.Host != r.Host { return "", false } return validReturnTo(u.RequestURI()) } // Helper function to generate random strings func generateRandomString(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { // Unreachable on a working system: crypto/rand.Read never // returns an error. If the entropy source is gone there is no // safe degraded behaviour — every caller here is minting a // CSRF-grade secret — so the process stops rather than issue a // guessable state, nonce or code verifier. panic(fmt.Sprintf("auth: no entropy for a random string: %v", err)) } return base64.RawURLEncoding.EncodeToString(b) } // claimsReader is the part of *oidc.IDToken that rolesFromIDToken needs, so // the role path can be exercised in tests without a signed token. type claimsReader interface { Claims(v any) error } // roleClaimLocations are the claim keys extractRoles reads. Their presence // (even empty) is what tells a correctly mapped ID token apart from one the // IdP never mapped roles into. var roleClaimLocations = []string{"roles", "groups", "realm_access", "resource_access"} // rolesFromIDToken reads roles from the verified ID token. The ID token is // the token OIDC defines for the client to consume, and by the time it // reaches here it has passed signature and nonce verification. The access // token is deliberately not read: OAuth2 leaves its format opaque, so parsing // it as a JWT only works with IdPs that happen to issue JWT access tokens, // and with any other IdP every operator would silently receive no roles. // // An ID token carrying none of the role claim locations at all is logged // loudly: that shape means the IdP is not mapping roles into the ID token // (docs/identity-provider-setup.md), and without the log the only symptom // is an operator who cannot get in. A location that is present but empty is // an ordinary member with no roles and is not logged. func rolesFromIDToken(ctx context.Context, idToken claimsReader, clientID, email string) []string { logger := logging.FromContext(ctx) var present map[string]json.RawMessage if err := idToken.Claims(&present); err != nil { logger.Error("could not parse the ID token's claims while reading roles", slog.Any("error", err)) return nil } if !hasRoleClaimLocation(present) { logger.Warn("ID token carries no role claims; the identity provider must map roles into the ID token, see docs/identity-provider-setup.md", slog.String("email", email), slog.String("expected_claims", strings.Join(roleClaimLocations, ", "))) return nil } var claims rolesClaims if err := idToken.Claims(&claims); err != nil { logger.Error("could not parse roles from the ID token", slog.Any("error", err)) return nil } return extractRoles(claims, clientID) } // hasRoleClaimLocation reports whether any of roleClaimLocations is present // in the decoded claims, regardless of its value. func hasRoleClaimLocation(claims map[string]json.RawMessage) bool { for _, key := range roleClaimLocations { if _, ok := claims[key]; ok { return true } } return false } // rolesClaims is the subset of ID-token claims that may carry roles, // across IdPs: top-level roles/groups (generic), realm_access (Keycloak // realm roles), and resource_access (Keycloak client-scoped roles, keyed by // client ID). type rolesClaims struct { Roles []string `json:"roles"` Groups []string `json:"groups"` RealmAccess struct { Roles []string `json:"roles"` } `json:"realm_access"` ResourceAccess map[string]struct { Roles []string `json:"roles"` } `json:"resource_access"` } // extractRoles merges every claim location an IdP may put roles in, rather // than letting the first non-empty location mask the rest: a deployment's // role names are its contract, and they count wherever the IdP carries them. // The result is deduplicated and order-stable (source order above, then // claim order). clientID scopes the resource_access lookup to this app's // OIDC client; other clients' roles never leak in. func extractRoles(claims rolesClaims, clientID string) []string { var merged []string seen := make(map[string]struct{}) add := func(roles []string) { for _, role := range roles { if _, dup := seen[role]; dup { continue } seen[role] = struct{}{} merged = append(merged, role) } } add(claims.Roles) add(claims.Groups) add(claims.RealmAccess.Roles) add(claims.ResourceAccess[clientID].Roles) return merged } // idTokenUsableAsHint reports whether the stored raw ID token is still // worth sending as an OIDC id_token_hint. The exp claim is read without // signature verification: this only decides whether to attach the hint, // and the IdP re-verifies whatever it receives. A malformed token is not // usable. The 30-second headroom covers the redirect hop. func idTokenUsableAsHint(raw string, now time.Time) bool { parts := strings.Split(raw, ".") if len(parts) != 3 { return false } payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { return false } var claims struct { Exp int64 `json:"exp"` } if err := json.Unmarshal(payload, &claims); err != nil || claims.Exp == 0 { return false } return now.Add(30*time.Second).Unix() < claims.Exp }