Add server-side sessions, session cookie and logout
CI / ci (push) Successful in 1m25s
CI / ci (pull_request) Successful in 1m24s
Independent review Codex gpt-6-sol high: CHANGES_REQUIRED (1 medium)

A successful OIDC callback now creates a sessions row through the new
auth.SessionManager: a random 256-bit ID whose SHA-256 hash is the only
stored form, the subject, email, name and groups claims, a separate random
CSRF token and created/last_seen/expires timestamps. The ID travels only in
the __Host-mealquest_session cookie (HttpOnly, Secure, SameSite=Lax, Path=/,
no Domain).

SessionFor rejects a session unused for MEALQUEST_SESSION_IDLE or older
than MEALQUEST_SESSION_MAX, deletes it, and otherwise slides last_seen_at.
POST /auth/logout deletes the row, clears the cookie and redirects to the
provider's end_session_endpoint, which discovery now requires.

The session lifetimes are optional configuration (defaults 720h and 4320h,
per D-004); set values must be positive with MAX at least IDLE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 10:02:17 -04:00
co-authored by Claude Opus 5.5
parent 99c8f1625f
commit 1dfba7b9db
8 changed files with 851 additions and 27 deletions
+40 -14
View File
@@ -25,6 +25,7 @@ import (
const (
LoginPath = "/auth/login"
CallbackPath = "/auth/callback"
LogoutPath = "/auth/logout"
)
// providerTimeout bounds every request to the identity provider.
@@ -43,11 +44,14 @@ type Claims struct {
Groups []string `json:"groups"`
}
// SessionStarter is the session layer's entry point. StartSession is called
// once per successful login with claims from a verified ID token; it must set
// the session cookie on w. A returned error aborts the login with 500.
type SessionStarter interface {
// Sessions is the session layer. StartSession is called once per successful
// login with claims from a verified ID token; it must set the session cookie
// on w. EndSession is called on logout; it must end the session r carries,
// if any, and clear the cookie on w. A returned error fails the request with
// 500.
type Sessions interface {
StartSession(w http.ResponseWriter, r *http.Request, claims Claims) error
EndSession(w http.ResponseWriter, r *http.Request) error
}
// Config is what New needs to reach the identity provider.
@@ -69,13 +73,16 @@ type Auth struct {
verifier *oidc.IDTokenVerifier
client *http.Client
cookie loginCookie
sessions SessionStarter
logger *slog.Logger
sessions Sessions
// endSessionURL is the provider's end_session_endpoint; logout ends
// there so the provider's own session ends too.
endSessionURL string
logger *slog.Logger
}
// New discovers the provider at cfg.Issuer and returns the login flow. It
// fails if discovery fails or the issuer the provider reports differs.
func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog.Logger) (*Auth, error) {
func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger) (*Auth, error) {
switch {
case cfg.Issuer == "", cfg.ClientID == "", cfg.ClientSecret == "":
return nil, errors.New("auth: issuer, client ID and client secret are required")
@@ -84,7 +91,7 @@ func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog.
case len(cfg.SecretKey) == 0:
return nil, errors.New("auth: secret key is required")
case sessions == nil:
return nil, errors.New("auth: session starter is required")
return nil, errors.New("auth: session layer is required")
}
if err := checkEndpoint("issuer", cfg.Issuer); err != nil {
@@ -98,7 +105,8 @@ func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog.
}
endpoint := provider.Endpoint()
var discovered struct {
JWKSURI string `json:"jwks_uri"`
JWKSURI string `json:"jwks_uri"`
EndSessionURI string `json:"end_session_endpoint"`
}
if err := provider.Claims(&discovered); err != nil {
return nil, fmt.Errorf("auth: OIDC discovery: %w", err)
@@ -109,6 +117,7 @@ func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog.
{"authorization endpoint", endpoint.AuthURL},
{"token endpoint", endpoint.TokenURL},
{"JWKS endpoint", discovered.JWKSURI},
{"end session endpoint", discovered.EndSessionURI},
} {
if err := checkEndpoint(e.name, e.raw); err != nil {
return nil, err
@@ -129,10 +138,11 @@ func New(ctx context.Context, cfg Config, sessions SessionStarter, logger *slog.
ClientID: cfg.ClientID,
SupportedSigningAlgs: []string{oidc.RS256},
}),
client: client,
cookie: newLoginCookie(cfg.SecretKey),
sessions: sessions,
logger: logger,
client: client,
cookie: newLoginCookie(cfg.SecretKey),
sessions: sessions,
endSessionURL: discovered.EndSessionURI,
logger: logger,
}, nil
}
@@ -169,12 +179,16 @@ func checkRedirect(req *http.Request, via []*http.Request) error {
return nil
}
// Mount registers GET LoginPath and GET CallbackPath on r.
// Mount registers GET LoginPath, GET CallbackPath and POST LogoutPath on r.
func (a *Auth) Mount(r chi.Router) {
r.Get(LoginPath, a.login)
r.Get(CallbackPath, a.callback)
r.Post(LogoutPath, a.logout)
}
// EndSessionURL is the provider's end_session_endpoint.
func (a *Auth) EndSessionURL() string { return a.endSessionURL }
// login starts the authorization code flow with PKCE (S256). The optional
// "return" query parameter must be a same-origin path; anything else fails
// the login with 400.
@@ -250,6 +264,18 @@ func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, state.Return, http.StatusSeeOther)
}
// logout ends the session, clears the cookie and sends the browser to the
// provider's end_session_endpoint to end the provider session as well.
func (a *Auth) logout(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if err := a.sessions.EndSession(w, r); err != nil {
a.logger.ErrorContext(r.Context(), "end session", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, a.endSessionURL, http.StatusSeeOther)
}
// verify exchanges code for tokens and returns the verified ID token claims.
// The verifier enforces RS256, issuer, audience and expiry; nonce is checked
// here.