Files
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

348 lines
13 KiB
Go

// Package authapi exposes the two browser-facing OIDC endpoints that complete the
// authorization code flow implemented in internal/auth: GET /auth/login starts a
// flow and GET /auth/callback finishes it by issuing a Pulse session.
//
// Flow state (state, nonce, PKCE verifier and the post-login path) never leaves the
// server; the browser only carries a short-lived opaque flow identifier cookie.
// Every failure path destroys the identified flow, issues no session and redirects
// to a fixed in-app error route with a reason code from a closed set. A flow whose
// identifier never comes back simply expires. Provider-supplied
// text is never reflected into a response, and tokens, codes and PKCE verifiers are
// never logged.
//
// Wiring in cmd/api/main.go, after the session manager exists:
//
// oidcAuth, err := authapi.New(authapi.Options{
// OIDC: auth.OIDCConfig{
// Issuer: application.OIDCIssuer,
// ClientID: application.OIDCClientID,
// ClientSecret: application.OIDCClientSecret,
// RedirectURL: application.OIDCRedirectURL,
// },
// RoleMapping: map[string]auth.Role{
// "pulse-viewer": auth.RoleViewer, "pulse-operator": auth.RoleOperator,
// "pulse-editor": auth.RoleEditor, "pulse-admin": auth.RoleAdministrator,
// },
// Sessions: sessions,
// Secure: application.Environment == config.Production,
// Logger: logger,
// Audit: func(ctx context.Context, actor, result string) error {
// if pool == nil {
// return nil
// }
// return audit.RecordSecurityAction(ctx, audit.PostgresStore{Pool: pool}, actor, "auth.login", result, correlation.FromContext(ctx))
// },
// })
// if err != nil {
// return err
// }
// mux.Handle("/auth/login", oidcAuth.LoginHandler())
// mux.Handle("/auth/callback", oidcAuth.CallbackHandler())
//
// New only fails on incomplete configuration, so registration is safe when
// PULSE_AUTH_MODE is oidc; guard it with `if application.AuthMode == "oidc"` so a
// mock-mode development run keeps working. The callback path registered here must
// equal the path of PULSE_OIDC_REDIRECT_URL. Provider discovery happens lazily on
// the first login and is cached, so a temporarily unreachable IdP does not prevent
// the API from starting.
//
// The runtime mapping is supplied by PULSE_OIDC_ROLE_MAPPING through
// internal/config. An empty mapping authorizes nobody, and production startup
// rejects it before the handlers are registered.
package authapi
import (
"context"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/itworx/pulse/internal/auth"
"github.com/itworx/pulse/internal/correlation"
"github.com/itworx/pulse/internal/problem"
)
const (
flowCookieName = "pulse_auth_flow"
defaultErrorPath = "/login/error"
defaultRedirect = "/"
maxRedirectLength = 512
discoveryTimeout = 10 * time.Second
tokenTimeout = 15 * time.Second
)
// Reason codes are a closed set; the provider never influences their value.
const (
reasonInvalidRequest = "invalid_request"
reasonExpired = "expired"
reasonDenied = "denied"
reasonProviderUnavailable = "provider_unavailable"
reasonNotAuthorized = "not_authorized"
reasonUnavailable = "unavailable"
)
// SessionIssuer is the part of *auth.SessionManager the callback needs.
type SessionIssuer interface {
Issue(response http.ResponseWriter, principal auth.Principal, now time.Time) error
}
type Options struct {
// OIDC is the provider configuration; issuer, client ID and redirect URL are required.
OIDC auth.OIDCConfig
// RoleMapping maps IdP group claim values to Pulse roles. Empty means nobody can log in.
RoleMapping map[string]auth.Role
// GroupsClaim is the ID token claim holding role values; defaults to "groups".
GroupsClaim string
// Sessions issues the Pulse session cookie after a verified login.
Sessions SessionIssuer
// Secure marks the flow cookie Secure; set it in production.
Secure bool
// FlowTTL bounds how long a started flow stays valid; defaults to 10 minutes.
FlowTTL time.Duration
// MaxFlows caps concurrently pending flows; defaults to 1024.
MaxFlows int
// DefaultRedirect is the post-login path when none was requested; defaults to "/".
DefaultRedirect string
// ErrorPath is the in-app route failures redirect to; defaults to "/login/error".
ErrorPath string
// Logger receives structured, secret-free flow events; optional.
Logger *slog.Logger
// Now overrides the clock; defaults to time.Now().UTC(). It must stay close to
// real time because the OIDC provider validates token freshness independently.
Now func() time.Time
// Audit records the security event before a session is issued. A returned error
// fails the login closed; optional.
Audit func(ctx context.Context, actor, result string) error
}
// Handler serves the login and callback endpoints. Create it with New.
type Handler struct {
options Options
flows *flowStore
mu sync.Mutex
discovery auth.Discovery
resolved bool
}
func New(options Options) (*Handler, error) {
if strings.TrimSpace(options.OIDC.Issuer) == "" || strings.TrimSpace(options.OIDC.ClientID) == "" || strings.TrimSpace(options.OIDC.RedirectURL) == "" {
return nil, &configError{"OIDC issuer, client ID and redirect URL are required"}
}
if options.Sessions == nil {
return nil, &configError{"session issuer is required"}
}
if options.GroupsClaim == "" {
options.GroupsClaim = "groups"
}
options.DefaultRedirect = safePath(options.DefaultRedirect, defaultRedirect)
if strings.ContainsAny(options.ErrorPath, "?#") {
options.ErrorPath = ""
}
options.ErrorPath = safePath(options.ErrorPath, defaultErrorPath)
if options.Logger == nil {
options.Logger = slog.New(slog.DiscardHandler)
}
if options.Now == nil {
options.Now = func() time.Time { return time.Now().UTC() }
}
return &Handler{options: options, flows: newFlowStore(options.FlowTTL, options.MaxFlows)}, nil
}
type configError struct{ detail string }
func (e *configError) Error() string { return "authapi configuration invalid: " + e.detail }
// LoginHandler starts the authorization code flow. Register it on /auth/login.
func (handler *Handler) LoginHandler() http.Handler { return http.HandlerFunc(handler.login) }
// CallbackHandler completes the flow. Register it on the path of the configured
// OIDC redirect URL, normally /auth/callback.
func (handler *Handler) CallbackHandler() http.Handler { return http.HandlerFunc(handler.callback) }
func (handler *Handler) login(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet {
methodNotAllowed(response, request)
return
}
now := handler.options.Now()
discovery, err := handler.discover(request.Context())
if err != nil {
handler.reject(response, request, reasonProviderUnavailable, "discovery_failed")
return
}
authorization, err := auth.BeginAuthorization(discovery.Endpoint, handler.options.OIDC, now)
if err != nil {
handler.reject(response, request, reasonProviderUnavailable, "authorization_start_failed")
return
}
redirect := safePath(request.URL.Query().Get("redirect"), handler.options.DefaultRedirect)
id, err := handler.flows.create(flow{authorization: authorization, redirect: redirect}, now)
if err != nil {
handler.reject(response, request, reasonUnavailable, "flow_not_stored")
return
}
http.SetCookie(response, &http.Cookie{
Name: flowCookieName,
Value: id,
Path: "/",
MaxAge: int(handler.flows.ttl.Seconds()),
Expires: now.Add(handler.flows.ttl),
HttpOnly: true,
Secure: handler.options.Secure,
SameSite: http.SameSiteLaxMode,
})
handler.options.Logger.Info("oidc login started", "correlation_id", correlation.FromContext(request.Context()), "pending_flows", handler.flows.size())
http.Redirect(response, request, authorization.URL, http.StatusFound)
}
func (handler *Handler) callback(response http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodGet {
methodNotAllowed(response, request)
return
}
now := handler.options.Now()
cookie, err := request.Cookie(flowCookieName)
handler.clearFlowCookie(response)
if err != nil || cookie.Value == "" {
handler.reject(response, request, reasonInvalidRequest, "flow_cookie_missing")
return
}
pending, ok := handler.flows.take(cookie.Value, now)
if !ok {
handler.reject(response, request, reasonExpired, "flow_unknown_or_expired")
return
}
query := request.URL.Query()
if providerError := query.Get("error"); providerError != "" {
reason := reasonProviderUnavailable
if providerError == "access_denied" {
reason = reasonDenied
}
handler.reject(response, request, reason, "provider_reported_error")
return
}
state, code := query.Get("state"), query.Get("code")
if err := auth.ValidateCallback(pending.authorization, state, code, now); err != nil {
handler.reject(response, request, reasonInvalidRequest, "callback_validation_failed")
return
}
discovery, err := handler.discover(request.Context())
if err != nil {
handler.reject(response, request, reasonProviderUnavailable, "discovery_failed")
return
}
ctx, cancel := context.WithTimeout(request.Context(), tokenTimeout)
defer cancel()
token, err := auth.Exchange(ctx, pending.authorization, handler.options.OIDC, discovery.Endpoint, state, code)
if err != nil {
handler.reject(response, request, reasonProviderUnavailable, "token_exchange_failed")
return
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok || rawIDToken == "" {
handler.reject(response, request, reasonProviderUnavailable, "id_token_missing")
return
}
idToken, err := auth.VerifyIDToken(ctx, discovery.Verifier, rawIDToken, pending.authorization.Nonce)
if err != nil {
handler.reject(response, request, reasonInvalidRequest, "id_token_rejected")
return
}
identity, err := auth.ExtractIdentity(idToken, handler.options.GroupsClaim)
if err != nil {
handler.reject(response, request, reasonInvalidRequest, "identity_incomplete")
return
}
role, err := auth.MapRoles(identity.Groups, handler.options.RoleMapping)
if err != nil {
handler.reject(response, request, reasonNotAuthorized, "no_authorized_role")
return
}
principal := auth.Principal{Subject: identity.Subject, Role: role}
if handler.options.Audit != nil {
if err := handler.options.Audit(request.Context(), principal.Subject, "success"); err != nil {
handler.reject(response, request, reasonUnavailable, "audit_unavailable")
return
}
}
if err := handler.options.Sessions.Issue(response, principal, now); err != nil {
handler.reject(response, request, reasonUnavailable, "session_not_issued")
return
}
handler.options.Logger.Info("oidc login completed", "correlation_id", correlation.FromContext(request.Context()), "role", string(role))
http.Redirect(response, request, safePath(pending.redirect, handler.options.DefaultRedirect), http.StatusFound)
}
// discover resolves and caches the provider endpoints and verifier.
func (handler *Handler) discover(ctx context.Context) (auth.Discovery, error) {
handler.mu.Lock()
defer handler.mu.Unlock()
if handler.resolved {
return handler.discovery, nil
}
discoveryContext, cancel := context.WithTimeout(ctx, discoveryTimeout)
defer cancel()
discovery, err := auth.Discover(discoveryContext, handler.options.OIDC)
if err != nil {
return auth.Discovery{}, err
}
handler.discovery, handler.resolved = discovery, true
return discovery, nil
}
// reject issues no session and sends the browser to the in-app error route with a
// fixed reason code. The flow state is already removed by the time it is called.
func (handler *Handler) reject(response http.ResponseWriter, request *http.Request, reason, event string) {
handler.options.Logger.Warn("oidc flow rejected", "correlation_id", correlation.FromContext(request.Context()), "reason", reason, "event", event)
target := handler.options.ErrorPath + "?" + url.Values{"reason": []string{reason}}.Encode()
http.Redirect(response, request, target, http.StatusFound)
}
func (handler *Handler) clearFlowCookie(response http.ResponseWriter) {
http.SetCookie(response, &http.Cookie{
Name: flowCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: handler.options.Secure,
SameSite: http.SameSiteLaxMode,
})
}
func methodNotAllowed(response http.ResponseWriter, request *http.Request) {
problem.Write(response, request, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", http.StatusText(http.StatusMethodNotAllowed), "This method is not supported.", nil)
}
// safePath accepts only in-app absolute paths: one leading slash, no scheme, no
// authority, no backslash and no control characters. Anything else falls back.
func safePath(candidate, fallback string) string {
target := strings.TrimSpace(candidate)
if target == "" || len(target) > maxRedirectLength {
return fallback
}
if !strings.HasPrefix(target, "/") || strings.HasPrefix(target, "//") {
return fallback
}
if strings.Contains(target, "\\") {
return fallback
}
for _, character := range target {
if character < 0x20 || character == 0x7f {
return fallback
}
}
parsed, err := url.Parse(target)
if err != nil || parsed.Scheme != "" || parsed.Host != "" || parsed.Opaque != "" || parsed.User != nil {
return fallback
}
if !strings.HasPrefix(parsed.Path, "/") {
return fallback
}
return target
}