forked from baron/baron-sso
feat(headless-login): add jwks cache visibility and refresh flow
- replace inline headless jwks support with jwksUri-only validation - add cached jwks refresh worker, manual refresh/revoke endpoints, and parsed key summaries - expose allowed algorithms and key previews in DevFront with regression coverage
This commit is contained in:
@@ -22,16 +22,17 @@ import (
|
||||
)
|
||||
|
||||
type DevHandler struct {
|
||||
Hydra *service.HydraAdminService
|
||||
Redis domain.RedisRepository
|
||||
SecretRepo domain.ClientSecretRepository
|
||||
AuditRepo domain.AuditRepository
|
||||
KratosAdmin service.KratosAdminService
|
||||
ConsentRepo repository.ClientConsentRepository
|
||||
Keto service.KetoService
|
||||
RPSvc service.RelyingPartyService
|
||||
TenantSvc service.TenantService
|
||||
Auth interface {
|
||||
Hydra *service.HydraAdminService
|
||||
Redis domain.RedisRepository
|
||||
HeadlessJWKS *service.HeadlessJWKSCacheService
|
||||
SecretRepo domain.ClientSecretRepository
|
||||
AuditRepo domain.AuditRepository
|
||||
KratosAdmin service.KratosAdminService
|
||||
ConsentRepo repository.ClientConsentRepository
|
||||
Keto service.KetoService
|
||||
RPSvc service.RelyingPartyService
|
||||
TenantSvc service.TenantService
|
||||
Auth interface {
|
||||
GetEnrichedProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error)
|
||||
}
|
||||
}
|
||||
@@ -54,16 +55,17 @@ func NewDevHandler(
|
||||
}
|
||||
|
||||
return &DevHandler{
|
||||
Hydra: service.NewHydraAdminService(),
|
||||
Redis: redis,
|
||||
SecretRepo: secretRepo,
|
||||
AuditRepo: nil,
|
||||
KratosAdmin: service.NewKratosAdminService(),
|
||||
ConsentRepo: consentRepo,
|
||||
Keto: keto,
|
||||
RPSvc: rpSvc,
|
||||
TenantSvc: tenantSvc,
|
||||
Auth: authProvider,
|
||||
Hydra: service.NewHydraAdminService(),
|
||||
Redis: redis,
|
||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(redis, nil),
|
||||
SecretRepo: secretRepo,
|
||||
AuditRepo: nil,
|
||||
KratosAdmin: service.NewKratosAdminService(),
|
||||
ConsentRepo: consentRepo,
|
||||
Keto: keto,
|
||||
RPSvc: rpSvc,
|
||||
TenantSvc: tenantSvc,
|
||||
Auth: authProvider,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,8 +104,9 @@ type clientListResponse struct {
|
||||
}
|
||||
|
||||
type clientDetailResponse struct {
|
||||
Client clientSummary `json:"client"`
|
||||
Endpoints clientEndpoints `json:"endpoints"`
|
||||
Client clientSummary `json:"client"`
|
||||
Endpoints clientEndpoints `json:"endpoints"`
|
||||
HeadlessJWKSCache *domain.HeadlessJWKSCacheState `json:"headlessJwksCache,omitempty"`
|
||||
}
|
||||
|
||||
type clientEndpoints struct {
|
||||
@@ -697,8 +700,11 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
|
||||
}
|
||||
}
|
||||
|
||||
cacheState, _ := h.publicHeadlessJWKSCacheState(summary.ID)
|
||||
|
||||
return c.JSON(clientDetailResponse{
|
||||
Client: summary,
|
||||
Client: summary,
|
||||
HeadlessJWKSCache: cacheState,
|
||||
Endpoints: clientEndpoints{
|
||||
Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration",
|
||||
Issuer: h.Hydra.PublicURL,
|
||||
@@ -709,6 +715,32 @@ func (h *DevHandler) GetClient(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *DevHandler) publicHeadlessJWKSCacheState(clientID string) (*domain.HeadlessJWKSCacheState, error) {
|
||||
if h.HeadlessJWKS == nil {
|
||||
h.HeadlessJWKS = service.NewHeadlessJWKSCacheService(h.Redis, nil)
|
||||
}
|
||||
if h.HeadlessJWKS == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return h.HeadlessJWKS.PublicState(clientID)
|
||||
}
|
||||
|
||||
func (h *DevHandler) syncHeadlessJWKSCache(ctx context.Context, client domain.HydraClient, reason string) {
|
||||
if h.HeadlessJWKS == nil {
|
||||
h.HeadlessJWKS = service.NewHeadlessJWKSCacheService(h.Redis, nil)
|
||||
}
|
||||
if h.HeadlessJWKS == nil {
|
||||
return
|
||||
}
|
||||
if !client.IsHeadlessLoginEnabled() {
|
||||
_ = h.HeadlessJWKS.DeleteState(client.ClientID)
|
||||
return
|
||||
}
|
||||
if _, err := h.HeadlessJWKS.ForceRefresh(ctx, client, reason); err != nil {
|
||||
slog.Warn("failed to refresh headless jwks cache after client save", "clientID", client.ClientID, "reason", reason, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
||||
tenantID := h.injectTenantContextFromHeader(c)
|
||||
clientID := c.Params("id")
|
||||
@@ -790,8 +822,10 @@ func (h *DevHandler) UpdateClientStatus(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
updatedSummary := h.mapClientSummary(*updated)
|
||||
cacheState, _ := h.publicHeadlessJWKSCacheState(updatedSummary.ID)
|
||||
return c.JSON(clientDetailResponse{
|
||||
Client: updatedSummary,
|
||||
Client: updatedSummary,
|
||||
HeadlessJWKSCache: cacheState,
|
||||
Endpoints: clientEndpoints{
|
||||
Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration",
|
||||
Issuer: h.Hydra.PublicURL,
|
||||
@@ -863,6 +897,9 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
if status != "active" && status != "inactive" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "status must be active or inactive")
|
||||
}
|
||||
if requestIncludesInlineHeadlessJWKS(req) {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "headless login supports jwksUri only; inline jwks is not supported")
|
||||
}
|
||||
|
||||
metadata := mergeMetadata(nil, req.Metadata)
|
||||
if metadata == nil {
|
||||
@@ -891,6 +928,9 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
tokenAuthMethod = "client_secret_basic"
|
||||
}
|
||||
}
|
||||
if err := validateHeadlessClientInput(clientType, valueOr(req.JwksUri, ""), req.Jwks, metadata); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
tokenAuthMethod, jwksURI, jwks, metadata := normalizeHeadlessClientConfig(
|
||||
clientType,
|
||||
tokenAuthMethod,
|
||||
@@ -928,6 +968,7 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
h.syncHeadlessJWKSCache(c.Context(), *created, "client_create")
|
||||
|
||||
// Store secret in metadata for later retrieval
|
||||
if created.ClientSecret != "" {
|
||||
@@ -945,8 +986,10 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error {
|
||||
h.setAuditDetailsExtra(c, map[string]any{"target_id": created.ClientID})
|
||||
|
||||
summary := h.mapClientSummary(*created)
|
||||
cacheState, _ := h.publicHeadlessJWKSCacheState(summary.ID)
|
||||
return c.Status(fiber.StatusCreated).JSON(clientDetailResponse{
|
||||
Client: summary,
|
||||
Client: summary,
|
||||
HeadlessJWKSCache: cacheState,
|
||||
Endpoints: clientEndpoints{
|
||||
Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration",
|
||||
Issuer: h.Hydra.PublicURL,
|
||||
@@ -1043,6 +1086,9 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||
if req.RedirectURIs != nil && len(*req.RedirectURIs) == 0 {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "redirectUris cannot be empty")
|
||||
}
|
||||
if requestIncludesInlineHeadlessJWKS(req) {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "headless login supports jwksUri only; inline jwks is not supported")
|
||||
}
|
||||
|
||||
metadata := mergeMetadata(current.Metadata, req.Metadata)
|
||||
if status != "" {
|
||||
@@ -1061,6 +1107,9 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||
if req.Jwks == nil {
|
||||
resolvedJWKS = current.JWKS
|
||||
}
|
||||
if err := validateHeadlessClientInput(resolvedClientType, resolvedJWKSURI, resolvedJWKS, metadata); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, err.Error())
|
||||
}
|
||||
resolvedTokenAuthMethod, resolvedJWKSURI, resolvedJWKS, metadata = normalizeHeadlessClientConfig(
|
||||
resolvedClientType,
|
||||
resolvedTokenAuthMethod,
|
||||
@@ -1105,6 +1154,7 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
h.syncHeadlessJWKSCache(c.Context(), *updatedClient, "client_update")
|
||||
|
||||
if updatedClient.ClientSecret != "" {
|
||||
if h.SecretRepo != nil {
|
||||
@@ -1116,8 +1166,10 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
summary := h.mapClientSummary(*updatedClient)
|
||||
cacheState, _ := h.publicHeadlessJWKSCacheState(summary.ID)
|
||||
return c.JSON(clientDetailResponse{
|
||||
Client: summary,
|
||||
Client: summary,
|
||||
HeadlessJWKSCache: cacheState,
|
||||
Endpoints: clientEndpoints{
|
||||
Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration",
|
||||
Issuer: h.Hydra.PublicURL,
|
||||
@@ -1451,9 +1503,11 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
|
||||
// Return the new secret
|
||||
updatedSummary := h.mapClientSummary(*updated)
|
||||
updatedSummary.ClientSecret = newSecret
|
||||
cacheState, _ := h.publicHeadlessJWKSCacheState(updatedSummary.ID)
|
||||
|
||||
return c.JSON(clientDetailResponse{
|
||||
Client: updatedSummary,
|
||||
Client: updatedSummary,
|
||||
HeadlessJWKSCache: cacheState,
|
||||
Endpoints: clientEndpoints{
|
||||
Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration",
|
||||
Issuer: h.Hydra.PublicURL,
|
||||
@@ -1464,6 +1518,134 @@ func (h *DevHandler) RotateClientSecret(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *DevHandler) RefreshHeadlessJWKSCache(c *fiber.Ctx) error {
|
||||
tenantID := h.injectTenantContextFromHeader(c)
|
||||
clientID := strings.TrimSpace(c.Params("id"))
|
||||
if clientID == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
||||
}
|
||||
|
||||
current, err := h.Hydra.GetClient(c.Context(), clientID)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrHydraNotFound) {
|
||||
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
||||
}
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
if isHiddenSystemClient(*current) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
|
||||
}
|
||||
|
||||
summary := h.mapClientSummary(*current)
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
||||
}
|
||||
role := normalizeUserRole(profile.Role)
|
||||
if !isDevConsoleRoleAllowed(role) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
||||
}
|
||||
isSuperAdmin := role == domain.RoleSuperAdmin
|
||||
userTenantID := tenantIDFromProfile(profile)
|
||||
if !isSuperAdmin {
|
||||
clientTenantID := resolveClientTenantID(summary)
|
||||
if clientTenantID != userTenantID {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
|
||||
}
|
||||
}
|
||||
if !isRPAdminClientAllowed(profile, summary.ID) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client")
|
||||
}
|
||||
|
||||
if !current.IsHeadlessLoginEnabled() {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "headless login is not enabled for this client")
|
||||
}
|
||||
|
||||
if h.HeadlessJWKS == nil {
|
||||
h.HeadlessJWKS = service.NewHeadlessJWKSCacheService(h.Redis, nil)
|
||||
}
|
||||
if h.HeadlessJWKS == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "headless jwks cache service is unavailable")
|
||||
}
|
||||
if _, err := h.HeadlessJWKS.ForceRefresh(c.Context(), *current, "manual_refresh"); err != nil {
|
||||
return errorJSON(c, fiber.StatusBadRequest, headlessClientAssertionErrorMessage(err))
|
||||
}
|
||||
|
||||
h.setAuditDetailsExtra(c, map[string]any{
|
||||
"action": "REFRESH_HEADLESS_JWKS_CACHE",
|
||||
"target_id": clientID,
|
||||
"tenant_id": tenantID,
|
||||
})
|
||||
|
||||
cacheState, _ := h.publicHeadlessJWKSCacheState(clientID)
|
||||
return c.JSON(clientDetailResponse{
|
||||
Client: summary,
|
||||
HeadlessJWKSCache: cacheState,
|
||||
Endpoints: clientEndpoints{
|
||||
Discovery: strings.TrimRight(h.Hydra.PublicURL, "/") + "/.well-known/openid-configuration",
|
||||
Issuer: h.Hydra.PublicURL,
|
||||
Authorization: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/auth",
|
||||
Token: strings.TrimRight(h.Hydra.PublicURL, "/") + "/oauth2/token",
|
||||
UserInfo: strings.TrimRight(h.Hydra.PublicURL, "/") + "/userinfo",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *DevHandler) RevokeHeadlessJWKSCache(c *fiber.Ctx) error {
|
||||
tenantID := h.injectTenantContextFromHeader(c)
|
||||
clientID := strings.TrimSpace(c.Params("id"))
|
||||
if clientID == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "client id is required")
|
||||
}
|
||||
|
||||
current, err := h.Hydra.GetClient(c.Context(), clientID)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrHydraNotFound) {
|
||||
return errorJSON(c, fiber.StatusNotFound, "client not found")
|
||||
}
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
if isHiddenSystemClient(*current) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: protected system client")
|
||||
}
|
||||
|
||||
summary := h.mapClientSummary(*current)
|
||||
profile := h.getCurrentProfile(c)
|
||||
if profile == nil {
|
||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized: authentication required")
|
||||
}
|
||||
role := normalizeUserRole(profile.Role)
|
||||
if !isDevConsoleRoleAllowed(role) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden")
|
||||
}
|
||||
isSuperAdmin := role == domain.RoleSuperAdmin
|
||||
userTenantID := tenantIDFromProfile(profile)
|
||||
if !isSuperAdmin {
|
||||
clientTenantID := resolveClientTenantID(summary)
|
||||
if clientTenantID != userTenantID {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: access denied to client in another tenant")
|
||||
}
|
||||
}
|
||||
if !isRPAdminClientAllowed(profile, summary.ID) {
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: rp_admin scope does not include this client")
|
||||
}
|
||||
|
||||
if h.HeadlessJWKS == nil {
|
||||
h.HeadlessJWKS = service.NewHeadlessJWKSCacheService(h.Redis, nil)
|
||||
}
|
||||
if h.HeadlessJWKS != nil {
|
||||
_ = h.HeadlessJWKS.DeleteState(clientID)
|
||||
}
|
||||
|
||||
h.setAuditDetailsExtra(c, map[string]any{
|
||||
"action": "REVOKE_HEADLESS_JWKS_CACHE",
|
||||
"target_id": clientID,
|
||||
"tenant_id": tenantID,
|
||||
})
|
||||
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *DevHandler) ListAuditLogs(c *fiber.Ctx) error {
|
||||
if h.AuditRepo == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "Audit service unavailable")
|
||||
@@ -1739,9 +1921,9 @@ func normalizeHeadlessClientConfig(
|
||||
}
|
||||
metadata[domain.MetadataHeadlessTokenEndpointAuthMethod] = headlessTokenAuthMethod
|
||||
|
||||
headlessJWKSURI := readMetadataStringValue(metadata, domain.MetadataHeadlessJWKSURI)
|
||||
if headlessJWKSURI == "" && strings.TrimSpace(jwksURI) != "" {
|
||||
headlessJWKSURI = strings.TrimSpace(jwksURI)
|
||||
headlessJWKSURI := strings.TrimSpace(jwksURI)
|
||||
if headlessJWKSURI == "" {
|
||||
headlessJWKSURI = readMetadataStringValue(metadata, domain.MetadataHeadlessJWKSURI)
|
||||
}
|
||||
if headlessJWKSURI != "" {
|
||||
metadata[domain.MetadataHeadlessJWKSURI] = headlessJWKSURI
|
||||
@@ -1749,12 +1931,7 @@ func normalizeHeadlessClientConfig(
|
||||
delete(metadata, domain.MetadataHeadlessJWKSURI)
|
||||
}
|
||||
|
||||
if _, ok := metadata[domain.MetadataHeadlessJWKS]; !ok && jwks != nil {
|
||||
metadata[domain.MetadataHeadlessJWKS] = jwks
|
||||
}
|
||||
if metadata[domain.MetadataHeadlessJWKS] == nil {
|
||||
delete(metadata, domain.MetadataHeadlessJWKS)
|
||||
}
|
||||
delete(metadata, domain.MetadataHeadlessJWKS)
|
||||
|
||||
return "none", "", nil, metadata
|
||||
}
|
||||
@@ -1765,6 +1942,36 @@ func normalizeHeadlessClientConfig(
|
||||
return tokenAuthMethod, jwksURI, jwks, metadata
|
||||
}
|
||||
|
||||
func validateHeadlessClientInput(clientType string, jwksURI string, jwks interface{}, metadata map[string]interface{}) error {
|
||||
if clientType != "pkce" || !readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if jwks != nil {
|
||||
return fmt.Errorf("headless login supports jwksUri only; inline jwks is not supported")
|
||||
}
|
||||
|
||||
resolvedURI := strings.TrimSpace(jwksURI)
|
||||
if resolvedURI == "" {
|
||||
resolvedURI = readMetadataStringValue(metadata, domain.MetadataHeadlessJWKSURI)
|
||||
}
|
||||
if resolvedURI == "" {
|
||||
return fmt.Errorf("headless login requires jwksUri; inline jwks is not supported")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requestIncludesInlineHeadlessJWKS(req clientUpsertRequest) bool {
|
||||
if req.Jwks != nil {
|
||||
return true
|
||||
}
|
||||
if req.Metadata == nil {
|
||||
return false
|
||||
}
|
||||
value, ok := (*req.Metadata)[domain.MetadataHeadlessJWKS]
|
||||
return ok && value != nil
|
||||
}
|
||||
|
||||
func defaultClientScopes() []string {
|
||||
return []string{"openid", "profile", "email"}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user