diff --git a/backend/internal/domain/hydra_models.go b/backend/internal/domain/hydra_models.go index 0923d9a4..e00f58ab 100644 --- a/backend/internal/domain/hydra_models.go +++ b/backend/internal/domain/hydra_models.go @@ -1,6 +1,16 @@ package domain -import "time" +import ( + "strings" + "time" +) + +const ( + MetadataHeadlessLoginEnabled = "headless_login_enabled" + MetadataHeadlessTokenEndpointAuthMethod = "headless_token_endpoint_auth_method" + MetadataHeadlessJWKSURI = "headless_jwks_uri" + MetadataHeadlessJWKS = "headless_jwks" +) type HydraClient struct { ClientID string `json:"client_id"` @@ -20,11 +30,42 @@ type HydraClient struct { func (c *HydraClient) IsTrustedRP() bool { // A Trusted RP must have a public key registered (URI or Inline) // and use private_key_jwt for token endpoint authentication. - hasPublicKey := c.JWKSUri != "" || c.JWKS != nil - isPrivateKeyJwt := c.TokenEndpointAuthMethod == "private_key_jwt" + hasPublicKey := c.HeadlessJWKSURI() != "" || c.HeadlessJWKS() != nil + isPrivateKeyJwt := c.HeadlessTokenEndpointAuthMethod() == "private_key_jwt" return hasPublicKey && isPrivateKeyJwt } +func (c *HydraClient) HeadlessTokenEndpointAuthMethod() string { + if c.Metadata != nil { + if raw, ok := c.Metadata[MetadataHeadlessTokenEndpointAuthMethod].(string); ok { + if value := strings.TrimSpace(raw); value != "" { + return value + } + } + } + return strings.TrimSpace(c.TokenEndpointAuthMethod) +} + +func (c *HydraClient) HeadlessJWKSURI() string { + if c.Metadata != nil { + if raw, ok := c.Metadata[MetadataHeadlessJWKSURI].(string); ok { + if value := strings.TrimSpace(raw); value != "" { + return value + } + } + } + return strings.TrimSpace(c.JWKSUri) +} + +func (c *HydraClient) HeadlessJWKS() interface{} { + if c.Metadata != nil { + if value, ok := c.Metadata[MetadataHeadlessJWKS]; ok && value != nil { + return value + } + } + return c.JWKS +} + func (c *HydraClient) IsHeadlessLoginEnabled() bool { if !c.IsTrustedRP() { return false @@ -32,7 +73,7 @@ func (c *HydraClient) IsHeadlessLoginEnabled() bool { if c.Metadata == nil { return false } - val, ok := c.Metadata["headless_login_enabled"] + val, ok := c.Metadata[MetadataHeadlessLoginEnabled] if !ok { return false } diff --git a/backend/internal/domain/hydra_models_test.go b/backend/internal/domain/hydra_models_test.go index 7d1af640..317ada11 100644 --- a/backend/internal/domain/hydra_models_test.go +++ b/backend/internal/domain/hydra_models_test.go @@ -3,6 +3,28 @@ package domain import "testing" func TestHydraClient_TrustedRPFlags(t *testing.T) { + t.Run("metadata-backed headless trusted rp is supported", func(t *testing.T) { + client := HydraClient{ + TokenEndpointAuthMethod: "none", + 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", + }}, + }, + }, + } + + if !client.IsTrustedRP() { + t.Fatalf("expected metadata-backed trusted rp") + } + if !client.IsHeadlessLoginEnabled() { + t.Fatalf("expected metadata-backed headless login enabled") + } + }) + t.Run("inline jwks with private_key_jwt and headless enabled", func(t *testing.T) { client := HydraClient{ TokenEndpointAuthMethod: "private_key_jwt", diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index bd961dd8..62943600 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -1699,14 +1699,14 @@ func containsHeadlessAudience(expected []string, actual headlessAssertionAud) bo func (h *AuthHandler) loadHeadlessJWKS(ctx context.Context, client domain.HydraClient) (*jose.JSONWebKeySet, error) { var raw []byte switch { - case client.JWKS != nil: - data, err := json.Marshal(client.JWKS) + 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 strings.TrimSpace(client.JWKSUri) != "": - req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimSpace(client.JWKSUri), nil) + 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) } diff --git a/backend/internal/handler/auth_handler_link_test.go b/backend/internal/handler/auth_handler_link_test.go index 08b663d3..39c28c6d 100644 --- a/backend/internal/handler/auth_handler_link_test.go +++ b/backend/internal/handler/auth_handler_link_test.go @@ -171,11 +171,12 @@ func TestHeadlessLinkInit_TrustedClientSuccess(t *testing.T) { Challenge: "challenge-123", Client: domain.HydraClient{ ClientID: "trusted-rp", - 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": jwks, }, }, }) @@ -232,11 +233,12 @@ func TestHeadlessLinkPoll_AfterApprovalReturnsRedirect(t *testing.T) { Challenge: "challenge-123", Client: domain.HydraClient{ ClientID: "trusted-rp", - 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": jwks, }, }, }) diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go index 4cdf17c5..391841e5 100644 --- a/backend/internal/handler/auth_handler_login_test.go +++ b/backend/internal/handler/auth_handler_login_test.go @@ -306,11 +306,12 @@ func TestHeadlessPasswordLogin_TrustedClientSuccess(t *testing.T) { Challenge: "challenge-123", Client: domain.HydraClient{ ClientID: "trusted-rp", - TokenEndpointAuthMethod: "private_key_jwt", - JWKSUri: jwksServer.URL + "/.well-known/jwks.json", + 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", }, }, }) @@ -524,10 +525,11 @@ func TestHeadlessPasswordLogin_HeadlessDisabledRejected(t *testing.T) { Challenge: "challenge-123", Client: domain.HydraClient{ ClientID: "trusted-rp", - TokenEndpointAuthMethod: "private_key_jwt", - JWKSUri: "https://rp.example.com/.well-known/jwks.json", + TokenEndpointAuthMethod: "none", Metadata: map[string]interface{}{ - "status": "active", + "status": "active", + "headless_jwks_uri": "https://rp.example.com/.well-known/jwks.json", + "headless_token_endpoint_auth_method": "private_key_jwt", }, }, }) @@ -576,11 +578,12 @@ func TestHeadlessPasswordLogin_ClientIDMismatchRejected(t *testing.T) { Challenge: "challenge-123", Client: domain.HydraClient{ ClientID: "other-rp", - TokenEndpointAuthMethod: "private_key_jwt", - JWKSUri: "https://rp.example.com/.well-known/jwks.json", + 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", }, }, }) diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index c96e6fda..1fddc6d9 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -891,6 +891,13 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { tokenAuthMethod = "client_secret_basic" } } + tokenAuthMethod, jwksURI, jwks, metadata := normalizeHeadlessClientConfig( + clientType, + tokenAuthMethod, + valueOr(req.JwksUri, ""), + req.Jwks, + metadata, + ) clientReq := domain.HydraClient{ ClientID: clientID, @@ -900,8 +907,8 @@ func (h *DevHandler) CreateClient(c *fiber.Ctx) error { ResponseTypes: responseTypes, Scope: strings.Join(scopes, " "), TokenEndpointAuthMethod: tokenAuthMethod, - JWKSUri: valueOr(req.JwksUri, ""), - JWKS: req.Jwks, + JWKSUri: jwksURI, + JWKS: jwks, Metadata: metadata, } @@ -1044,6 +1051,23 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { } metadata["status"] = status } + resolvedClientType := currentSummary.Type + if clientType != "" { + resolvedClientType = clientType + } + resolvedTokenAuthMethod := resolveTokenAuthMethod(tokenAuthMethod, current.TokenEndpointAuthMethod) + resolvedJWKSURI := valueOr(req.JwksUri, current.JWKSUri) + resolvedJWKS := req.Jwks + if req.Jwks == nil { + resolvedJWKS = current.JWKS + } + resolvedTokenAuthMethod, resolvedJWKSURI, resolvedJWKS, metadata = normalizeHeadlessClientConfig( + resolvedClientType, + resolvedTokenAuthMethod, + resolvedJWKSURI, + resolvedJWKS, + metadata, + ) updated := domain.HydraClient{ ClientID: current.ClientID, @@ -1052,14 +1076,11 @@ func (h *DevHandler) UpdateClient(c *fiber.Ctx) error { GrantTypes: derefSlice(req.GrantTypes, current.GrantTypes), ResponseTypes: derefSlice(req.ResponseTypes, current.ResponseTypes), Scope: buildScope(valueOrSlice(req.Scopes, strings.Fields(current.Scope))), - TokenEndpointAuthMethod: resolveTokenAuthMethod(tokenAuthMethod, current.TokenEndpointAuthMethod), - JWKSUri: valueOr(req.JwksUri, current.JWKSUri), - JWKS: req.Jwks, + TokenEndpointAuthMethod: resolvedTokenAuthMethod, + JWKSUri: resolvedJWKSURI, + JWKS: resolvedJWKS, Metadata: metadata, } - if req.Jwks == nil { - updated.JWKS = current.JWKS - } if err := validateReservedSystemClientName(updated.ClientID, updated.ClientName); err != nil { return errorJSON(c, fiber.StatusForbidden, err.Error()) } @@ -1676,6 +1697,70 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary { } } +func readMetadataStringValue(metadata map[string]interface{}, key string) string { + if metadata == nil { + return "" + } + raw, _ := metadata[key].(string) + return strings.TrimSpace(raw) +} + +func readMetadataBoolValue(metadata map[string]interface{}, key string) bool { + if metadata == nil { + return false + } + value, _ := metadata[key].(bool) + return value +} + +func normalizeHeadlessClientConfig( + clientType string, + tokenAuthMethod string, + jwksURI string, + jwks interface{}, + metadata map[string]interface{}, +) (string, string, interface{}, map[string]interface{}) { + if metadata == nil { + metadata = map[string]interface{}{} + } + + headlessEnabled := readMetadataBoolValue(metadata, domain.MetadataHeadlessLoginEnabled) + if clientType == "pkce" && headlessEnabled { + headlessTokenAuthMethod := readMetadataStringValue(metadata, domain.MetadataHeadlessTokenEndpointAuthMethod) + if headlessTokenAuthMethod == "" && !strings.EqualFold(strings.TrimSpace(tokenAuthMethod), "none") { + headlessTokenAuthMethod = strings.TrimSpace(tokenAuthMethod) + } + if headlessTokenAuthMethod == "" { + headlessTokenAuthMethod = "private_key_jwt" + } + metadata[domain.MetadataHeadlessTokenEndpointAuthMethod] = headlessTokenAuthMethod + + headlessJWKSURI := readMetadataStringValue(metadata, domain.MetadataHeadlessJWKSURI) + if headlessJWKSURI == "" && strings.TrimSpace(jwksURI) != "" { + headlessJWKSURI = strings.TrimSpace(jwksURI) + } + if headlessJWKSURI != "" { + metadata[domain.MetadataHeadlessJWKSURI] = headlessJWKSURI + } else { + 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) + } + + return "none", "", nil, metadata + } + + delete(metadata, domain.MetadataHeadlessTokenEndpointAuthMethod) + delete(metadata, domain.MetadataHeadlessJWKSURI) + delete(metadata, domain.MetadataHeadlessJWKS) + return tokenAuthMethod, jwksURI, jwks, metadata +} + func defaultClientScopes() []string { return []string{"openid", "profile", "email"} } diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 334c3e57..7b9fbb20 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -676,9 +676,10 @@ func TestCreateClient_TrustedRPPayloadMapping(t *testing.T) { resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusCreated, resp.StatusCode) - assert.Equal(t, "private_key_jwt", captured.TokenEndpointAuthMethod) - assert.NotNil(t, captured.JWKS) - assert.True(t, captured.IsTrustedRP()) + 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"]) @@ -754,9 +755,10 @@ func TestUpdateClient_TrustedRPPayloadMapping(t *testing.T) { resp, _ := app.Test(req, -1) assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, "private_key_jwt", captured.TokenEndpointAuthMethod) - assert.Equal(t, "https://rp.example.com/.well-known/jwks.json", captured.JWKSUri) - assert.True(t, captured.IsTrustedRP()) + assert.Equal(t, "none", captured.TokenEndpointAuthMethod) + 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"]) assert.True(t, captured.IsHeadlessLoginEnabled()) assert.Equal(t, true, captured.Metadata["headless_login_enabled"]) } diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index fce5a6ac..18ba1210 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -72,6 +72,17 @@ function readMetadataString( return typeof value === "string" ? value : ""; } +function readMetadataObject( + metadata: Record, + key: string, +): Record | undefined { + const value = metadata[key]; + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + return value as Record; +} + function isValidUrl(value: string): boolean { try { const url = new URL(value); @@ -150,15 +161,42 @@ function ClientGeneralPage() { setStatus(client.status); setInitialStatus(client.status); + const metadata = client.metadata ?? {}; + if (typeof metadata.description === "string") + setDescription(metadata.description); + if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url); + + const headlessEnabled = !!metadata.headless_login_enabled; + setHeadlessLoginEnabled(headlessEnabled); + const savedAuthMethod = client.tokenEndpointAuthMethod || (client.type === "pkce" ? "none" : "client_secret_basic"); - if (isTokenEndpointAuthMethod(savedAuthMethod)) { - setTokenEndpointAuthMethod(savedAuthMethod); + const headlessAuthMethod = readMetadataString( + metadata, + "headless_token_endpoint_auth_method", + ); + const selectedAuthMethod = + headlessEnabled && isTokenEndpointAuthMethod(headlessAuthMethod) + ? headlessAuthMethod + : savedAuthMethod; + if (isTokenEndpointAuthMethod(selectedAuthMethod)) { + setTokenEndpointAuthMethod(selectedAuthMethod); } - if (client.jwksUri) { + 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( @@ -166,18 +204,16 @@ function ClientGeneralPage() { ? client.jwks : JSON.stringify(client.jwks, null, 2), ); + setJwksUri(""); + setJwksSource("inline"); + } else { + setJwksUri(""); + setJwksText(""); setJwksSource("inline"); } - const metadata = client.metadata ?? {}; - if (typeof metadata.description === "string") - setDescription(metadata.description); - if (typeof metadata.logo_url === "string") setLogoUrl(metadata.logo_url); - - setHeadlessLoginEnabled(!!metadata.headless_login_enabled); - // Fallbacks from metadata if top-level fields are empty - if (!client.tokenEndpointAuthMethod) { + if (!client.tokenEndpointAuthMethod && !headlessEnabled) { const metaAuth = readMetadataString( metadata, "token_endpoint_auth_method", @@ -187,7 +223,7 @@ function ClientGeneralPage() { } } - if (!client.jwksUri && !client.jwks) { + if (!client.jwksUri && !client.jwks && !headlessEnabled) { const metaJwksUri = readMetadataString(metadata, "jwks_uri"); if (metaJwksUri) { setJwksUri(metaJwksUri); @@ -342,11 +378,7 @@ function ClientGeneralPage() { const scopeNames = scopes.map((scope) => scope.name).filter(Boolean); let finalJwks: ClientUpsertRequest["jwks"]; - if ( - tokenEndpointAuthMethod === "private_key_jwt" && - jwksSource === "inline" && - trimmedJwksText - ) { + if (jwksSource === "inline" && trimmedJwksText) { try { finalJwks = JSON.parse(trimmedJwksText); } catch (e) { @@ -354,23 +386,48 @@ function ClientGeneralPage() { } } + const effectiveTokenEndpointAuthMethod = + clientType === "pkce" && headlessLoginEnabled + ? "none" + : tokenEndpointAuthMethod; + const payload: ClientUpsertRequest = { name, type: clientType, scopes: scopeNames, - tokenEndpointAuthMethod, + tokenEndpointAuthMethod: effectiveTokenEndpointAuthMethod, jwksUri: - tokenEndpointAuthMethod === "private_key_jwt" && jwksSource === "uri" + effectiveTokenEndpointAuthMethod === "private_key_jwt" && + jwksSource === "uri" ? trimmedJwksUri : undefined, - jwks: finalJwks, + jwks: + effectiveTokenEndpointAuthMethod === "private_key_jwt" + ? finalJwks + : undefined, metadata: { description, logo_url: logoUrl, structured_scopes: scopes, - token_endpoint_auth_method: tokenEndpointAuthMethod, + token_endpoint_auth_method: effectiveTokenEndpointAuthMethod, request_object_signing_alg: trimmedRequestObjectSigningAlg, headless_login_enabled: headlessLoginEnabled, + headless_token_endpoint_auth_method: + clientType === "pkce" && headlessLoginEnabled + ? tokenEndpointAuthMethod + : undefined, + headless_jwks_uri: + clientType === "pkce" && + headlessLoginEnabled && + jwksSource === "uri" + ? trimmedJwksUri + : undefined, + headless_jwks: + clientType === "pkce" && + headlessLoginEnabled && + jwksSource === "inline" + ? finalJwks + : undefined, }, };