Files
member-console/internal/auth/auth.go
T
cgalo5758 ea4fee18b6 Fix five findings from security audit run 2
- Rotate the session token at the OIDC callback and restore the full
  lifetime; cap pre-auth sessions at 15 minutes and write no session
  for bare anonymous requests
- Treat db-dsn as a secret: accept db-dsn-file, log only host, port,
  database and user, and never echo a malformed DSN in an error
- Guard the logout callback with a state cookie so a forged visit
  cannot end a live session
- Collapse FedWiki site actions on a foreign tenant's domain to the
  not-found answer, as for a domain that does not exist
2026-09-09 20:53:31 -05:00

1598 lines
64 KiB
Go

// 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
}