1
0
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:
Lectom C Han
2026-04-01 18:33:22 +09:00
parent f51cdba51a
commit 9facd24a00
20 changed files with 2393 additions and 499 deletions

View File

@@ -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` 미설정

View File

@@ -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)

View 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:"-"`
}

View File

@@ -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
}

View File

@@ -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")
}
})

View File

@@ -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) {

View File

@@ -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",
},
},
})

View File

@@ -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)

View File

@@ -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"}
}

View File

@@ -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"},

View 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)
}
}

View 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
}

View File

@@ -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 && (

View File

@@ -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}`);
}

View File

@@ -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;
}

View File

@@ -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."

View File

@@ -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."

View File

@@ -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 = ""

View File

@@ -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);
});
});

View File

@@ -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") || "";