Files
certctl/internal/api/handler/auth_session_oidc_bcl.go
T
shankar0123 cd374b243e 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.
2026-05-14 10:22:33 +00:00

226 lines
8.3 KiB
Go

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