mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 18:31:37 +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.
208 lines
8.0 KiB
Go
208 lines
8.0 KiB
Go
// 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})
|
|
}
|