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