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