mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:51: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.
470 lines
18 KiB
Go
470 lines
18 KiB
Go
// 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)
|
|
}
|