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:
@@ -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"},
|
||||
|
||||
Reference in New Issue
Block a user