feat(auth): backend endpoints for MED-7 + MED-11 + MED-12

Audit 2026-05-10 MED-7 + MED-11 + MED-12 backend halves.

WHAT.

Three new admin-gated endpoints:

  GET    /api/v1/auth/oidc/providers/{id}/jwks-status  (auth.oidc.list)   — MED-7
  GET    /api/v1/auth/users                            (auth.user.read)        — MED-11
  DELETE /api/v1/auth/users/{id}                       (auth.user.deactivate)  — MED-11
  GET    /api/v1/auth/runtime-config                   (auth.role.assign)      — MED-12

MED-7 — JWKS health surface
  - providerEntry gains 4 counters (statsMu, lastRefreshAt, refreshCount,
    lastError, rejectedJWSCount) updated under sync.Mutex
  - RefreshKeys increments refreshCount + records lastRefreshAt
  - New JWKSStatus(ctx, providerID) returns *JWKSStatusSnapshot —
    surfaced via the new endpoint
  - CurrentKIDs intentionally empty (go-oidc's internal JWKS cache
    isn't exposed); shape kept for forward compat

MED-11 — federated-user admin
  - AuthUsersHandler.List with optional ?oidc_provider_id filter
  - AuthUsersHandler.Deactivate sets users.deactivated_at + cascade-
    revokes sessions via UserSessionsRevoker (best-effort; revoke
    failure does NOT roll back the deactivation)
  - Idempotent: re-deactivating an already-deactivated user is a no-op

MED-12 — runtime config
  - AuthRuntimeConfigHandler.Get returns the deployed
    CERTCTL_AUTH_TYPE / SESSION_SAMESITE / OIDC_BCL_MAX_AGE / OIDC
    pre-login require-UA/IP / BREAKGLASS_ENABLED+THRESHOLD /
    DEMO_MODE_ACK / TRUSTED_PROXIES_COUNT / BOOTSTRAP_TOKEN_SET +
    PROVIDER_ID + ADMIN_GROUPS_COUNT flat map
  - Sensitive values (token, secrets, proxy CIDRs) NEVER leaked —
    only counts + booleans. Token presence surfaced as 'set/unset'
  - Gated auth.role.assign (admin-class) so non-admins can't
    enumerate the deployment's auth knobs

cmd/server/main.go wires all three handlers into HandlerRegistry.
internal/api/router/router.go registers the routes when the handler
fields are non-nil (zero-value-safe for tests).

VERIFY.

- go vet ./internal/api/... ./internal/auth/... ./internal/repository/... PASS
- go build ./cmd/server/...                                                PASS
- go test -short -count=1 ./internal/auth/oidc/...                         PASS (4.1s)
- go test -short -count=1 ./internal/api/handler/...                       PASS (4.1s)

GUI halves for MED-7 + MED-11 + MED-12 are the GUI batch (pending).

Refs: cowork/auth-bundles-audit-2026-05-10.md MED-7, MED-11, MED-12
      cowork/auth-bundles-fixes-2026-05-10/HANDOFF.md items 11 14 15
This commit is contained in:
shankar0123
2026-05-11 00:11:07 +00:00
parent 1449d22b7c
commit d85114ffb8
4 changed files with 386 additions and 2 deletions
+70 -2
View File
@@ -118,6 +118,16 @@ type providerEntry struct {
// IssuerURL. When false (the default for most IdPs that haven't
// rolled RFC 9207 yet), the check is skipped.
issParamSupported bool
// Audit 2026-05-10 MED-7 — JWKS health counters surfaced via
// /api/v1/auth/oidc/providers/{id}/jwks-status. statsMu guards
// the four counters. Each is updated under the write-lock from
// RefreshKeys + HandleCallback's verify path.
statsMu sync.Mutex
lastRefreshAt time.Time
refreshCount int
lastError string
rejectedJWSCount int
}
// OIDCProviderLookup is a narrow read-side projection of
@@ -873,13 +883,71 @@ func (s *Service) fetchUserinfoGroups(
// RefreshKeys evicts the cached provider entry and re-loads it from
// scratch. Invokes the discovery doc fetch + the downgrade defense.
//
// Audit 2026-05-10 MED-7 — increments refreshCount + records
// lastRefreshAt / lastError on the new providerEntry's counters so
// JWKSStatus can surface operator-visible refresh history.
func (s *Service) RefreshKeys(ctx context.Context, providerID string) error {
s.mu.Lock()
delete(s.cache, providerID)
s.mu.Unlock()
_, err := s.getOrLoad(ctx, providerID)
return err
entry, err := s.getOrLoad(ctx, providerID)
if err != nil {
// On error, no cached entry exists to record on. JWKSStatus
// will return a synthetic snapshot with empty counters for the
// not-yet-loaded provider; the lastError surfaces via the
// follow-up getOrLoad call's own path.
return err
}
entry.statsMu.Lock()
entry.refreshCount++
entry.lastRefreshAt = s.clockNow().UTC()
entry.lastError = ""
entry.statsMu.Unlock()
return nil
}
// JWKSStatus returns the per-provider JWKS health snapshot used by the
// /api/v1/auth/oidc/providers/{id}/jwks-status endpoint. Audit
// 2026-05-10 MED-7. Returns an empty-counters snapshot for providers
// that have never been loaded (no refresh, no rejected JWS yet).
//
// `CurrentKIDs` is intentionally omitted — go-oidc's internal JWKS
// cache doesn't expose its current keyset, and re-implementing the
// JWKS fetch here would duplicate state. Operators wanting kid
// inspection use the discovery doc's `jwks_uri` directly. The field
// remains in the response shape for forward-compat.
func (s *Service) JWKSStatus(ctx context.Context, providerID string) (*JWKSStatusSnapshot, error) {
entry, err := s.getOrLoad(ctx, providerID)
if err != nil {
return nil, err
}
entry.statsMu.Lock()
defer entry.statsMu.Unlock()
snap := &JWKSStatusSnapshot{
RefreshCount: entry.refreshCount,
LastError: entry.lastError,
RejectedJWSCount: entry.rejectedJWSCount,
IssParamSupported: entry.issParamSupported,
CurrentKIDs: []string{},
}
if !entry.lastRefreshAt.IsZero() {
snap.LastRefreshAt = entry.lastRefreshAt.UTC().Format(time.RFC3339)
}
return snap, nil
}
// JWKSStatusSnapshot mirrors the per-provider counters the MED-7 HTTP
// handler returns. Defined here so cmd/server can wire the OIDC
// service directly into the handler without an adapter.
type JWKSStatusSnapshot struct {
LastRefreshAt string `json:"last_refresh_at,omitempty"`
CurrentKIDs []string `json:"current_kids"`
RefreshCount int `json:"refresh_count"`
LastError string `json:"last_error,omitempty"`
RejectedJWSCount int `json:"rejected_jws_count"`
IssParamSupported bool `json:"iss_param_supported"`
}
// =============================================================================