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:
43
README.md
43
README.md
@@ -143,19 +143,52 @@ flowchart
|
||||
- Hydra login request의 `client.client_id`와 요청 바디의 `client_id`가 반드시 같아야 합니다.
|
||||
- client가 headless login 선행 조건을 만족해야 합니다.
|
||||
- `headless_token_endpoint_auth_method == "private_key_jwt"` 또는 top-level `token_endpoint_auth_method == "private_key_jwt"`
|
||||
- `headless_jwks_uri` 또는 `headless_jwks`가 존재해야 합니다.
|
||||
- `headless_jwks_uri`가 존재해야 합니다.
|
||||
- inline `headless_jwks`는 더 이상 지원하지 않습니다.
|
||||
- `headless_login_enabled == true`가 필요합니다.
|
||||
- `metadata.status == "inactive"`인 client는 차단됩니다.
|
||||
|
||||
#### `client_assertion` 규칙
|
||||
- 구현상 `client_assertion`은 현재 필수입니다.
|
||||
- 허용 서명 알고리즘:
|
||||
- `RS256`, `RS384`, `RS512`
|
||||
- `PS256`, `PS384`, `PS512`
|
||||
- `ES256`, `ES384`, `ES512`
|
||||
- `EdDSA`
|
||||
- JWT claim의 `iss`와 `sub`는 모두 `client_id`와 같아야 합니다.
|
||||
- `exp`는 현재 시각 이후여야 합니다.
|
||||
- `nbf`, `iat`가 있으면 미래 시각이면 안 됩니다.
|
||||
- `aud`는 다음 둘 중 하나와 일치해야 합니다.
|
||||
- `https://<backend-origin>/api/v1/auth/headless/password/login`
|
||||
- `/api/v1/auth/headless/password/login`
|
||||
- 서명 검증용 public key는 `headless_jwks_uri` 또는 `headless_jwks`에서 읽습니다.
|
||||
- 서명 검증용 public key는 `headless_jwks_uri`에서만 읽습니다.
|
||||
|
||||
#### 내부 JWKS 캐시 정책
|
||||
- Baron Backend는 `headless_jwks_uri`를 직접 외부 스펙으로 저장하고, 실제 JWKS 문서는 내부 캐시에 저장해 사용합니다.
|
||||
- 등록/수정 이후에는 내부 캐시 동기화를 시도하고, 성공/실패 상태를 DevFront에서 확인할 수 있습니다.
|
||||
- 로그인 시 재조회는 다음 조건으로 제한합니다.
|
||||
- 캐시에 `kid`가 없을 때
|
||||
- `kid`는 있지만 서명 검증이 실패할 때
|
||||
- 캐시 TTL이 만료되었을 때
|
||||
- 그 외에는 내부 캐시를 사용합니다.
|
||||
- 백그라운드 worker가 TTL보다 짧은 주기로 `jwksUri`를 선제 점검해 첫 사용자 실패를 줄입니다.
|
||||
|
||||
#### DevFront 운영 액션
|
||||
- Settings > `Public Key Registration` 카드에서 다음 정보를 확인할 수 있습니다.
|
||||
- 최근 JWKS 캐시 갱신 시각
|
||||
- 최근 검증 성공 시각
|
||||
- 최근 에러와 연속 실패 횟수
|
||||
- 현재 cached `kid` 목록
|
||||
- 파싱된 key summary
|
||||
- `kid`
|
||||
- `kty`
|
||||
- `use`
|
||||
- `alg`
|
||||
- RSA key의 `n` preview (앞/뒤 일부만 표시)
|
||||
- 수동 운영 액션:
|
||||
- `Refresh JWKS Cache`
|
||||
- `Revoke JWKS Cache`
|
||||
- RP가 키를 교체했으면 실제 트래픽 전에 `Refresh JWKS Cache`를 먼저 호출하는 것을 권장합니다.
|
||||
|
||||
#### 일반 로그인과의 차이
|
||||
- `POST /api/v1/auth/password/login`
|
||||
@@ -172,9 +205,13 @@ flowchart
|
||||
- 필수 필드 누락
|
||||
- `client_assertion` 누락
|
||||
- `401 invalid_client_assertion`
|
||||
- JWKS 조회 실패
|
||||
- `jwksUri` 조회 실패
|
||||
- 서명 불일치
|
||||
- `aud`/`iss`/`sub`/`exp` 검증 실패
|
||||
- `401 invalid_client_assertion` with explicit message
|
||||
- `Headless login requires jwksUri. Inline jwks is not supported.`
|
||||
- `Configured jwksUri returned no keys for headless login.`
|
||||
- `Failed to refresh headless login jwks from jwksUri.`
|
||||
- `403 forbidden`
|
||||
- `client_id` 불일치
|
||||
- `headless_login_enabled` 미설정
|
||||
|
||||
@@ -270,14 +270,20 @@ func main() {
|
||||
tenantService.SetKetoService(ketoService) // Keto 주입
|
||||
|
||||
hydraService := service.NewHydraAdminService()
|
||||
headlessJWKSCache := service.NewHeadlessJWKSCacheService(redisService, nil)
|
||||
headlessJWKSWorker := service.NewHeadlessJWKSCacheWorker(hydraService, headlessJWKSCache)
|
||||
go headlessJWKSWorker.Start(context.Background())
|
||||
slog.Info("✅ Headless JWKS Cache Worker started")
|
||||
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService, ketoOutboxRepo)
|
||||
secretRepo := repository.NewClientSecretRepository(db)
|
||||
consentRepo := repository.NewClientConsentRepository(db)
|
||||
|
||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
|
||||
authHandler.HeadlessJWKS = headlessJWKSCache
|
||||
adminHandler := handler.NewAdminHandler(ketoService)
|
||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, tenantService, authHandler)
|
||||
devHandler.HeadlessJWKS = headlessJWKSCache
|
||||
devHandler.AuditRepo = auditRepo
|
||||
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService)
|
||||
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
||||
@@ -673,6 +679,8 @@ func main() {
|
||||
dev.Post("/clients", devHandler.CreateClient)
|
||||
dev.Get("/clients/:id", devHandler.GetClient)
|
||||
dev.Put("/clients/:id", devHandler.UpdateClient)
|
||||
dev.Post("/clients/:id/headless-jwks/refresh", devHandler.RefreshHeadlessJWKSCache)
|
||||
dev.Delete("/clients/:id/headless-jwks/cache", devHandler.RevokeHeadlessJWKSCache)
|
||||
dev.Post("/clients/:id/secret/rotate", devHandler.RotateClientSecret)
|
||||
dev.Patch("/clients/:id/status", devHandler.UpdateClientStatus)
|
||||
dev.Delete("/clients/:id", devHandler.DeleteClient)
|
||||
|
||||
29
backend/internal/domain/headless_jwks_cache.go
Normal file
29
backend/internal/domain/headless_jwks_cache.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type HeadlessJWKSParsedKey struct {
|
||||
Kid string `json:"kid,omitempty"`
|
||||
Kty string `json:"kty,omitempty"`
|
||||
Use string `json:"use,omitempty"`
|
||||
Alg string `json:"alg,omitempty"`
|
||||
NPreview string `json:"nPreview,omitempty"`
|
||||
}
|
||||
|
||||
// HeadlessJWKSCacheState는 headless login용 JWKS 캐시 상태와 최근 동기화 결과를 나타냅니다.
|
||||
type HeadlessJWKSCacheState struct {
|
||||
ClientID string `json:"clientId"`
|
||||
JWKSURI string `json:"jwksUri"`
|
||||
CachedAt *time.Time `json:"cachedAt,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||
LastCheckedAt *time.Time `json:"lastCheckedAt,omitempty"`
|
||||
LastSuccessfulVerificationAt *time.Time `json:"lastSuccessfulVerificationAt,omitempty"`
|
||||
LastRefreshStatus string `json:"lastRefreshStatus,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
ConsecutiveFailures int `json:"consecutiveFailures,omitempty"`
|
||||
CachedKids []string `json:"cachedKids,omitempty"`
|
||||
ParsedKeys []HeadlessJWKSParsedKey `json:"parsedKeys,omitempty"`
|
||||
ETag string `json:"etag,omitempty"`
|
||||
LastModified string `json:"lastModified,omitempty"`
|
||||
RawJWKS string `json:"-"`
|
||||
}
|
||||
@@ -28,9 +28,8 @@ type HydraClient struct {
|
||||
}
|
||||
|
||||
func (c *HydraClient) SupportsHeadlessLogin() bool {
|
||||
// A headless login client must have a public key registered (URI or Inline)
|
||||
// and use private_key_jwt for token endpoint authentication.
|
||||
hasPublicKey := c.HeadlessJWKSURI() != "" || c.HeadlessJWKS() != nil
|
||||
// Headless login now supports jwksUri only.
|
||||
hasPublicKey := c.HeadlessJWKSURI() != ""
|
||||
isPrivateKeyJwt := c.HeadlessTokenEndpointAuthMethod() == "private_key_jwt"
|
||||
return hasPublicKey && isPrivateKeyJwt
|
||||
}
|
||||
|
||||
@@ -9,11 +9,7 @@ func TestHydraClient_HeadlessLoginFlags(t *testing.T) {
|
||||
Metadata: map[string]any{
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks": map[string]any{
|
||||
"keys": []map[string]any{{
|
||||
"kty": "RSA",
|
||||
}},
|
||||
},
|
||||
"headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -25,7 +21,7 @@ func TestHydraClient_HeadlessLoginFlags(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("inline jwks with private_key_jwt and headless enabled", func(t *testing.T) {
|
||||
t.Run("inline jwks without jwks uri does not support headless login", func(t *testing.T) {
|
||||
client := HydraClient{
|
||||
TokenEndpointAuthMethod: "private_key_jwt",
|
||||
JWKS: map[string]any{
|
||||
@@ -38,11 +34,11 @@ func TestHydraClient_HeadlessLoginFlags(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if !client.SupportsHeadlessLogin() {
|
||||
t.Fatalf("expected headless login client")
|
||||
if client.SupportsHeadlessLogin() {
|
||||
t.Fatalf("expected headless login prerequisites to be missing")
|
||||
}
|
||||
if !client.IsHeadlessLoginEnabled() {
|
||||
t.Fatalf("expected headless login enabled")
|
||||
if client.IsHeadlessLoginEnabled() {
|
||||
t.Fatalf("expected headless login disabled without jwks uri")
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -87,6 +87,7 @@ type AuthHandler struct {
|
||||
SmsService domain.SmsService
|
||||
EmailService domain.EmailService
|
||||
RedisService domain.RedisRepository
|
||||
HeadlessJWKS *service.HeadlessJWKSCacheService
|
||||
KratosAdmin service.KratosAdminService
|
||||
IdpProvider domain.IdentityProvider
|
||||
AuditRepo domain.AuditRepository
|
||||
@@ -193,6 +194,7 @@ func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.Iden
|
||||
SmsService: service.NewSmsService(),
|
||||
EmailService: service.NewEmailService(),
|
||||
RedisService: redisService,
|
||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(redisService, nil),
|
||||
KratosAdmin: kratos,
|
||||
IdpProvider: idpProvider,
|
||||
AuditRepo: auditRepo,
|
||||
@@ -1740,47 +1742,15 @@ func containsHeadlessAudience(expected []string, actual headlessAssertionAud) bo
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *AuthHandler) loadHeadlessJWKS(ctx context.Context, client domain.HydraClient) (*jose.JSONWebKeySet, error) {
|
||||
var raw []byte
|
||||
switch {
|
||||
case client.HeadlessJWKS() != nil:
|
||||
data, err := json.Marshal(client.HeadlessJWKS())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode jwks: %w", err)
|
||||
}
|
||||
raw = data
|
||||
case client.HeadlessJWKSURI() != "":
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, client.HeadlessJWKSURI(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build jwks request: %w", err)
|
||||
}
|
||||
client := &http.Client{Timeout: headlessJWKSFetchTTL}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch jwks: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return nil, fmt.Errorf("failed to fetch jwks status=%d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read jwks response: %w", err)
|
||||
}
|
||||
raw = body
|
||||
default:
|
||||
return nil, fmt.Errorf("headless login public key is not configured")
|
||||
func (h *AuthHandler) loadHeadlessJWKS(ctx context.Context, client domain.HydraClient, expectedKid string) (*jose.JSONWebKeySet, bool, error) {
|
||||
if h.HeadlessJWKS == nil {
|
||||
h.HeadlessJWKS = service.NewHeadlessJWKSCacheService(h.RedisService, nil)
|
||||
}
|
||||
|
||||
var keySet jose.JSONWebKeySet
|
||||
if err := json.Unmarshal(raw, &keySet); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode jwks: %w", err)
|
||||
keySet, _, refreshed, err := h.HeadlessJWKS.EnsureFreshKeySet(ctx, client, expectedKid)
|
||||
if err != nil {
|
||||
return nil, refreshed, err
|
||||
}
|
||||
if len(keySet.Keys) == 0 {
|
||||
return nil, fmt.Errorf("headless login jwks has no keys")
|
||||
}
|
||||
return &keySet, nil
|
||||
return keySet, refreshed, nil
|
||||
}
|
||||
|
||||
func validateHeadlessClientAssertionClaims(c *fiber.Ctx, claims headlessClientAssertionClaims, clientID string) error {
|
||||
@@ -1809,12 +1779,6 @@ func (h *AuthHandler) verifyHeadlessClientAssertion(c *fiber.Ctx, client domain.
|
||||
return errorJSONCode(c, fiber.StatusBadRequest, "bad_request", "client_assertion is required")
|
||||
}
|
||||
|
||||
keySet, err := h.loadHeadlessJWKS(c.Context(), client)
|
||||
if err != nil {
|
||||
slog.Error("failed to load jwks for headless client assertion", "clientID", clientID, "error", err)
|
||||
return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_client_assertion", "Failed to verify client assertion")
|
||||
}
|
||||
|
||||
token, err := josejwt.ParseSigned(assertion, []jose.SignatureAlgorithm{
|
||||
jose.RS256, jose.RS384, jose.RS512,
|
||||
jose.PS256, jose.PS384, jose.PS512,
|
||||
@@ -1830,6 +1794,13 @@ func (h *AuthHandler) verifyHeadlessClientAssertion(c *fiber.Ctx, client domain.
|
||||
expectedKid = strings.TrimSpace(token.Headers[0].KeyID)
|
||||
}
|
||||
|
||||
keySet, refreshed, err := h.loadHeadlessJWKS(c.Context(), client, expectedKid)
|
||||
if err != nil {
|
||||
slog.Error("failed to load jwks for headless client assertion", "clientID", clientID, "error", err)
|
||||
return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_client_assertion", headlessClientAssertionErrorMessage(err))
|
||||
}
|
||||
|
||||
matchingKidPresent := expectedKid != "" && containsHeadlessKeyID(keySet, expectedKid)
|
||||
for _, key := range keySet.Keys {
|
||||
if expectedKid != "" && key.KeyID != "" && key.KeyID != expectedKid {
|
||||
continue
|
||||
@@ -1840,12 +1811,65 @@ func (h *AuthHandler) verifyHeadlessClientAssertion(c *fiber.Ctx, client domain.
|
||||
continue
|
||||
}
|
||||
if err := validateHeadlessClientAssertionClaims(c, claims, clientID); err != nil {
|
||||
return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_client_assertion", "Failed to verify client assertion")
|
||||
return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_client_assertion", "Failed to verify client assertion claims")
|
||||
}
|
||||
_ = h.HeadlessJWKS.MarkVerificationSuccess(clientID)
|
||||
return nil
|
||||
}
|
||||
|
||||
return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_client_assertion", "Failed to verify client assertion")
|
||||
if matchingKidPresent && !refreshed && h.HeadlessJWKS != nil {
|
||||
refreshedKeySet, _, refreshErr := h.HeadlessJWKS.ForceRefreshKeySet(c.Context(), client, "signature_verification_failed")
|
||||
if refreshErr == nil && refreshedKeySet != nil {
|
||||
for _, key := range refreshedKeySet.Keys {
|
||||
if expectedKid != "" && key.KeyID != "" && key.KeyID != expectedKid {
|
||||
continue
|
||||
}
|
||||
|
||||
var claims headlessClientAssertionClaims
|
||||
if err := token.Claims(key.Key, &claims); err != nil {
|
||||
continue
|
||||
}
|
||||
if err := validateHeadlessClientAssertionClaims(c, claims, clientID); err != nil {
|
||||
return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_client_assertion", "Failed to verify client assertion claims")
|
||||
}
|
||||
_ = h.HeadlessJWKS.MarkVerificationSuccess(clientID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errorJSONCode(c, fiber.StatusUnauthorized, "invalid_client_assertion", "Failed to verify client assertion signature with jwksUri")
|
||||
}
|
||||
|
||||
func headlessClientAssertionErrorMessage(err error) string {
|
||||
if err == nil {
|
||||
return "Failed to verify client assertion"
|
||||
}
|
||||
message := strings.TrimSpace(err.Error())
|
||||
switch {
|
||||
case strings.Contains(message, "requires jwksUri"):
|
||||
return "Headless login requires jwksUri. Inline jwks is not supported."
|
||||
case strings.Contains(message, "no keys"):
|
||||
return "Configured jwksUri returned no keys for headless login."
|
||||
case strings.Contains(message, "failed to fetch jwksUri"):
|
||||
return "Failed to refresh headless login jwks from jwksUri."
|
||||
case strings.Contains(message, "failed to decode jwks"):
|
||||
return "Configured jwksUri returned an invalid jwks document."
|
||||
default:
|
||||
return "Failed to verify client assertion"
|
||||
}
|
||||
}
|
||||
|
||||
func containsHeadlessKeyID(keySet *jose.JSONWebKeySet, expectedKid string) bool {
|
||||
if keySet == nil {
|
||||
return false
|
||||
}
|
||||
for _, key := range keySet.Keys {
|
||||
if strings.TrimSpace(key.KeyID) == strings.TrimSpace(expectedKid) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *AuthHandler) storeHeadlessLinkState(pendingRef string, state headlessLinkState, ttl time.Duration) {
|
||||
|
||||
@@ -175,6 +175,12 @@ func TestPollEnchantedLink_ExpiredToken_ReturnsCode(t *testing.T) {
|
||||
func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBody)
|
||||
}))
|
||||
defer jwksServer.Close()
|
||||
|
||||
idp := &mockIdpProvider{
|
||||
userExists: true,
|
||||
@@ -192,7 +198,7 @@ func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks": jwks,
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -236,6 +242,12 @@ func TestHeadlessLinkInit_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
|
||||
redis := &mockRedisRepo{data: make(map[string]string)}
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBody)
|
||||
}))
|
||||
defer jwksServer.Close()
|
||||
|
||||
idp := &mockIdpProvider{
|
||||
userExists: true,
|
||||
@@ -254,7 +266,7 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) {
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks": jwks,
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"baron-sso-backend/internal/service"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
@@ -199,6 +202,183 @@ func mustHeadlessClientAssertion(t *testing.T, privateKey *rsa.PrivateKey, clien
|
||||
return raw
|
||||
}
|
||||
|
||||
func mustHeadlessJWKForAlgorithm(t *testing.T, alg jose.SignatureAlgorithm) (any, map[string]any) {
|
||||
t.Helper()
|
||||
|
||||
var privateKey any
|
||||
var publicKey any
|
||||
|
||||
switch alg {
|
||||
case jose.RS256, jose.RS384, jose.RS512, jose.PS256, jose.PS384, jose.PS512:
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate rsa key: %v", err)
|
||||
}
|
||||
privateKey = key
|
||||
publicKey = &key.PublicKey
|
||||
case jose.ES256:
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate ecdsa key: %v", err)
|
||||
}
|
||||
privateKey = key
|
||||
publicKey = &key.PublicKey
|
||||
case jose.ES384:
|
||||
key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate ecdsa key: %v", err)
|
||||
}
|
||||
privateKey = key
|
||||
publicKey = &key.PublicKey
|
||||
case jose.ES512:
|
||||
key, err := ecdsa.GenerateKey(elliptic.P521(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate ecdsa key: %v", err)
|
||||
}
|
||||
privateKey = key
|
||||
publicKey = &key.PublicKey
|
||||
case jose.EdDSA:
|
||||
_, key, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate ed25519 key: %v", err)
|
||||
}
|
||||
privateKey = key
|
||||
publicKey = key.Public()
|
||||
default:
|
||||
t.Fatalf("unsupported test algorithm: %s", alg)
|
||||
}
|
||||
|
||||
keySet := jose.JSONWebKeySet{
|
||||
Keys: []jose.JSONWebKey{
|
||||
{
|
||||
Key: publicKey,
|
||||
KeyID: "test-kid",
|
||||
Use: "sig",
|
||||
Algorithm: string(alg),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(keySet)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal jwks: %v", err)
|
||||
}
|
||||
|
||||
var jwks map[string]any
|
||||
if err := json.Unmarshal(raw, &jwks); err != nil {
|
||||
t.Fatalf("failed to decode jwks map: %v", err)
|
||||
}
|
||||
|
||||
return privateKey, jwks
|
||||
}
|
||||
|
||||
func mustHeadlessClientAssertionWithAlgorithm(t *testing.T, privateKey any, alg jose.SignatureAlgorithm, clientID, audience string) string {
|
||||
t.Helper()
|
||||
|
||||
signer, err := jose.NewSigner(jose.SigningKey{
|
||||
Algorithm: alg,
|
||||
Key: jose.JSONWebKey{
|
||||
Key: privateKey,
|
||||
KeyID: "test-kid",
|
||||
Use: "sig",
|
||||
Algorithm: string(alg),
|
||||
},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create signer: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
raw, err := josejwt.Signed(signer).Claims(josejwt.Claims{
|
||||
Issuer: clientID,
|
||||
Subject: clientID,
|
||||
Audience: josejwt.Audience{audience},
|
||||
Expiry: josejwt.NewNumericDate(now.Add(5 * time.Minute)),
|
||||
IssuedAt: josejwt.NewNumericDate(now),
|
||||
NotBefore: josejwt.NewNumericDate(now.Add(-1 * time.Minute)),
|
||||
ID: "assertion-1",
|
||||
}).Serialize()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to sign client assertion: %v", err)
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
|
||||
func runHeadlessPasswordLoginWithAssertion(t *testing.T, jwks map[string]any, clientAssertion string) *http.Response {
|
||||
t.Helper()
|
||||
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
Subject: "kratos-identity-id",
|
||||
}, nil)
|
||||
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-identity-id", nil)
|
||||
|
||||
jwksBody, err := json.Marshal(jwks)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal jwks body: %v", err)
|
||||
}
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBody)
|
||||
}))
|
||||
t.Cleanup(jwksServer.Close)
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet:
|
||||
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||
Challenge: "challenge-123",
|
||||
Client: domain.HydraClient{
|
||||
ClientID: "headless-login-client",
|
||||
TokenEndpointAuthMethod: "none",
|
||||
Metadata: map[string]interface{}{
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut:
|
||||
json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"})
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
h := &AuthHandler{
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
},
|
||||
}
|
||||
|
||||
app := newHeadlessPasswordLoginTestApp(h)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"client_id": "headless-login-client",
|
||||
"client_assertion": clientAssertion,
|
||||
"loginId": "employee001",
|
||||
"password": "password",
|
||||
"login_challenge": "challenge-123",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/password/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// mockHydraTransport simulates Hydra API responses
|
||||
func mockHydraTransport(handler http.Handler) http.RoundTripper {
|
||||
return roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
@@ -375,6 +555,206 @@ func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(t *testing.T) {
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
Subject: "kratos-identity-id",
|
||||
}, nil)
|
||||
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBody)
|
||||
}))
|
||||
defer jwksServer.Close()
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet:
|
||||
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||
Challenge: "challenge-123",
|
||||
Client: domain.HydraClient{
|
||||
ClientID: "headless-login-client",
|
||||
TokenEndpointAuthMethod: "none",
|
||||
Metadata: map[string]interface{}{
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
"headless_jwks": map[string]any{
|
||||
"keys": []map[string]any{},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut:
|
||||
json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-identity-id", nil)
|
||||
|
||||
h := &AuthHandler{
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
},
|
||||
}
|
||||
|
||||
app := newHeadlessPasswordLoginTestApp(h)
|
||||
|
||||
clientAssertion := mustHeadlessClientAssertion(
|
||||
t,
|
||||
privateKey,
|
||||
"headless-login-client",
|
||||
"http://example.com/api/v1/auth/headless/password/login",
|
||||
)
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"client_id": "headless-login-client",
|
||||
"client_assertion": clientAssertion,
|
||||
"loginId": "employee001",
|
||||
"password": "password",
|
||||
"login_challenge": "challenge-123",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/password/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessPasswordLogin_RefreshesJWKSWhenSignatureFailsForCachedKid(t *testing.T) {
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
SessionToken: &domain.Token{JWT: "valid-jwt"},
|
||||
Subject: "kratos-identity-id",
|
||||
}, nil)
|
||||
|
||||
stalePrivateKey, staleJWKS := mustHeadlessRSAJWK(t)
|
||||
staleRaw, err := json.Marshal(staleJWKS)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal stale jwks: %v", err)
|
||||
}
|
||||
|
||||
freshPrivateKey, freshJWKS := mustHeadlessRSAJWK(t)
|
||||
freshRaw, err := json.Marshal(freshJWKS)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal fresh jwks: %v", err)
|
||||
}
|
||||
|
||||
fetchCount := 0
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fetchCount++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(freshRaw)
|
||||
}))
|
||||
defer jwksServer.Close()
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet:
|
||||
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
|
||||
Challenge: "challenge-123",
|
||||
Client: domain.HydraClient{
|
||||
ClientID: "headless-login-client",
|
||||
TokenEndpointAuthMethod: "none",
|
||||
Metadata: map[string]interface{}{
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
},
|
||||
},
|
||||
})
|
||||
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut:
|
||||
json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
|
||||
mockKratos := new(MockKratosAdminService)
|
||||
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "employee001").Return("kratos-identity-id", nil)
|
||||
|
||||
redisRepo := &testRedisRepo{values: map[string]string{}}
|
||||
cacheService := service.NewHeadlessJWKSCacheService(redisRepo, jwksServer.Client())
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(30 * time.Minute)
|
||||
if err := cacheService.SaveState("headless-login-client", domain.HeadlessJWKSCacheState{
|
||||
ClientID: "headless-login-client",
|
||||
JWKSURI: jwksServer.URL + "/.well-known/jwks.json",
|
||||
RawJWKS: string(staleRaw),
|
||||
CachedKids: []string{"test-kid"},
|
||||
CachedAt: &now,
|
||||
LastCheckedAt: &now,
|
||||
ExpiresAt: &expiresAt,
|
||||
LastRefreshStatus: "success",
|
||||
}); err != nil {
|
||||
t.Fatalf("failed to save cached jwks state: %v", err)
|
||||
}
|
||||
|
||||
h := &AuthHandler{
|
||||
IdpProvider: mockIdp,
|
||||
KratosAdmin: mockKratos,
|
||||
RedisService: redisRepo,
|
||||
HeadlessJWKS: cacheService,
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
|
||||
},
|
||||
}
|
||||
|
||||
app := newHeadlessPasswordLoginTestApp(h)
|
||||
|
||||
clientAssertion := mustHeadlessClientAssertion(
|
||||
t,
|
||||
freshPrivateKey,
|
||||
"headless-login-client",
|
||||
"http://example.com/api/v1/auth/headless/password/login",
|
||||
)
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"client_id": "headless-login-client",
|
||||
"client_assertion": clientAssertion,
|
||||
"loginId": "employee001",
|
||||
"password": "password",
|
||||
"login_challenge": "challenge-123",
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/headless/password/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
if fetchCount != 1 {
|
||||
t.Fatalf("expected exactly one jwks refresh fetch, got %d", fetchCount)
|
||||
}
|
||||
if stalePrivateKey == nil {
|
||||
t.Fatalf("expected stale key to be generated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessPasswordLogin_MissingClientAssertionRejected(t *testing.T) {
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
||||
@@ -391,13 +771,12 @@ func TestHeadlessPasswordLogin_MissingClientAssertionRejected(t *testing.T) {
|
||||
Challenge: "challenge-123",
|
||||
Client: domain.HydraClient{
|
||||
ClientID: "headless-login-client",
|
||||
TokenEndpointAuthMethod: "private_key_jwt",
|
||||
JWKS: map[string]any{
|
||||
"keys": []map[string]any{},
|
||||
},
|
||||
TokenEndpointAuthMethod: "none",
|
||||
Metadata: map[string]interface{}{
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -453,6 +832,12 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
|
||||
validKey, jwks := mustHeadlessRSAJWK(t)
|
||||
invalidKey, _ := mustHeadlessRSAJWK(t)
|
||||
_ = validKey
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(jwksBody)
|
||||
}))
|
||||
defer jwksServer.Close()
|
||||
|
||||
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
@@ -461,11 +846,12 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
|
||||
Challenge: "challenge-123",
|
||||
Client: domain.HydraClient{
|
||||
ClientID: "headless-login-client",
|
||||
TokenEndpointAuthMethod: "private_key_jwt",
|
||||
JWKS: jwks,
|
||||
TokenEndpointAuthMethod: "none",
|
||||
Metadata: map[string]interface{}{
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": jwksServer.URL + "/.well-known/jwks.json",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -516,6 +902,42 @@ func TestHeadlessPasswordLogin_InvalidClientAssertionRejected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessPasswordLogin_AcceptsConfiguredClientAssertionAlgorithms(t *testing.T) {
|
||||
algorithms := []jose.SignatureAlgorithm{
|
||||
jose.RS256,
|
||||
jose.RS384,
|
||||
jose.RS512,
|
||||
jose.PS256,
|
||||
jose.PS384,
|
||||
jose.PS512,
|
||||
jose.ES256,
|
||||
jose.ES384,
|
||||
jose.ES512,
|
||||
jose.EdDSA,
|
||||
}
|
||||
|
||||
for _, algorithm := range algorithms {
|
||||
t.Run(string(algorithm), func(t *testing.T) {
|
||||
privateKey, jwks := mustHeadlessJWKForAlgorithm(t, algorithm)
|
||||
clientAssertion := mustHeadlessClientAssertionWithAlgorithm(
|
||||
t,
|
||||
privateKey,
|
||||
algorithm,
|
||||
"headless-login-client",
|
||||
"http://example.com/api/v1/auth/headless/password/login",
|
||||
)
|
||||
|
||||
resp := runHeadlessPasswordLoginWithAssertion(t, jwks, clientAssertion)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200 for %s, got %d, body: %s", algorithm, resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlessPasswordLogin_HeadlessDisabledRejected(t *testing.T) {
|
||||
mockIdp := new(MockIdentityProvider)
|
||||
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -89,6 +90,32 @@ func (m *devEnhancedMockAuditRepo) CountActiveSessionsSince(ctx context.Context,
|
||||
return m.countSessions, nil
|
||||
}
|
||||
|
||||
func devTestJWKSFirstKeyString(t *testing.T, jwks map[string]any, field string) string {
|
||||
t.Helper()
|
||||
|
||||
keys, ok := jwks["keys"].([]any)
|
||||
if !ok || len(keys) == 0 {
|
||||
t.Fatalf("expected jwks keys")
|
||||
}
|
||||
key, ok := keys[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected jwks key object")
|
||||
}
|
||||
value, ok := key[field].(string)
|
||||
if !ok {
|
||||
t.Fatalf("expected jwks field %s", field)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func devTestPreviewValue(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) <= 24 {
|
||||
return value
|
||||
}
|
||||
return value[:12] + "..." + value[len(value)-12:]
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestListClients_Success(t *testing.T) {
|
||||
@@ -652,6 +679,64 @@ func TestCreateClient_HeadlessLoginPayloadMapping(t *testing.T) {
|
||||
})
|
||||
app.Post("/api/v1/dev/clients", h.CreateClient)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"name": "Headless Login App",
|
||||
"type": "pkce",
|
||||
"redirectUris": []string{"https://rp.example.com/callback"},
|
||||
"scopes": []string{"openid", "profile"},
|
||||
"tokenEndpointAuthMethod": "private_key_jwt",
|
||||
"jwksUri": "https://rp.example.com/.well-known/jwks.json",
|
||||
"metadata": map[string]any{
|
||||
"headless_login_enabled": true,
|
||||
"request_object_signing_alg": "RS256",
|
||||
},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
assert.Equal(t, "none", captured.TokenEndpointAuthMethod)
|
||||
assert.Nil(t, captured.JWKS)
|
||||
assert.Equal(t, "private_key_jwt", captured.Metadata["headless_token_endpoint_auth_method"])
|
||||
assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.Metadata["headless_jwks_uri"])
|
||||
assert.True(t, captured.IsHeadlessLoginEnabled())
|
||||
assert.Equal(t, true, captured.Metadata["headless_login_enabled"])
|
||||
assert.Equal(t, "RS256", captured.Metadata["request_object_signing_alg"])
|
||||
}
|
||||
|
||||
func TestCreateClient_HeadlessLoginRejectsInlineJWKS(t *testing.T) {
|
||||
var hydraCalled bool
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
PublicURL: "http://hydra.public",
|
||||
HTTPClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
hydraCalled = true
|
||||
return httpJSONAny(r, http.StatusCreated, map[string]any{
|
||||
"client_id": "client-headless-login",
|
||||
"client_name": "Headless Login App",
|
||||
"redirect_uris": []string{"https://rp.example.com/callback"},
|
||||
"grant_types": []string{"authorization_code", "refresh_token"},
|
||||
"response_types": []string{"code"},
|
||||
"scope": "openid profile",
|
||||
"token_endpoint_auth_method": "none",
|
||||
"metadata": map[string]any{
|
||||
"headless_login_enabled": true,
|
||||
},
|
||||
}), nil
|
||||
})},
|
||||
},
|
||||
Keto: new(devMockKetoService),
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Post("/api/v1/dev/clients", h.CreateClient)
|
||||
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"name": "Headless Login App",
|
||||
"type": "pkce",
|
||||
@@ -675,14 +760,12 @@ func TestCreateClient_HeadlessLoginPayloadMapping(t *testing.T) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
assert.Equal(t, "none", captured.TokenEndpointAuthMethod)
|
||||
assert.Nil(t, captured.JWKS)
|
||||
assert.Equal(t, "private_key_jwt", captured.Metadata["headless_token_endpoint_auth_method"])
|
||||
assert.NotNil(t, captured.Metadata["headless_jwks"])
|
||||
assert.True(t, captured.IsHeadlessLoginEnabled())
|
||||
assert.Equal(t, true, captured.Metadata["headless_login_enabled"])
|
||||
assert.Equal(t, "RS256", captured.Metadata["request_object_signing_alg"])
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
assert.Contains(t, string(bodyBytes), "headless login supports jwksUri only")
|
||||
assert.False(t, hydraCalled)
|
||||
}
|
||||
|
||||
func TestUpdateClient_HeadlessLoginPayloadMapping(t *testing.T) {
|
||||
@@ -699,7 +782,10 @@ func TestUpdateClient_HeadlessLoginPayloadMapping(t *testing.T) {
|
||||
"scope": "openid profile",
|
||||
"token_endpoint_auth_method": "none",
|
||||
"metadata": map[string]any{
|
||||
"status": "active",
|
||||
"status": "active",
|
||||
"headless_jwks": map[string]any{"keys": []map[string]any{}},
|
||||
"headless_jwks_uri": "https://stale.example.com/old.json",
|
||||
"headless_login_enabled": true,
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
@@ -759,10 +845,128 @@ func TestUpdateClient_HeadlessLoginPayloadMapping(t *testing.T) {
|
||||
assert.Equal(t, "", captured.JWKSUri)
|
||||
assert.Equal(t, "private_key_jwt", captured.Metadata["headless_token_endpoint_auth_method"])
|
||||
assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.Metadata["headless_jwks_uri"])
|
||||
_, hasInlineJWKS := captured.Metadata["headless_jwks"]
|
||||
assert.False(t, hasInlineJWKS)
|
||||
assert.True(t, captured.IsHeadlessLoginEnabled())
|
||||
assert.Equal(t, true, captured.Metadata["headless_login_enabled"])
|
||||
}
|
||||
|
||||
func TestRefreshHeadlessJWKSCache_ReturnsUpdatedCacheState(t *testing.T) {
|
||||
privateKey, jwks := mustHeadlessRSAJWK(t)
|
||||
_ = privateKey
|
||||
jwksBody, _ := json.Marshal(jwks)
|
||||
expectedNPreview := devTestPreviewValue(devTestJWKSFirstKeyString(t, jwks, "n"))
|
||||
redisRepo := &devMockRedisRepo{data: map[string]string{}}
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
PublicURL: "http://hydra.public",
|
||||
HTTPClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-headless-login" {
|
||||
return httpJSONAny(r, http.StatusOK, domain.HydraClient{
|
||||
ClientID: "client-headless-login",
|
||||
Metadata: map[string]any{
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
"headless_token_endpoint_auth_method": "private_key_jwt",
|
||||
"headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})},
|
||||
},
|
||||
Redis: redisRepo,
|
||||
HeadlessJWKS: service.NewHeadlessJWKSCacheService(redisRepo, &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", r.URL.String())
|
||||
var payload map[string]any
|
||||
_ = json.Unmarshal(jwksBody, &payload)
|
||||
return httpJSONAny(r, http.StatusOK, payload), nil
|
||||
})}),
|
||||
Keto: new(devMockKetoService),
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Post("/api/v1/dev/clients/:id/headless-jwks/refresh", h.RefreshHeadlessJWKSCache)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/dev/clients/client-headless-login/headless-jwks/refresh", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var got clientDetailResponse
|
||||
err := json.NewDecoder(resp.Body).Decode(&got)
|
||||
assert.NoError(t, err)
|
||||
if assert.NotNil(t, got.HeadlessJWKSCache) {
|
||||
assert.Equal(t, "success", got.HeadlessJWKSCache.LastRefreshStatus)
|
||||
assert.Equal(t, []string{"test-kid"}, got.HeadlessJWKSCache.CachedKids)
|
||||
if assert.Len(t, got.HeadlessJWKSCache.ParsedKeys, 1) {
|
||||
assert.Equal(t, "test-kid", got.HeadlessJWKSCache.ParsedKeys[0].Kid)
|
||||
assert.Equal(t, "RSA", got.HeadlessJWKSCache.ParsedKeys[0].Kty)
|
||||
assert.Equal(t, "sig", got.HeadlessJWKSCache.ParsedKeys[0].Use)
|
||||
assert.Equal(t, "RS256", got.HeadlessJWKSCache.ParsedKeys[0].Alg)
|
||||
assert.Equal(t, expectedNPreview, got.HeadlessJWKSCache.ParsedKeys[0].NPreview)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeHeadlessJWKSCache_DeletesCachedState(t *testing.T) {
|
||||
redisRepo := &devMockRedisRepo{data: map[string]string{}}
|
||||
cacheService := service.NewHeadlessJWKSCacheService(redisRepo, nil)
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(30 * time.Minute)
|
||||
err := cacheService.SaveState("client-headless-login", domain.HeadlessJWKSCacheState{
|
||||
ClientID: "client-headless-login",
|
||||
JWKSURI: "https://rp.example.com/.well-known/jwks.json",
|
||||
CachedAt: &now,
|
||||
ExpiresAt: &expiresAt,
|
||||
LastRefreshStatus: "success",
|
||||
ConsecutiveFailures: 0,
|
||||
RawJWKS: `{"keys":[{"kid":"cached-key","kty":"RSA","n":"AQIDBAUGBw","e":"AQAB"}]}`,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{
|
||||
AdminURL: "http://hydra.test",
|
||||
PublicURL: "http://hydra.public",
|
||||
HTTPClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/clients/client-headless-login" {
|
||||
return httpJSONAny(r, http.StatusOK, domain.HydraClient{
|
||||
ClientID: "client-headless-login",
|
||||
Metadata: map[string]any{
|
||||
"status": "active",
|
||||
"headless_login_enabled": true,
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return httpJSONAny(r, http.StatusNotFound, nil), nil
|
||||
})},
|
||||
},
|
||||
Redis: redisRepo,
|
||||
HeadlessJWKS: cacheService,
|
||||
Keto: new(devMockKetoService),
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Use(func(c *fiber.Ctx) error {
|
||||
c.Locals("user_profile", &domain.UserProfileResponse{ID: "test-user", Role: domain.RoleSuperAdmin})
|
||||
return c.Next()
|
||||
})
|
||||
app.Delete("/api/v1/dev/clients/:id/headless-jwks/cache", h.RevokeHeadlessJWKSCache)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/api/v1/dev/clients/client-headless-login/headless-jwks/cache", nil)
|
||||
resp, _ := app.Test(req, -1)
|
||||
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||
|
||||
stored, err := cacheService.GetState("client-headless-login")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, stored)
|
||||
}
|
||||
|
||||
func TestListAuditLogs_TenantMemberForbidden(t *testing.T) {
|
||||
h := &DevHandler{
|
||||
Hydra: &service.HydraAdminService{AdminURL: "http://hydra.test"},
|
||||
|
||||
505
backend/internal/service/headless_jwks_cache.go
Normal file
505
backend/internal/service/headless_jwks_cache.go
Normal file
@@ -0,0 +1,505 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
headlessJWKSCacheKeyPrefix = "headless_jwks_cache:"
|
||||
)
|
||||
|
||||
type HeadlessJWKSCacheService struct {
|
||||
Redis domain.RedisRepository
|
||||
HTTPClient *http.Client
|
||||
TTL time.Duration
|
||||
PrefetchWindow time.Duration
|
||||
RequestTimeout time.Duration
|
||||
}
|
||||
|
||||
type headlessJWKSCacheStateStore struct {
|
||||
ClientID string `json:"clientId"`
|
||||
JWKSURI string `json:"jwksUri"`
|
||||
CachedAt *time.Time `json:"cachedAt,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||
LastCheckedAt *time.Time `json:"lastCheckedAt,omitempty"`
|
||||
LastSuccessfulVerificationAt *time.Time `json:"lastSuccessfulVerificationAt,omitempty"`
|
||||
LastRefreshStatus string `json:"lastRefreshStatus,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
ConsecutiveFailures int `json:"consecutiveFailures,omitempty"`
|
||||
CachedKids []string `json:"cachedKids,omitempty"`
|
||||
ETag string `json:"etag,omitempty"`
|
||||
LastModified string `json:"lastModified,omitempty"`
|
||||
RawJWKS string `json:"rawJwks,omitempty"`
|
||||
}
|
||||
|
||||
type HeadlessJWKSCacheWorker struct {
|
||||
Hydra *HydraAdminService
|
||||
Cache *HeadlessJWKSCacheService
|
||||
Interval time.Duration
|
||||
PageSize int
|
||||
}
|
||||
|
||||
func NewHeadlessJWKSCacheService(redis domain.RedisRepository, httpClient *http.Client) *HeadlessJWKSCacheService {
|
||||
ttlSeconds, _ := strconv.Atoi(strings.TrimSpace(getenv("HEADLESS_JWKS_CACHE_TTL_SECONDS", "1800")))
|
||||
if ttlSeconds <= 0 {
|
||||
ttlSeconds = 1800
|
||||
}
|
||||
|
||||
prefetchSeconds, _ := strconv.Atoi(strings.TrimSpace(getenv("HEADLESS_JWKS_PREFETCH_WINDOW_SECONDS", "600")))
|
||||
if prefetchSeconds <= 0 {
|
||||
prefetchSeconds = 600
|
||||
}
|
||||
|
||||
timeoutSeconds, _ := strconv.Atoi(strings.TrimSpace(getenv("HEADLESS_JWKS_FETCH_TIMEOUT_SECONDS", "5")))
|
||||
if timeoutSeconds <= 0 {
|
||||
timeoutSeconds = 5
|
||||
}
|
||||
|
||||
return &HeadlessJWKSCacheService{
|
||||
Redis: redis,
|
||||
HTTPClient: httpClient,
|
||||
TTL: time.Duration(ttlSeconds) * time.Second,
|
||||
PrefetchWindow: time.Duration(prefetchSeconds) * time.Second,
|
||||
RequestTimeout: time.Duration(timeoutSeconds) * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func NewHeadlessJWKSCacheWorker(hydra *HydraAdminService, cache *HeadlessJWKSCacheService) *HeadlessJWKSCacheWorker {
|
||||
intervalSeconds, _ := strconv.Atoi(strings.TrimSpace(getenv("HEADLESS_JWKS_REFRESH_INTERVAL_SECONDS", "600")))
|
||||
if intervalSeconds <= 0 {
|
||||
intervalSeconds = 600
|
||||
}
|
||||
|
||||
return &HeadlessJWKSCacheWorker{
|
||||
Hydra: hydra,
|
||||
Cache: cache,
|
||||
Interval: time.Duration(intervalSeconds) * time.Second,
|
||||
PageSize: 100,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HeadlessJWKSCacheService) httpClient() *http.Client {
|
||||
if s.HTTPClient != nil {
|
||||
return s.HTTPClient
|
||||
}
|
||||
timeout := s.RequestTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
return &http.Client{Timeout: timeout}
|
||||
}
|
||||
|
||||
func (s *HeadlessJWKSCacheService) cacheKey(clientID string) string {
|
||||
return headlessJWKSCacheKeyPrefix + strings.TrimSpace(clientID)
|
||||
}
|
||||
|
||||
func (s *HeadlessJWKSCacheService) SaveState(clientID string, state domain.HeadlessJWKSCacheState) error {
|
||||
if s == nil || s.Redis == nil || strings.TrimSpace(clientID) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(headlessJWKSCacheStateStore{
|
||||
ClientID: state.ClientID,
|
||||
JWKSURI: state.JWKSURI,
|
||||
CachedAt: state.CachedAt,
|
||||
ExpiresAt: state.ExpiresAt,
|
||||
LastCheckedAt: state.LastCheckedAt,
|
||||
LastSuccessfulVerificationAt: state.LastSuccessfulVerificationAt,
|
||||
LastRefreshStatus: state.LastRefreshStatus,
|
||||
LastError: state.LastError,
|
||||
ConsecutiveFailures: state.ConsecutiveFailures,
|
||||
CachedKids: state.CachedKids,
|
||||
ETag: state.ETag,
|
||||
LastModified: state.LastModified,
|
||||
RawJWKS: state.RawJWKS,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Redis.Set(s.cacheKey(clientID), string(payload), 0)
|
||||
}
|
||||
|
||||
func (s *HeadlessJWKSCacheService) GetState(clientID string) (*domain.HeadlessJWKSCacheState, error) {
|
||||
if s == nil || s.Redis == nil || strings.TrimSpace(clientID) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
raw, err := s.Redis.Get(s.cacheKey(clientID))
|
||||
if err != nil || strings.TrimSpace(raw) == "" {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var stored headlessJWKSCacheStateStore
|
||||
if err := json.Unmarshal([]byte(raw), &stored); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &domain.HeadlessJWKSCacheState{
|
||||
ClientID: stored.ClientID,
|
||||
JWKSURI: stored.JWKSURI,
|
||||
CachedAt: stored.CachedAt,
|
||||
ExpiresAt: stored.ExpiresAt,
|
||||
LastCheckedAt: stored.LastCheckedAt,
|
||||
LastSuccessfulVerificationAt: stored.LastSuccessfulVerificationAt,
|
||||
LastRefreshStatus: stored.LastRefreshStatus,
|
||||
LastError: stored.LastError,
|
||||
ConsecutiveFailures: stored.ConsecutiveFailures,
|
||||
CachedKids: stored.CachedKids,
|
||||
ETag: stored.ETag,
|
||||
LastModified: stored.LastModified,
|
||||
RawJWKS: stored.RawJWKS,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *HeadlessJWKSCacheService) DeleteState(clientID string) error {
|
||||
if s == nil || s.Redis == nil {
|
||||
return nil
|
||||
}
|
||||
return s.Redis.Delete(s.cacheKey(clientID))
|
||||
}
|
||||
|
||||
func (s *HeadlessJWKSCacheService) PublicState(clientID string) (*domain.HeadlessJWKSCacheState, error) {
|
||||
state, err := s.GetState(clientID)
|
||||
if err != nil || state == nil {
|
||||
return state, err
|
||||
}
|
||||
state.ParsedKeys = summarizeHeadlessJWKS(state.RawJWKS)
|
||||
state.RawJWKS = ""
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s *HeadlessJWKSCacheService) MarkVerificationSuccess(clientID string) error {
|
||||
state, err := s.GetState(clientID)
|
||||
if err != nil || state == nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
state.LastSuccessfulVerificationAt = &now
|
||||
return s.SaveState(clientID, *state)
|
||||
}
|
||||
|
||||
func (s *HeadlessJWKSCacheService) ShouldPrefetch(state *domain.HeadlessJWKSCacheState, now time.Time) bool {
|
||||
if state == nil {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(state.RawJWKS) == "" {
|
||||
return true
|
||||
}
|
||||
if state.ExpiresAt == nil {
|
||||
return true
|
||||
}
|
||||
return !state.ExpiresAt.After(now.Add(s.PrefetchWindow))
|
||||
}
|
||||
|
||||
func (s *HeadlessJWKSCacheService) EnsureFreshKeySet(ctx context.Context, client domain.HydraClient, expectedKid string) (*jose.JSONWebKeySet, *domain.HeadlessJWKSCacheState, bool, error) {
|
||||
if s == nil {
|
||||
return nil, nil, false, fmt.Errorf("headless jwks cache service is not configured")
|
||||
}
|
||||
|
||||
jwksURI := strings.TrimSpace(client.HeadlessJWKSURI())
|
||||
if jwksURI == "" {
|
||||
return nil, nil, false, fmt.Errorf("headless login requires jwksUri; inline jwks is not supported")
|
||||
}
|
||||
|
||||
state, err := s.GetState(client.ClientID)
|
||||
if err != nil {
|
||||
slog.Warn("failed to load headless jwks cache state", "clientID", client.ClientID, "error", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
switch {
|
||||
case state == nil:
|
||||
return s.refreshClient(ctx, client, nil, "cache_miss")
|
||||
case strings.TrimSpace(state.JWKSURI) != jwksURI:
|
||||
return s.refreshClient(ctx, client, state, "config_changed")
|
||||
case strings.TrimSpace(state.RawJWKS) == "":
|
||||
return s.refreshClient(ctx, client, state, "cache_empty")
|
||||
case state.ExpiresAt == nil || !state.ExpiresAt.After(now):
|
||||
return s.refreshClient(ctx, client, state, "ttl_expired")
|
||||
case expectedKid != "" && !containsString(state.CachedKids, expectedKid):
|
||||
return s.refreshClient(ctx, client, state, "kid_missing")
|
||||
default:
|
||||
keySet, err := decodeHeadlessJWKS(state.RawJWKS)
|
||||
if err != nil {
|
||||
return s.refreshClient(ctx, client, state, "cache_corrupt")
|
||||
}
|
||||
return keySet, state, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HeadlessJWKSCacheService) ForceRefresh(ctx context.Context, client domain.HydraClient, reason string) (*domain.HeadlessJWKSCacheState, error) {
|
||||
_, state, err := s.ForceRefreshKeySet(ctx, client, reason)
|
||||
return state, err
|
||||
}
|
||||
|
||||
func (s *HeadlessJWKSCacheService) ForceRefreshKeySet(ctx context.Context, client domain.HydraClient, reason string) (*jose.JSONWebKeySet, *domain.HeadlessJWKSCacheState, error) {
|
||||
previous, err := s.GetState(client.ClientID)
|
||||
if err != nil {
|
||||
slog.Warn("failed to load headless jwks cache state before force refresh", "clientID", client.ClientID, "error", err)
|
||||
}
|
||||
keySet, state, _, err := s.refreshClient(ctx, client, previous, reason)
|
||||
return keySet, state, err
|
||||
}
|
||||
|
||||
func (s *HeadlessJWKSCacheService) refreshClient(ctx context.Context, client domain.HydraClient, previous *domain.HeadlessJWKSCacheState, reason string) (*jose.JSONWebKeySet, *domain.HeadlessJWKSCacheState, bool, error) {
|
||||
jwksURI := strings.TrimSpace(client.HeadlessJWKSURI())
|
||||
if jwksURI == "" {
|
||||
return nil, nil, false, fmt.Errorf("headless login requires jwksUri; inline jwks is not supported")
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, jwksURI, nil)
|
||||
if err != nil {
|
||||
return nil, s.persistRefreshFailure(client, previous, fmt.Errorf("failed to build jwks request: %w", err)), false, err
|
||||
}
|
||||
if previous != nil {
|
||||
if etag := strings.TrimSpace(previous.ETag); etag != "" {
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
}
|
||||
if lastModified := strings.TrimSpace(previous.LastModified); lastModified != "" {
|
||||
req.Header.Set("If-Modified-Since", lastModified)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := s.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, s.persistRefreshFailure(client, previous, fmt.Errorf("failed to fetch jwksUri: %w", err)), false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
now := time.Now()
|
||||
if resp.StatusCode == http.StatusNotModified && previous != nil && strings.TrimSpace(previous.RawJWKS) != "" {
|
||||
updated := *previous
|
||||
updated.JWKSURI = jwksURI
|
||||
updated.LastCheckedAt = &now
|
||||
updated.ExpiresAt = ptrTime(now.Add(s.TTL))
|
||||
updated.LastRefreshStatus = "success"
|
||||
updated.LastError = ""
|
||||
updated.ConsecutiveFailures = 0
|
||||
_ = s.SaveState(client.ClientID, updated)
|
||||
keySet, decodeErr := decodeHeadlessJWKS(updated.RawJWKS)
|
||||
return keySet, &updated, true, decodeErr
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
err = fmt.Errorf("failed to fetch jwksUri status=%d body=%s", resp.StatusCode, string(body))
|
||||
return nil, s.persistRefreshFailure(client, previous, err), false, err
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
|
||||
if err != nil {
|
||||
return nil, s.persistRefreshFailure(client, previous, fmt.Errorf("failed to read jwks response: %w", err)), false, err
|
||||
}
|
||||
|
||||
keySet, err := decodeHeadlessJWKS(string(body))
|
||||
if err != nil {
|
||||
return nil, s.persistRefreshFailure(client, previous, err), false, err
|
||||
}
|
||||
|
||||
state := domain.HeadlessJWKSCacheState{
|
||||
ClientID: client.ClientID,
|
||||
JWKSURI: jwksURI,
|
||||
CachedAt: &now,
|
||||
ExpiresAt: ptrTime(now.Add(s.TTL)),
|
||||
LastCheckedAt: &now,
|
||||
LastSuccessfulVerificationAt: previousLastVerification(previous),
|
||||
LastRefreshStatus: "success",
|
||||
LastError: "",
|
||||
ConsecutiveFailures: 0,
|
||||
CachedKids: extractHeadlessKids(keySet),
|
||||
ETag: strings.TrimSpace(resp.Header.Get("ETag")),
|
||||
LastModified: strings.TrimSpace(resp.Header.Get("Last-Modified")),
|
||||
RawJWKS: string(body),
|
||||
}
|
||||
if err := s.SaveState(client.ClientID, state); err != nil {
|
||||
return nil, &state, false, err
|
||||
}
|
||||
slog.Info("headless jwks cache refreshed", "clientID", client.ClientID, "reason", reason, "keyCount", len(keySet.Keys))
|
||||
return keySet, &state, true, nil
|
||||
}
|
||||
|
||||
func (s *HeadlessJWKSCacheService) persistRefreshFailure(client domain.HydraClient, previous *domain.HeadlessJWKSCacheState, refreshErr error) *domain.HeadlessJWKSCacheState {
|
||||
now := time.Now()
|
||||
state := domain.HeadlessJWKSCacheState{
|
||||
ClientID: client.ClientID,
|
||||
JWKSURI: strings.TrimSpace(client.HeadlessJWKSURI()),
|
||||
LastCheckedAt: &now,
|
||||
LastRefreshStatus: "failure",
|
||||
LastError: refreshErr.Error(),
|
||||
ConsecutiveFailures: 1,
|
||||
}
|
||||
if previous != nil {
|
||||
state.CachedAt = previous.CachedAt
|
||||
state.ExpiresAt = previous.ExpiresAt
|
||||
state.LastSuccessfulVerificationAt = previous.LastSuccessfulVerificationAt
|
||||
state.CachedKids = previous.CachedKids
|
||||
state.ETag = previous.ETag
|
||||
state.LastModified = previous.LastModified
|
||||
state.RawJWKS = previous.RawJWKS
|
||||
state.ConsecutiveFailures = previous.ConsecutiveFailures + 1
|
||||
}
|
||||
_ = s.SaveState(client.ClientID, state)
|
||||
return &state
|
||||
}
|
||||
|
||||
func decodeHeadlessJWKS(raw string) (*jose.JSONWebKeySet, error) {
|
||||
var keySet jose.JSONWebKeySet
|
||||
if err := json.Unmarshal([]byte(raw), &keySet); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode jwks from jwksUri: %w", err)
|
||||
}
|
||||
if len(keySet.Keys) == 0 {
|
||||
return nil, fmt.Errorf("configured jwksUri returned no keys")
|
||||
}
|
||||
return &keySet, nil
|
||||
}
|
||||
|
||||
type headlessJWKSPreviewDocument struct {
|
||||
Keys []headlessJWKSPreviewKey `json:"keys"`
|
||||
}
|
||||
|
||||
type headlessJWKSPreviewKey struct {
|
||||
Kid string `json:"kid"`
|
||||
Kty string `json:"kty"`
|
||||
Use string `json:"use"`
|
||||
Alg string `json:"alg"`
|
||||
N string `json:"n"`
|
||||
}
|
||||
|
||||
func summarizeHeadlessJWKS(raw string) []domain.HeadlessJWKSParsedKey {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var document headlessJWKSPreviewDocument
|
||||
if err := json.Unmarshal([]byte(raw), &document); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
parsedKeys := make([]domain.HeadlessJWKSParsedKey, 0, len(document.Keys))
|
||||
for _, key := range document.Keys {
|
||||
parsedKeys = append(parsedKeys, domain.HeadlessJWKSParsedKey{
|
||||
Kid: strings.TrimSpace(key.Kid),
|
||||
Kty: strings.TrimSpace(key.Kty),
|
||||
Use: strings.TrimSpace(key.Use),
|
||||
Alg: strings.TrimSpace(key.Alg),
|
||||
NPreview: previewHeadlessJWKValue(key.N),
|
||||
})
|
||||
}
|
||||
return parsedKeys
|
||||
}
|
||||
|
||||
func previewHeadlessJWKValue(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) <= 24 {
|
||||
return value
|
||||
}
|
||||
return value[:12] + "..." + value[len(value)-12:]
|
||||
}
|
||||
|
||||
func extractHeadlessKids(keySet *jose.JSONWebKeySet) []string {
|
||||
if keySet == nil {
|
||||
return nil
|
||||
}
|
||||
kids := make([]string, 0, len(keySet.Keys))
|
||||
for _, key := range keySet.Keys {
|
||||
if kid := strings.TrimSpace(key.KeyID); kid != "" {
|
||||
kids = append(kids, kid)
|
||||
}
|
||||
}
|
||||
return kids
|
||||
}
|
||||
|
||||
func containsString(values []string, needle string) bool {
|
||||
needle = strings.TrimSpace(needle)
|
||||
if needle == "" {
|
||||
return false
|
||||
}
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func previousLastVerification(previous *domain.HeadlessJWKSCacheState) *time.Time {
|
||||
if previous == nil {
|
||||
return nil
|
||||
}
|
||||
return previous.LastSuccessfulVerificationAt
|
||||
}
|
||||
|
||||
func ptrTime(value time.Time) *time.Time {
|
||||
return &value
|
||||
}
|
||||
|
||||
func (w *HeadlessJWKSCacheWorker) Start(ctx context.Context) {
|
||||
if w == nil || w.Hydra == nil || w.Cache == nil {
|
||||
return
|
||||
}
|
||||
w.runOnce(ctx)
|
||||
ticker := time.NewTicker(w.Interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
w.runOnce(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *HeadlessJWKSCacheWorker) runOnce(ctx context.Context) {
|
||||
offset := 0
|
||||
pageSize := w.PageSize
|
||||
if pageSize <= 0 {
|
||||
pageSize = 100
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
for {
|
||||
clients, err := w.Hydra.ListClients(ctx, pageSize, offset)
|
||||
if err != nil {
|
||||
slog.Warn("headless jwks worker failed to list clients", "error", err)
|
||||
return
|
||||
}
|
||||
if len(clients) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, client := range clients {
|
||||
if !client.IsHeadlessLoginEnabled() {
|
||||
continue
|
||||
}
|
||||
state, err := w.Cache.GetState(client.ClientID)
|
||||
if err != nil {
|
||||
slog.Warn("headless jwks worker failed to load cache state", "clientID", client.ClientID, "error", err)
|
||||
continue
|
||||
}
|
||||
if !w.Cache.ShouldPrefetch(state, now) {
|
||||
continue
|
||||
}
|
||||
if _, err := w.Cache.ForceRefresh(ctx, client, "cron_prefetch"); err != nil {
|
||||
slog.Warn("headless jwks worker refresh failed", "clientID", client.ClientID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(clients) < pageSize {
|
||||
return
|
||||
}
|
||||
offset += len(clients)
|
||||
}
|
||||
}
|
||||
164
backend/internal/service/headless_jwks_cache_test.go
Normal file
164
backend/internal/service/headless_jwks_cache_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type headlessJWKSCacheTestRedis struct {
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
func (m *headlessJWKSCacheTestRedis) Set(key string, value string, expiration time.Duration) error {
|
||||
if m.data == nil {
|
||||
m.data = map[string]string{}
|
||||
}
|
||||
m.data[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *headlessJWKSCacheTestRedis) Get(key string) (string, error) {
|
||||
if m.data == nil {
|
||||
return "", nil
|
||||
}
|
||||
return m.data[key], nil
|
||||
}
|
||||
|
||||
func (m *headlessJWKSCacheTestRedis) Delete(key string) error {
|
||||
if m.data != nil {
|
||||
delete(m.data, key)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *headlessJWKSCacheTestRedis) StoreVerificationCode(phone, code string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *headlessJWKSCacheTestRedis) GetVerificationCode(phone string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (m *headlessJWKSCacheTestRedis) DeleteVerificationCode(phone string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestHeadlessJWKSCacheService_EnsureFreshKeySet_UsesCachedJWKSWhenFresh(t *testing.T) {
|
||||
_, jwks := mustServiceHeadlessRSAJWK(t, "cached-key")
|
||||
raw, err := json.Marshal(jwks)
|
||||
require.NoError(t, err)
|
||||
|
||||
redisRepo := &headlessJWKSCacheTestRedis{}
|
||||
cacheService := NewHeadlessJWKSCacheService(redisRepo, nil)
|
||||
now := time.Now()
|
||||
err = cacheService.SaveState("client-headless", domain.HeadlessJWKSCacheState{
|
||||
ClientID: "client-headless",
|
||||
JWKSURI: "https://rp.example.com/.well-known/jwks.json",
|
||||
RawJWKS: string(raw),
|
||||
CachedKids: []string{"cached-key"},
|
||||
CachedAt: &now,
|
||||
LastCheckedAt: &now,
|
||||
ExpiresAt: ptrTestTime(now.Add(30 * time.Minute)),
|
||||
LastRefreshStatus: "success",
|
||||
ConsecutiveFailures: 0,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cacheService.HTTPClient = clientForHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("unexpected network fetch: %s", r.URL.String())
|
||||
}))
|
||||
|
||||
keySet, state, refreshed, err := cacheService.EnsureFreshKeySet(context.Background(), domain.HydraClient{
|
||||
ClientID: "client-headless",
|
||||
Metadata: map[string]any{
|
||||
domain.MetadataHeadlessLoginEnabled: true,
|
||||
domain.MetadataHeadlessJWKSURI: "https://rp.example.com/.well-known/jwks.json",
|
||||
},
|
||||
}, "cached-key")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, refreshed)
|
||||
require.NotNil(t, keySet)
|
||||
assert.Len(t, keySet.Keys, 1)
|
||||
require.NotNil(t, state)
|
||||
assert.Equal(t, []string{"cached-key"}, state.CachedKids)
|
||||
}
|
||||
|
||||
func TestHeadlessJWKSCacheService_EnsureFreshKeySet_RefreshesWhenKidMissing(t *testing.T) {
|
||||
_, staleJWKS := mustServiceHeadlessRSAJWK(t, "stale-key")
|
||||
staleRaw, err := json.Marshal(staleJWKS)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, freshJWKS := mustServiceHeadlessRSAJWK(t, "fresh-key")
|
||||
freshRaw, err := json.Marshal(freshJWKS)
|
||||
require.NoError(t, err)
|
||||
|
||||
redisRepo := &headlessJWKSCacheTestRedis{}
|
||||
cacheService := NewHeadlessJWKSCacheService(redisRepo, nil)
|
||||
now := time.Now()
|
||||
err = cacheService.SaveState("client-headless", domain.HeadlessJWKSCacheState{
|
||||
ClientID: "client-headless",
|
||||
JWKSURI: "https://rp.example.com/.well-known/jwks.json",
|
||||
RawJWKS: string(staleRaw),
|
||||
CachedKids: []string{"stale-key"},
|
||||
CachedAt: &now,
|
||||
LastCheckedAt: &now,
|
||||
ExpiresAt: ptrTestTime(now.Add(30 * time.Minute)),
|
||||
LastRefreshStatus: "success",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cacheService.HTTPClient = clientForHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", r.URL.String())
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(freshRaw)
|
||||
}))
|
||||
|
||||
keySet, state, refreshed, err := cacheService.EnsureFreshKeySet(context.Background(), domain.HydraClient{
|
||||
ClientID: "client-headless",
|
||||
Metadata: map[string]any{
|
||||
domain.MetadataHeadlessLoginEnabled: true,
|
||||
domain.MetadataHeadlessJWKSURI: "https://rp.example.com/.well-known/jwks.json",
|
||||
},
|
||||
}, "fresh-key")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, refreshed)
|
||||
require.NotNil(t, keySet)
|
||||
assert.Len(t, keySet.Keys, 1)
|
||||
require.NotNil(t, state)
|
||||
assert.Equal(t, []string{"fresh-key"}, state.CachedKids)
|
||||
|
||||
stored, err := cacheService.GetState("client-headless")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, stored)
|
||||
assert.Equal(t, []string{"fresh-key"}, stored.CachedKids)
|
||||
}
|
||||
|
||||
func mustServiceHeadlessRSAJWK(t *testing.T, kid string) (*rsa.PrivateKey, jose.JSONWebKeySet) {
|
||||
t.Helper()
|
||||
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
publicJWK := jose.JSONWebKey{
|
||||
Key: &privateKey.PublicKey,
|
||||
KeyID: kid,
|
||||
Algorithm: string(jose.RS256),
|
||||
Use: "sig",
|
||||
}
|
||||
|
||||
return privateKey, jose.JSONWebKeySet{Keys: []jose.JSONWebKey{publicJWK}}
|
||||
}
|
||||
|
||||
func ptrTestTime(value time.Time) *time.Time {
|
||||
return &value
|
||||
}
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
createClient,
|
||||
deleteClient,
|
||||
fetchClient,
|
||||
refreshHeadlessJwksCache,
|
||||
revokeHeadlessJwksCache,
|
||||
updateClient,
|
||||
updateClientStatus,
|
||||
} from "../../lib/devApi";
|
||||
@@ -38,7 +40,6 @@ import type {
|
||||
ClientUpsertRequest,
|
||||
} from "../../lib/devApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { tryConvertToJwks } from "../../lib/keyUtils";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
interface ScopeItem {
|
||||
@@ -54,6 +55,19 @@ type TokenEndpointAuthMethod =
|
||||
| "client_secret_basic"
|
||||
| "private_key_jwt";
|
||||
|
||||
const HEADLESS_LOGIN_ALLOWED_ALGORITHMS = [
|
||||
"RS256",
|
||||
"RS384",
|
||||
"RS512",
|
||||
"PS256",
|
||||
"PS384",
|
||||
"PS512",
|
||||
"ES256",
|
||||
"ES384",
|
||||
"ES512",
|
||||
"EdDSA",
|
||||
] as const;
|
||||
|
||||
function isTokenEndpointAuthMethod(
|
||||
value: string,
|
||||
): value is TokenEndpointAuthMethod {
|
||||
@@ -72,17 +86,6 @@ function readMetadataString(
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function readMetadataObject(
|
||||
metadata: Record<string, unknown>,
|
||||
key: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
const value = metadata[key];
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function isValidUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
@@ -92,14 +95,17 @@ function isValidUrl(value: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function isValidJson(value: string): boolean {
|
||||
if (!value.trim()) return false;
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
function formatDateTime(value?: string) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function previewKeyMaterial(value?: string) {
|
||||
if (!value) return "-";
|
||||
if (value.length <= 23) return value;
|
||||
return `${value.slice(0, 10)}...${value.slice(-10)}`;
|
||||
}
|
||||
|
||||
function ClientGeneralPage() {
|
||||
@@ -125,9 +131,7 @@ function ClientGeneralPage() {
|
||||
// Public Key Registration States
|
||||
const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] =
|
||||
useState<TokenEndpointAuthMethod>("client_secret_basic");
|
||||
const [jwksSource, setJwksSource] = useState<"uri" | "inline">("inline");
|
||||
const [jwksUri, setJwksUri] = useState("");
|
||||
const [jwksText, setJwksText] = useState("");
|
||||
const [requestObjectSigningAlg, setRequestObjectSigningAlg] =
|
||||
useState("RS256");
|
||||
const [headlessLoginEnabled, setHeadlessLoginEnabled] = useState(false);
|
||||
@@ -185,31 +189,12 @@ function ClientGeneralPage() {
|
||||
}
|
||||
|
||||
const headlessJwksUri = readMetadataString(metadata, "headless_jwks_uri");
|
||||
const headlessJwks = readMetadataObject(metadata, "headless_jwks");
|
||||
if (headlessJwksUri) {
|
||||
setJwksUri(headlessJwksUri);
|
||||
setJwksText("");
|
||||
setJwksSource("uri");
|
||||
} else if (headlessJwks) {
|
||||
setJwksText(JSON.stringify(headlessJwks, null, 2));
|
||||
setJwksUri("");
|
||||
setJwksSource("inline");
|
||||
} else if (client.jwksUri) {
|
||||
setJwksUri(client.jwksUri);
|
||||
setJwksText("");
|
||||
setJwksSource("uri");
|
||||
} else if (client.jwks) {
|
||||
setJwksText(
|
||||
typeof client.jwks === "string"
|
||||
? client.jwks
|
||||
: JSON.stringify(client.jwks, null, 2),
|
||||
);
|
||||
setJwksUri("");
|
||||
setJwksSource("inline");
|
||||
} else {
|
||||
setJwksUri("");
|
||||
setJwksText("");
|
||||
setJwksSource("inline");
|
||||
}
|
||||
|
||||
// Fallbacks from metadata if top-level fields are empty
|
||||
@@ -223,11 +208,10 @@ function ClientGeneralPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!client.jwksUri && !client.jwks && !headlessEnabled) {
|
||||
if (!client.jwksUri && !headlessEnabled) {
|
||||
const metaJwksUri = readMetadataString(metadata, "jwks_uri");
|
||||
if (metaJwksUri) {
|
||||
setJwksUri(metaJwksUri);
|
||||
setJwksSource("uri");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,46 +303,25 @@ function ClientGeneralPage() {
|
||||
);
|
||||
};
|
||||
|
||||
// Convert on blur or change if desired, here we try to convert before validation
|
||||
const finalJwksText = tryConvertToJwks(jwksText);
|
||||
const validationErrors: string[] = [];
|
||||
const trimmedJwksUri = jwksUri.trim();
|
||||
const trimmedJwksText = finalJwksText.trim();
|
||||
const trimmedRequestObjectSigningAlg = requestObjectSigningAlg.trim();
|
||||
|
||||
if (headlessLoginEnabled) {
|
||||
if (jwksSource === "uri") {
|
||||
if (!trimmedJwksUri) {
|
||||
validationErrors.push(
|
||||
t(
|
||||
"msg.dev.clients.general.public_key.validation.missing_jwks_uri",
|
||||
"JWKS URI를 입력해야 합니다.",
|
||||
),
|
||||
);
|
||||
} else if (!isValidUrl(trimmedJwksUri)) {
|
||||
validationErrors.push(
|
||||
t(
|
||||
"msg.dev.clients.general.public_key.validation.invalid_jwks_uri",
|
||||
"JWKS URI 형식이 올바르지 않습니다.",
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (jwksSource === "inline") {
|
||||
if (!trimmedJwksText) {
|
||||
validationErrors.push(
|
||||
t(
|
||||
"msg.dev.clients.general.public_key.validation.missing_jwks_inline",
|
||||
"공개키(JWKS 또는 SSH-RSA)를 입력해야 합니다.",
|
||||
),
|
||||
);
|
||||
} else if (!isValidJson(trimmedJwksText)) {
|
||||
validationErrors.push(
|
||||
t(
|
||||
"msg.dev.clients.general.public_key.validation.invalid_jwks_inline",
|
||||
"입력값이 유효한 JSON(JWKS) 형식이 아닙니다. SSH-RSA의 경우 'ssh-rsa'로 시작해야 합니다.",
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!trimmedJwksUri) {
|
||||
validationErrors.push(
|
||||
t(
|
||||
"msg.dev.clients.general.public_key.validation.missing_jwks_uri",
|
||||
"JWKS URI를 입력해야 합니다.",
|
||||
),
|
||||
);
|
||||
} else if (!isValidUrl(trimmedJwksUri)) {
|
||||
validationErrors.push(
|
||||
t(
|
||||
"msg.dev.clients.general.public_key.validation.invalid_jwks_uri",
|
||||
"JWKS URI 형식이 올바르지 않습니다.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (trimmedRequestObjectSigningAlg === "") {
|
||||
@@ -372,20 +335,75 @@ function ClientGeneralPage() {
|
||||
}
|
||||
|
||||
const hasValidationErrors = validationErrors.length > 0;
|
||||
const currentHeadlessJwksCache = data?.headlessJwksCache;
|
||||
|
||||
const refreshHeadlessJwksCacheMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!clientId) throw new Error("Missing client id");
|
||||
return refreshHeadlessJwksCache(clientId);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
if (clientId) {
|
||||
queryClient.setQueryData(["client", clientId], result);
|
||||
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
|
||||
}
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.general.public_key.cache_refreshed",
|
||||
"JWKS 캐시를 새로 고쳤습니다.",
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
const errorMessage =
|
||||
(err as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||
(err as Error)?.message ??
|
||||
t("msg.common.unknown_error", "unknown error");
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.general.public_key.cache_refresh_failed",
|
||||
"JWKS 캐시 새로고침에 실패했습니다: {{error}}",
|
||||
{ error: errorMessage },
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const revokeHeadlessJwksCacheMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!clientId) throw new Error("Missing client id");
|
||||
return revokeHeadlessJwksCache(clientId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (clientId) {
|
||||
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
|
||||
}
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.general.public_key.cache_revoked",
|
||||
"JWKS 캐시를 삭제했습니다.",
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (err) => {
|
||||
const errorMessage =
|
||||
(err as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||
(err as Error)?.message ??
|
||||
t("msg.common.unknown_error", "unknown error");
|
||||
toast(
|
||||
t(
|
||||
"msg.dev.clients.general.public_key.cache_revoke_failed",
|
||||
"JWKS 캐시 삭제에 실패했습니다: {{error}}",
|
||||
{ error: errorMessage },
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
|
||||
|
||||
let finalJwks: ClientUpsertRequest["jwks"];
|
||||
if (jwksSource === "inline" && trimmedJwksText) {
|
||||
try {
|
||||
finalJwks = JSON.parse(trimmedJwksText);
|
||||
} catch (e) {
|
||||
throw new Error("Invalid Public Key Format");
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveTokenEndpointAuthMethod =
|
||||
clientType === "pkce" && headlessLoginEnabled
|
||||
? "none"
|
||||
@@ -398,13 +416,9 @@ function ClientGeneralPage() {
|
||||
tokenEndpointAuthMethod: effectiveTokenEndpointAuthMethod,
|
||||
jwksUri:
|
||||
effectiveTokenEndpointAuthMethod === "private_key_jwt" &&
|
||||
jwksSource === "uri"
|
||||
trimmedJwksUri
|
||||
? trimmedJwksUri
|
||||
: undefined,
|
||||
jwks:
|
||||
effectiveTokenEndpointAuthMethod === "private_key_jwt"
|
||||
? finalJwks
|
||||
: undefined,
|
||||
metadata: {
|
||||
description,
|
||||
logo_url: logoUrl,
|
||||
@@ -418,16 +432,9 @@ function ClientGeneralPage() {
|
||||
: undefined,
|
||||
headless_jwks_uri:
|
||||
clientType === "pkce" &&
|
||||
headlessLoginEnabled &&
|
||||
jwksSource === "uri"
|
||||
headlessLoginEnabled
|
||||
? trimmedJwksUri
|
||||
: undefined,
|
||||
headless_jwks:
|
||||
clientType === "pkce" &&
|
||||
headlessLoginEnabled &&
|
||||
jwksSource === "inline"
|
||||
? finalJwks
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1045,74 +1052,60 @@ function ClientGeneralPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t(
|
||||
"ui.dev.clients.general.public_key.request_object_alg",
|
||||
"Request Object Signing Algorithm",
|
||||
)}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={requestObjectSigningAlg}
|
||||
onChange={(e) => setRequestObjectSigningAlg(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.dev.clients.general.public_key.request_object_alg_placeholder",
|
||||
"예: RS256",
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.public_key.request_object_alg_help",
|
||||
"Headless Login을 사용할 때 JAR(Request Object) 서명 검증에 사용할 알고리즘을 명시합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-xl border border-border bg-muted/5 p-4">
|
||||
<div className="space-y-1 pb-2 border-b border-border/50">
|
||||
<Label className="text-sm font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.public_key.source",
|
||||
"Public Key Source",
|
||||
)}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.public_key.source_help",
|
||||
"OIDC 검증을 위한 공개키 제공 방식을 선택합니다. (운영 환경에서는 JWKS URI 사용을 권장합니다)",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="jwksSource"
|
||||
checked={jwksSource === "inline"}
|
||||
onChange={() => setJwksSource("inline")}
|
||||
className="accent-primary"
|
||||
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<div className="space-y-3 rounded-xl border border-border bg-muted/5 p-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold" htmlFor="request-object-signing-alg">
|
||||
{t(
|
||||
"ui.dev.clients.general.public_key.request_object_alg",
|
||||
"Request Object Signing Algorithm",
|
||||
)}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="request-object-signing-alg"
|
||||
value={requestObjectSigningAlg}
|
||||
onChange={(e) => setRequestObjectSigningAlg(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.dev.clients.general.public_key.request_object_alg_placeholder",
|
||||
"예: RS256",
|
||||
)}
|
||||
/>
|
||||
<span>Inline Public Key</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name="jwksSource"
|
||||
checked={jwksSource === "uri"}
|
||||
onChange={() => setJwksSource("uri")}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<span>JWKS URI</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.public_key.request_object_alg_help",
|
||||
"Headless Login을 사용할 때 JAR(Request Object) 서명 검증에 사용할 알고리즘을 명시합니다.",
|
||||
)}
|
||||
</p>
|
||||
<div className="space-y-2 rounded-lg border border-border bg-background/60 p-3">
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.general.public_key.allowed_algorithms",
|
||||
"Allowed Algorithms",
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{HEADLESS_LOGIN_ALLOWED_ALGORITHMS.map((algorithm) => (
|
||||
<Badge
|
||||
key={algorithm}
|
||||
variant="outline"
|
||||
className="font-mono text-[11px]"
|
||||
>
|
||||
{algorithm}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.public_key.allowed_algorithms_help",
|
||||
"Headless Login JAR 검증은 이 목록에 있는 서명 알고리즘만 허용합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{jwksSource === "uri" && (
|
||||
<div className="space-y-2 animate-in fade-in slide-in-from-top-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
<div className="space-y-2 pt-2">
|
||||
<Label className="text-sm font-semibold" htmlFor="jwks-uri">
|
||||
{t(
|
||||
"ui.dev.clients.general.public_key.jwks_uri",
|
||||
"JWKS URI",
|
||||
@@ -1120,6 +1113,7 @@ function ClientGeneralPage() {
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="jwks-uri"
|
||||
value={jwksUri}
|
||||
onChange={(e) => setJwksUri(e.target.value)}
|
||||
placeholder={t(
|
||||
@@ -1134,35 +1128,250 @@ function ClientGeneralPage() {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{jwksSource === "inline" && (
|
||||
<div className="space-y-2 animate-in fade-in slide-in-from-top-2">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t(
|
||||
"ui.dev.clients.general.public_key.jwks_inline",
|
||||
"JWKS 또는 OpenSSH 공개키",
|
||||
)}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
rows={8}
|
||||
value={jwksText}
|
||||
onChange={(e) => setJwksText(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.dev.clients.general.public_key.jwks_inline_placeholder",
|
||||
"JWKS (JSON) 또는 'ssh-rsa AAA...' 형식의 공개키를 붙여넣으세요.",
|
||||
)}
|
||||
className="font-mono text-xs leading-tight"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.public_key.jwks_inline_help",
|
||||
"OIDC 표준인 JWKS(JSON) 형식을 권장하지만, SSH-RSA 공개키를 입력하면 자동으로 변환하여 저장합니다.",
|
||||
)}
|
||||
</p>
|
||||
<div className="space-y-3 rounded-xl border border-border bg-card p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<Label className="text-sm font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.public_key.cache.title",
|
||||
"JWKS Cache",
|
||||
)}
|
||||
</Label>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.public_key.cache_help",
|
||||
"백엔드가 마지막으로 검증한 공개키 캐시 상태입니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
refreshHeadlessJwksCacheMutation.mutate()
|
||||
}
|
||||
disabled={refreshHeadlessJwksCacheMutation.isPending}
|
||||
>
|
||||
{refreshHeadlessJwksCacheMutation.isPending
|
||||
? t("msg.common.requesting", "요청 중...")
|
||||
: t("ui.common.refresh", "Refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (
|
||||
!currentHeadlessJwksCache ||
|
||||
revokeHeadlessJwksCacheMutation.isPending
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const confirmed = window.confirm(
|
||||
t(
|
||||
"msg.dev.clients.general.public_key.cache_revoke_confirm",
|
||||
"JWKS 캐시를 삭제하면 다음 검증 전에 다시 갱신해야 합니다. 계속할까요?",
|
||||
),
|
||||
);
|
||||
if (confirmed) {
|
||||
revokeHeadlessJwksCacheMutation.mutate();
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
!currentHeadlessJwksCache ||
|
||||
revokeHeadlessJwksCacheMutation.isPending
|
||||
}
|
||||
>
|
||||
{revokeHeadlessJwksCacheMutation.isPending
|
||||
? t("msg.common.requesting", "요청 중...")
|
||||
: t(
|
||||
"ui.dev.clients.general.public_key.revoke_cache",
|
||||
"Revoke Cache",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentHeadlessJwksCache ? (
|
||||
<div className="grid gap-3 text-sm md:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.general.public_key.cache.status",
|
||||
"Status",
|
||||
)}
|
||||
</p>
|
||||
<Badge
|
||||
variant="info"
|
||||
className="w-fit capitalize"
|
||||
>
|
||||
{currentHeadlessJwksCache.lastRefreshStatus ||
|
||||
t("ui.common.unknown", "Unknown")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.general.public_key.cache.uri",
|
||||
"JWKS URI",
|
||||
)}
|
||||
</p>
|
||||
<p className="break-all font-mono text-xs">
|
||||
{currentHeadlessJwksCache.jwksUri}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.general.public_key.cache.cached_at",
|
||||
"Cached At",
|
||||
)}
|
||||
</p>
|
||||
<p>{formatDateTime(currentHeadlessJwksCache.cachedAt)}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.general.public_key.cache.expires_at",
|
||||
"Expires At",
|
||||
)}
|
||||
</p>
|
||||
<p>{formatDateTime(currentHeadlessJwksCache.expiresAt)}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.general.public_key.cache.last_checked_at",
|
||||
"Last Checked",
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{formatDateTime(
|
||||
currentHeadlessJwksCache.lastCheckedAt,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.general.public_key.cache.last_success",
|
||||
"Last Successful Verification",
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{formatDateTime(
|
||||
currentHeadlessJwksCache.lastSuccessfulVerificationAt,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.general.public_key.cache.failures",
|
||||
"Consecutive Failures",
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{currentHeadlessJwksCache.consecutiveFailures ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.general.public_key.cache.kids",
|
||||
"Cached KIDs",
|
||||
)}
|
||||
</p>
|
||||
<p className="font-mono text-xs">
|
||||
{currentHeadlessJwksCache.cachedKids?.length
|
||||
? currentHeadlessJwksCache.cachedKids.join(", ")
|
||||
: "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.general.public_key.cache.error",
|
||||
"Last Error",
|
||||
)}
|
||||
</p>
|
||||
<p className="break-words text-xs text-muted-foreground">
|
||||
{currentHeadlessJwksCache.lastError || "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3 md:col-span-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.general.public_key.cache.parsed_keys",
|
||||
"Parsed Keys",
|
||||
)}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.public_key.cache.parsed_keys_help",
|
||||
"Raw JWKS stays hidden. Only parsed key metadata is shown here.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{currentHeadlessJwksCache.parsedKeys?.length ? (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{currentHeadlessJwksCache.parsedKeys.map((key, index) => (
|
||||
<div
|
||||
key={`${key.kid || "key"}-${index}`}
|
||||
className="rounded-xl border border-border bg-muted/30 p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="font-mono">
|
||||
{key.kid || "-"}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{key.kty || "-"}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{key.use || "-"}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{key.alg || "-"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1">
|
||||
<p className="text-[11px] font-semibold uppercase text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.general.public_key.cache.parsed_key_n",
|
||||
"n Preview",
|
||||
)}
|
||||
</p>
|
||||
<p className="break-all rounded-lg border border-border bg-background px-3 py-2 font-mono text-[11px]">
|
||||
{previewKeyMaterial(key.n)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-border px-4 py-5 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.public_key.cache.parsed_keys_empty",
|
||||
"No parsed JWKS keys are available yet.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.public_key.cache_empty",
|
||||
"아직 캐시된 JWKS가 없습니다. Refresh를 눌러 백엔드 캐시 상태를 조회하세요.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasValidationErrors && (
|
||||
|
||||
@@ -12,7 +12,6 @@ export type ClientSummary = {
|
||||
clientSecret?: string;
|
||||
tokenEndpointAuthMethod?: string;
|
||||
jwksUri?: string;
|
||||
jwks?: string | Record<string, unknown>;
|
||||
redirectUris: string[];
|
||||
scopes: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
@@ -63,6 +62,27 @@ export type ClientDetailResponse = {
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
endpoints: ClientEndpoints;
|
||||
headlessJwksCache?: {
|
||||
clientId: string;
|
||||
jwksUri: string;
|
||||
cachedAt: string;
|
||||
expiresAt: string;
|
||||
lastCheckedAt?: string;
|
||||
lastSuccessfulVerificationAt?: string;
|
||||
lastRefreshStatus?: "success" | "failure" | "pending";
|
||||
lastError?: string;
|
||||
consecutiveFailures?: number;
|
||||
cachedKids?: string[];
|
||||
etag?: string;
|
||||
lastModified?: string;
|
||||
parsedKeys?: Array<{
|
||||
kid?: string;
|
||||
kty?: string;
|
||||
use?: string;
|
||||
alg?: string;
|
||||
n?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
export type ClientUpsertRequest = {
|
||||
@@ -76,7 +96,6 @@ export type ClientUpsertRequest = {
|
||||
responseTypes?: string[];
|
||||
tokenEndpointAuthMethod?: string;
|
||||
jwksUri?: string;
|
||||
jwks?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@@ -182,6 +201,17 @@ export async function rotateClientSecret(clientId: string) {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function refreshHeadlessJwksCache(clientId: string) {
|
||||
const { data } = await apiClient.post<ClientDetailResponse>(
|
||||
`/dev/clients/${clientId}/headless-jwks/refresh`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function revokeHeadlessJwksCache(clientId: string) {
|
||||
await apiClient.delete(`/dev/clients/${clientId}/headless-jwks/cache`);
|
||||
}
|
||||
|
||||
export async function deleteClient(clientId: string) {
|
||||
await apiClient.delete(`/dev/clients/${clientId}`);
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* Key Utilities for converting various public key formats (PEM, OpenSSH) to JWKS.
|
||||
*/
|
||||
|
||||
interface JWK {
|
||||
kty: string;
|
||||
n: string;
|
||||
e: string;
|
||||
kid?: string;
|
||||
use?: string;
|
||||
alg?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Base64 string to a URL-safe Base64 string (RFC 7515).
|
||||
*/
|
||||
function toBase64Url(base64: string): string {
|
||||
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts RSA Modulus (n) and Exponent (e) from a SubjectPublicKeyInfo (PEM).
|
||||
* This is a simplified parser for common RSA keys.
|
||||
*/
|
||||
export function parsePemToJwk(pem: string): JWK | null {
|
||||
try {
|
||||
// Remove headers, footers and whitespace
|
||||
pem
|
||||
.replace(/-----BEGIN PUBLIC KEY-----/, "")
|
||||
.replace(/-----END PUBLIC KEY-----/, "")
|
||||
.replace(/\s/g, "");
|
||||
|
||||
// In a real browser environment without heavy libraries,
|
||||
// we would need a full ASN.1 parser.
|
||||
// For now, we recommend using JWKS or OpenSSH formats for reliability,
|
||||
// or we can hint the user that complex PEMs might fail.
|
||||
// However, we'll try to support a basic one.
|
||||
|
||||
return null; // Placeholder: PEM parsing is complex without libs.
|
||||
} catch (e) {
|
||||
console.error("Failed to parse PEM", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an OpenSSH Public Key (ssh-rsa AAAA...) into a JWK.
|
||||
*/
|
||||
export function parseSshRsaToJwk(sshKey: string): JWK | null {
|
||||
try {
|
||||
const parts = sshKey.trim().split(" ");
|
||||
if (parts.length < 2 || parts[0] !== "ssh-rsa") return null;
|
||||
|
||||
const keyData = atob(parts[1]);
|
||||
let offset = 0;
|
||||
|
||||
const readBlob = () => {
|
||||
const len =
|
||||
(keyData.charCodeAt(offset) << 24) |
|
||||
(keyData.charCodeAt(offset + 1) << 16) |
|
||||
(keyData.charCodeAt(offset + 2) << 8) |
|
||||
keyData.charCodeAt(offset + 3);
|
||||
offset += 4;
|
||||
const blob = keyData.slice(offset, offset + len);
|
||||
offset += len;
|
||||
return blob;
|
||||
};
|
||||
|
||||
const type = readBlob(); // "ssh-rsa"
|
||||
if (type !== "ssh-rsa") return null;
|
||||
|
||||
const eBlob = readBlob();
|
||||
const nBlob = readBlob();
|
||||
|
||||
return {
|
||||
kty: "RSA",
|
||||
n: semanticsBase64Url(nBlob),
|
||||
e: semanticsBase64Url(eBlob),
|
||||
alg: "RS256",
|
||||
use: "sig",
|
||||
};
|
||||
} catch (e) {
|
||||
console.error("Failed to parse SSH key", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function semanticsBase64Url(blob: string): string {
|
||||
// Ensure leading zero removal for BigInt representations if necessary
|
||||
let start = 0;
|
||||
while (start < blob.length && blob.charCodeAt(start) === 0) {
|
||||
start++;
|
||||
}
|
||||
return toBase64Url(btoa(blob.slice(start)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to auto-detect and convert input to JWKS JSON string.
|
||||
* Returns the original string if it's already JSON or conversion fails.
|
||||
*/
|
||||
export function tryConvertToJwks(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
|
||||
// 1. If it looks like JSON, return as is (validation happens in component)
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// 2. Try SSH RSA
|
||||
if (trimmed.startsWith("ssh-rsa")) {
|
||||
const jwk = parseSshRsaToJwk(trimmed);
|
||||
if (jwk) {
|
||||
return JSON.stringify({ keys: [jwk] }, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. PEM (Simplified check)
|
||||
if (trimmed.includes("BEGIN PUBLIC KEY")) {
|
||||
// For PEM, we suggest the user uses JWKS or SSH-RSA for now
|
||||
// as JS doesn't have a built-in ASN1 parser and we want to avoid heavy deps.
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
@@ -402,11 +402,19 @@ guide_step_1 = "Generate a key pair on the RP server and keep the private key on
|
||||
guide_step_2 = "Expose the public key from the RP backend through a JWKS (JSON Web Key Set) endpoint."
|
||||
guide_step_3 = "Enter a URL such as https://rp.example.com/.well-known/jwks.json in DevFront."
|
||||
headless_help = "You can design your own login UI within the application. While the UI is yours, the actual identity verification and security checks are handled in the background via Baron's API."
|
||||
jwks_inline_help = "Prefer the SSH-RSA public key format first. If you paste an 'ssh-rsa AAA...' key, Baron converts it to OIDC-standard JWKS (JSON) before saving."
|
||||
jwks_uri_help = "Enter the public key endpoint URL exposed by the RP backend. Example: https://rp.example.com/.well-known/jwks.json"
|
||||
request_object_alg_help = "Specify the JAR (Request Object) signing algorithm used for headless login."
|
||||
source_help = "Register the JWKS URI served by the RP so Baron can verify the public key."
|
||||
allowed_algorithms_help = "Headless login JAR verification accepts only the algorithms listed below."
|
||||
subtitle = "Manage the public key and headless login settings required for Headless Login evaluation."
|
||||
cache_empty = "No cached JWKS exists yet. Use Refresh to ask the backend to verify and cache the key."
|
||||
cache_help = "Shows the last JWKS verification state stored by the backend."
|
||||
cache_parsed_keys_help = "Raw JWKS stays hidden. Only parsed key metadata is shown here."
|
||||
cache_parsed_keys_empty = "No parsed JWKS keys are available yet."
|
||||
cache_refresh_failed = "Failed to refresh the JWKS cache: {{error}}"
|
||||
cache_refreshed = "JWKS cache refreshed."
|
||||
cache_revoke_confirm = "Deleting the JWKS cache means the backend must fetch and verify it again before the next use. Continue?"
|
||||
cache_revoke_failed = "Failed to delete the JWKS cache: {{error}}"
|
||||
cache_revoked = "JWKS cache deleted."
|
||||
|
||||
[msg.dev.clients.general.public_key.validation]
|
||||
headless_requires_alg = "Headless login requires a Request Object Signing Algorithm."
|
||||
@@ -1407,16 +1415,25 @@ guide_toggle = "JWKS URI Setup Guide"
|
||||
headless_disabled = "Headless Disabled"
|
||||
headless_enabled = "Headless Enabled"
|
||||
headless_toggle = "Headless Login"
|
||||
jwks_inline = "SSH-RSA or JWKS Public Key"
|
||||
jwks_inline_placeholder = "Paste an 'ssh-rsa AAA...' public key first. JWKS (JSON) is also accepted if needed."
|
||||
jwks_uri = "JWKS URI"
|
||||
jwks_uri_placeholder = "https://rp.example.com/.well-known/jwks.json"
|
||||
request_object_alg = "Request Object Signing Algorithm"
|
||||
request_object_alg_placeholder = "RS256"
|
||||
source = "Public Key Source"
|
||||
source_uri = "JWKS URI"
|
||||
allowed_algorithms = "Allowed Algorithms"
|
||||
title = "Public Key Registration"
|
||||
validation_title = "Check before saving"
|
||||
cache_error = "Last Error"
|
||||
cache_cached_at = "Cached At"
|
||||
cache_expires_at = "Expires At"
|
||||
cache_failures = "Consecutive Failures"
|
||||
cache_kids = "Cached KIDs"
|
||||
cache_last_checked_at = "Last Checked"
|
||||
cache_last_success = "Last Successful Verification"
|
||||
cache_parsed_keys = "Parsed Keys"
|
||||
cache_parsed_key_n = "n Preview"
|
||||
cache_status = "Status"
|
||||
cache_uri = "JWKS URI"
|
||||
revoke_cache = "Revoke Cache"
|
||||
|
||||
[ui.dev.clients.help]
|
||||
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
|
||||
|
||||
@@ -402,18 +402,25 @@ guide_step_1 = "RP 서버에서 key pair를 생성하고 private key는 RP backe
|
||||
guide_step_2 = "RP backend가 public key를 JWKS(JSON Web Key Set) 형태로 제공하는 endpoint를 준비합니다."
|
||||
guide_step_3 = "예: https://rp.example.com/.well-known/jwks.json 같은 URL을 DevFront에 입력합니다."
|
||||
headless_help = "애플리케이션 고유의 디자인으로 로그인 화면을 구성할 수 있습니다. 실제 아이디/비밀번호 확인 및 보안 검증 로직은 Baron API를 통해 백그라운드에서 처리됩니다."
|
||||
jwks_inline_help = "SSH-RSA 공개키 형식을 우선 권장합니다. 'ssh-rsa AAA...' 형식으로 입력하면 Baron이 OIDC 표준인 JWKS(JSON)로 자동 변환하여 저장합니다."
|
||||
jwks_uri_help = "RP backend가 제공하는 공개키 endpoint URL을 입력하세요. 예: https://rp.example.com/.well-known/jwks.json"
|
||||
request_object_alg_help = "Headless Login을 사용할 때 JAR(Request Object) 서명 알고리즘을 명시합니다."
|
||||
source_help = "애플리케이션의 공개키(SSH-RSA)를 직접 등록하거나, 운영 환경이라면 JWKS URI를 통해 자동으로 검증할 수 있습니다."
|
||||
allowed_algorithms_help = "Headless Login JAR 검증은 아래 알고리즘만 허용합니다."
|
||||
subtitle = "Headless Login 판정에 필요한 공개키와 관련 설정을 관리합니다."
|
||||
cache_empty = "아직 캐시된 JWKS가 없습니다. Refresh를 눌러 백엔드가 공개키를 검증하고 캐시하도록 요청하세요."
|
||||
cache_help = "백엔드가 저장한 마지막 JWKS 검증 상태를 보여줍니다."
|
||||
cache_parsed_keys_help = "원본 JWKS 전체는 숨기고, 파싱된 키 메타데이터만 보여줍니다."
|
||||
cache_parsed_keys_empty = "아직 파싱된 JWKS 키가 없습니다."
|
||||
cache_refresh_failed = "JWKS 캐시 새로고침에 실패했습니다: {{error}}"
|
||||
cache_refreshed = "JWKS 캐시를 새로 고쳤습니다."
|
||||
cache_revoke_confirm = "JWKS 캐시를 삭제하면 다음 사용 전에 백엔드가 다시 가져와 검증해야 합니다. 계속할까요?"
|
||||
cache_revoke_failed = "JWKS 캐시 삭제에 실패했습니다: {{error}}"
|
||||
cache_revoked = "JWKS 캐시를 삭제했습니다."
|
||||
|
||||
[msg.dev.clients.general.public_key.validation]
|
||||
headless_requires_alg = "Headless Login을 사용하려면 Request Object Signing Algorithm을 입력해야 합니다."
|
||||
headless_requires_private_key_jwt = "Headless Login을 사용하려면 token endpoint auth method가 private_key_jwt여야 합니다."
|
||||
headless_requires_public_key = "Headless Login을 사용하려면 JWKS URI가 필요합니다."
|
||||
invalid_jwks_uri = "JWKS URI 형식이 올바르지 않습니다."
|
||||
missing_jwks_inline = "공개키(SSH-RSA 또는 JWKS)를 입력해야 합니다."
|
||||
private_key_jwt_requires_public_key = "서명 키 기반 인증을 사용하려면 JWKS URI가 필요합니다."
|
||||
|
||||
[msg.dev.clients.help]
|
||||
@@ -1407,16 +1414,25 @@ guide_toggle = "JWKS URI 준비 가이드"
|
||||
headless_disabled = "Headless Disabled"
|
||||
headless_enabled = "Headless Enabled"
|
||||
headless_toggle = "Headless Login"
|
||||
jwks_inline = "SSH-RSA 또는 JWKS 공개키"
|
||||
jwks_inline_placeholder = "'ssh-rsa AAA...' 형식의 공개키를 먼저 붙여넣으세요. 필요하면 JWKS (JSON)도 입력할 수 있습니다."
|
||||
jwks_uri = "JWKS URI"
|
||||
jwks_uri_placeholder = "https://rp.example.com/.well-known/jwks.json"
|
||||
request_object_alg = "Request Object Signing Algorithm"
|
||||
request_object_alg_placeholder = "RS256"
|
||||
source = "Public Key Source"
|
||||
source_uri = "JWKS URI"
|
||||
allowed_algorithms = "허용 알고리즘"
|
||||
title = "공개키 등록"
|
||||
validation_title = "저장 전 확인 필요"
|
||||
cache_error = "마지막 오류"
|
||||
cache_cached_at = "캐시 시각"
|
||||
cache_expires_at = "만료 시각"
|
||||
cache_failures = "연속 실패 횟수"
|
||||
cache_kids = "캐시된 KID"
|
||||
cache_last_checked_at = "마지막 확인"
|
||||
cache_last_success = "마지막 성공 검증"
|
||||
cache_parsed_keys = "파싱된 키"
|
||||
cache_parsed_key_n = "n 미리보기"
|
||||
cache_status = "상태"
|
||||
cache_uri = "JWKS URI"
|
||||
revoke_cache = "캐시 삭제"
|
||||
|
||||
[ui.dev.clients.help]
|
||||
docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips."
|
||||
|
||||
@@ -402,11 +402,19 @@ guide_step_1 = ""
|
||||
guide_step_2 = ""
|
||||
guide_step_3 = ""
|
||||
headless_help = ""
|
||||
jwks_inline_help = ""
|
||||
jwks_uri_help = ""
|
||||
request_object_alg_help = ""
|
||||
source_help = ""
|
||||
allowed_algorithms_help = ""
|
||||
subtitle = ""
|
||||
cache_empty = ""
|
||||
cache_help = ""
|
||||
cache_parsed_keys_help = ""
|
||||
cache_parsed_keys_empty = ""
|
||||
cache_refresh_failed = ""
|
||||
cache_refreshed = ""
|
||||
cache_revoke_confirm = ""
|
||||
cache_revoke_failed = ""
|
||||
cache_revoked = ""
|
||||
|
||||
[msg.dev.clients.general.public_key.validation]
|
||||
headless_requires_alg = ""
|
||||
@@ -1406,16 +1414,25 @@ guide_toggle = ""
|
||||
headless_disabled = ""
|
||||
headless_enabled = ""
|
||||
headless_toggle = ""
|
||||
jwks_inline = ""
|
||||
jwks_inline_placeholder = ""
|
||||
jwks_uri = ""
|
||||
jwks_uri_placeholder = ""
|
||||
request_object_alg = ""
|
||||
request_object_alg_placeholder = ""
|
||||
source = ""
|
||||
source_uri = ""
|
||||
allowed_algorithms = ""
|
||||
title = ""
|
||||
validation_title = ""
|
||||
cache_error = ""
|
||||
cache_cached_at = ""
|
||||
cache_expires_at = ""
|
||||
cache_failures = ""
|
||||
cache_kids = ""
|
||||
cache_last_checked_at = ""
|
||||
cache_last_success = ""
|
||||
cache_parsed_keys = ""
|
||||
cache_parsed_key_n = ""
|
||||
cache_status = ""
|
||||
cache_uri = ""
|
||||
revoke_cache = ""
|
||||
|
||||
[ui.dev.clients.help]
|
||||
docs_body = ""
|
||||
|
||||
@@ -8,8 +8,7 @@ import {
|
||||
} from "./helpers/devfront-fixtures";
|
||||
|
||||
const appNamePlaceholder = /My Awesome Application|예: 멋진 애플리케이션/i;
|
||||
const sshRsaPublicKey =
|
||||
"ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAABwECAwQFBgc= test@example";
|
||||
const jwksUri = "https://rp.example.com/.well-known/jwks.json";
|
||||
|
||||
test.describe("DevFront clients lifecycle", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -123,7 +122,7 @@ test.describe("DevFront clients lifecycle", () => {
|
||||
).toHaveValue(/https:\/\/after\.example\.com\/callback/);
|
||||
});
|
||||
|
||||
test("pkce headless login with inline ssh-rsa key should persist mapped payload", async ({
|
||||
test("pkce headless login uses jwks uri only and shows cache actions", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state = {
|
||||
@@ -131,10 +130,44 @@ test.describe("DevFront clients lifecycle", () => {
|
||||
makeClient("client-headless-login", {
|
||||
name: "Headless Login App",
|
||||
type: "pkce",
|
||||
headlessJwksCache: {
|
||||
clientId: "client-headless-login",
|
||||
jwksUri,
|
||||
cachedAt: "2026-03-31T00:00:00.000Z",
|
||||
expiresAt: "2026-04-01T00:00:00.000Z",
|
||||
lastCheckedAt: "2026-03-31T12:00:00.000Z",
|
||||
lastSuccessfulVerificationAt: "2026-03-31T12:00:00.000Z",
|
||||
lastRefreshStatus: "success",
|
||||
lastError: "",
|
||||
consecutiveFailures: 0,
|
||||
cachedKids: ["kid-1"],
|
||||
etag: 'W/"cache-etag"',
|
||||
lastModified: "Tue, 31 Mar 2026 00:00:00 GMT",
|
||||
parsedKeys: [
|
||||
{
|
||||
kid: "kid-1",
|
||||
kty: "RSA",
|
||||
use: "sig",
|
||||
alg: "RS256",
|
||||
n: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
consents: [] as Consent[],
|
||||
auditLogsByCursor: undefined,
|
||||
onRefreshHeadlessJwks(clientId: string) {
|
||||
this.clients[0].headlessJwksCache = {
|
||||
...this.clients[0].headlessJwksCache!,
|
||||
lastRefreshStatus: "success",
|
||||
lastCheckedAt: "2026-04-01T00:00:00.000Z",
|
||||
};
|
||||
expect(clientId).toBe("client-headless-login");
|
||||
},
|
||||
onRevokeHeadlessJwksCache(clientId: string) {
|
||||
expect(clientId).toBe("client-headless-login");
|
||||
},
|
||||
};
|
||||
await installDevApiMock(page, state);
|
||||
|
||||
@@ -147,16 +180,15 @@ test.describe("DevFront clients lifecycle", () => {
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
name: /공개키 등록|Public Key Registration/i,
|
||||
}),
|
||||
page.getByRole("heading", { name: /공개키 등록|Public Key Registration/i }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole("radio", { name: /Inline Public Key|Inline/i }),
|
||||
).toHaveCount(0);
|
||||
await page
|
||||
.getByPlaceholder(
|
||||
/ssh-rsa AAA\.\.\.|Paste an 'ssh-rsa AAA\.\.\.' public key first/i,
|
||||
)
|
||||
.fill(sshRsaPublicKey);
|
||||
.getByPlaceholder(/https:\/\/rp\.example\.com\/\.well-known\/jwks\.json/i)
|
||||
.fill(jwksUri);
|
||||
await page.getByRole("button", { name: /^저장$|^Save$/i }).click();
|
||||
|
||||
await expect
|
||||
@@ -171,25 +203,57 @@ test.describe("DevFront clients lifecycle", () => {
|
||||
)
|
||||
.toBe("private_key_jwt");
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
(
|
||||
state.clients[0]?.metadata?.headless_jwks as {
|
||||
keys?: Array<{ kty?: string; alg?: string }>;
|
||||
}
|
||||
)?.keys?.[0]?.kty,
|
||||
)
|
||||
.toBe("RSA");
|
||||
.poll(() => state.clients[0]?.metadata?.headless_jwks_uri)
|
||||
.toBe(jwksUri);
|
||||
|
||||
await expect(
|
||||
page.getByText(/cached at|캐시됨|last refresh|마지막 갱신/i),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(/Parsed Keys|파싱된 키/i)).toBeVisible();
|
||||
await expect(page.getByText("kid-1", { exact: true }).last()).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/Allowed algorithms|허용 알고리즘/i),
|
||||
).toBeVisible();
|
||||
for (const algorithm of [
|
||||
"RS256",
|
||||
"RS384",
|
||||
"RS512",
|
||||
"PS256",
|
||||
"PS384",
|
||||
"PS512",
|
||||
"ES256",
|
||||
"ES384",
|
||||
"ES512",
|
||||
"EdDSA",
|
||||
]) {
|
||||
await expect(page.getByText(algorithm, { exact: true }).last()).toBeVisible();
|
||||
}
|
||||
await expect(
|
||||
page.getByText("abcdefghij...0123456789", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /refresh|새로고침/i }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: /^(캐시 삭제|Revoke Cache)$/i }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /refresh|새로고침/i }).click();
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
(
|
||||
state.clients[0]?.metadata?.headless_jwks as {
|
||||
keys?: Array<{ kty?: string; alg?: string }>;
|
||||
}
|
||||
)?.keys?.[0]?.alg,
|
||||
)
|
||||
.toBe("RS256");
|
||||
.poll(() => state.clients[0]?.headlessJwksCache?.lastCheckedAt)
|
||||
.toBe("2026-04-01T00:00:00.000Z");
|
||||
|
||||
page.removeAllListeners("dialog");
|
||||
page.once("dialog", async (dialog) => {
|
||||
expect(dialog.message()).toMatch(/revoke|삭제|cache/i);
|
||||
await dialog.accept();
|
||||
});
|
||||
await page
|
||||
.getByRole("button", { name: /^(캐시 삭제|Revoke Cache)$/i })
|
||||
.click();
|
||||
await expect
|
||||
.poll(() => state.clients[0]?.headlessJwksCache)
|
||||
.toBeUndefined();
|
||||
|
||||
await page.reload();
|
||||
await expect(
|
||||
@@ -197,10 +261,6 @@ test.describe("DevFront clients lifecycle", () => {
|
||||
name: /공개키 등록|Public Key Registration/i,
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByPlaceholder(
|
||||
/ssh-rsa AAA\.\.\.|Paste an 'ssh-rsa AAA\.\.\.' public key first/i,
|
||||
),
|
||||
).toHaveValue(/"kty": "RSA"/);
|
||||
await expect(page.getByRole("textbox", { name: /JWKS URI|JWKS URI/i })).toHaveValue(jwksUri);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,27 @@ export type Client = {
|
||||
tokenEndpointAuthMethod?: string;
|
||||
jwksUri?: string;
|
||||
jwks?: Record<string, unknown> | string;
|
||||
headlessJwksCache?: {
|
||||
clientId: string;
|
||||
jwksUri: string;
|
||||
cachedAt: string;
|
||||
expiresAt: string;
|
||||
lastCheckedAt?: string;
|
||||
lastSuccessfulVerificationAt?: string;
|
||||
lastRefreshStatus?: "success" | "failure" | "pending";
|
||||
lastError?: string;
|
||||
consecutiveFailures?: number;
|
||||
cachedKids?: string[];
|
||||
etag?: string;
|
||||
lastModified?: string;
|
||||
parsedKeys?: Array<{
|
||||
kid?: string;
|
||||
kty?: string;
|
||||
use?: string;
|
||||
alg?: string;
|
||||
n?: string;
|
||||
}>;
|
||||
};
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@@ -53,6 +74,8 @@ export type DevApiMockState = {
|
||||
auditLogs?: AuditLog[];
|
||||
onUpdateStatus?: (status: ClientStatus) => void;
|
||||
onRotateSecret?: (newSecret: string) => void;
|
||||
onRefreshHeadlessJwks?: (clientId: string) => void;
|
||||
onRevokeHeadlessJwksCache?: (clientId: string) => void;
|
||||
};
|
||||
|
||||
export function makeClient(
|
||||
@@ -337,13 +360,6 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
|
||||
});
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/api/v1/dev/clients/") && method === "DELETE") {
|
||||
const clientId = parseClientId(pathname);
|
||||
state.clients = state.clients.filter((client) => client.id !== clientId);
|
||||
appendAuditLog("CLIENT_DELETE", "DELETE_CLIENT", clientId);
|
||||
return route.fulfill({ status: 204 });
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/api/v1/dev/clients/") && method === "GET") {
|
||||
const clientId = parseClientId(pathname);
|
||||
const found = state.clients.find((client) => client.id === clientId);
|
||||
@@ -357,9 +373,56 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
|
||||
token: "https://issuer/oauth2/token",
|
||||
userinfo: "https://issuer/userinfo",
|
||||
},
|
||||
headlessJwksCache: found.headlessJwksCache,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
pathname.startsWith("/api/v1/dev/clients/") &&
|
||||
pathname.endsWith("/headless-jwks/cache") &&
|
||||
method === "DELETE"
|
||||
) {
|
||||
const clientId = pathname.split("/")[5] ?? "";
|
||||
const found = state.clients.find((client) => client.id === clientId);
|
||||
if (!found) return json(route, { error: "not found" }, 404);
|
||||
found.headlessJwksCache = undefined;
|
||||
state.onRevokeHeadlessJwksCache?.(clientId);
|
||||
return route.fulfill({ status: 204 });
|
||||
}
|
||||
|
||||
if (
|
||||
pathname.startsWith("/api/v1/dev/clients/") &&
|
||||
pathname.endsWith("/headless-jwks/refresh") &&
|
||||
method === "POST"
|
||||
) {
|
||||
const clientId = pathname.split("/")[5] ?? "";
|
||||
const found = state.clients.find((client) => client.id === clientId);
|
||||
if (!found) return json(route, { error: "not found" }, 404);
|
||||
state.onRefreshHeadlessJwks?.(clientId);
|
||||
return json(route, {
|
||||
client: found,
|
||||
endpoints: {
|
||||
discovery: "https://issuer/.well-known/openid-configuration",
|
||||
issuer: "https://issuer",
|
||||
authorization: "https://issuer/oauth2/auth",
|
||||
token: "https://issuer/oauth2/token",
|
||||
userinfo: "https://issuer/userinfo",
|
||||
},
|
||||
headlessJwksCache: found.headlessJwksCache,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
pathname.startsWith("/api/v1/dev/clients/") &&
|
||||
!pathname.endsWith("/headless-jwks/cache") &&
|
||||
method === "DELETE"
|
||||
) {
|
||||
const clientId = parseClientId(pathname);
|
||||
state.clients = state.clients.filter((client) => client.id !== clientId);
|
||||
appendAuditLog("CLIENT_DELETE", "DELETE_CLIENT", clientId);
|
||||
return route.fulfill({ status: 204 });
|
||||
}
|
||||
|
||||
if (pathname === "/api/v1/dev/consents" && method === "GET") {
|
||||
const subject = searchParams.get("subject") || "";
|
||||
const clientId = searchParams.get("client_id") || "";
|
||||
|
||||
Reference in New Issue
Block a user