Public source validation / validate (push) Failing after 3m8s
348 lines
13 KiB
Go
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
|
|
}
|