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

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