mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:21:31 +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).
|
||||
// All endpoints return 404 when CERTCTL_BREAKGLASS_ENABLED=false.
|
||||
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 repos + service-layer Authorizer / RoleService /
|
||||
// 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.
|
||||
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
|
||||
// surface under /api/v1/issuers/{id}/intermediates and
|
||||
// /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.
|
||||
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.
|
||||
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))
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user