mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 17:31:30 +00:00
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:
@@ -1329,6 +1329,40 @@ func main() {
|
|||||||
// HTTP surface. 4 endpoints (1 public login + 3 admin CRUD).
|
// HTTP surface. 4 endpoints (1 public login + 3 admin CRUD).
|
||||||
// All endpoints return 404 when CERTCTL_BREAKGLASS_ENABLED=false.
|
// All endpoints return 404 when CERTCTL_BREAKGLASS_ENABLED=false.
|
||||||
AuthBreakglass: breakglassHandler,
|
AuthBreakglass: breakglassHandler,
|
||||||
|
|
||||||
|
// Audit 2026-05-10 MED-11 — federated-user admin surface.
|
||||||
|
AuthUsers: handler.NewAuthUsersHandler(
|
||||||
|
oidcUserRepo,
|
||||||
|
sessionService, // satisfies UserSessionsRevoker via RevokeAllForActor
|
||||||
|
auditService,
|
||||||
|
authdomainAlias.DefaultTenantID,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Audit 2026-05-10 MED-12 — runtime config read endpoint.
|
||||||
|
AuthRuntimeConfig: handler.NewAuthRuntimeConfigHandler(
|
||||||
|
func() map[string]string {
|
||||||
|
// Lazy build — re-read cfg.Auth.* values on every call so
|
||||||
|
// post-startup re-evaluation reflects any (future) mutation.
|
||||||
|
return map[string]string{
|
||||||
|
"CERTCTL_AUTH_TYPE": string(cfg.Auth.Type),
|
||||||
|
"CERTCTL_SESSION_SAMESITE": cfg.Auth.Session.SameSite,
|
||||||
|
"CERTCTL_OIDC_BCL_MAX_AGE_SECONDS": strconv.Itoa(cfg.Auth.OIDCBCLMaxAgeSeconds),
|
||||||
|
"CERTCTL_OIDC_PRELOGIN_REQUIRE_UA": strconv.FormatBool(cfg.Auth.OIDCPreLoginRequireUA),
|
||||||
|
"CERTCTL_OIDC_PRELOGIN_REQUIRE_IP": strconv.FormatBool(cfg.Auth.OIDCPreLoginRequireIP),
|
||||||
|
"CERTCTL_BREAKGLASS_ENABLED": strconv.FormatBool(cfg.Auth.Breakglass.Enabled),
|
||||||
|
"CERTCTL_BREAKGLASS_LOCKOUT_THRESHOLD": strconv.Itoa(cfg.Auth.Breakglass.LockoutThreshold),
|
||||||
|
"CERTCTL_DEMO_MODE_ACK": strconv.FormatBool(cfg.Auth.DemoModeAck),
|
||||||
|
"CERTCTL_TRUSTED_PROXIES_COUNT": strconv.Itoa(len(cfg.Auth.TrustedProxies)),
|
||||||
|
"CERTCTL_BOOTSTRAP_TOKEN_SET": strconv.FormatBool(cfg.Auth.BootstrapToken != ""),
|
||||||
|
"CERTCTL_BOOTSTRAP_OIDC_PROVIDER_ID": cfg.Auth.BootstrapOIDCProviderID,
|
||||||
|
"CERTCTL_BOOTSTRAP_ADMIN_GROUPS_COUNT": strconv.Itoa(len(cfg.Auth.BootstrapAdminGroups)),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
auditService,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Audit 2026-05-10 MED-7 — per-provider JWKS health surface.
|
||||||
|
AuthOIDCJWKSStatus: handler.NewAuthOIDCJWKSStatusHandler(oidcService, auditService),
|
||||||
// Auth — RBAC primitive (Bundle 1 Phase 4). Wires the postgres
|
// Auth — RBAC primitive (Bundle 1 Phase 4). Wires the postgres
|
||||||
// auth repos + service-layer Authorizer / RoleService /
|
// auth repos + service-layer Authorizer / RoleService /
|
||||||
// ActorRoleService / PermissionService into the HTTP surface
|
// ActorRoleService / PermissionService into the HTTP surface
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
// Audit 2026-05-10 MED-11 closure — federated-user admin surface.
|
||||||
|
//
|
||||||
|
// GET /api/v1/auth/users → gated auth.user.read
|
||||||
|
// DELETE /api/v1/auth/users/{id} → gated auth.user.deactivate
|
||||||
|
//
|
||||||
|
// The DELETE path is SOFT-DELETE — it sets users.deactivated_at and
|
||||||
|
// cascade-revokes the user's active sessions in the same operation.
|
||||||
|
// The row is the OIDC binding (tuple of (oidc_provider_id, oidc_subject));
|
||||||
|
// destroying it would re-mint a fresh user on the next IdP login under
|
||||||
|
// the same subject, losing the audit trail.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
oidcsvc "github.com/certctl-io/certctl/internal/auth/oidc"
|
||||||
|
userdomain "github.com/certctl-io/certctl/internal/auth/user/domain"
|
||||||
|
"github.com/certctl-io/certctl/internal/domain"
|
||||||
|
"github.com/certctl-io/certctl/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthUsersHandler exposes the federated-user admin surface.
|
||||||
|
type AuthUsersHandler struct {
|
||||||
|
users repository.UserRepository
|
||||||
|
sessions UserSessionsRevoker
|
||||||
|
audit AuditRecorder
|
||||||
|
tenantID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserSessionsRevoker is the slice of *session.Service the user-handler
|
||||||
|
// uses to cascade-revoke a deactivated user's active sessions in the
|
||||||
|
// same operation. Nil-safe: when unset (tests without session wiring),
|
||||||
|
// Deactivate logs an audit row but skips the revoke step.
|
||||||
|
type UserSessionsRevoker interface {
|
||||||
|
RevokeAllForActor(ctx context.Context, actorID, actorType string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthUsersHandler constructs a federated-user admin handler.
|
||||||
|
func NewAuthUsersHandler(users repository.UserRepository, sessions UserSessionsRevoker, audit AuditRecorder, tenantID string) *AuthUsersHandler {
|
||||||
|
return &AuthUsersHandler{users: users, sessions: sessions, audit: audit, tenantID: tenantID}
|
||||||
|
}
|
||||||
|
|
||||||
|
type userResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
OIDCSubject string `json:"oidc_subject"`
|
||||||
|
OIDCProviderID string `json:"oidc_provider_id"`
|
||||||
|
LastLoginAt string `json:"last_login_at"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
DeactivatedAt *string `json:"deactivated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func userToResponse(u *userdomain.User) userResponse {
|
||||||
|
r := userResponse{
|
||||||
|
ID: u.ID,
|
||||||
|
TenantID: u.TenantID,
|
||||||
|
Email: u.Email,
|
||||||
|
DisplayName: u.DisplayName,
|
||||||
|
OIDCSubject: u.OIDCSubject,
|
||||||
|
OIDCProviderID: u.OIDCProviderID,
|
||||||
|
LastLoginAt: u.LastLoginAt.UTC().Format(time.RFC3339),
|
||||||
|
CreatedAt: u.CreatedAt.UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if u.DeactivatedAt != nil {
|
||||||
|
s := u.DeactivatedAt.UTC().Format(time.RFC3339)
|
||||||
|
r.DeactivatedAt = &s
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns every user in the active tenant. Pagination + filter
|
||||||
|
// are accepted as query parameters; the repository's ListAll returns
|
||||||
|
// every row and we filter client-side for simplicity.
|
||||||
|
func (h *AuthUsersHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caller, err := callerFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
writeAuthError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
users, lerr := h.users.ListAll(r.Context(), h.tenantID)
|
||||||
|
if lerr != nil {
|
||||||
|
Error(w, http.StatusInternalServerError, "could not list users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
providerFilter := r.URL.Query().Get("oidc_provider_id")
|
||||||
|
out := make([]userResponse, 0, len(users))
|
||||||
|
for _, u := range users {
|
||||||
|
if providerFilter != "" && u.OIDCProviderID != providerFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, userToResponse(u))
|
||||||
|
}
|
||||||
|
_ = h.audit.RecordEventWithCategory(r.Context(), caller.ActorID, caller.ActorType, "auth.user_list",
|
||||||
|
domain.EventCategoryAuth, "user", "",
|
||||||
|
map[string]interface{}{"count": len(out), "provider_filter": providerFilter})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"users": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate sets deactivated_at on the user and cascade-revokes
|
||||||
|
// active sessions. Returns 204 on success.
|
||||||
|
func (h *AuthUsersHandler) Deactivate(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 user id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, gerr := h.users.Get(r.Context(), id)
|
||||||
|
if gerr != nil {
|
||||||
|
if errors.Is(gerr, repository.ErrUserNotFound) {
|
||||||
|
Error(w, http.StatusNotFound, "user not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Error(w, http.StatusInternalServerError, "could not load user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Idempotent: deactivating an already-deactivated user is a no-op
|
||||||
|
// from the wire's perspective.
|
||||||
|
if u.DeactivatedAt != nil {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
u.DeactivatedAt = &now
|
||||||
|
if uerr := h.users.Update(r.Context(), u); uerr != nil {
|
||||||
|
Error(w, http.StatusInternalServerError, "could not deactivate user")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Cascade-revoke active sessions. Best-effort: revoke failures do
|
||||||
|
// NOT roll back the deactivation (the user is already marked
|
||||||
|
// deactivated; a leftover session expires at the absolute-TTL anyway).
|
||||||
|
revokeStatus := "skipped_no_revoker"
|
||||||
|
if h.sessions != nil {
|
||||||
|
if rerr := h.sessions.RevokeAllForActor(r.Context(), u.ID, string(domain.ActorTypeUser)); rerr != nil {
|
||||||
|
revokeStatus = "failed"
|
||||||
|
} else {
|
||||||
|
revokeStatus = "ok"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = h.audit.RecordEventWithCategory(r.Context(), caller.ActorID, caller.ActorType, "auth.user_deactivated",
|
||||||
|
domain.EventCategoryAuth, "user", u.ID,
|
||||||
|
map[string]interface{}{
|
||||||
|
"user_id": u.ID,
|
||||||
|
"oidc_provider_id": u.OIDCProviderID,
|
||||||
|
"session_revoke_status": revokeStatus,
|
||||||
|
})
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MED-12 — Auth runtime config read endpoint.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// AuthRuntimeConfigHandler exposes a flat-map view of the auth-related
|
||||||
|
// CERTCTL_* env vars so operators can verify the deployed
|
||||||
|
// configuration matches their intent from the GUI. Read-only — no
|
||||||
|
// mutation surface (config changes require a restart + env-var edit
|
||||||
|
// by design).
|
||||||
|
type AuthRuntimeConfigHandler struct {
|
||||||
|
cfg func() map[string]string
|
||||||
|
audit AuditRecorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthRuntimeConfigHandler constructs the runtime-config handler.
|
||||||
|
// `cfg` is a closure so wires can be lazily evaluated against the
|
||||||
|
// running config without snapshot drift.
|
||||||
|
func NewAuthRuntimeConfigHandler(cfg func() map[string]string, audit AuditRecorder) *AuthRuntimeConfigHandler {
|
||||||
|
return &AuthRuntimeConfigHandler{cfg: cfg, audit: audit}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthRuntimeConfigHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caller, err := callerFromRequest(r)
|
||||||
|
if err != nil {
|
||||||
|
writeAuthError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m := h.cfg()
|
||||||
|
if m == nil {
|
||||||
|
m = map[string]string{}
|
||||||
|
}
|
||||||
|
_ = h.audit.RecordEventWithCategory(r.Context(), caller.ActorID, caller.ActorType, "auth.runtime_config_read",
|
||||||
|
domain.EventCategoryAuth, "config", "",
|
||||||
|
map[string]interface{}{"key_count": len(m)})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"runtime_config": m})
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// MED-7 — JWKS health endpoint.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
// JWKSStatusProbe is the projection of *oidc.Service the JWKS-status
|
||||||
|
// handler uses to read the per-provider verifier counters. Production
|
||||||
|
// *oidc.Service satisfies this directly via the JWKSStatus method.
|
||||||
|
type JWKSStatusProbe interface {
|
||||||
|
JWKSStatus(ctx context.Context, providerID string) (*oidcsvc.JWKSStatusSnapshot, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthOIDCJWKSStatusHandler exposes per-provider JWKS health.
|
||||||
|
type AuthOIDCJWKSStatusHandler struct {
|
||||||
|
probe JWKSStatusProbe
|
||||||
|
audit AuditRecorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthOIDCJWKSStatusHandler constructs the JWKS-status handler.
|
||||||
|
func NewAuthOIDCJWKSStatusHandler(probe JWKSStatusProbe, audit AuditRecorder) *AuthOIDCJWKSStatusHandler {
|
||||||
|
return &AuthOIDCJWKSStatusHandler{probe: probe, audit: audit}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthOIDCJWKSStatusHandler) Status(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
|
||||||
|
}
|
||||||
|
snap, perr := h.probe.JWKSStatus(r.Context(), id)
|
||||||
|
if perr != nil {
|
||||||
|
if errors.Is(perr, repository.ErrOIDCProviderNotFound) {
|
||||||
|
Error(w, http.StatusNotFound, "provider not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Error(w, http.StatusInternalServerError, "could not read JWKS status")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.audit.RecordEventWithCategory(r.Context(), caller.ActorID, caller.ActorType, "auth.oidc_jwks_status_read",
|
||||||
|
domain.EventCategoryAuth, "oidc_provider", id,
|
||||||
|
map[string]interface{}{"provider_id": id})
|
||||||
|
writeJSON(w, http.StatusOK, snap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuditRecorder is reused from auth_session_oidc.go — same package.
|
||||||
@@ -303,6 +303,21 @@ type HandlerRegistry struct {
|
|||||||
// Optional — when nil the routes are not registered.
|
// Optional — when nil the routes are not registered.
|
||||||
AuthBreakglass *handler.AuthBreakglassHandler
|
AuthBreakglass *handler.AuthBreakglassHandler
|
||||||
|
|
||||||
|
// AuthUsers handles the MED-11 federated-user admin surface
|
||||||
|
// (GET /api/v1/auth/users; DELETE /api/v1/auth/users/{id}).
|
||||||
|
// Optional — when nil the routes are not registered.
|
||||||
|
AuthUsers *handler.AuthUsersHandler
|
||||||
|
|
||||||
|
// AuthRuntimeConfig handles the MED-12 admin-only runtime
|
||||||
|
// config read endpoint (GET /api/v1/auth/runtime-config).
|
||||||
|
// Optional — when nil the route is not registered.
|
||||||
|
AuthRuntimeConfig *handler.AuthRuntimeConfigHandler
|
||||||
|
|
||||||
|
// AuthOIDCJWKSStatus handles the MED-7 per-provider JWKS health
|
||||||
|
// endpoint (GET /api/v1/auth/oidc/providers/{id}/jwks-status).
|
||||||
|
// Optional — when nil the route is not registered.
|
||||||
|
AuthOIDCJWKSStatus *handler.AuthOIDCJWKSStatusHandler
|
||||||
|
|
||||||
// IntermediateCAs handles the admin-gated CA-hierarchy management
|
// IntermediateCAs handles the admin-gated CA-hierarchy management
|
||||||
// surface under /api/v1/issuers/{id}/intermediates and
|
// surface under /api/v1/issuers/{id}/intermediates and
|
||||||
// /api/v1/intermediates/{id}. Rank 8 of the 2026-05-03 deep-
|
// /api/v1/intermediates/{id}. Rank 8 of the 2026-05-03 deep-
|
||||||
@@ -464,6 +479,28 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) {
|
|||||||
// reachability without persisting.
|
// reachability without persisting.
|
||||||
r.Register("POST /api/v1/auth/oidc/test", rbacGate(reg.Checker, "auth.oidc.create", reg.AuthSessionOIDC.TestProvider))
|
r.Register("POST /api/v1/auth/oidc/test", rbacGate(reg.Checker, "auth.oidc.create", reg.AuthSessionOIDC.TestProvider))
|
||||||
|
|
||||||
|
// Audit 2026-05-10 MED-7 — JWKS health surface.
|
||||||
|
if reg.AuthOIDCJWKSStatus != nil {
|
||||||
|
r.Register("GET /api/v1/auth/oidc/providers/{id}/jwks-status",
|
||||||
|
rbacGate(reg.Checker, "auth.oidc.list", reg.AuthOIDCJWKSStatus.Status))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit 2026-05-10 MED-11 — federated-user admin surface.
|
||||||
|
if reg.AuthUsers != nil {
|
||||||
|
r.Register("GET /api/v1/auth/users",
|
||||||
|
rbacGate(reg.Checker, "auth.user.read", reg.AuthUsers.List))
|
||||||
|
r.Register("DELETE /api/v1/auth/users/{id}",
|
||||||
|
rbacGate(reg.Checker, "auth.user.deactivate", reg.AuthUsers.Deactivate))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audit 2026-05-10 MED-12 — auth runtime config read.
|
||||||
|
// Gated auth.role.assign (admin-class) so non-admins can't
|
||||||
|
// enumerate the deployment's auth knobs.
|
||||||
|
if reg.AuthRuntimeConfig != nil {
|
||||||
|
r.Register("GET /api/v1/auth/runtime-config",
|
||||||
|
rbacGate(reg.Checker, "auth.role.assign", reg.AuthRuntimeConfig.Get))
|
||||||
|
}
|
||||||
|
|
||||||
// Group-mapping CRUD.
|
// Group-mapping CRUD.
|
||||||
r.Register("GET /api/v1/auth/oidc/group-mappings", rbacGate(reg.Checker, "auth.oidc.list", reg.AuthSessionOIDC.ListGroupMappings))
|
r.Register("GET /api/v1/auth/oidc/group-mappings", rbacGate(reg.Checker, "auth.oidc.list", reg.AuthSessionOIDC.ListGroupMappings))
|
||||||
r.Register("POST /api/v1/auth/oidc/group-mappings", rbacGate(reg.Checker, "auth.oidc.edit", reg.AuthSessionOIDC.AddGroupMapping))
|
r.Register("POST /api/v1/auth/oidc/group-mappings", rbacGate(reg.Checker, "auth.oidc.edit", reg.AuthSessionOIDC.AddGroupMapping))
|
||||||
|
|||||||
@@ -118,6 +118,16 @@ type providerEntry struct {
|
|||||||
// IssuerURL. When false (the default for most IdPs that haven't
|
// IssuerURL. When false (the default for most IdPs that haven't
|
||||||
// rolled RFC 9207 yet), the check is skipped.
|
// rolled RFC 9207 yet), the check is skipped.
|
||||||
issParamSupported bool
|
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
|
// OIDCProviderLookup is a narrow read-side projection of
|
||||||
@@ -873,14 +883,72 @@ func (s *Service) fetchUserinfoGroups(
|
|||||||
|
|
||||||
// RefreshKeys evicts the cached provider entry and re-loads it from
|
// RefreshKeys evicts the cached provider entry and re-loads it from
|
||||||
// scratch. Invokes the discovery doc fetch + the downgrade defense.
|
// 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 {
|
func (s *Service) RefreshKeys(ctx context.Context, providerID string) error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
delete(s.cache, providerID)
|
delete(s.cache, providerID)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
_, err := s.getOrLoad(ctx, providerID)
|
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
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Provider load + cache + IdP downgrade defense.
|
// Provider load + cache + IdP downgrade defense.
|
||||||
|
|||||||
Reference in New Issue
Block a user