mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 17:31:30 +00:00
cd374b243e
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.
226 lines
8.3 KiB
Go
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
|
|
}
|