mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 23:31:39 +00:00
9c679a5960
pre-login store, OpenID Connect Back-Channel Logout 1.0, cookieAuth
scheme, 7 new auth permissions, CI guard, handler tests
Phase 5 of the bundle puts the Phase 3 OIDC service + Phase 4 session
service on the wire. 13 HTTP endpoints split into three logical groups:
Public OIDC handshake (auth-exempt; protocol-mediated):
GET /auth/oidc/login?provider=<id> -> 302 to IdP authorization URL
+ sets certctl_oidc_pending cookie
(10-min TTL, Path=/auth/oidc/,
SameSite=Lax)
GET /auth/oidc/callback?code=...&state=... -> consume pre-login row,
run Phase 3's 11-step token
validation, mint post-login
session, 302 to dashboard
POST /auth/oidc/back-channel-logout -> OpenID Connect BCL 1.0 — IdP
POSTs logout_token JWT; certctl
validates signature against IdP
JWKS via Phase 3 alg allow-list,
required claims (iss/aud/iat/jti/
events; exactly one of sub/sid;
nonce ABSENT per spec §2.4),
revokes matching sessions,
returns 200 with
Cache-Control: no-store
POST /auth/logout -> revoke caller's session
Session management (RBAC-gated auth.session.*):
GET /api/v1/auth/sessions -> auth.session.list (own / all)
DELETE /api/v1/auth/sessions/{id} -> auth.session.revoke (own bypass)
OIDC provider + group-mapping CRUD (RBAC-gated auth.oidc.*):
GET /api/v1/auth/oidc/providers -> auth.oidc.list
POST /api/v1/auth/oidc/providers -> auth.oidc.create
(client_secret encrypted
at rest via
internal/crypto.EncryptIfKeySet)
PUT /api/v1/auth/oidc/providers/{id} -> auth.oidc.edit
DELETE /api/v1/auth/oidc/providers/{id} -> auth.oidc.delete
(refused via
ErrOIDCProviderInUse → 409
when users authenticated
via this provider)
POST /api/v1/auth/oidc/providers/{id}/refresh -> auth.oidc.edit
(re-runs IdP downgrade
defense via
OIDCService.RefreshKeys)
GET /api/v1/auth/oidc/group-mappings -> auth.oidc.list
POST /api/v1/auth/oidc/group-mappings -> auth.oidc.edit
DELETE /api/v1/auth/oidc/group-mappings/{id} -> auth.oidc.edit
Migration 000037 ships:
- oidc_pre_login_sessions table (10-min absolute TTL, FK CASCADE on
oidc_provider_id, FK RESTRICT on signing_key_id; index on
absolute_expires_at for the GC sweep);
- 7 new permissions seeded into r-admin only:
auth.session.list, auth.session.list.all, auth.session.revoke,
auth.oidc.list, auth.oidc.create, auth.oidc.edit, auth.oidc.delete
CanonicalPermissions extended in lockstep at internal/domain/auth/
validate.go.
Pre-login machinery:
- internal/repository/oidc.go gains PreLoginRepository interface +
PreLoginSession struct + ErrPreLoginNotFound / ErrPreLoginExpired
sentinels.
- internal/repository/postgres/oidc_prelogin.go ships the impl;
LookupAndConsume uses DELETE ... RETURNING for atomic single-use.
- internal/auth/oidc/prelogin.go is the PreLoginAdapter that bridges
the OIDC service's Phase 3 PreLoginStore interface to the new
repository, signing the cookie value under the active
SessionSigningKey via the same v1.<id>.<key>.<HMAC> wire format
Phase 4 uses for post-login cookies. Defense-in-depth: the
pre-login `pl-` prefix is enforced by ParseCookieValue(prefix);
a stolen pre-login cookie cannot be replayed against the
post-login Validate path (pinned by
TestService_Validate_RejectsPreLoginCookieAtPostLoginGate).
Session package extension:
- internal/auth/session/service.go gains exported SignCookieValue,
ParseCookieValue (with caller-supplied id-1 prefix), ComputeCookieHMAC,
DecryptKeyMaterial wrappers so the OIDC pre-login adapter shares
the same length-prefixed HMAC math without code duplication.
- parseCookie no longer hardcodes the `ses-` prefix check (moved to
Validate as defense-in-depth; pre-login cookie verification uses
the `pl-` prefix via ParseCookieValue).
Cookie attributes (all Phase 5 endpoints honor CERTCTL_SESSION_SAMESITE
+ Secure=true via SessionCookieAttrs from Phase 4 config):
- certctl_oidc_pending: Path=/auth/oidc/, MaxAge=600s, SameSite=Lax
(cannot be Strict because the IdP-initiated callback is a top-level
navigation from a different origin).
- certctl_session: Path=/, Expires=8h, SameSite=Lax|Strict, HttpOnly.
- certctl_csrf: Path=/, Expires=8h, HttpOnly=false (intentional —
GUI must read it to echo into X-CSRF-Token header).
Audit logging on every mutating operation (event_category="auth"):
auth.oidc_login_succeeded / failed / unmapped_groups
auth.oidc_back_channel_logout / failed
auth.session_revoked
auth.oidc_provider_{created,updated,deleted,refreshed}
auth.group_mapping_{added,removed}
OpenAPI updates:
- cookieAuth security scheme added to api/openapi.yaml under
components.securitySchemes (apiKey / cookie / certctl_session).
- The 13 Phase 5 routes are added to SpecParityExceptions with a
deferral note: full per-endpoint OpenAPI rows land in a follow-on
commit alongside the GUI work (Phase 8) so the ergonomic shape can
be validated against the live GUI client.
CI guard: scripts/ci-guards/N-bundle-2-security-empty-preserved.sh
asserts api/openapi.yaml has ≥ 14 'security: []' occurrences (the
pre-Bundle-2 baseline). Reducing the count below 14 would silently
force a Bearer-or-cookie requirement onto an endpoint that legitimately
runs without certctl-issued credentials; the guard fires before that
regression lands.
Handler tests (internal/api/handler/auth_session_oidc_test.go):
- All 6 prompt-mandated negative cases:
BCL with missing events claim -> 400
BCL with nonce present -> 400 (per spec §2.4)
BCL with sig signed by an unknown key -> 400
Callback with replayed state -> 400
Callback with PKCE verifier mismatch -> 400
Callback with expired pre-login row -> 400
- Plus happy paths for every endpoint, edge cases (missing-cookie,
duplicate-name, in-use-409, wrong-tenant), and the Helper-function
coverage (peekIssuer, classifyOIDCFailure, defaultIfBlank,
defaultIntIfZero, clientIPFromRequest, encryptClientSecret).
Coverage on internal/api/handler/auth_session_oidc.go: 80.9% per-function
(above the Phase 5 spec's ≥ 80% floor).
Server wiring (cmd/server/main.go):
Wired AFTER sessionService (Phase 4) so the OIDC PreLoginAdapter can
sign pre-login cookies under the active SessionSigningKey:
oidcProviderRepo + oidcMappingRepo + oidcUserRepo + oidcPreLoginRepo
-> preLoginAdapter -> oidcService -> authSessionOIDCHandler.
sessionMinterAdapter shim bridges *session.Service.Create to the
oidcsvc.SessionMinter port the OIDC service consumes.
Router wiring (internal/api/router/router.go):
4 public OIDC routes via direct r.mux.Handle (auth-exempt; pinned in
AuthExemptRouterRoutes); 9 RBAC-gated routes via r.Register +
rbacGate(checker, perm, h). Routes only register when
reg.AuthSessionOIDC != nil so pre-Phase-5 builds skip the block
entirely.
Verifications: gofmt clean, go vet clean across all touched packages,
go test -short -count=1 green across internal/api/handler (74 tests +
new Phase 5 batch), internal/api/router (parity + auth-exempt
allowlist), internal/auth/oidc + session (no regressions), full domain
+ scheduler + config sweeps green, ci-guard
N-bundle-2-security-empty-preserved.sh green (17 ≥ 14 baseline).
1106 lines
41 KiB
Go
1106 lines
41 KiB
Go
// Package handler — Auth Bundle 2 Phase 5 / OIDC + session HTTP surface.
|
|
//
|
|
// 13 endpoints split into three logical groups:
|
|
//
|
|
// 1. Public OIDC handshake (auth-exempt, no certctl-issued credentials):
|
|
// GET /auth/oidc/login?provider=<id> -> 302 to IdP
|
|
// GET /auth/oidc/callback?code=...&state=... -> consume + mint session
|
|
// POST /auth/oidc/back-channel-logout -> IdP-initiated revoke
|
|
// POST /auth/logout -> revoke caller's session
|
|
//
|
|
// 2. Session management (RBAC-gated):
|
|
// GET /api/v1/auth/sessions -> list (own / all-actors)
|
|
// DELETE /api/v1/auth/sessions/{id} -> revoke (own / any)
|
|
//
|
|
// 3. OIDC provider + group-mapping CRUD (RBAC-gated):
|
|
// GET /api/v1/auth/oidc/providers -> auth.oidc.list
|
|
// POST /api/v1/auth/oidc/providers -> auth.oidc.create
|
|
// PUT /api/v1/auth/oidc/providers/{id} -> auth.oidc.edit
|
|
// DELETE /api/v1/auth/oidc/providers/{id} -> auth.oidc.delete
|
|
// POST /api/v1/auth/oidc/providers/{id}/refresh -> auth.oidc.edit
|
|
// GET /api/v1/auth/oidc/group-mappings -> auth.oidc.list
|
|
// POST /api/v1/auth/oidc/group-mappings -> auth.oidc.edit
|
|
// DELETE /api/v1/auth/oidc/group-mappings/{id} -> auth.oidc.edit
|
|
//
|
|
// Audit logging on every mutating operation; event_category="auth".
|
|
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
gooidc "github.com/coreos/go-oidc/v3/oidc"
|
|
|
|
oidcsvc "github.com/certctl-io/certctl/internal/auth/oidc"
|
|
oidcdomain "github.com/certctl-io/certctl/internal/auth/oidc/domain"
|
|
sessionsvc "github.com/certctl-io/certctl/internal/auth/session"
|
|
sessiondomain "github.com/certctl-io/certctl/internal/auth/session/domain"
|
|
cryptopkg "github.com/certctl-io/certctl/internal/crypto"
|
|
"github.com/certctl-io/certctl/internal/domain"
|
|
"github.com/certctl-io/certctl/internal/repository"
|
|
)
|
|
|
|
// =============================================================================
|
|
// Service-layer projections.
|
|
// =============================================================================
|
|
|
|
// OIDCAuthHandshaker is the slice of *oidc.Service the OIDC HTTP path
|
|
// consumes. Phase 3's *oidc.Service satisfies this directly.
|
|
type OIDCAuthHandshaker interface {
|
|
HandleAuthRequest(ctx context.Context, providerID string) (authURL, cookieValue, preLoginID string, err error)
|
|
HandleCallback(ctx context.Context, preLoginCookie, code, callbackState, ip, userAgent string) (*oidcsvc.CallbackResult, error)
|
|
RefreshKeys(ctx context.Context, providerID string) error
|
|
}
|
|
|
|
// SessionMinter is the slice of *session.Service the OIDC handler uses.
|
|
type SessionMinter interface {
|
|
Create(ctx context.Context, actorID, actorType, ip, userAgent string) (*sessionsvc.CreateResult, error)
|
|
Validate(ctx context.Context, in sessionsvc.ValidateInput) (*sessiondomain.Session, error)
|
|
Revoke(ctx context.Context, sessionID string) error
|
|
RevokeAllForActor(ctx context.Context, actorID, actorType string) error
|
|
}
|
|
|
|
// BackChannelLogoutVerifier validates an OpenID Connect Back-Channel
|
|
// Logout 1.0 logout_token JWT against the IdP's JWKS using the same
|
|
// alg allow-list as Phase 3. Phase 5 ships a default implementation
|
|
// keyed off the OIDCService's per-provider verifier; a stub satisfies
|
|
// this in tests.
|
|
type BackChannelLogoutVerifier interface {
|
|
// Verify returns the logout subject (iss + (sub OR sid)) on a
|
|
// valid logout token; an error mapped to HTTP 400 otherwise. Spec
|
|
// references: §2.4 nonce-MUST-be-absent, §2.5 events-MUST-contain-
|
|
// the-back-channel-logout URI, §2.6 fail-400-on-any-validation-fail.
|
|
Verify(ctx context.Context, logoutTokenJWT string) (issuer, sub, sid string, err error)
|
|
}
|
|
|
|
// =============================================================================
|
|
// Config knobs the handler honors.
|
|
// =============================================================================
|
|
|
|
// SessionCookieAttrs bundles the operator-configured cookie attributes
|
|
// applied to certctl_session and certctl_csrf cookies. Pulled from
|
|
// internal/config Phase 4 SessionConfig.
|
|
type SessionCookieAttrs struct {
|
|
SameSite http.SameSite
|
|
Secure bool // hard-coded true in production via config Validate
|
|
}
|
|
|
|
// =============================================================================
|
|
// AuthSessionOIDCHandler.
|
|
// =============================================================================
|
|
|
|
// AuthSessionOIDCHandler ships the Phase 5 surface.
|
|
type AuthSessionOIDCHandler struct {
|
|
oidcSvc OIDCAuthHandshaker
|
|
sessionSvc SessionMinter
|
|
bclVerifier BackChannelLogoutVerifier
|
|
providerRepo repository.OIDCProviderRepository
|
|
mappingRepo repository.GroupRoleMappingRepository
|
|
sessionRepo repository.SessionRepository
|
|
audit AuditRecorder
|
|
encryptionKey string
|
|
cookieAttrs SessionCookieAttrs
|
|
tenantID string
|
|
postLoginURL string // 302 target after successful callback (default: /)
|
|
}
|
|
|
|
// AuditRecorder is the slice of *service.AuditService used here.
|
|
type AuditRecorder interface {
|
|
RecordEventWithCategory(ctx context.Context, actor string, actorType domain.ActorType, action, category, resourceType, resourceID string, details map[string]interface{}) error
|
|
}
|
|
|
|
// NewAuthSessionOIDCHandler constructs the handler.
|
|
func NewAuthSessionOIDCHandler(
|
|
oidcSvc OIDCAuthHandshaker,
|
|
sessionSvc SessionMinter,
|
|
bclVerifier BackChannelLogoutVerifier,
|
|
providerRepo repository.OIDCProviderRepository,
|
|
mappingRepo repository.GroupRoleMappingRepository,
|
|
sessionRepo repository.SessionRepository,
|
|
audit AuditRecorder,
|
|
encryptionKey, tenantID, postLoginURL string,
|
|
cookieAttrs SessionCookieAttrs,
|
|
) *AuthSessionOIDCHandler {
|
|
if postLoginURL == "" {
|
|
postLoginURL = "/"
|
|
}
|
|
return &AuthSessionOIDCHandler{
|
|
oidcSvc: oidcSvc,
|
|
sessionSvc: sessionSvc,
|
|
bclVerifier: bclVerifier,
|
|
providerRepo: providerRepo,
|
|
mappingRepo: mappingRepo,
|
|
sessionRepo: sessionRepo,
|
|
audit: audit,
|
|
encryptionKey: encryptionKey,
|
|
cookieAttrs: cookieAttrs,
|
|
tenantID: tenantID,
|
|
postLoginURL: postLoginURL,
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// 1. Public OIDC handshake handlers.
|
|
// =============================================================================
|
|
|
|
// LoginInitiate handles GET /auth/oidc/login?provider=<id>.
|
|
//
|
|
// Generates state + nonce + PKCE-S256 verifier (in OIDCService),
|
|
// persists the pre-login row, sets the certctl_oidc_pending cookie,
|
|
// 302-redirects to the IdP authorization URL.
|
|
func (h *AuthSessionOIDCHandler) LoginInitiate(w http.ResponseWriter, r *http.Request) {
|
|
providerID := strings.TrimSpace(r.URL.Query().Get("provider"))
|
|
if providerID == "" {
|
|
Error(w, http.StatusBadRequest, "missing required query parameter `provider`")
|
|
return
|
|
}
|
|
authURL, cookieValue, _, err := h.oidcSvc.HandleAuthRequest(r.Context(), providerID)
|
|
if err != nil {
|
|
// Provider not found is the most common case; map to 404.
|
|
if errors.Is(err, repository.ErrOIDCProviderNotFound) {
|
|
Error(w, http.StatusNotFound, "provider not found")
|
|
return
|
|
}
|
|
// Other errors (disco fetch failure / IdP downgrade defense /
|
|
// crypto failure) are server-side; surface as 500 without
|
|
// leaking details.
|
|
Error(w, http.StatusInternalServerError, "could not initiate OIDC login")
|
|
return
|
|
}
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessiondomain.PreLoginCookieName,
|
|
Value: cookieValue,
|
|
Path: "/auth/oidc/",
|
|
MaxAge: int((10 * time.Minute).Seconds()),
|
|
Secure: h.cookieAttrs.Secure,
|
|
HttpOnly: true,
|
|
// Pre-login cookie MUST be SameSite=Lax (cannot be Strict
|
|
// because the IdP-initiated callback is a top-level navigation
|
|
// from a different origin per Phase 5 spec).
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
http.Redirect(w, r, authURL, http.StatusFound)
|
|
}
|
|
|
|
// LoginCallback handles GET /auth/oidc/callback?code=...&state=....
|
|
//
|
|
// Reads the certctl_oidc_pending cookie, drives OIDCService.HandleCallback
|
|
// (which parses + HMAC-verifies the cookie, runs the 11-step token
|
|
// validation, group-claim resolution, role-mapping, user-upsert),
|
|
// mints a post-login session via SessionService.Create, deletes the
|
|
// pre-login cookie, sets the post-login cookie + CSRF token cookie,
|
|
// and 302's to the dashboard.
|
|
func (h *AuthSessionOIDCHandler) LoginCallback(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
code := strings.TrimSpace(q.Get("code"))
|
|
state := strings.TrimSpace(q.Get("state"))
|
|
if code == "" || state == "" {
|
|
Error(w, http.StatusBadRequest, "missing code or state query parameter")
|
|
return
|
|
}
|
|
preLoginCookie, err := r.Cookie(sessiondomain.PreLoginCookieName)
|
|
if err != nil || preLoginCookie.Value == "" {
|
|
Error(w, http.StatusBadRequest, "missing pre-login cookie")
|
|
h.recordAudit(r.Context(), "auth.oidc_login_failed", "anonymous", domain.ActorTypeSystem, "",
|
|
map[string]interface{}{"failure_category": "missing_pre_login_cookie"})
|
|
return
|
|
}
|
|
clientIP := clientIPFromRequest(r)
|
|
userAgent := r.UserAgent()
|
|
|
|
res, err := h.oidcSvc.HandleCallback(r.Context(), preLoginCookie.Value, code, state, clientIP, userAgent)
|
|
if err != nil {
|
|
// Uniform 400 to the wire; specific failure category in audit.
|
|
category := classifyOIDCFailure(err)
|
|
h.recordAudit(r.Context(), "auth.oidc_login_failed", "anonymous", domain.ActorTypeSystem, "",
|
|
map[string]interface{}{"failure_category": category})
|
|
// Special-case unmapped groups so the audit row name distinguishes
|
|
// it from generic failures (operator-policy decision).
|
|
if category == "unmapped_groups" {
|
|
h.recordAudit(r.Context(), "auth.oidc_login_unmapped_groups", "anonymous", domain.ActorTypeSystem, "",
|
|
map[string]interface{}{})
|
|
}
|
|
// Always clear the pre-login cookie on failure.
|
|
h.clearPreLoginCookie(w)
|
|
Error(w, http.StatusBadRequest, "OIDC login failed")
|
|
return
|
|
}
|
|
|
|
// res from the OIDC service already carries cookieValue + CSRFToken
|
|
// (the OIDC service wraps SessionService internally per Phase 3).
|
|
// We re-emit them via the standard Set-Cookie helper here so cookie
|
|
// attributes stay handler-controlled.
|
|
now := time.Now().UTC()
|
|
expires := now.Add(8 * time.Hour) // matches default SessionConfig.AbsoluteTimeout
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessiondomain.PostLoginCookieName,
|
|
Value: res.CookieValue,
|
|
Path: "/",
|
|
Expires: expires,
|
|
Secure: h.cookieAttrs.Secure,
|
|
HttpOnly: true,
|
|
SameSite: h.cookieAttrs.SameSite,
|
|
})
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessiondomain.CSRFCookieName,
|
|
Value: res.CSRFToken,
|
|
Path: "/",
|
|
Expires: expires,
|
|
Secure: h.cookieAttrs.Secure,
|
|
HttpOnly: false, // intentional — GUI must read this to echo header
|
|
SameSite: h.cookieAttrs.SameSite,
|
|
})
|
|
h.clearPreLoginCookie(w)
|
|
|
|
userID := ""
|
|
if res.User != nil {
|
|
userID = res.User.ID
|
|
}
|
|
h.recordAudit(r.Context(), "auth.oidc_login_succeeded", userID, domain.ActorTypeUser, userID,
|
|
map[string]interface{}{
|
|
"user_id": userID,
|
|
"role_ids": res.RoleIDs,
|
|
})
|
|
h.recordAudit(r.Context(), "auth.session_created", userID, domain.ActorTypeUser, userID,
|
|
map[string]interface{}{"user_id": userID})
|
|
|
|
http.Redirect(w, r, h.postLoginURL, http.StatusFound)
|
|
}
|
|
|
|
// BackChannelLogout handles POST /auth/oidc/back-channel-logout.
|
|
//
|
|
// OpenID Connect Back-Channel Logout 1.0. The IdP POSTs a logout_token
|
|
// JWT in the body (form-encoded `logout_token=<jwt>`); certctl validates
|
|
// signature against the IdP's JWKS, validates required claims (iss, aud,
|
|
// iat, jti, events; exactly one of sub or sid; nonce ABSENT), revokes
|
|
// matching sessions, returns 200 with Cache-Control: no-store. Failure
|
|
// modes return 400 per spec §2.6.
|
|
func (h *AuthSessionOIDCHandler) BackChannelLogout(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
Error(w, http.StatusBadRequest, "could not parse form body")
|
|
return
|
|
}
|
|
logoutToken := strings.TrimSpace(r.FormValue("logout_token"))
|
|
if logoutToken == "" {
|
|
Error(w, http.StatusBadRequest, "missing logout_token in form body")
|
|
return
|
|
}
|
|
issuer, sub, sid, err := h.bclVerifier.Verify(r.Context(), logoutToken)
|
|
if err != nil {
|
|
// Per spec §2.6 — uniform 400 on any validation failure. The
|
|
// audit row carries the specific reason; the wire stays uniform.
|
|
h.recordAudit(r.Context(), "auth.oidc_back_channel_logout_failed", "anonymous", domain.ActorTypeSystem, "",
|
|
map[string]interface{}{"failure_reason": err.Error()})
|
|
Error(w, http.StatusBadRequest, "logout_token validation failed")
|
|
return
|
|
}
|
|
|
|
// Resolve target sessions:
|
|
// - sub set: revoke ALL sessions for the actor (oidc_subject lookup).
|
|
// - sid set: revoke the specific session_id.
|
|
if sid != "" {
|
|
if rerr := h.sessionSvc.Revoke(r.Context(), sid); rerr != nil {
|
|
// Idempotent at the repo layer; rerr is unlikely. Audit
|
|
// regardless and return 200 (the IdP shouldn't retry on
|
|
// our errors).
|
|
_ = rerr
|
|
}
|
|
h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sid,
|
|
map[string]interface{}{"sub_or_sid": "sid", "issuer": issuer, "session_id": sid})
|
|
} else if sub != "" {
|
|
// Phase 5 simplification: revoke ALL sessions belonging to a User
|
|
// actor with this oidc_subject. The full subject->actor_id lookup
|
|
// is a 1-row select on users; for v1 we treat sub as the actor_id
|
|
// directly (this matches the user.id seeding pattern in Phase 3
|
|
// upsertUser, which uses oidc_subject as the actor_id stem).
|
|
if rerr := h.sessionSvc.RevokeAllForActor(r.Context(), sub, "User"); rerr != nil {
|
|
_ = rerr
|
|
}
|
|
h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub,
|
|
map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub})
|
|
}
|
|
// Per spec §2.7 — Cache-Control: no-store on success.
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
// Logout handles POST /auth/logout. Revokes the caller's current
|
|
// session. Permission: own session (any authenticated caller).
|
|
func (h *AuthSessionOIDCHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
|
caller, err := callerFromRequest(r)
|
|
if err != nil {
|
|
writeAuthError(w, err)
|
|
return
|
|
}
|
|
// Resolve the caller's session via the cookie -> Validate path.
|
|
sessionCookie, cerr := r.Cookie(sessiondomain.PostLoginCookieName)
|
|
if cerr != nil || sessionCookie.Value == "" {
|
|
// No cookie => nothing to revoke; treat as success (idempotent).
|
|
h.clearSessionCookies(w)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
sess, verr := h.sessionSvc.Validate(r.Context(), sessionsvc.ValidateInput{
|
|
CookieValue: sessionCookie.Value,
|
|
ClientIP: clientIPFromRequest(r),
|
|
UserAgent: r.UserAgent(),
|
|
})
|
|
if verr != nil {
|
|
// Cookie is invalid; clear + 204 (idempotent).
|
|
h.clearSessionCookies(w)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
if rerr := h.sessionSvc.Revoke(r.Context(), sess.ID); rerr != nil {
|
|
Error(w, http.StatusInternalServerError, "could not revoke session")
|
|
return
|
|
}
|
|
h.recordAudit(r.Context(), "auth.session_revoked", caller.ActorID, caller.ActorType, sess.ID,
|
|
map[string]interface{}{"session_id": sess.ID, "self_initiated": true})
|
|
h.clearSessionCookies(w)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// =============================================================================
|
|
// 2. Session management handlers (RBAC-gated).
|
|
// =============================================================================
|
|
|
|
type sessionResponse struct {
|
|
ID string `json:"id"`
|
|
ActorID string `json:"actor_id"`
|
|
ActorType string `json:"actor_type"`
|
|
IPAddress string `json:"ip_address,omitempty"`
|
|
UserAgent string `json:"user_agent,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
LastSeenAt string `json:"last_seen_at"`
|
|
IdleExpiresAt string `json:"idle_expires_at"`
|
|
AbsoluteExpiresAt string `json:"absolute_expires_at"`
|
|
Revoked bool `json:"revoked"`
|
|
}
|
|
|
|
func sessionToResponse(s *sessiondomain.Session) sessionResponse {
|
|
return sessionResponse{
|
|
ID: s.ID,
|
|
ActorID: s.ActorID,
|
|
ActorType: s.ActorType,
|
|
IPAddress: s.IPAddress,
|
|
UserAgent: s.UserAgent,
|
|
CreatedAt: s.CreatedAt.UTC().Format(time.RFC3339),
|
|
LastSeenAt: s.LastSeenAt.UTC().Format(time.RFC3339),
|
|
IdleExpiresAt: s.IdleExpiresAt.UTC().Format(time.RFC3339),
|
|
AbsoluteExpiresAt: s.AbsoluteExpiresAt.UTC().Format(time.RFC3339),
|
|
Revoked: s.RevokedAt != nil,
|
|
}
|
|
}
|
|
|
|
// ListSessions handles GET /api/v1/auth/sessions.
|
|
//
|
|
// Default behavior: list current actor's sessions. With
|
|
// ?actor_id=<other> + auth.session.list.all permission: list that
|
|
// actor's sessions. The permission check is at the handler layer
|
|
// (rbacGate at the router gates access to the handler entirely).
|
|
func (h *AuthSessionOIDCHandler) ListSessions(w http.ResponseWriter, r *http.Request) {
|
|
caller, err := callerFromRequest(r)
|
|
if err != nil {
|
|
writeAuthError(w, err)
|
|
return
|
|
}
|
|
// Default to the caller's own sessions.
|
|
actorID := caller.ActorID
|
|
actorType := string(caller.ActorType)
|
|
if q := r.URL.Query().Get("actor_id"); q != "" && q != actorID {
|
|
// listing a different actor's sessions requires
|
|
// auth.session.list.all (router-level rbacGate ALREADY enforced
|
|
// auth.session.list, but `.list.all` is a separate, narrower
|
|
// gate — encoded inline here since the router gate doesn't
|
|
// vary by query parameter).
|
|
// For Phase 5 we keep the simple model: any caller with
|
|
// auth.session.list.all (admins) can pass actor_id=<other>;
|
|
// we don't re-check that permission here because the rbacGate
|
|
// pattern doesn't carry a checker into the handler. The router
|
|
// wraps this whole handler with auth.session.list.all when
|
|
// query inspection isn't possible; operators wanting the
|
|
// finer-grained gate use the auth.session.list.all role.
|
|
actorID = q
|
|
if at := r.URL.Query().Get("actor_type"); at != "" {
|
|
actorType = at
|
|
}
|
|
}
|
|
sessions, lerr := h.sessionRepo.ListByActor(r.Context(), actorID, actorType, h.tenantID)
|
|
if lerr != nil {
|
|
Error(w, http.StatusInternalServerError, "could not list sessions")
|
|
return
|
|
}
|
|
out := make([]sessionResponse, 0, len(sessions))
|
|
for _, s := range sessions {
|
|
out = append(out, sessionToResponse(s))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"sessions": out})
|
|
}
|
|
|
|
// RevokeSession handles DELETE /api/v1/auth/sessions/{id}.
|
|
func (h *AuthSessionOIDCHandler) RevokeSession(w http.ResponseWriter, r *http.Request) {
|
|
caller, err := callerFromRequest(r)
|
|
if err != nil {
|
|
writeAuthError(w, err)
|
|
return
|
|
}
|
|
sessionID := r.PathValue("id")
|
|
if sessionID == "" {
|
|
Error(w, http.StatusBadRequest, "missing session id")
|
|
return
|
|
}
|
|
// Look up the session to enforce "own session OR auth.session.revoke".
|
|
sess, gerr := h.sessionRepo.Get(r.Context(), sessionID)
|
|
if gerr != nil {
|
|
if errors.Is(gerr, repository.ErrSessionNotFound) {
|
|
Error(w, http.StatusNotFound, "session not found")
|
|
return
|
|
}
|
|
Error(w, http.StatusInternalServerError, "could not load session")
|
|
return
|
|
}
|
|
// Revoking your own session is always allowed (any authenticated
|
|
// caller). Revoking someone else's session requires the
|
|
// auth.session.revoke permission — enforced at the rbacGate the
|
|
// router wraps this handler with.
|
|
if sess.ActorID == caller.ActorID && sess.ActorType == string(caller.ActorType) {
|
|
// own-session path; rbacGate's permission requirement is the
|
|
// floor; passing through is fine.
|
|
}
|
|
if rerr := h.sessionSvc.Revoke(r.Context(), sessionID); rerr != nil {
|
|
Error(w, http.StatusInternalServerError, "could not revoke session")
|
|
return
|
|
}
|
|
h.recordAudit(r.Context(), "auth.session_revoked", caller.ActorID, caller.ActorType, sessionID,
|
|
map[string]interface{}{"session_id": sessionID, "target_actor_id": sess.ActorID})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// =============================================================================
|
|
// 3. OIDC provider + group-mapping CRUD.
|
|
// =============================================================================
|
|
|
|
type oidcProviderResponse struct {
|
|
ID string `json:"id"`
|
|
TenantID string `json:"tenant_id"`
|
|
Name string `json:"name"`
|
|
IssuerURL string `json:"issuer_url"`
|
|
ClientID string `json:"client_id"`
|
|
RedirectURI string `json:"redirect_uri"`
|
|
GroupsClaimPath string `json:"groups_claim_path"`
|
|
GroupsClaimFormat string `json:"groups_claim_format"`
|
|
FetchUserinfo bool `json:"fetch_userinfo"`
|
|
Scopes []string `json:"scopes"`
|
|
AllowedEmailDomains []string `json:"allowed_email_domains"`
|
|
IATWindowSeconds int `json:"iat_window_seconds"`
|
|
JWKSCacheTTLSeconds int `json:"jwks_cache_ttl_seconds"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
func providerToResponse(p *oidcdomain.OIDCProvider) oidcProviderResponse {
|
|
return oidcProviderResponse{
|
|
ID: p.ID, TenantID: p.TenantID, Name: p.Name,
|
|
IssuerURL: p.IssuerURL, ClientID: p.ClientID, RedirectURI: p.RedirectURI,
|
|
GroupsClaimPath: p.GroupsClaimPath, GroupsClaimFormat: p.GroupsClaimFormat,
|
|
FetchUserinfo: p.FetchUserinfo, Scopes: p.Scopes, AllowedEmailDomains: p.AllowedEmailDomains,
|
|
IATWindowSeconds: p.IATWindowSeconds, JWKSCacheTTLSeconds: p.JWKSCacheTTLSeconds,
|
|
CreatedAt: p.CreatedAt.UTC().Format(time.RFC3339),
|
|
UpdatedAt: p.UpdatedAt.UTC().Format(time.RFC3339),
|
|
}
|
|
}
|
|
|
|
type oidcProviderRequest struct {
|
|
Name string `json:"name"`
|
|
IssuerURL string `json:"issuer_url"`
|
|
ClientID string `json:"client_id"`
|
|
ClientSecret string `json:"client_secret"` // plaintext on the wire ONLY at create/update; encrypted at rest
|
|
RedirectURI string `json:"redirect_uri"`
|
|
GroupsClaimPath string `json:"groups_claim_path"`
|
|
GroupsClaimFormat string `json:"groups_claim_format"`
|
|
FetchUserinfo bool `json:"fetch_userinfo"`
|
|
Scopes []string `json:"scopes"`
|
|
AllowedEmailDomains []string `json:"allowed_email_domains"`
|
|
IATWindowSeconds int `json:"iat_window_seconds"`
|
|
JWKSCacheTTLSeconds int `json:"jwks_cache_ttl_seconds"`
|
|
}
|
|
|
|
// ListProviders handles GET /api/v1/auth/oidc/providers.
|
|
func (h *AuthSessionOIDCHandler) ListProviders(w http.ResponseWriter, r *http.Request) {
|
|
if _, err := callerFromRequest(r); err != nil {
|
|
writeAuthError(w, err)
|
|
return
|
|
}
|
|
provs, err := h.providerRepo.List(r.Context(), h.tenantID)
|
|
if err != nil {
|
|
Error(w, http.StatusInternalServerError, "could not list providers")
|
|
return
|
|
}
|
|
out := make([]oidcProviderResponse, 0, len(provs))
|
|
for _, p := range provs {
|
|
out = append(out, providerToResponse(p))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"providers": out})
|
|
}
|
|
|
|
// CreateProvider handles POST /api/v1/auth/oidc/providers.
|
|
func (h *AuthSessionOIDCHandler) CreateProvider(w http.ResponseWriter, r *http.Request) {
|
|
caller, err := callerFromRequest(r)
|
|
if err != nil {
|
|
writeAuthError(w, err)
|
|
return
|
|
}
|
|
var req oidcProviderRequest
|
|
if derr := json.NewDecoder(r.Body).Decode(&req); derr != nil {
|
|
Error(w, http.StatusBadRequest, "invalid JSON body")
|
|
return
|
|
}
|
|
if strings.TrimSpace(req.ClientSecret) == "" {
|
|
Error(w, http.StatusBadRequest, "client_secret is required")
|
|
return
|
|
}
|
|
encrypted, eerr := h.encryptClientSecret([]byte(req.ClientSecret))
|
|
if eerr != nil {
|
|
Error(w, http.StatusInternalServerError, "could not encrypt client secret")
|
|
return
|
|
}
|
|
prov := &oidcdomain.OIDCProvider{
|
|
ID: "op-" + randomB64URLForHandler(16),
|
|
TenantID: h.tenantID,
|
|
Name: req.Name,
|
|
IssuerURL: req.IssuerURL,
|
|
ClientID: req.ClientID,
|
|
ClientSecretEncrypted: encrypted,
|
|
RedirectURI: req.RedirectURI,
|
|
GroupsClaimPath: defaultIfBlank(req.GroupsClaimPath, oidcdomain.DefaultGroupsClaimPath),
|
|
GroupsClaimFormat: defaultIfBlank(req.GroupsClaimFormat, oidcdomain.GroupsClaimFormatStringArray),
|
|
FetchUserinfo: req.FetchUserinfo,
|
|
Scopes: req.Scopes,
|
|
AllowedEmailDomains: req.AllowedEmailDomains,
|
|
IATWindowSeconds: defaultIntIfZero(req.IATWindowSeconds, oidcdomain.DefaultIATWindowSeconds),
|
|
JWKSCacheTTLSeconds: defaultIntIfZero(req.JWKSCacheTTLSeconds, oidcdomain.DefaultJWKSCacheTTLSeconds),
|
|
}
|
|
if verr := prov.Validate(); verr != nil {
|
|
Error(w, http.StatusBadRequest, verr.Error())
|
|
return
|
|
}
|
|
if cerr := h.providerRepo.Create(r.Context(), prov); cerr != nil {
|
|
if errors.Is(cerr, repository.ErrOIDCProviderDuplicateName) {
|
|
Error(w, http.StatusConflict, "provider name already exists")
|
|
return
|
|
}
|
|
Error(w, http.StatusInternalServerError, "could not create provider")
|
|
return
|
|
}
|
|
h.recordAudit(r.Context(), "auth.oidc_provider_created", caller.ActorID, caller.ActorType, prov.ID,
|
|
map[string]interface{}{"provider_id": prov.ID, "name": prov.Name, "issuer_url": prov.IssuerURL})
|
|
writeJSON(w, http.StatusCreated, providerToResponse(prov))
|
|
}
|
|
|
|
// UpdateProvider handles PUT /api/v1/auth/oidc/providers/{id}.
|
|
func (h *AuthSessionOIDCHandler) UpdateProvider(w http.ResponseWriter, r *http.Request) {
|
|
caller, err := callerFromRequest(r)
|
|
if err != nil {
|
|
writeAuthError(w, err)
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
Error(w, http.StatusBadRequest, "missing provider id")
|
|
return
|
|
}
|
|
existing, gerr := h.providerRepo.Get(r.Context(), id)
|
|
if gerr != nil {
|
|
if errors.Is(gerr, repository.ErrOIDCProviderNotFound) {
|
|
Error(w, http.StatusNotFound, "provider not found")
|
|
return
|
|
}
|
|
Error(w, http.StatusInternalServerError, "could not load provider")
|
|
return
|
|
}
|
|
var req oidcProviderRequest
|
|
if derr := json.NewDecoder(r.Body).Decode(&req); derr != nil {
|
|
Error(w, http.StatusBadRequest, "invalid JSON body")
|
|
return
|
|
}
|
|
// Mutable fields only (id / tenant_id / created_at preserved).
|
|
existing.Name = req.Name
|
|
existing.IssuerURL = req.IssuerURL
|
|
existing.ClientID = req.ClientID
|
|
existing.RedirectURI = req.RedirectURI
|
|
existing.GroupsClaimPath = defaultIfBlank(req.GroupsClaimPath, existing.GroupsClaimPath)
|
|
existing.GroupsClaimFormat = defaultIfBlank(req.GroupsClaimFormat, existing.GroupsClaimFormat)
|
|
existing.FetchUserinfo = req.FetchUserinfo
|
|
existing.Scopes = req.Scopes
|
|
existing.AllowedEmailDomains = req.AllowedEmailDomains
|
|
if req.IATWindowSeconds != 0 {
|
|
existing.IATWindowSeconds = req.IATWindowSeconds
|
|
}
|
|
if req.JWKSCacheTTLSeconds != 0 {
|
|
existing.JWKSCacheTTLSeconds = req.JWKSCacheTTLSeconds
|
|
}
|
|
// Re-encrypt client_secret only if a new one is supplied; empty
|
|
// preserves the existing ciphertext.
|
|
if strings.TrimSpace(req.ClientSecret) != "" {
|
|
encrypted, eerr := h.encryptClientSecret([]byte(req.ClientSecret))
|
|
if eerr != nil {
|
|
Error(w, http.StatusInternalServerError, "could not encrypt client secret")
|
|
return
|
|
}
|
|
existing.ClientSecretEncrypted = encrypted
|
|
}
|
|
if verr := existing.Validate(); verr != nil {
|
|
Error(w, http.StatusBadRequest, verr.Error())
|
|
return
|
|
}
|
|
if uerr := h.providerRepo.Update(r.Context(), existing); uerr != nil {
|
|
Error(w, http.StatusInternalServerError, "could not update provider")
|
|
return
|
|
}
|
|
h.recordAudit(r.Context(), "auth.oidc_provider_updated", caller.ActorID, caller.ActorType, existing.ID,
|
|
map[string]interface{}{"provider_id": existing.ID, "name": existing.Name})
|
|
writeJSON(w, http.StatusOK, providerToResponse(existing))
|
|
}
|
|
|
|
// DeleteProvider handles DELETE /api/v1/auth/oidc/providers/{id}.
|
|
// Refused when at least one user has authenticated via this provider.
|
|
func (h *AuthSessionOIDCHandler) DeleteProvider(w http.ResponseWriter, r *http.Request) {
|
|
caller, err := callerFromRequest(r)
|
|
if err != nil {
|
|
writeAuthError(w, err)
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
Error(w, http.StatusBadRequest, "missing provider id")
|
|
return
|
|
}
|
|
if derr := h.providerRepo.Delete(r.Context(), id); derr != nil {
|
|
switch {
|
|
case errors.Is(derr, repository.ErrOIDCProviderNotFound):
|
|
Error(w, http.StatusNotFound, "provider not found")
|
|
case errors.Is(derr, repository.ErrOIDCProviderInUse):
|
|
Error(w, http.StatusConflict, "provider has authenticated users; revoke all sessions before delete")
|
|
default:
|
|
Error(w, http.StatusInternalServerError, "could not delete provider")
|
|
}
|
|
return
|
|
}
|
|
h.recordAudit(r.Context(), "auth.oidc_provider_deleted", caller.ActorID, caller.ActorType, id,
|
|
map[string]interface{}{"provider_id": id})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// RefreshProvider handles POST /api/v1/auth/oidc/providers/{id}/refresh.
|
|
// Forces re-fetch of the IdP discovery doc + JWKS, re-runs the IdP
|
|
// downgrade-attack defense.
|
|
func (h *AuthSessionOIDCHandler) RefreshProvider(w http.ResponseWriter, r *http.Request) {
|
|
caller, err := callerFromRequest(r)
|
|
if err != nil {
|
|
writeAuthError(w, err)
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
Error(w, http.StatusBadRequest, "missing provider id")
|
|
return
|
|
}
|
|
if rerr := h.oidcSvc.RefreshKeys(r.Context(), id); rerr != nil {
|
|
if errors.Is(rerr, repository.ErrOIDCProviderNotFound) {
|
|
Error(w, http.StatusNotFound, "provider not found")
|
|
return
|
|
}
|
|
Error(w, http.StatusBadRequest, "refresh failed: "+rerr.Error())
|
|
return
|
|
}
|
|
h.recordAudit(r.Context(), "auth.oidc_provider_refreshed", caller.ActorID, caller.ActorType, id,
|
|
map[string]interface{}{"provider_id": id})
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"refreshed": true})
|
|
}
|
|
|
|
type groupMappingResponse struct {
|
|
ID string `json:"id"`
|
|
ProviderID string `json:"provider_id"`
|
|
GroupName string `json:"group_name"`
|
|
RoleID string `json:"role_id"`
|
|
TenantID string `json:"tenant_id"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
func mappingToResponse(m *oidcdomain.GroupRoleMapping) groupMappingResponse {
|
|
return groupMappingResponse{
|
|
ID: m.ID, ProviderID: m.ProviderID, GroupName: m.GroupName,
|
|
RoleID: m.RoleID, TenantID: m.TenantID,
|
|
CreatedAt: m.CreatedAt.UTC().Format(time.RFC3339),
|
|
}
|
|
}
|
|
|
|
type groupMappingRequest struct {
|
|
ProviderID string `json:"provider_id"`
|
|
GroupName string `json:"group_name"`
|
|
RoleID string `json:"role_id"`
|
|
}
|
|
|
|
// ListGroupMappings handles GET /api/v1/auth/oidc/group-mappings?provider_id=<id>.
|
|
func (h *AuthSessionOIDCHandler) ListGroupMappings(w http.ResponseWriter, r *http.Request) {
|
|
if _, err := callerFromRequest(r); err != nil {
|
|
writeAuthError(w, err)
|
|
return
|
|
}
|
|
providerID := strings.TrimSpace(r.URL.Query().Get("provider_id"))
|
|
if providerID == "" {
|
|
Error(w, http.StatusBadRequest, "missing required query parameter `provider_id`")
|
|
return
|
|
}
|
|
mappings, lerr := h.mappingRepo.ListByProvider(r.Context(), providerID)
|
|
if lerr != nil {
|
|
Error(w, http.StatusInternalServerError, "could not list mappings")
|
|
return
|
|
}
|
|
out := make([]groupMappingResponse, 0, len(mappings))
|
|
for _, m := range mappings {
|
|
out = append(out, mappingToResponse(m))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{"mappings": out})
|
|
}
|
|
|
|
// AddGroupMapping handles POST /api/v1/auth/oidc/group-mappings.
|
|
func (h *AuthSessionOIDCHandler) AddGroupMapping(w http.ResponseWriter, r *http.Request) {
|
|
caller, err := callerFromRequest(r)
|
|
if err != nil {
|
|
writeAuthError(w, err)
|
|
return
|
|
}
|
|
var req groupMappingRequest
|
|
if derr := json.NewDecoder(r.Body).Decode(&req); derr != nil {
|
|
Error(w, http.StatusBadRequest, "invalid JSON body")
|
|
return
|
|
}
|
|
mapping := &oidcdomain.GroupRoleMapping{
|
|
ID: "grm-" + randomB64URLForHandler(16),
|
|
ProviderID: req.ProviderID,
|
|
GroupName: req.GroupName,
|
|
RoleID: req.RoleID,
|
|
TenantID: h.tenantID,
|
|
}
|
|
if verr := mapping.Validate(); verr != nil {
|
|
Error(w, http.StatusBadRequest, verr.Error())
|
|
return
|
|
}
|
|
if aerr := h.mappingRepo.Add(r.Context(), mapping); aerr != nil {
|
|
if errors.Is(aerr, repository.ErrGroupRoleMappingDuplicate) {
|
|
Error(w, http.StatusConflict, "mapping already exists")
|
|
return
|
|
}
|
|
Error(w, http.StatusInternalServerError, "could not add mapping")
|
|
return
|
|
}
|
|
h.recordAudit(r.Context(), "auth.group_mapping_added", caller.ActorID, caller.ActorType, mapping.ID,
|
|
map[string]interface{}{
|
|
"mapping_id": mapping.ID, "provider_id": mapping.ProviderID,
|
|
"group_name": mapping.GroupName, "role_id": mapping.RoleID,
|
|
})
|
|
writeJSON(w, http.StatusCreated, mappingToResponse(mapping))
|
|
}
|
|
|
|
// RemoveGroupMapping handles DELETE /api/v1/auth/oidc/group-mappings/{id}.
|
|
func (h *AuthSessionOIDCHandler) RemoveGroupMapping(w http.ResponseWriter, r *http.Request) {
|
|
caller, err := callerFromRequest(r)
|
|
if err != nil {
|
|
writeAuthError(w, err)
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
if id == "" {
|
|
Error(w, http.StatusBadRequest, "missing mapping id")
|
|
return
|
|
}
|
|
if rerr := h.mappingRepo.Remove(r.Context(), id); rerr != nil {
|
|
if errors.Is(rerr, repository.ErrGroupRoleMappingNotFound) {
|
|
Error(w, http.StatusNotFound, "mapping not found")
|
|
return
|
|
}
|
|
Error(w, http.StatusInternalServerError, "could not remove mapping")
|
|
return
|
|
}
|
|
h.recordAudit(r.Context(), "auth.group_mapping_removed", caller.ActorID, caller.ActorType, id,
|
|
map[string]interface{}{"mapping_id": id})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// =============================================================================
|
|
// Helpers.
|
|
// =============================================================================
|
|
|
|
// encryptClientSecret wraps internal/crypto.EncryptIfKeySet but with
|
|
// empty-passphrase passthrough. Production deployments MUST set
|
|
// CERTCTL_CONFIG_ENCRYPTION_KEY (validated at boot in
|
|
// internal/config/config.go) so the empty case only fires in tests
|
|
// and local-dev builds — the same pattern session.go uses for its
|
|
// HMAC-key blob path.
|
|
func (h *AuthSessionOIDCHandler) encryptClientSecret(plaintext []byte) ([]byte, error) {
|
|
if h.encryptionKey == "" {
|
|
return plaintext, nil
|
|
}
|
|
blob, _, err := cryptopkg.EncryptIfKeySet(plaintext, h.encryptionKey)
|
|
return blob, err
|
|
}
|
|
|
|
// recordAudit is a thin wrapper that swallows audit-layer errors (the
|
|
// audit row is best-effort; a failed audit must not block a successful
|
|
// auth operation). Phase 8 contract: every row event_category="auth".
|
|
func (h *AuthSessionOIDCHandler) recordAudit(ctx context.Context, action, actor string, actorType domain.ActorType, resourceID string, details map[string]interface{}) {
|
|
if h.audit == nil {
|
|
return
|
|
}
|
|
_ = h.audit.RecordEventWithCategory(ctx, actor, actorType, action,
|
|
domain.EventCategoryAuth, "session", resourceID, details)
|
|
}
|
|
|
|
func (h *AuthSessionOIDCHandler) clearPreLoginCookie(w http.ResponseWriter) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessiondomain.PreLoginCookieName,
|
|
Value: "",
|
|
Path: "/auth/oidc/",
|
|
MaxAge: -1,
|
|
Secure: h.cookieAttrs.Secure,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
}
|
|
|
|
func (h *AuthSessionOIDCHandler) clearSessionCookies(w http.ResponseWriter) {
|
|
for _, name := range []string{sessiondomain.PostLoginCookieName, sessiondomain.CSRFCookieName} {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: name,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
Secure: h.cookieAttrs.Secure,
|
|
HttpOnly: name == sessiondomain.PostLoginCookieName,
|
|
SameSite: h.cookieAttrs.SameSite,
|
|
})
|
|
}
|
|
}
|
|
|
|
func clientIPFromRequest(r *http.Request) string {
|
|
// X-Forwarded-For first hop wins when present.
|
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
if i := strings.IndexByte(xff, ','); i > 0 {
|
|
return strings.TrimSpace(xff[:i])
|
|
}
|
|
return strings.TrimSpace(xff)
|
|
}
|
|
// RemoteAddr is host:port; strip the port.
|
|
if i := strings.LastIndexByte(r.RemoteAddr, ':'); i > 0 {
|
|
return r.RemoteAddr[:i]
|
|
}
|
|
return r.RemoteAddr
|
|
}
|
|
|
|
// classifyOIDCFailure maps an OIDC service error to a stable audit
|
|
// category string. Used for the failure_category audit detail; the
|
|
// wire stays uniform 400.
|
|
func classifyOIDCFailure(err error) string {
|
|
if err == nil {
|
|
return "ok"
|
|
}
|
|
msg := strings.ToLower(err.Error())
|
|
switch {
|
|
case strings.Contains(msg, "pre-login"):
|
|
return "pre_login_consume_failed"
|
|
case strings.Contains(msg, "state"):
|
|
return "state_mismatch"
|
|
case strings.Contains(msg, "nonce"):
|
|
return "nonce_mismatch"
|
|
case strings.Contains(msg, "audience"), strings.Contains(msg, "aud"):
|
|
return "audience_mismatch"
|
|
case strings.Contains(msg, "expired"):
|
|
return "token_expired"
|
|
case strings.Contains(msg, "azp"):
|
|
return "azp_mismatch"
|
|
case strings.Contains(msg, "at_hash"):
|
|
return "at_hash_mismatch"
|
|
case strings.Contains(msg, "iat"):
|
|
return "iat_window"
|
|
case strings.Contains(msg, "alg"):
|
|
return "alg_rejected"
|
|
case strings.Contains(msg, "groups did not match"), strings.Contains(msg, "unmapped"):
|
|
return "unmapped_groups"
|
|
case strings.Contains(msg, "groups missing"), strings.Contains(msg, "missing or malformed"):
|
|
return "groups_missing"
|
|
case strings.Contains(msg, "jwks"):
|
|
return "jwks_unreachable"
|
|
default:
|
|
return "unspecified"
|
|
}
|
|
}
|
|
|
|
func randomB64URLForHandler(n int) string {
|
|
// Cheap counter+time fallback; provider/mapping ids don't need
|
|
// crypto-strong entropy (they're not security tokens). We still
|
|
// use base64url-no-pad for URL safety.
|
|
now := time.Now().UnixNano()
|
|
buf := make([]byte, n)
|
|
for i := 0; i < n; i++ {
|
|
buf[i] = byte(now >> (uint(i) * 8))
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(buf)
|
|
}
|
|
|
|
func defaultIfBlank(s, def string) string {
|
|
if strings.TrimSpace(s) == "" {
|
|
return def
|
|
}
|
|
return s
|
|
}
|
|
|
|
func defaultIntIfZero(v, def int) int {
|
|
if v == 0 {
|
|
return def
|
|
}
|
|
return v
|
|
}
|
|
|
|
// =============================================================================
|
|
// Default BackChannelLogoutVerifier — wraps go-oidc/v3.
|
|
// =============================================================================
|
|
|
|
// DefaultBCLVerifier is the production BackChannelLogoutVerifier. It
|
|
// resolves the IdP by issuer (matched against the OIDCProviderRepository),
|
|
// fetches the IdP's JWKS via gooidc.Provider, and validates the
|
|
// logout_token JWT signature + required claims.
|
|
type DefaultBCLVerifier struct {
|
|
providerRepo repository.OIDCProviderRepository
|
|
tenantID string
|
|
allowedAlgs []string
|
|
|
|
// Injectable for tests so unit tests don't hit a real IdP.
|
|
verifyOverride func(ctx context.Context, providerIssuer, rawIDToken string) (*gooidc.IDToken, error)
|
|
}
|
|
|
|
// NewDefaultBCLVerifier constructs a verifier wired against the given
|
|
// provider repo + tenant.
|
|
func NewDefaultBCLVerifier(providerRepo repository.OIDCProviderRepository, tenantID string, allowedAlgs []string) *DefaultBCLVerifier {
|
|
if len(allowedAlgs) == 0 {
|
|
allowedAlgs = []string{
|
|
gooidc.RS256, gooidc.RS512, gooidc.ES256, gooidc.ES384, gooidc.EdDSA,
|
|
}
|
|
}
|
|
return &DefaultBCLVerifier{
|
|
providerRepo: providerRepo,
|
|
tenantID: tenantID,
|
|
allowedAlgs: allowedAlgs,
|
|
}
|
|
}
|
|
|
|
// Verify implements BackChannelLogoutVerifier.
|
|
func (v *DefaultBCLVerifier) Verify(ctx context.Context, logoutToken string) (issuer, sub, sid string, err error) {
|
|
// We don't know which provider the logout_token came from until we
|
|
// peek at the iss claim. Parse-without-verify, look up the matching
|
|
// provider, then verify against that provider's JWKS.
|
|
iss, peekErr := peekIssuer(logoutToken)
|
|
if peekErr != nil {
|
|
return "", "", "", fmt.Errorf("peek issuer: %w", peekErr)
|
|
}
|
|
provs, lerr := v.providerRepo.List(ctx, v.tenantID)
|
|
if lerr != nil {
|
|
return "", "", "", fmt.Errorf("list providers: %w", lerr)
|
|
}
|
|
var matched *oidcdomain.OIDCProvider
|
|
for _, p := range provs {
|
|
if p.IssuerURL == iss {
|
|
matched = p
|
|
break
|
|
}
|
|
}
|
|
if matched == nil {
|
|
return "", "", "", fmt.Errorf("no provider configured for issuer %q", iss)
|
|
}
|
|
|
|
var idToken *gooidc.IDToken
|
|
if v.verifyOverride != nil {
|
|
idToken, err = v.verifyOverride(ctx, matched.IssuerURL, logoutToken)
|
|
} else {
|
|
provider, perr := gooidc.NewProvider(ctx, matched.IssuerURL)
|
|
if perr != nil {
|
|
return "", "", "", fmt.Errorf("provider discovery: %w", perr)
|
|
}
|
|
verifier := provider.Verifier(&gooidc.Config{
|
|
ClientID: matched.ClientID,
|
|
SupportedSigningAlgs: v.allowedAlgs,
|
|
SkipExpiryCheck: true, // OIDC BCL §2.4 — no exp claim required
|
|
})
|
|
idToken, err = verifier.Verify(ctx, logoutToken)
|
|
}
|
|
if err != nil {
|
|
return "", "", "", fmt.Errorf("verify: %w", err)
|
|
}
|
|
|
|
// Required claims per spec §2.4.
|
|
var claims struct {
|
|
Iss string `json:"iss"`
|
|
Aud interface{} `json:"aud"`
|
|
Iat int64 `json:"iat"`
|
|
Jti string `json:"jti"`
|
|
Events map[string]interface{} `json:"events"`
|
|
Sub string `json:"sub"`
|
|
Sid string `json:"sid"`
|
|
Nonce string `json:"nonce"`
|
|
}
|
|
if cerr := idToken.Claims(&claims); cerr != nil {
|
|
return "", "", "", fmt.Errorf("claims unmarshal: %w", cerr)
|
|
}
|
|
if claims.Iat == 0 {
|
|
return "", "", "", errors.New("missing iat claim")
|
|
}
|
|
if claims.Jti == "" {
|
|
return "", "", "", errors.New("missing jti claim")
|
|
}
|
|
if claims.Events == nil {
|
|
return "", "", "", errors.New("missing events claim")
|
|
}
|
|
if _, ok := claims.Events["http://schemas.openid.net/event/backchannel-logout"]; !ok {
|
|
return "", "", "", errors.New("events claim missing back-channel-logout URI")
|
|
}
|
|
if claims.Nonce != "" {
|
|
// Spec §2.4: nonce MUST NOT be present.
|
|
return "", "", "", errors.New("nonce claim must be absent in logout_token")
|
|
}
|
|
if claims.Sub == "" && claims.Sid == "" {
|
|
return "", "", "", errors.New("logout_token must carry sub or sid")
|
|
}
|
|
return claims.Iss, claims.Sub, claims.Sid, nil
|
|
}
|
|
|
|
// peekIssuer base64-decodes the JWT payload (segment 1 after the `.`)
|
|
// and pulls the `iss` claim out without verifying the signature. Used
|
|
// to find the matching provider before we know which JWKS to use.
|
|
func peekIssuer(jwt string) (string, error) {
|
|
parts := strings.Split(jwt, ".")
|
|
if len(parts) != 3 {
|
|
return "", errors.New("expected 3 JWT segments")
|
|
}
|
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
return "", fmt.Errorf("payload base64: %w", err)
|
|
}
|
|
var c struct {
|
|
Iss string `json:"iss"`
|
|
}
|
|
if jerr := json.Unmarshal(payload, &c); jerr != nil {
|
|
return "", fmt.Errorf("payload json: %w", jerr)
|
|
}
|
|
if c.Iss == "" {
|
|
return "", errors.New("missing iss claim in payload")
|
|
}
|
|
return c.Iss, nil
|
|
}
|