- 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
1598 lines
64 KiB
Go
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
|
|
}
|