mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 09:19:24 +00:00
refactor(handler): split auth_session_oidc.go by handler-section (Phase 9, 11 of N)
Phase 9 ARCH-M2 closure Sprint 11. Splits
internal/api/handler/auth_session_oidc.go (was 1577 LOC, the
fifth-largest backend hotspot from the original audit) via the
Option B sibling-file pattern — new files stay in `package handler`
so every external caller of
`handler.AuthSessionOIDCHandler.{LoginInitiate, LoginCallback,
BackChannelLogout, Logout, ListSessions, RevokeSession,
RevokeAllExceptCurrent, ListProviders, CreateProvider,
UpdateProvider, DeleteProvider, TestProvider, RefreshProvider,
ListGroupMappings, AddGroupMapping, RemoveGroupMapping}` and
`handler.{DefaultBCLVerifier, NewDefaultBCLVerifier,
DefaultBCLVerifierMaxAge}` resolves the same way. Pure mechanical
relocation; no signature, no behavior, no import-graph change.
Section-based split (Option B + audit's verb prescription)
==========================================================
The audit's Tasks-Deferred row prescribed splitting "per handler
verb (login / callback / refresh / logout / backchannel)." The
file itself documents a three-section layout in its package
doc-comment:
1. Public OIDC handshake (auth-exempt)
2. Session management (RBAC-gated)
3. OIDC provider + group-mapping CRUD (RBAC-gated)
Going strictly verb-by-verb would have:
- mis-grouped RefreshProvider (which is an ADMIN op on a
provider's signing-key cache, not a session refresh — same
auth.oidc.edit permission as Update/Delete);
- split LoginInitiate + LoginCallback into separate files
despite them sharing the state cookie + pre-login row flow;
- left the other 9 handlers (Sessions, Provider CRUD, Group
Mappings) with no obvious home.
Sprint 11 follows the file's own self-described section split
plus a fourth file for the DefaultBCLVerifier, which the original
file already kept under a separate banner.
What moved
==========
New `internal/api/handler/auth_session_oidc_handshake.go` (391 LOC)
— Section 1 / Public OIDC handshake handlers (auth-exempt):
- LoginInitiate (GET /auth/oidc/login?provider=<id>)
- LoginCallback (GET /auth/oidc/callback?code=...&state=...)
- BackChannelLogout (POST /auth/oidc/back-channel-logout)
- Logout (POST /auth/logout)
New `internal/api/handler/auth_session_oidc_sessions.go` (208 LOC)
— Section 2 / Session-management handlers (RBAC-gated):
- sessionResponse projection type + sessionToResponse mapper
- ListSessions (GET /api/v1/auth/sessions)
- RevokeSession (DELETE /api/v1/auth/sessions/{id})
- RevokeAllExceptCurrent
(DELETE /api/v1/auth/sessions/all-except-current)
New `internal/api/handler/auth_session_oidc_crud.go` (470 LOC) —
Section 3 / OIDC provider + group-mapping CRUD (RBAC-gated):
- oidcProviderResponse + oidcProviderRequest projection types,
providerToResponse mapper
- ListProviders / CreateProvider / UpdateProvider /
DeleteProvider / TestProvider / RefreshProvider
- groupMappingResponse + groupMappingRequest projection types,
mappingToResponse mapper
- ListGroupMappings / AddGroupMapping / RemoveGroupMapping
New `internal/api/handler/auth_session_oidc_bcl.go` (225 LOC) —
DefaultBCLVerifier (handler's default implementation of the
BackChannelLogoutVerifier interface declared in
auth_session_oidc.go):
- DefaultBCLVerifierMaxAge constant
- DefaultBCLVerifier struct + NewDefaultBCLVerifier
- WithMaxAge builder
- Verify (the OpenID Connect Back-Channel Logout 1.0 §2.6
verification: events claim, iat window, algorithm allowlist,
audience match, sub/sid/jti decode)
- peekIssuer unexported helper
What stays in auth_session_oidc.go (452 LOC, down from 1577)
============================================================
- Package + import block.
- Service-layer interface projections (OIDCAuthHandshaker,
SessionMinter, BackChannelLogoutVerifier) — declared once and
consumed by every section.
- SessionCookieAttrs config struct.
- AuthSessionOIDCHandler struct + permissionChecker /
BCLReplayConsumer / AuditRecorder interfaces + NewAuthSession-
OIDCHandler constructor + the WithPermissionChecker /
WithBCLReplayConsumer builder methods.
- The shared helpers consumed across multiple sections:
encryptClientSecret, recordAudit, clearPreLoginCookie,
clearSessionCookies, clientIPFromRequest, classifyOIDCFailure,
randomB64URLForHandler, defaultIfBlank, defaultIntIfZero.
Side-effect import cleanup
==========================
Four imports drop from auth_session_oidc.go as a clean side effect
of the cut:
- "encoding/json" (used only in CRUD + BCL — moved out)
- "fmt" (used only in BCL — moved out)
- gooidc "github.com/coreos/go-oidc/v3/oidc"
(used only in BCL — moved out)
- oidcdomain "github.com/certctl-io/certctl/internal/auth/oidc/domain"
(used in handshake + CRUD + BCL — moved out)
Per-import audit on every new sibling file is in the commit's diff:
each carries only the imports its extracted code actually consumes.
Net effect
==========
auth_session_oidc.go: 1577 → 452 LOC (-1,125 = -71.3%). Four new
sibling files at 1,294 LOC total (1,125 moved + ~169 of header +
Phase 9 doc-comment overhead). The original hotspot drops below
the cmd/agent/main.go target for Sprint 12 (1489 LOC).
Cumulative Phase 9 progress (top 5 hotspots)
============================================
config.go 3403 → 1342 (-60.6%, Sprints 1-7)
cmd/server/main.go 2966 → 2260 (-23.8%, Sprints 8 + 8b)
service/acme.go 1965 → 1162 (-40.9%, Sprints 9 + 9b)
mcp/tools.go 1867 → 109 (-94.2%, Sprint 10)
auth_session_oidc 1577 → 452 (-71.3%, Sprint 11)
TOTAL across 5 files: 11,778 → 5,325 LOC = -6,453 (-54.8%)
Behavior preservation contract
==============================
1. gofmt -l clean across all 5 affected files.
2. go vet ./internal/api/handler/... — no findings.
3. staticcheck ./internal/api/handler/... — no findings.
4. go test -short -count=1 ./internal/api/handler/... — green
(includes the 1,439-line auth_session_oidc_test.go suite that
pins every moved handler's behavior including BCL replay,
CSRF rotation, audit emission, and the Phase-5 RBAC path).
5. Broader-importer build green: go build ./... .
6. Broader-importer tests green: go test -short -count=1
./cmd/server/... ./internal/api/router/... .
cmd/server/main.go consumes handler.DefaultBCLVerifier +
handler.NewDefaultBCLVerifier + handler.DefaultBCLVerifierMaxAge
across three call sites; all three resolve unchanged through Go's
same-package public-export mechanism (the type + constructor
moved to a sibling file in the same `handler` package). The
mcp/tools_auth_bundle2.go comment string referencing
"oidcProviderRequest" is descriptive prose, not an import.
What remains for Phase 9
========================
One sibling-file split queued:
- Sprint 12: cmd/agent/main.go (1489 LOC) → main + poll +
deploy + register sibling files in same cmd/agent package
(mirrors the cmd/server pattern from Sprints 8 + 8b).
Refs: ARCH-M2 (god-files), Phase 9 audit. Sprint 11 closes the
auth-session-OIDC handler hotspot from the audit's top-5 list.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,225 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
gooidc "github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
|
||||||
|
oidcdomain "github.com/certctl-io/certctl/internal/auth/oidc/domain"
|
||||||
|
"github.com/certctl-io/certctl/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Phase 9 ARCH-M2 closure Sprint 11 (2026-05-14): extracted from
|
||||||
|
// internal/api/handler/auth_session_oidc.go via the Option B
|
||||||
|
// sibling-file pattern.
|
||||||
|
//
|
||||||
|
// This file holds the DefaultBCLVerifier — the default
|
||||||
|
// implementation of the BackChannelLogoutVerifier interface
|
||||||
|
// declared in auth_session_oidc.go. Verifies an OIDC
|
||||||
|
// back-channel-logout token per OpenID Connect Back-Channel
|
||||||
|
// Logout 1.0 §2.6: enforces the events claim, iat window,
|
||||||
|
// algorithm allowlist, audience match against the provider's
|
||||||
|
// configured client ID, and decodes sub/sid/jti for the
|
||||||
|
// revocation lookup.
|
||||||
|
//
|
||||||
|
// External callers:
|
||||||
|
// - cmd/server/main.go wires NewDefaultBCLVerifier(...) +
|
||||||
|
// DefaultBCLVerifierMaxAge into the AuthSessionOIDCHandler
|
||||||
|
// via WithBCLReplayConsumer.
|
||||||
|
//
|
||||||
|
// peekIssuer (unexported) is consumed only by Verify so it moves
|
||||||
|
// with the verifier. The go-oidc/v3 client is the underlying JWS
|
||||||
|
// verification + IdP-key-cache; everything else here is policy.
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Default BackChannelLogoutVerifier — wraps go-oidc/v3.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// DefaultBCLVerifierMaxAge is the default iat-freshness skew window
|
||||||
|
// (60 seconds; tokens older or newer than this are rejected). Override
|
||||||
|
// per-server via CERTCTL_OIDC_BCL_MAX_AGE_SECONDS. Audit 2026-05-10
|
||||||
|
// HIGH-3 closure.
|
||||||
|
const DefaultBCLVerifierMaxAge = 60 * time.Second
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// maxAge is the iat-freshness skew window. Tokens with iat in the
|
||||||
|
// past beyond this OR in the future beyond this are rejected. Set
|
||||||
|
// via WithMaxAge; defaults to DefaultBCLVerifierMaxAge.
|
||||||
|
maxAge time.Duration
|
||||||
|
// nowFn is the clock seam (test injection).
|
||||||
|
nowFn func() time.Time
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
maxAge: DefaultBCLVerifierMaxAge,
|
||||||
|
nowFn: time.Now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithMaxAge returns a copy of the verifier with the iat-skew window
|
||||||
|
// overridden. Audit 2026-05-10 HIGH-3 — operator-configurable via
|
||||||
|
// CERTCTL_OIDC_BCL_MAX_AGE_SECONDS at cmd/server/main.go.
|
||||||
|
func (v *DefaultBCLVerifier) WithMaxAge(d time.Duration) *DefaultBCLVerifier {
|
||||||
|
v.maxAge = d
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify implements BackChannelLogoutVerifier.
|
||||||
|
func (v *DefaultBCLVerifier) Verify(ctx context.Context, logoutToken string) (issuer, sub, sid, jti string, iat int64, 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 "", "", "", "", 0, fmt.Errorf("peek issuer: %w", peekErr)
|
||||||
|
}
|
||||||
|
provs, lerr := v.providerRepo.List(ctx, v.tenantID)
|
||||||
|
if lerr != nil {
|
||||||
|
return "", "", "", "", 0, 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 "", "", "", "", 0, 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 "", "", "", "", 0, 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 "", "", "", "", 0, 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 "", "", "", "", 0, fmt.Errorf("claims unmarshal: %w", cerr)
|
||||||
|
}
|
||||||
|
if claims.Iat == 0 {
|
||||||
|
return "", "", "", "", 0, errors.New("missing iat claim")
|
||||||
|
}
|
||||||
|
// Audit 2026-05-10 HIGH-3 — iat freshness check. Reject tokens
|
||||||
|
// whose iat is outside the skew window. RFC 9700 §2.7 + the
|
||||||
|
// existing ID-token-path skew tolerance (oidc/service.go:463).
|
||||||
|
maxAge := v.maxAge
|
||||||
|
if maxAge <= 0 {
|
||||||
|
maxAge = DefaultBCLVerifierMaxAge
|
||||||
|
}
|
||||||
|
now := v.nowFn().UTC()
|
||||||
|
iatTime := time.Unix(claims.Iat, 0).UTC()
|
||||||
|
if iatTime.After(now.Add(maxAge)) {
|
||||||
|
return "", "", "", "", 0, fmt.Errorf("iat is in the future beyond max-age %s", maxAge)
|
||||||
|
}
|
||||||
|
if now.Sub(iatTime) > maxAge {
|
||||||
|
return "", "", "", "", 0, fmt.Errorf("iat is stale (age %s > max-age %s)", now.Sub(iatTime), maxAge)
|
||||||
|
}
|
||||||
|
if claims.Jti == "" {
|
||||||
|
return "", "", "", "", 0, errors.New("missing jti claim")
|
||||||
|
}
|
||||||
|
if claims.Events == nil {
|
||||||
|
return "", "", "", "", 0, errors.New("missing events claim")
|
||||||
|
}
|
||||||
|
if _, ok := claims.Events["http://schemas.openid.net/event/backchannel-logout"]; !ok {
|
||||||
|
return "", "", "", "", 0, errors.New("events claim missing back-channel-logout URI")
|
||||||
|
}
|
||||||
|
if claims.Nonce != "" {
|
||||||
|
// Spec §2.4: nonce MUST NOT be present.
|
||||||
|
return "", "", "", "", 0, errors.New("nonce claim must be absent in logout_token")
|
||||||
|
}
|
||||||
|
if claims.Sub == "" && claims.Sid == "" {
|
||||||
|
return "", "", "", "", 0, errors.New("logout_token must carry sub or sid")
|
||||||
|
}
|
||||||
|
return claims.Iss, claims.Sub, claims.Sid, claims.Jti, claims.Iat, 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.
|
||||||
|
// peekIssuer extracts the `iss` claim from an unsigned JWT payload —
|
||||||
|
// used by the BCL handler to route the logout_token to the right
|
||||||
|
// provider for verification.
|
||||||
|
//
|
||||||
|
// Audit 2026-05-10 Nit-3 — peekIssuer is INTENTIONALLY unsigned-permissive.
|
||||||
|
// The returned issuer is used ONLY to select the verifier; the full
|
||||||
|
// signature + claim verification happens in DefaultBCLVerifier.Verify
|
||||||
|
// (which re-checks the `iss` claim against the matched provider's
|
||||||
|
// IssuerURL after JWS signature validation). Callers MUST NOT trust
|
||||||
|
// peekIssuer output for any access-control decision before the verify
|
||||||
|
// step completes; the pin is encoded in the BCL handler's call shape
|
||||||
|
// (peek → match provider → verify-against-provider → consume).
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,469 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
oidcsvc "github.com/certctl-io/certctl/internal/auth/oidc"
|
||||||
|
oidcdomain "github.com/certctl-io/certctl/internal/auth/oidc/domain"
|
||||||
|
"github.com/certctl-io/certctl/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Phase 9 ARCH-M2 closure Sprint 11 (2026-05-14): extracted from
|
||||||
|
// internal/api/handler/auth_session_oidc.go via the Option B
|
||||||
|
// sibling-file pattern.
|
||||||
|
//
|
||||||
|
// This file holds Section 3 of the original three-section layout:
|
||||||
|
// OIDC PROVIDER + GROUP-MAPPING CRUD (RBAC-gated). Eight
|
||||||
|
// endpoints across two related resources:
|
||||||
|
//
|
||||||
|
// 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}/test -> auth.oidc.edit
|
||||||
|
// 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
|
||||||
|
//
|
||||||
|
// The four request/response projection types (oidcProviderRequest,
|
||||||
|
// oidcProviderResponse, groupMappingRequest, groupMappingResponse)
|
||||||
|
// move with their handler callers. The encryptClientSecret +
|
||||||
|
// recordAudit + randomB64URLForHandler + defaultIfBlank +
|
||||||
|
// defaultIntIfZero helpers stay in auth_session_oidc.go — they're
|
||||||
|
// also consumed elsewhere (recordAudit is used by every section)
|
||||||
|
// or are generic utilities that don't have a single owner.
|
||||||
|
//
|
||||||
|
// NOTE: the audit's verb-based prescription (login / callback /
|
||||||
|
// refresh / logout / backchannel) named "refresh" as a separate
|
||||||
|
// sibling file. The RefreshProvider handler here is the only
|
||||||
|
// "refresh" in this file, but operationally it's an ADMIN
|
||||||
|
// operation on a provider's signing-key cache, not a session
|
||||||
|
// refresh. Sprint 11 keeps it grouped with the rest of the
|
||||||
|
// provider CRUD where it belongs by call-graph + permission scope
|
||||||
|
// (auth.oidc.edit, the same RBAC permission as Update/Delete).
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProvider handles POST /api/v1/auth/oidc/test.
|
||||||
|
//
|
||||||
|
// Audit 2026-05-10 MED-5 closure. Dry-run validator for an OIDC
|
||||||
|
// provider config: runs OIDC discovery, the alg-downgrade defense,
|
||||||
|
// the RFC 9207 iss-parameter detection, and a JWKS fetch — without
|
||||||
|
// persisting anything. Body: `{issuer_url, client_id, scopes}`
|
||||||
|
// (client_secret accepted but ignored — discovery + JWKS don't
|
||||||
|
// require it). Response: TestDiscoveryResult; HTTP 200 even when
|
||||||
|
// individual checks fail (the response Errors field carries them so
|
||||||
|
// the GUI can render per-check status rows).
|
||||||
|
//
|
||||||
|
// Permission gate: `auth.oidc.create` (the operator is dry-running a
|
||||||
|
// provider they're about to create; the lookup endpoints have their
|
||||||
|
// own .list gate so this can't be used as a roundabout reconnaissance
|
||||||
|
// vector beyond what those already permit).
|
||||||
|
func (h *AuthSessionOIDCHandler) TestProvider(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caller, err := callerFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
writeAuthError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
IssuerURL string `json:"issuer_url"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
ClientSecret string `json:"client_secret"`
|
||||||
|
Scopes []string `json:"scopes"`
|
||||||
|
}
|
||||||
|
if derr := json.NewDecoder(r.Body).Decode(&req); derr != nil {
|
||||||
|
Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.IssuerURL) == "" {
|
||||||
|
Error(w, http.StatusBadRequest, "issuer_url is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Type-assert to the concrete service so we can reach the
|
||||||
|
// TestDiscovery method. The OIDCAuthHandshaker interface is
|
||||||
|
// intentionally narrow; rather than widening it (which would force
|
||||||
|
// every test stub to implement TestDiscovery) we accept the
|
||||||
|
// concrete reference for this single endpoint. Production code
|
||||||
|
// always supplies *oidcsvc.Service.
|
||||||
|
type discoveryTester interface {
|
||||||
|
TestDiscovery(ctx context.Context, issuerURL string) (*oidcsvc.TestDiscoveryResult, error)
|
||||||
|
}
|
||||||
|
tester, ok := h.oidcSvc.(discoveryTester)
|
||||||
|
if !ok {
|
||||||
|
Error(w, http.StatusInternalServerError, "OIDC service does not support discovery test")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, terr := tester.TestDiscovery(r.Context(), strings.TrimSpace(req.IssuerURL))
|
||||||
|
if terr != nil {
|
||||||
|
Error(w, http.StatusInternalServerError, "discovery test execution failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.recordAudit(r.Context(), "auth.oidc_provider_tested", caller.ActorID, caller.ActorType, "",
|
||||||
|
map[string]interface{}{
|
||||||
|
"issuer_url": req.IssuerURL,
|
||||||
|
"discovery_succeeded": res.DiscoverySucceeded,
|
||||||
|
"jwks_reachable": res.JWKSReachable,
|
||||||
|
"iss_param_supported": res.IssParamSupported,
|
||||||
|
"error_count": len(res.Errors),
|
||||||
|
})
|
||||||
|
writeJSON(w, http.StatusOK, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
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"
|
||||||
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
|
"github.com/certctl-io/certctl/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Phase 9 ARCH-M2 closure Sprint 11 (2026-05-14): extracted from
|
||||||
|
// internal/api/handler/auth_session_oidc.go via the Option B
|
||||||
|
// sibling-file pattern. Package stays `handler`; every external
|
||||||
|
// caller of `handler.AuthSessionOIDCHandler.{LoginInitiate,
|
||||||
|
// LoginCallback, BackChannelLogout, Logout}` resolves the same
|
||||||
|
// way — pure mechanical relocation. The router wiring in
|
||||||
|
// internal/api/router/router.go is unaffected.
|
||||||
|
//
|
||||||
|
// This file holds Section 1 of the original file's three-section
|
||||||
|
// layout (per its own package doc-comment): the PUBLIC OIDC
|
||||||
|
// HANDSHAKE handlers. These four endpoints are auth-exempt — they
|
||||||
|
// run before the caller has a certctl-issued credential:
|
||||||
|
//
|
||||||
|
// GET /auth/oidc/login?provider=<id> -> 302 to IdP
|
||||||
|
// GET /auth/oidc/callback?code=...&state=... -> consume + mint
|
||||||
|
// POST /auth/oidc/back-channel-logout -> IdP-initiated
|
||||||
|
// POST /auth/logout -> revoke caller's
|
||||||
|
//
|
||||||
|
// Helpers (h.clearPreLoginCookie / h.clearSessionCookies /
|
||||||
|
// h.recordAudit / clientIPFromRequest / classifyOIDCFailure) stay
|
||||||
|
// in auth_session_oidc.go alongside the AuthSessionOIDCHandler
|
||||||
|
// struct + constructor — same-package resolution makes the calls
|
||||||
|
// reach across the file boundary at zero compile-time cost.
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
// Audit 2026-05-10 MED-16 — capture clientIP + UA at /auth/oidc/login
|
||||||
|
// so HandleCallback can reject a stolen pre-login cookie replayed
|
||||||
|
// from a different browser/source. clientIPFromRequest already
|
||||||
|
// honours the LOW-5 trusted-proxy gating; r.UserAgent() reads the
|
||||||
|
// header verbatim.
|
||||||
|
loginIP := clientIPFromRequest(r)
|
||||||
|
loginUA := r.UserAgent()
|
||||||
|
authURL, cookieValue, _, err := h.oidcSvc.HandleAuthRequest(r.Context(), providerID, loginIP, loginUA)
|
||||||
|
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,
|
||||||
|
// Audit 2026-05-10 MED-14 — `__Host-` prefix requires Path=/.
|
||||||
|
// The cookie lives 10 minutes and is only ever consumed by the
|
||||||
|
// callback handler; the wider path scope is harmless.
|
||||||
|
Path: "/",
|
||||||
|
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"))
|
||||||
|
// Audit 2026-05-10 MED-17 — RFC 9207 iss URL parameter. NOT
|
||||||
|
// trimmed; preserved exactly as sent so the service-layer compare
|
||||||
|
// against the matched provider's IssuerURL is byte-strict. The IdP
|
||||||
|
// emits this only when advertised in its discovery doc; the
|
||||||
|
// service-layer check is a no-op otherwise.
|
||||||
|
callbackIss := q.Get("iss")
|
||||||
|
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, callbackIss, clientIP, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
// Audit 2026-05-10 HIGH-7 — instead of a blank 400, redirect
|
||||||
|
// to /login?error=oidc_failed&reason=<category>. The LoginPage
|
||||||
|
// reads the query params and renders an operator-friendly
|
||||||
|
// alert. The audit row still carries the specific
|
||||||
|
// failure_category so server-side observability is unchanged.
|
||||||
|
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)
|
||||||
|
// 302 to the login page; the reason categorizes the failure for
|
||||||
|
// the GUI to render. Keep the redirect target relative — the
|
||||||
|
// SPA serves /login.
|
||||||
|
http.Redirect(w, r, "/login?error=oidc_failed&reason="+category, http.StatusFound)
|
||||||
|
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, jti, _, 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.
|
||||||
|
// iat-skew rejections (Audit 2026-05-10 HIGH-3 iat-window check)
|
||||||
|
// land here too — the reason string distinguishes them.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit 2026-05-10 HIGH-3 — jti consumed-set. Atomic single-use
|
||||||
|
// semantics via the postgres ON CONFLICT DO NOTHING path. On
|
||||||
|
// replay return 200 + audit outcome=jti_replayed (RFC 9700 §2.7).
|
||||||
|
// On transient repo error return 503 so the IdP follows its retry
|
||||||
|
// semantics. When the consumer is nil (test path / pre-fix
|
||||||
|
// deployments) the consume step is skipped.
|
||||||
|
if h.bclReplay != nil && jti != "" {
|
||||||
|
ttl := h.bclMaxAge * 2
|
||||||
|
if ttl < 24*time.Hour {
|
||||||
|
ttl = 24 * time.Hour
|
||||||
|
}
|
||||||
|
if cerr := h.bclReplay.ConsumeJTI(r.Context(), jti, issuer, ttl); cerr != nil {
|
||||||
|
if errors.Is(cerr, repository.ErrBCLJTIAlreadyConsumed) {
|
||||||
|
h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub,
|
||||||
|
map[string]interface{}{"issuer": issuer, "subject": sub, "jti": jti, "outcome": "jti_replayed"})
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Transient — let the IdP retry.
|
||||||
|
h.recordAudit(r.Context(), "auth.oidc_back_channel_logout_failed", "anonymous", domain.ActorTypeSystem, sub,
|
||||||
|
map[string]interface{}{"issuer": issuer, "subject": sub, "jti": jti, "outcome": "jti_consume_failed", "err": cerr.Error()})
|
||||||
|
http.Error(w, "transient", http.StatusServiceUnavailable)
|
||||||
|
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 != "" {
|
||||||
|
// CRIT-2 closure of the 2026-05-10 audit. Pre-fix this branch called
|
||||||
|
// RevokeAllForActor(sub, "User") under the false assumption that
|
||||||
|
// the OIDC subject was used as the actor_id stem. In reality,
|
||||||
|
// internal/auth/oidc/service.go::upsertUser mints
|
||||||
|
// u.ID = "u-" + randomB64URL(16) and stores the OIDC subject in
|
||||||
|
// a separate column, so the pre-fix lookup never found a session
|
||||||
|
// row and the error was silently swallowed. BCL silently revoked
|
||||||
|
// nothing — CWE-613.
|
||||||
|
//
|
||||||
|
// The fix resolves the IdP-signed `iss` claim back to a provider
|
||||||
|
// row via providerRepo.List + IssuerURL filter, then resolves
|
||||||
|
// sub → user.ID via userRepo.GetByOIDCSubject, then revokes all
|
||||||
|
// sessions for that actor. Outcome categories audited:
|
||||||
|
// - revoked (happy path)
|
||||||
|
// - issuer_unknown (iss doesn't match any configured provider)
|
||||||
|
// - user_unknown (provider matched, but no user.id seeded for this subject)
|
||||||
|
// - revoke_failed (DB hiccup at the revoke step)
|
||||||
|
// - provider_lookup_failed / user_lookup_failed → 503 (transient; IdP retries)
|
||||||
|
// All success-shaped outcomes return 200 + Cache-Control: no-store
|
||||||
|
// per OIDC BCL 1.0 §2.7. Transient errors return 503 so the IdP
|
||||||
|
// follows its own retry semantics.
|
||||||
|
providers, plerr := h.providerRepo.List(r.Context(), h.tenantID)
|
||||||
|
if plerr != nil {
|
||||||
|
h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub,
|
||||||
|
map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "provider_lookup_failed"})
|
||||||
|
http.Error(w, "transient", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var matched *oidcdomain.OIDCProvider
|
||||||
|
for _, p := range providers {
|
||||||
|
if p.IssuerURL == issuer {
|
||||||
|
matched = p
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if matched == nil {
|
||||||
|
h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub,
|
||||||
|
map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "issuer_unknown"})
|
||||||
|
// Idempotent — return 200 per spec.
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, uerr := h.userRepo.GetByOIDCSubject(r.Context(), matched.ID, sub)
|
||||||
|
if uerr != nil {
|
||||||
|
if errors.Is(uerr, repository.ErrUserNotFound) {
|
||||||
|
// Idempotent: nothing to revoke. IdP may BCL a user we
|
||||||
|
// never logged in. RFC compliance: still 200.
|
||||||
|
h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub,
|
||||||
|
map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "user_unknown"})
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Transient — let the IdP retry.
|
||||||
|
h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", "anonymous", domain.ActorTypeSystem, sub,
|
||||||
|
map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "user_lookup_failed"})
|
||||||
|
http.Error(w, "transient", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if rerr := h.sessionSvc.RevokeAllForActor(r.Context(), user.ID, string(domain.ActorTypeUser)); rerr != nil {
|
||||||
|
// Revoke failed — BCL is best-effort per §2.8; still 200,
|
||||||
|
// audit the failure.
|
||||||
|
h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", user.ID, domain.ActorTypeUser, sub,
|
||||||
|
map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "revoke_failed"})
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.recordAudit(r.Context(), "auth.oidc_back_channel_logout", user.ID, domain.ActorTypeUser, sub,
|
||||||
|
map[string]interface{}{"sub_or_sid": "sub", "issuer": issuer, "subject": sub, "outcome": "revoked"})
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
// Audit 2026-05-11 Fix 13 — HIGH-2 fourth call site. Rotate the CSRF
|
||||||
|
// token on the actor's remaining sessions so a token captured in
|
||||||
|
// this device's browser pre-logout (DevTools, malicious extension,
|
||||||
|
// session-storage leak) can't be replayed against a sibling session
|
||||||
|
// (other browser, other device) after the user logged out here.
|
||||||
|
// The just-revoked session also rotates but its CSRF lookup will
|
||||||
|
// fail at the sessions table's revoked_at IS NOT NULL filter
|
||||||
|
// anyway; rotation on the revoked row is harmless. RotateCSRFTokenForActor
|
||||||
|
// returns the count rotated and NEVER errors — rotation is defense
|
||||||
|
// in depth and must not block the logout success.
|
||||||
|
rotated := h.sessionSvc.RotateCSRFTokenForActor(r.Context(), caller.ActorID, string(caller.ActorType))
|
||||||
|
h.recordAudit(r.Context(), "auth.session_revoked", caller.ActorID, caller.ActorType, sess.ID,
|
||||||
|
map[string]interface{}{"session_id": sess.ID, "self_initiated": true, "csrf_rotated": rotated})
|
||||||
|
h.clearSessionCookies(w)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sessionsvc "github.com/certctl-io/certctl/internal/auth/session"
|
||||||
|
sessiondomain "github.com/certctl-io/certctl/internal/auth/session/domain"
|
||||||
|
"github.com/certctl-io/certctl/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Phase 9 ARCH-M2 closure Sprint 11 (2026-05-14): extracted from
|
||||||
|
// internal/api/handler/auth_session_oidc.go via the Option B
|
||||||
|
// sibling-file pattern.
|
||||||
|
//
|
||||||
|
// This file holds Section 2 of the original three-section layout:
|
||||||
|
// the SESSION MANAGEMENT handlers (RBAC-gated). Three endpoints:
|
||||||
|
//
|
||||||
|
// GET /api/v1/auth/sessions -> list (own / all-actors)
|
||||||
|
// DELETE /api/v1/auth/sessions/{id} -> revoke (own / any)
|
||||||
|
// DELETE /api/v1/auth/sessions/all-except-current
|
||||||
|
// -> revoke-all-except-current
|
||||||
|
//
|
||||||
|
// The sessionResponse projection type lives here alongside its
|
||||||
|
// callers (sessionToResponse + the three handler methods). It's
|
||||||
|
// the shape the API renders externally; no external caller relies
|
||||||
|
// on its exact file location.
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 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 {
|
||||||
|
// Audit 2026-05-10 MED-2 closure — listing a different
|
||||||
|
// actor's sessions requires the narrower auth.session.list.all
|
||||||
|
// permission. The router gate already enforced
|
||||||
|
// auth.session.list (the floor for any session-list call),
|
||||||
|
// but the all-actors variant is an admin-class capability and
|
||||||
|
// must be checked separately because the rbacGate can't see
|
||||||
|
// the query param. When the handler is wired with
|
||||||
|
// WithPermissionChecker (production), we re-check inline; when
|
||||||
|
// it isn't (legacy tests), the router gate's auth.session.list
|
||||||
|
// floor is the only check.
|
||||||
|
if h.checker != nil {
|
||||||
|
ok, perr := h.checker.CheckPermission(r.Context(),
|
||||||
|
caller.ActorID, string(caller.ActorType), h.tenantID,
|
||||||
|
"auth.session.list.all", "global", nil)
|
||||||
|
if perr != nil {
|
||||||
|
Error(w, http.StatusInternalServerError, "permission check failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
Error(w, http.StatusForbidden, "auth.session.list.all required to list another actor's sessions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeAllExceptCurrent handles DELETE /api/v1/auth/sessions?except=current.
|
||||||
|
//
|
||||||
|
// Audit 2026-05-10 MED-3 closure — backs the "Sign out all other
|
||||||
|
// sessions" SessionsPage button. Revokes every active session for the
|
||||||
|
// caller EXCEPT the session that issued the current request (so the
|
||||||
|
// user doesn't get logged out by the action they just took).
|
||||||
|
//
|
||||||
|
// The current session ID is read from the request's session cookie via
|
||||||
|
// the SessionMiddleware's actor context — for Bearer-mode callers this
|
||||||
|
// is the empty string and ALL the actor's sessions are revoked (matches
|
||||||
|
// the "log me out everywhere" semantic for API-key-mode users).
|
||||||
|
//
|
||||||
|
// Audit row records the count for compliance (one summary row per
|
||||||
|
// invocation; per-session detail is implicit in the count + actor).
|
||||||
|
func (h *AuthSessionOIDCHandler) RevokeAllExceptCurrent(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caller, err := callerFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
writeAuthError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("except") != "current" {
|
||||||
|
Error(w, http.StatusBadRequest, "only ?except=current is supported")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Current session ID — empty for Bearer/API-key callers (acceptable;
|
||||||
|
// the repo's RevokeAllExceptForActor handles "" by revoking
|
||||||
|
// literally every active session). Read from the session middleware's
|
||||||
|
// SessionFromContext helper which populates the validated session
|
||||||
|
// on the request context for cookie-mode callers.
|
||||||
|
currentSessionID := ""
|
||||||
|
if sess := sessionsvc.SessionFromContext(r.Context()); sess != nil {
|
||||||
|
currentSessionID = sess.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
count, rerr := h.sessionRepo.RevokeAllExceptForActor(r.Context(),
|
||||||
|
caller.ActorID, string(caller.ActorType), h.tenantID, currentSessionID)
|
||||||
|
if rerr != nil {
|
||||||
|
Error(w, http.StatusInternalServerError, "could not revoke sessions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.recordAudit(r.Context(), "auth.sessions_revoked_all_except_current",
|
||||||
|
caller.ActorID, caller.ActorType, currentSessionID,
|
||||||
|
map[string]interface{}{
|
||||||
|
"count": count,
|
||||||
|
"current_session_id": currentSessionID,
|
||||||
|
})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"revoked_count": count})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user