This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user