diff --git a/backend/internal/agent/service.go b/backend/internal/agent/service.go index 9fcc071077..12182a86a5 100644 --- a/backend/internal/agent/service.go +++ b/backend/internal/agent/service.go @@ -1651,20 +1651,15 @@ func translateOAuthValidationError(err error) *tidcommon.ServiceError { Key: "error.agentservice.private_key_jwt_cannot_have_client_secret_description", DefaultValue: "private_key_jwt authentication method cannot have a client secret", }) - case errors.Is(err, inboundclient.ErrOAuthClientSecretCannotHaveCertificate): - return tidcommon.CustomServiceError(ErrorInvalidOAuthConfiguration, tidcommon.I18nMessage{ - Key: "error.agentservice.client_secret_cannot_have_certificate_description", - DefaultValue: "client_secret authentication methods cannot have a certificate", - }) case errors.Is(err, inboundclient.ErrOAuthNoneAuthRequiresPublicClient): return tidcommon.CustomServiceError(ErrorInvalidOAuthConfiguration, tidcommon.I18nMessage{ Key: "error.agentservice.none_auth_method_requires_public_client_description", DefaultValue: "'none' authentication method requires the client to be a public client", }) - case errors.Is(err, inboundclient.ErrOAuthNoneAuthCannotHaveCertOrSecret): + case errors.Is(err, inboundclient.ErrOAuthNoneAuthCannotHaveSecret): return tidcommon.CustomServiceError(ErrorInvalidOAuthConfiguration, tidcommon.I18nMessage{ - Key: "error.agentservice.none_auth_method_cannot_have_cert_or_secret_description", - DefaultValue: "'none' authentication method cannot have a certificate or client secret", + Key: "error.agentservice.none_auth_method_cannot_have_secret_description", + DefaultValue: "'none' authentication method cannot have a client secret", }) case errors.Is(err, inboundclient.ErrOAuthClientCredentialsCannotUseNoneAuth): return tidcommon.CustomServiceError(ErrorInvalidOAuthConfiguration, tidcommon.I18nMessage{ @@ -1730,11 +1725,6 @@ func translateUserInfoValidationError(err error) *tidcommon.ServiceError { Key: "error.agentservice.userinfo_unsupported_response_type_description", DefaultValue: "userinfo responseType is not supported", }) - case errors.Is(err, inboundclient.ErrOAuthUserInfoJWSRequiresSigningAlg): - return tidcommon.CustomServiceError(ErrorInvalidOAuthConfiguration, tidcommon.I18nMessage{ - Key: "error.agentservice.userinfo_jws_requires_signing_alg_description", - DefaultValue: "signingAlg is required when userinfo responseType is JWS", - }) case errors.Is(err, inboundclient.ErrOAuthUserInfoJWERequiresEncryption): return tidcommon.CustomServiceError(ErrorInvalidOAuthConfiguration, tidcommon.I18nMessage{ Key: "error.agentservice.userinfo_jwe_requires_encryption_description", @@ -1743,7 +1733,7 @@ func translateUserInfoValidationError(err error) *tidcommon.ServiceError { case errors.Is(err, inboundclient.ErrOAuthUserInfoNestedJWTRequiresAll): return tidcommon.CustomServiceError(ErrorInvalidOAuthConfiguration, tidcommon.I18nMessage{ Key: "error.agentservice.userinfo_nested_jwt_requires_all_description", - DefaultValue: "signingAlg, encryptionAlg, and encryptionEnc are required " + + DefaultValue: "encryptionAlg and encryptionEnc are required " + "when userinfo responseType is NESTED_JWT", }) case errors.Is(err, inboundclient.ErrOAuthUserInfoAlgRequiresResponseType): diff --git a/backend/internal/agent/service_test.go b/backend/internal/agent/service_test.go index 511e089755..bca5df673a 100644 --- a/backend/internal/agent/service_test.go +++ b/backend/internal/agent/service_test.go @@ -1397,15 +1397,12 @@ func (suite *AgentServiceTestSuite) TestTranslateOAuthValidationError() { {"PrivateKeyJWTCannotHaveClientSecret", inboundclient.ErrOAuthPrivateKeyJWTCannotHaveClientSecret, ErrorInvalidOAuthConfiguration.Code, "error.agentservice.private_key_jwt_cannot_have_client_secret_description"}, - {"ClientSecretCannotHaveCertificate", inboundclient.ErrOAuthClientSecretCannotHaveCertificate, - ErrorInvalidOAuthConfiguration.Code, - "error.agentservice.client_secret_cannot_have_certificate_description"}, {"NoneAuthRequiresPublicClient", inboundclient.ErrOAuthNoneAuthRequiresPublicClient, ErrorInvalidOAuthConfiguration.Code, "error.agentservice.none_auth_method_requires_public_client_description"}, - {"NoneAuthCannotHaveCertOrSecret", inboundclient.ErrOAuthNoneAuthCannotHaveCertOrSecret, + {"NoneAuthCannotHaveSecret", inboundclient.ErrOAuthNoneAuthCannotHaveSecret, ErrorInvalidOAuthConfiguration.Code, - "error.agentservice.none_auth_method_cannot_have_cert_or_secret_description"}, + "error.agentservice.none_auth_method_cannot_have_secret_description"}, {"ClientCredentialsCannotUseNoneAuth", inboundclient.ErrOAuthClientCredentialsCannotUseNoneAuth, ErrorInvalidOAuthConfiguration.Code, "error.agentservice.client_credentials_cannot_use_none_auth_description"}, @@ -1453,8 +1450,6 @@ func (suite *AgentServiceTestSuite) TestTranslateUserInfoValidationError() { "error.agentservice.userinfo_jwks_uri_not_ssrf_safe_description"}, {"UnsupportedResponseType", inboundclient.ErrOAuthUserInfoUnsupportedResponseType, "error.agentservice.userinfo_unsupported_response_type_description"}, - {"JWSRequiresSigningAlg", inboundclient.ErrOAuthUserInfoJWSRequiresSigningAlg, - "error.agentservice.userinfo_jws_requires_signing_alg_description"}, {"JWERequiresEncryption", inboundclient.ErrOAuthUserInfoJWERequiresEncryption, "error.agentservice.userinfo_jwe_requires_encryption_description"}, {"NestedJWTRequiresAll", inboundclient.ErrOAuthUserInfoNestedJWTRequiresAll, diff --git a/backend/internal/application/service.go b/backend/internal/application/service.go index 64c7fc50ff..34418eb089 100644 --- a/backend/internal/application/service.go +++ b/backend/internal/application/service.go @@ -1384,20 +1384,15 @@ func translateOAuthValidationError(err error) *tidcommon.ServiceError { Key: "error.applicationservice.private_key_jwt_cannot_have_client_secret_description", DefaultValue: "private_key_jwt authentication method cannot have a client secret", }) - case errors.Is(err, inboundclient.ErrOAuthClientSecretCannotHaveCertificate): - return tidcommon.CustomServiceError(ErrorInvalidOAuthConfiguration, tidcommon.I18nMessage{ - Key: "error.applicationservice.client_secret_cannot_have_certificate_description", - DefaultValue: "client_secret authentication methods cannot have a certificate", - }) case errors.Is(err, inboundclient.ErrOAuthNoneAuthRequiresPublicClient): return tidcommon.CustomServiceError(ErrorInvalidOAuthConfiguration, tidcommon.I18nMessage{ Key: "error.applicationservice.none_auth_method_requires_public_client_description", DefaultValue: "'none' authentication method requires the client to be a public client", }) - case errors.Is(err, inboundclient.ErrOAuthNoneAuthCannotHaveCertOrSecret): + case errors.Is(err, inboundclient.ErrOAuthNoneAuthCannotHaveSecret): return tidcommon.CustomServiceError(ErrorInvalidOAuthConfiguration, tidcommon.I18nMessage{ - Key: "error.applicationservice.none_auth_method_cannot_have_cert_or_secret_description", - DefaultValue: "'none' authentication method cannot have a certificate or client secret", + Key: "error.applicationservice.none_auth_method_cannot_have_secret_description", + DefaultValue: "'none' authentication method cannot have a client secret", }) case errors.Is(err, inboundclient.ErrOAuthClientCredentialsCannotUseNoneAuth): return tidcommon.CustomServiceError(ErrorInvalidOAuthConfiguration, tidcommon.I18nMessage{ @@ -1464,11 +1459,6 @@ func translateUserInfoValidationError(err error) *tidcommon.ServiceError { Key: "error.applicationservice.userinfo_unsupported_response_type_description", DefaultValue: "userinfo responseType is not supported", }) - case errors.Is(err, inboundclient.ErrOAuthUserInfoJWSRequiresSigningAlg): - return tidcommon.CustomServiceError(ErrorInvalidOAuthConfiguration, tidcommon.I18nMessage{ - Key: "error.applicationservice.userinfo_jws_requires_signing_alg_description", - DefaultValue: "signingAlg is required when userinfo responseType is JWS", - }) case errors.Is(err, inboundclient.ErrOAuthUserInfoJWERequiresEncryption): return tidcommon.CustomServiceError(ErrorInvalidOAuthConfiguration, tidcommon.I18nMessage{ Key: "error.applicationservice.userinfo_jwe_requires_encryption_description", @@ -1477,7 +1467,7 @@ func translateUserInfoValidationError(err error) *tidcommon.ServiceError { case errors.Is(err, inboundclient.ErrOAuthUserInfoNestedJWTRequiresAll): return tidcommon.CustomServiceError(ErrorInvalidOAuthConfiguration, tidcommon.I18nMessage{ Key: "error.applicationservice.userinfo_nested_jwt_requires_all_description", - DefaultValue: "signingAlg, encryptionAlg, and encryptionEnc are required " + + DefaultValue: "encryptionAlg and encryptionEnc are required " + "when userinfo responseType is NESTED_JWT", }) case errors.Is(err, inboundclient.ErrOAuthUserInfoAlgRequiresResponseType): diff --git a/backend/internal/application/service_test.go b/backend/internal/application/service_test.go index 21003e014d..ec6ae928b0 100644 --- a/backend/internal/application/service_test.go +++ b/backend/internal/application/service_test.go @@ -3489,12 +3489,6 @@ func (suite *ServiceTestSuite) TestTranslateOAuthValidationError() { wantCode: ErrorInvalidOAuthConfiguration.Code, wantDescKey: "error.applicationservice.private_key_jwt_cannot_have_client_secret_description", }, - { - name: "ClientSecretCannotHaveCertificate", - err: inboundclient.ErrOAuthClientSecretCannotHaveCertificate, - wantCode: ErrorInvalidOAuthConfiguration.Code, - wantDescKey: "error.applicationservice.client_secret_cannot_have_certificate_description", - }, { name: "NoneAuthRequiresPublicClient", err: inboundclient.ErrOAuthNoneAuthRequiresPublicClient, @@ -3502,10 +3496,10 @@ func (suite *ServiceTestSuite) TestTranslateOAuthValidationError() { wantDescKey: "error.applicationservice.none_auth_method_requires_public_client_description", }, { - name: "NoneAuthCannotHaveCertOrSecret", - err: inboundclient.ErrOAuthNoneAuthCannotHaveCertOrSecret, + name: "NoneAuthCannotHaveSecret", + err: inboundclient.ErrOAuthNoneAuthCannotHaveSecret, wantCode: ErrorInvalidOAuthConfiguration.Code, - wantDescKey: "error.applicationservice.none_auth_method_cannot_have_cert_or_secret_description", + wantDescKey: "error.applicationservice.none_auth_method_cannot_have_secret_description", }, { name: "ClientCredentialsCannotUseNoneAuth", @@ -3585,11 +3579,6 @@ func (suite *ServiceTestSuite) TestTranslateUserInfoValidationError() { err: inboundclient.ErrOAuthUserInfoUnsupportedResponseType, wantDescKey: "error.applicationservice.userinfo_unsupported_response_type_description", }, - { - name: "JWSRequiresSigningAlg", - err: inboundclient.ErrOAuthUserInfoJWSRequiresSigningAlg, - wantDescKey: "error.applicationservice.userinfo_jws_requires_signing_alg_description", - }, { name: "JWERequiresEncryption", err: inboundclient.ErrOAuthUserInfoJWERequiresEncryption, diff --git a/backend/internal/inboundclient/error_constants.go b/backend/internal/inboundclient/error_constants.go index 80f8f3adcc..ba448eef56 100644 --- a/backend/internal/inboundclient/error_constants.go +++ b/backend/internal/inboundclient/error_constants.go @@ -103,12 +103,10 @@ var ( ErrOAuthCertificateRequiresClientID = errors.New("certificate requires an OAuth client ID") // ErrOAuthPrivateKeyJWTCannotHaveClientSecret is returned when private_key_jwt is used with a client secret. ErrOAuthPrivateKeyJWTCannotHaveClientSecret = errors.New("private_key_jwt cannot have a client secret") - // ErrOAuthClientSecretCannotHaveCertificate is returned when client-secret auth is used with a certificate. - ErrOAuthClientSecretCannotHaveCertificate = errors.New("client secret auth cannot have a certificate") // ErrOAuthNoneAuthRequiresPublicClient is returned when none auth method is used without a public client. ErrOAuthNoneAuthRequiresPublicClient = errors.New("none auth method requires a public client") - // ErrOAuthNoneAuthCannotHaveCertOrSecret is returned when none auth method is used with a certificate or secret. - ErrOAuthNoneAuthCannotHaveCertOrSecret = errors.New("none auth method cannot have certificate or secret") + // ErrOAuthNoneAuthCannotHaveSecret is returned when none auth method is used with a client secret. + ErrOAuthNoneAuthCannotHaveSecret = errors.New("none auth method cannot have a client secret") // ErrOAuthClientCredentialsCannotUseNoneAuth is returned when client_credentials uses none auth method. ErrOAuthClientCredentialsCannotUseNoneAuth = errors.New("client_credentials cannot use none auth method") // ErrOAuthClientJWTBearerCannotUseNoneAuth is returned when the jwt-bearer grant uses none auth method. @@ -148,14 +146,12 @@ var ( ErrOAuthUserInfoJWKSURINotSSRFSafe = errors.New("userinfo JWKS URI must be a publicly reachable HTTPS URL") // ErrOAuthUserInfoUnsupportedResponseType is returned when an unsupported userinfo response type is specified. ErrOAuthUserInfoUnsupportedResponseType = errors.New("unsupported userinfo response type") - // ErrOAuthUserInfoJWSRequiresSigningAlg is returned when responseType is JWS but signingAlg is not set. - ErrOAuthUserInfoJWSRequiresSigningAlg = errors.New("signingAlg is required when userinfo responseType is JWS") // ErrOAuthUserInfoJWERequiresEncryption is returned when responseType is JWE but encryption fields are missing. ErrOAuthUserInfoJWERequiresEncryption = errors.New( "encryptionAlg and encryptionEnc are required when userinfo responseType is JWE") - // ErrOAuthUserInfoNestedJWTRequiresAll is returned when responseType is NESTED_JWT but fields are missing. + // ErrOAuthUserInfoNestedJWTRequiresAll is returned when responseType is NESTED_JWT but encryption is missing. ErrOAuthUserInfoNestedJWTRequiresAll = errors.New( - "signingAlg, encryptionAlg, and encryptionEnc are required when userinfo responseType is NESTED_JWT") + "encryptionAlg and encryptionEnc are required when userinfo responseType is NESTED_JWT") // ErrOAuthUserInfoAlgRequiresResponseType is returned when algorithm fields // are set without an explicit responseType. ErrOAuthUserInfoAlgRequiresResponseType = errors.New( diff --git a/backend/internal/inboundclient/service.go b/backend/internal/inboundclient/service.go index 59cba9e2e5..074eb06614 100644 --- a/backend/internal/inboundclient/service.go +++ b/backend/internal/inboundclient/service.go @@ -864,15 +864,13 @@ func validateUserInfoConfig(p *providers.OAuthProfile) error { if cfg.ResponseType != "" { switch cfg.ResponseType { case providers.UserInfoResponseTypeJWS: - if cfg.SigningAlg == "" { - return ErrOAuthUserInfoJWSRequiresSigningAlg - } + // Signing uses only the server's signing key as of now. case providers.UserInfoResponseTypeJWE: if cfg.EncryptionAlg == "" || cfg.EncryptionEnc == "" { return ErrOAuthUserInfoJWERequiresEncryption } case providers.UserInfoResponseTypeNESTEDJWT: - if cfg.SigningAlg == "" || cfg.EncryptionAlg == "" || cfg.EncryptionEnc == "" { + if cfg.EncryptionAlg == "" || cfg.EncryptionEnc == "" { return ErrOAuthUserInfoNestedJWTRequiresAll } case providers.UserInfoResponseTypeJSON: @@ -1049,11 +1047,6 @@ func validateTokenEndpointAuthMethod(p *providers.OAuthProfile, hasClientSecret return err } hasCert := p.Certificate != nil && p.Certificate.Type != "" - userInfoNeedsCert := p.UserInfo != nil && p.UserInfo.EncryptionAlg != "" - idTokenNeedsCert := p.Token != nil && p.Token.IDToken != nil && - (p.Token.IDToken.ResponseType == providers.IDTokenResponseTypeJWE || - p.Token.IDToken.ResponseType == providers.IDTokenResponseTypeNESTEDJWT) - needsCert := userInfoNeedsCert || idTokenNeedsCert switch providers.TokenEndpointAuthMethod(p.TokenEndpointAuthMethod) { case providers.TokenEndpointAuthMethodPrivateKeyJWT: @@ -1064,15 +1057,14 @@ func validateTokenEndpointAuthMethod(p *providers.OAuthProfile, hasClientSecret return ErrOAuthPrivateKeyJWTCannotHaveClientSecret } case providers.TokenEndpointAuthMethodClientSecretBasic, providers.TokenEndpointAuthMethodClientSecretPost: - if hasCert && !needsCert { - return ErrOAuthClientSecretCannotHaveCertificate - } + // A certificate is allowed: it carries the client's public key for token encryption + // (JWE / NESTED_JWT), independent of how the client authenticates. case providers.TokenEndpointAuthMethodNone: if !p.PublicClient { return ErrOAuthNoneAuthRequiresPublicClient } - if (hasCert && !needsCert) || hasClientSecret { - return ErrOAuthNoneAuthCannotHaveCertOrSecret + if hasClientSecret { + return ErrOAuthNoneAuthCannotHaveSecret } if slices.Contains(p.GrantTypes, string(providers.GrantTypeClientCredentials)) { return ErrOAuthClientCredentialsCannotUseNoneAuth diff --git a/backend/internal/inboundclient/service_test.go b/backend/internal/inboundclient/service_test.go index 6f31aa07cc..94443e0bdf 100644 --- a/backend/internal/inboundclient/service_test.go +++ b/backend/internal/inboundclient/service_test.go @@ -629,13 +629,14 @@ func (suite *InboundClientServiceTestSuite) TestValidateTokenEndpoint_CertAllowe assert.NoError(suite.T(), validateTokenEndpointAuthMethod(p, true)) } -func (suite *InboundClientServiceTestSuite) TestValidateTokenEndpoint_CertRejectedWhenUserInfoDoesNotNeedIt() { +func (suite *InboundClientServiceTestSuite) TestValidateTokenEndpoint_CertAllowedUnderClientSecret() { + // A certificate is allowed under client_secret auth even without encryption configured: it may + // be staged before enabling an encrypted token format, and an unused certificate is harmless. p := &providers.OAuthProfile{ TokenEndpointAuthMethod: "client_secret_basic", Certificate: &inboundmodel.Certificate{Type: cert.CertificateTypeJWKS, Value: "{}"}, } - err := validateTokenEndpointAuthMethod(p, true) - assert.ErrorIs(suite.T(), err, ErrOAuthClientSecretCannotHaveCertificate) + assert.NoError(suite.T(), validateTokenEndpointAuthMethod(p, true)) } func (suite *InboundClientServiceTestSuite) TestValidateTokenEndpointAuthMethod_PrivateKeyJWTHappy() { @@ -667,14 +668,24 @@ func (suite *InboundClientServiceTestSuite) TestValidateTokenEndpointAuthMethod_ assert.ErrorIs(suite.T(), err, ErrOAuthNoneAuthRequiresPublicClient) } -func (suite *InboundClientServiceTestSuite) TestValidateTokenEndpointAuthMethod_NoneRejectsCertOrSecret() { +func (suite *InboundClientServiceTestSuite) TestValidateTokenEndpointAuthMethod_NoneAllowsCert() { + // A certificate is allowed under none auth (e.g. to encrypt tokens to a public client's key); + // only a client secret is rejected. p := &providers.OAuthProfile{ TokenEndpointAuthMethod: "none", PublicClient: true, Certificate: &inboundmodel.Certificate{Type: cert.CertificateTypeJWKS, Value: "{}"}, } - err := validateTokenEndpointAuthMethod(p, false) - assert.ErrorIs(suite.T(), err, ErrOAuthNoneAuthCannotHaveCertOrSecret) + assert.NoError(suite.T(), validateTokenEndpointAuthMethod(p, false)) +} + +func (suite *InboundClientServiceTestSuite) TestValidateTokenEndpointAuthMethod_NoneRejectsSecret() { + p := &providers.OAuthProfile{ + TokenEndpointAuthMethod: "none", + PublicClient: true, + } + err := validateTokenEndpointAuthMethod(p, true) + assert.ErrorIs(suite.T(), err, ErrOAuthNoneAuthCannotHaveSecret) } func (suite *InboundClientServiceTestSuite) TestValidateTokenEndpointAuthMethod_NoneClientCredentialsRejected() { @@ -757,6 +768,18 @@ func (suite *InboundClientServiceTestSuite) TestValidateUserInfoConfig_NestedJWT assert.NoError(suite.T(), validateUserInfoConfig(p)) } +func (suite *InboundClientServiceTestSuite) TestValidateUserInfoConfig_NestedJWTWithoutSigningAlg() { + p := &providers.OAuthProfile{ + Certificate: &inboundmodel.Certificate{Type: cert.CertificateTypeJWKS, Value: "{}"}, + UserInfo: &providers.UserInfoConfig{ + ResponseType: providers.UserInfoResponseTypeNESTEDJWT, + EncryptionAlg: "RSA-OAEP-256", + EncryptionEnc: "A256GCM", + }, + } + assert.NoError(suite.T(), validateUserInfoConfig(p)) +} + // validateUserInfoConfig — error paths func (suite *InboundClientServiceTestSuite) TestValidateUserInfoConfig_UnsupportedSigningAlg() { @@ -811,11 +834,11 @@ func (suite *InboundClientServiceTestSuite) TestValidateUserInfoConfig_JWKSURISS assert.ErrorIs(suite.T(), validateUserInfoConfig(p), ErrOAuthUserInfoJWKSURINotSSRFSafe) } -func (suite *InboundClientServiceTestSuite) TestValidateUserInfoConfig_JWSMissingSigningAlg() { +func (suite *InboundClientServiceTestSuite) TestValidateUserInfoConfig_JWSWithoutSigningAlg() { p := &providers.OAuthProfile{ UserInfo: &providers.UserInfoConfig{ResponseType: providers.UserInfoResponseTypeJWS}, } - assert.ErrorIs(suite.T(), validateUserInfoConfig(p), ErrOAuthUserInfoJWSRequiresSigningAlg) + assert.NoError(suite.T(), validateUserInfoConfig(p)) } func (suite *InboundClientServiceTestSuite) TestValidateUserInfoConfig_JWEMissingEncryption() { diff --git a/backend/internal/system/i18n/core/defaults.go b/backend/internal/system/i18n/core/defaults.go index 7fa7438bda..8e276c767a 100644 --- a/backend/internal/system/i18n/core/defaults.go +++ b/backend/internal/system/i18n/core/defaults.go @@ -54,7 +54,6 @@ var defaultMessages = map[string]string{ "error.agentservice.certificate_requires_client_id_description": "certificate configuration requires an OAuth client ID", "error.agentservice.client_credentials_cannot_use_none_auth_description": "client_credentials grant type cannot use 'none' authentication method", "error.agentservice.client_credentials_cannot_use_response_types_description": "client_credentials grant type cannot be used with response types", - "error.agentservice.client_secret_cannot_have_certificate_description": "client_secret authentication methods cannot have a certificate", "error.agentservice.error_retrieving_flow_definition": "Error retrieving flow definition", "error.agentservice.error_retrieving_flow_definition_description": "An error occurred while retrieving the flow definition", "error.agentservice.idtoken_encryption_alg_requires_enc_description": "idToken encryptionEnc is required when encryptionAlg is set", @@ -113,7 +112,7 @@ var defaultMessages = map[string]string{ "error.agentservice.missing_agent_id_description": "The agent ID is required", "error.agentservice.multiple_oauth_configs": "Multiple OAuth inbound auth configs are not allowed", "error.agentservice.multiple_oauth_configs_description": "An entity may have at most one inbound auth config per protocol", - "error.agentservice.none_auth_method_cannot_have_cert_or_secret_description": "'none' authentication method cannot have a certificate or client secret", + "error.agentservice.none_auth_method_cannot_have_secret_description": "'none' authentication method cannot have a client secret", "error.agentservice.none_auth_method_requires_public_client_description": "'none' authentication method requires the client to be a public client", "error.agentservice.organization_unit_not_found": "Organization unit not found", "error.agentservice.organization_unit_not_found_description": "The specified organization unit does not exist", @@ -137,8 +136,7 @@ var defaultMessages = map[string]string{ "error.agentservice.userinfo_encryption_requires_certificate_description": "a certificate (JWKS or JWKS_URI) is required when userinfo encryption is configured", "error.agentservice.userinfo_jwe_requires_encryption_description": "encryptionAlg and encryptionEnc are required when userinfo responseType is JWE", "error.agentservice.userinfo_jwks_uri_not_ssrf_safe_description": "userinfo JWKS URI must be a publicly reachable HTTPS URL", - "error.agentservice.userinfo_jws_requires_signing_alg_description": "signingAlg is required when userinfo responseType is JWS", - "error.agentservice.userinfo_nested_jwt_requires_all_description": "signingAlg, encryptionAlg, and encryptionEnc are required when userinfo responseType is NESTED_JWT", + "error.agentservice.userinfo_nested_jwt_requires_all_description": "encryptionAlg and encryptionEnc are required when userinfo responseType is NESTED_JWT", "error.agentservice.userinfo_unsupported_encryption_alg_description": "userinfo encryption algorithm is not supported", "error.agentservice.userinfo_unsupported_encryption_enc_description": "userinfo content-encryption algorithm is not supported", "error.agentservice.userinfo_unsupported_response_type_description": "userinfo responseType is not supported", @@ -168,7 +166,6 @@ var defaultMessages = map[string]string{ "error.applicationservice.certificate_requires_client_id_description": "certificate configuration requires an OAuth client ID", "error.applicationservice.client_credentials_cannot_use_none_auth_description": "client_credentials grant type cannot use 'none' authentication method", "error.applicationservice.client_credentials_cannot_use_response_types_description": "client_credentials grant type cannot be used with response types", - "error.applicationservice.client_secret_cannot_have_certificate_description": "client_secret authentication methods cannot have a certificate", "error.applicationservice.error_retrieving_flow_definition": "Error retrieving flow definition", "error.applicationservice.error_retrieving_flow_definition_description": "An error occurred while retrieving the flow definition", "error.applicationservice.idtoken_encryption_alg_requires_enc_description": "idToken encryptionEnc is required when encryptionAlg is set", @@ -234,7 +231,7 @@ var defaultMessages = map[string]string{ "error.applicationservice.multiple_oauth_configs_description": "An application may have at most one inbound auth config per protocol", "error.applicationservice.native_flow_not_allowed_for_spa": "Native flow execution is not allowed for single-page applications", "error.applicationservice.native_flow_not_allowed_for_spa_description": "Single-page applications (public clients) must use the authorization_code grant type with PKCE for redirect-based flows. Direct (native) flow execution is not supported for browser-based single-page applications.", - "error.applicationservice.none_auth_method_cannot_have_cert_or_secret_description": "'none' authentication method cannot have a certificate or client secret", + "error.applicationservice.none_auth_method_cannot_have_secret_description": "'none' authentication method cannot have a client secret", "error.applicationservice.none_auth_method_requires_public_client_description": "'none' authentication method requires the client to be a public client", "error.applicationservice.pkce_requires_authorization_code_description": "PKCE can only be enabled when the authorization_code grant type is selected", "error.applicationservice.private_key_jwt_cannot_have_client_secret_description": "private_key_jwt authentication method cannot have a client secret", @@ -253,8 +250,7 @@ var defaultMessages = map[string]string{ "error.applicationservice.userinfo_encryption_requires_certificate_description": "a certificate (JWKS or JWKS_URI) is required when userinfo encryption is configured", "error.applicationservice.userinfo_jwe_requires_encryption_description": "encryptionAlg and encryptionEnc are required when userinfo responseType is JWE", "error.applicationservice.userinfo_jwks_uri_not_ssrf_safe_description": "userinfo JWKS URI must be a publicly reachable HTTPS URL", - "error.applicationservice.userinfo_jws_requires_signing_alg_description": "signingAlg is required when userinfo responseType is JWS", - "error.applicationservice.userinfo_nested_jwt_requires_all_description": "signingAlg, encryptionAlg, and encryptionEnc are required when userinfo responseType is NESTED_JWT", + "error.applicationservice.userinfo_nested_jwt_requires_all_description": "encryptionAlg and encryptionEnc are required when userinfo responseType is NESTED_JWT", "error.applicationservice.userinfo_unsupported_encryption_alg_description": "userinfo encryption algorithm is not supported", "error.applicationservice.userinfo_unsupported_encryption_enc_description": "userinfo content-encryption algorithm is not supported", "error.applicationservice.userinfo_unsupported_response_type_description": "userinfo responseType is not supported", diff --git a/docs/content/guides/protocols/oauth-oidc/dynamic-client-registration.mdx b/docs/content/guides/protocols/oauth-oidc/dynamic-client-registration.mdx index 3e99abdbc1..343b3ee5d4 100644 --- a/docs/content/guides/protocols/oauth-oidc/dynamic-client-registration.mdx +++ b/docs/content/guides/protocols/oauth-oidc/dynamic-client-registration.mdx @@ -85,7 +85,7 @@ A successful registration returns `201 Created` with the assigned `client_id`, ` | `jwks_uri` | No | URL of the client's JWKS endpoint. fetches public keys from this URL to verify signed requests. Required for `private_key_jwt`. Cannot be used together with `jwks`. | | `jwks` | No | Inline JSON Web Key Set. Required for `private_key_jwt` when a hosted JWKS endpoint is not available. Cannot be used together with `jwks_uri`. | | `require_pushed_authorization_requests` | No | When `true`, the client must use the `/oauth2/par` endpoint before starting an authorization flow (RFC 9126). Defaults to `false`. | -| `userinfo_signed_response_alg` | No | Algorithm used to sign the userinfo response. When set, the userinfo endpoint returns a signed JWT. Supported values: `RS256`, `RS512`, `PS256`, `ES256`, `ES384`, `ES512`, `EdDSA`. | +| `userinfo_signed_response_alg` | No | Requests a signed (JWS) userinfo response. Signing uses the deployment signing key, so set this to an algorithm advertised in `userinfo_signing_alg_values_supported` ([Server Metadata](../server-metadata)). | | `userinfo_encrypted_response_alg` | No | Key-management algorithm for userinfo response encryption. Supported values: `RSA-OAEP`, `RSA-OAEP-256`. | | `userinfo_encrypted_response_enc` | No | Content-encryption algorithm for userinfo response encryption. Required when `userinfo_encrypted_response_alg` is set. Supported values: `A128CBC-HS256`, `A256GCM`. | | `id_token_encrypted_response_alg` | No | Key-management algorithm for ID token encryption. Supported values: `RSA-OAEP`, `RSA-OAEP-256`. | diff --git a/docs/content/guides/protocols/oauth-oidc/openid-connect.mdx b/docs/content/guides/protocols/oauth-oidc/openid-connect.mdx index 1a7f516c35..1fd362fb55 100644 --- a/docs/content/guides/protocols/oauth-oidc/openid-connect.mdx +++ b/docs/content/guides/protocols/oauth-oidc/openid-connect.mdx @@ -78,7 +78,7 @@ Agents typically use OIDC features only when running an `authorization_code` gra 1. Open **Applications** or **Agents** in the Console and select your client. 2. Open the **Token** tab and ensure `openid` is among the allowed `scopes` (it is by default). -3. Optionally configure ID Token format, signing algorithm, and lifetime on the same tab (see [Token Formats](../token-formats)). +3. Optionally configure ID Token format, encryption, and lifetime on the same tab (see [Token Formats](../token-formats)). Signing uses the deployment signing key. 4. Save. ### Run an OIDC Sign-In diff --git a/docs/content/guides/protocols/oauth-oidc/token-formats.mdx b/docs/content/guides/protocols/oauth-oidc/token-formats.mdx index 39a21642c1..b2c5107f5b 100644 --- a/docs/content/guides/protocols/oauth-oidc/token-formats.mdx +++ b/docs/content/guides/protocols/oauth-oidc/token-formats.mdx @@ -2,7 +2,7 @@ title: Token Formats docType: reference sidebar_position: 4 -description: ID Token and UserInfo response formats in {{ProductName}}, JWS, JWE, and NESTED_JWT, with supported signing and encryption algorithms. +description: ID Token and UserInfo response formats in {{ProductName}}, JWS, JWE, and NESTED_JWT, how signing keys are chosen, and the supported encryption algorithms. --- import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; @@ -26,21 +26,13 @@ For the **ID Token**, JWS is the default and the most widely supported. Pick JWE For **UserInfo**, JSON is the default. Switch to JWS when integrity matters; to JWE or NESTED_JWT when confidentiality matters. -## Supported Signing Algorithms (JWS) +## Signing Algorithm (JWS) Applies to `JWS` and the signing step of `NESTED_JWT`. -| Algorithm | Family | Recommendation | -|---|---|---| -| `RS256` | RSA-PKCS#1 v1.5 | Widely supported. Strong default. | -| `RS512` | RSA-PKCS#1 v1.5 | Stronger hash. Higher CPU. | -| `PS256` | RSA-PSS | Probabilistic RSA. Preferred over `RS*` for new deployments. | -| `ES256` | ECDSA P-256 | Smaller signatures than RSA. Good default for performance-sensitive paths. | -| `ES384` | ECDSA P-384 | Larger curve. | -| `ES512` | ECDSA P-521 | Largest curve. | -| `EdDSA` | Ed25519 | Fastest signing and verification. Newest. | + signs every token (access token, ID Token, and signed UserInfo response) with the deployment signing key, and the algorithm follows that key's type, for example an RSA key signs with `RS256` and an Ed25519 key signs with `EdDSA`. -Symmetric algorithms (`HS256`, `HS384`, `HS512`) are **not** supported for token signing. +To see which algorithm a deployment signs with, read `id_token_signing_alg_values_supported` (and `userinfo_signing_alg_values_supported`) in the [Server Metadata](../server-metadata) document, or inspect the published keys at the [JWKS](../jwks) endpoint. Clients verify signatures against those keys. ## Supported Encryption Algorithms (JWE) @@ -53,6 +45,8 @@ Applies to `JWE` and the encryption step of `NESTED_JWT`. | `RSA-OAEP` | RSA-OAEP with SHA-1 | | `RSA-OAEP-256` | RSA-OAEP with SHA-256 (preferred) | +Both `alg` values are RSA-based, so the client certificate must contain an RSA encryption key (`use: enc`, or no `use`) that the selected `alg` can use. + ### Content Encryption (`enc`) | Algorithm | Description | @@ -99,7 +93,7 @@ Encrypted responses (`JWE`, `NESTED_JWT`) and `private_key_jwt` client authentic 1. Open **Applications** or **Agents** in the Console and select your client. 2. Open the **Token** tab. -3. Use the **ID Token** section to configure ID Token format, signing, and encryption. +3. Use the **ID Token** section to configure ID Token format and, for encrypted formats, encryption. Signing uses the deployment signing key. 4. Use the **UserInfo** section to configure the UserInfo response format. 5. For encrypted responses, configure a **Certificate** in the OAuth client settings. 6. Save. @@ -119,11 +113,9 @@ Content-Type: application/json "grant_types": ["authorization_code"], "response_types": ["code"], - "id_token_signed_response_alg": "PS256", "id_token_encrypted_response_alg": "RSA-OAEP-256", "id_token_encrypted_response_enc": "A256GCM", - "userinfo_signed_response_alg": "PS256", "userinfo_encrypted_response_alg": "RSA-OAEP-256", "userinfo_encrypted_response_enc": "A256GCM", diff --git a/docs/content/guides/protocols/oauth-oidc/userinfo.mdx b/docs/content/guides/protocols/oauth-oidc/userinfo.mdx index b2117306e6..0febf373e1 100644 --- a/docs/content/guides/protocols/oauth-oidc/userinfo.mdx +++ b/docs/content/guides/protocols/oauth-oidc/userinfo.mdx @@ -58,7 +58,7 @@ UserInfo is always available for tokens that carry the `openid` scope. To custom 1. Open **Applications** or **Agents** in the Console and select your client. 2. Open the **Token** tab and find the **UserInfo** section. 3. Pick the **Response Type** (`JSON` / `JWS` / `JWE` / `NESTED_JWT`). -4. For signed or encrypted responses, configure the signing algorithm, encryption algorithms, and certificate. +4. Signed responses (`JWS`, `NESTED_JWT`) use the deployment signing key. For encrypted responses (`JWE`, `NESTED_JWT`), pick the encryption algorithms and configure a certificate. 5. Choose the **User Attributes** to return. These typically mirror the ID Token attributes. 6. Save. @@ -74,11 +74,13 @@ Content-Type: application/json "redirect_uris": ["https://app.example.com/callback"], "grant_types": ["authorization_code"], "response_types": ["code"], - "userinfo_signed_response_alg": "RS256" + "userinfo_encrypted_response_alg": "RSA-OAEP-256", + "userinfo_encrypted_response_enc": "A256GCM", + "jwks_uri": "https://app.example.com/.well-known/jwks.json" } ``` -For encryption, add `userinfo_encrypted_response_alg` and `userinfo_encrypted_response_enc`. See [Token Formats](../token-formats) for the full algorithm tables. +Signed responses use the deployment signing key. Encrypted responses require `userinfo_encrypted_response_alg`, `userinfo_encrypted_response_enc`, and a client certificate (`jwks` or `jwks_uri`). See [Token Formats](../token-formats) for the full algorithm tables. diff --git a/frontend/apps/console/src/features/agents/components/edit-agent/credentials/CertificateSection.tsx b/frontend/apps/console/src/features/agents/components/edit-agent/credentials/CertificateSection.tsx index 71a8360f56..fdb5770612 100644 --- a/frontend/apps/console/src/features/agents/components/edit-agent/credentials/CertificateSection.tsx +++ b/frontend/apps/console/src/features/agents/components/edit-agent/credentials/CertificateSection.tsx @@ -17,7 +17,8 @@ */ import {SettingsCard} from '@thunderid/components'; -import {Stack, TextField, FormControl, FormLabel, Autocomplete, FormHelperText} from '@wso2/oxygen-ui'; +import {Stack, TextField, FormControl, FormLabel, Autocomplete, FormHelperText, Alert} from '@wso2/oxygen-ui'; +import {useState} from 'react'; import {useTranslation} from 'react-i18next'; import CertificateTypes from '../../../../applications/constants/certificate-types'; @@ -25,6 +26,11 @@ interface CertificateSectionProps { certificate?: {type?: string; value?: string} | null; onCertificateChange: (cert: {type: string; value: string} | null) => void; required?: boolean; + /** + * When true, an encrypted ID token response format depends on this certificate. Removing it is + * blocked (the backend would reject the config) and a warning tells the user to change the format. + */ + encryptionDependsOnCert?: boolean; disabled?: boolean; } @@ -32,9 +38,12 @@ export default function CertificateSection({ certificate = undefined, onCertificateChange, required = false, + encryptionDependsOnCert = false, disabled = false, }: CertificateSectionProps) { const {t} = useTranslation(); + // Set when the user attempts to remove a certificate that an encrypted token format still needs. + const [blockedRemoval, setBlockedRemoval] = useState(false); const certificateTypeOptions = [ {value: CertificateTypes.NONE, label: t('agents:edit.credentials.certificate.type.none', 'None')}, @@ -64,10 +73,17 @@ export default function CertificateSection({ onChange={(_, newValue) => { const newType = newValue?.value ?? CertificateTypes.NONE; if (newType === CertificateTypes.NONE) { + // Removing the certificate would invalidate an encrypted token format, so block it + // and prompt the user to change the format first instead of failing on save. + if (encryptionDependsOnCert) { + setBlockedRemoval(true); + return; + } onCertificateChange(null); } else { onCertificateChange({type: newType, value: currentCertValue}); } + setBlockedRemoval(false); }} options={certificateTypeOptions} getOptionLabel={(option) => option.label} @@ -88,6 +104,15 @@ export default function CertificateSection({ )} + {blockedRemoval && encryptionDependsOnCert && ( + + {t( + 'agents:edit.credentials.certificate.error.encryptionDependsOnCert', + 'This certificate is used to encrypt the ID token. Change the ID token format to a non-encrypted type before removing the certificate.', + )} + + )} + {currentCertType !== CertificateTypes.NONE && ( @@ -56,6 +61,7 @@ export default function EditCredentialsSettings({ certificate={oauth2Config?.certificate} onCertificateChange={(cert) => handleOAuth2ConfigChange({certificate: cert})} required={oauth2Config?.tokenEndpointAuthMethod === 'private_key_jwt'} + encryptionDependsOnCert={encryptionDependsOnCert} disabled={agent.isReadOnly} /> diff --git a/frontend/apps/console/src/features/agents/components/edit-agent/tokens/EditTokensSettings.tsx b/frontend/apps/console/src/features/agents/components/edit-agent/tokens/EditTokensSettings.tsx index e185315bdf..d088230d7e 100644 --- a/frontend/apps/console/src/features/agents/components/edit-agent/tokens/EditTokensSettings.tsx +++ b/frontend/apps/console/src/features/agents/components/edit-agent/tokens/EditTokensSettings.tsx @@ -95,6 +95,7 @@ export default function EditTokensSettings({ showUserInfoTab={false} showActorClaim actorSub={agent.id} + certificateLocation="Credentials" /> diff --git a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/CertificateSection.tsx b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/CertificateSection.tsx index 015bf8bee7..be37720e07 100644 --- a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/CertificateSection.tsx +++ b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/CertificateSection.tsx @@ -17,7 +17,8 @@ */ import {SettingsCard} from '@thunderid/components'; -import {Stack, TextField, FormControl, FormLabel, Autocomplete, FormHelperText} from '@wso2/oxygen-ui'; +import {Stack, TextField, FormControl, FormLabel, Autocomplete, FormHelperText, Alert} from '@wso2/oxygen-ui'; +import {useState} from 'react'; import {useTranslation} from 'react-i18next'; import CertificateTypes from '../../../constants/certificate-types'; @@ -40,6 +41,12 @@ interface CertificateSectionProps { * Use when tokenEndpointAuthMethod is private_key_jwt. */ required?: boolean; + /** + * When true, an encrypted ID token / UserInfo response format depends on this certificate. + * Removing the certificate is blocked (the backend would reject the config) and a warning is + * shown telling the user to change the encrypted format first. + */ + encryptionDependsOnCert?: boolean; /** * Whether inputs should be disabled (e.g. read-only resource). */ @@ -63,9 +70,12 @@ export default function CertificateSection({ certificate = undefined, onCertificateChange, required = false, + encryptionDependsOnCert = false, disabled = false, }: CertificateSectionProps) { const {t} = useTranslation(); + // Set when the user attempts to remove a certificate that an encrypted token format still needs. + const [blockedRemoval, setBlockedRemoval] = useState(false); const certificateTypeOptions = [ {value: CertificateTypes.NONE, label: t('applications:edit.advanced.certificate.type.none')}, @@ -90,10 +100,17 @@ export default function CertificateSection({ onChange={(_, newValue) => { const newType = newValue?.value ?? CertificateTypes.NONE; if (newType === CertificateTypes.NONE) { + // Removing the certificate would invalidate an encrypted token format, so block it + // and prompt the user to change the format first instead of failing on save. + if (encryptionDependsOnCert) { + setBlockedRemoval(true); + return; + } onCertificateChange(null); } else { onCertificateChange({type: newType, value: currentCertValue}); } + setBlockedRemoval(false); }} options={certificateTypeOptions} getOptionLabel={(option) => option.label} @@ -114,6 +131,15 @@ export default function CertificateSection({ )} + {blockedRemoval && encryptionDependsOnCert && ( + + {t( + 'applications:edit.advanced.certificate.error.encryptionDependsOnCert', + 'This certificate is used to encrypt the ID token or UserInfo response. Change those formats to a non-encrypted type before removing the certificate.', + )} + + )} + {currentCertType !== CertificateTypes.NONE && ( , oauth2Updates: Partial = {}) => { const currentInboundAuth: InboundAuthConfig[] = editedApp.inboundAuthConfig ?? application.inboundAuthConfig ?? []; const updatedInboundAuth = currentInboundAuth.map((auth) => @@ -190,6 +200,7 @@ export default function EditAdvancedSettings({ certificate={oauth2Config?.certificate} onCertificateChange={handleCertificateChange} required={oauth2Config?.tokenEndpointAuthMethod === 'private_key_jwt'} + encryptionDependsOnCert={encryptionDependsOnCert} disabled={application.isReadOnly} /> {showAttestation && ( diff --git a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/CertificateSection.test.tsx b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/CertificateSection.test.tsx index cf81fc4275..15bebadad2 100644 --- a/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/CertificateSection.test.tsx +++ b/frontend/apps/console/src/features/applications/components/edit-application/advanced-settings/__tests__/CertificateSection.test.tsx @@ -121,6 +121,52 @@ describe('CertificateSection', () => { expect(mockOnCertificateChange).toHaveBeenCalledWith(null); }); + it('should block removal and warn when an encrypted token format depends on the certificate', async () => { + const user = userEvent.setup(); + render( + , + ); + + const autocomplete = screen.getByRole('combobox'); + await user.click(autocomplete); + + const listbox = screen.getByRole('listbox'); + const noneOption = within(listbox).getByText('applications:edit.advanced.certificate.type.none'); + await user.click(noneOption); + + expect(mockOnCertificateChange).not.toHaveBeenCalled(); + expect( + screen.getByText('applications:edit.advanced.certificate.error.encryptionDependsOnCert'), + ).toBeInTheDocument(); + }); + + it('should allow switching to another certificate type even when encryption depends on the certificate', async () => { + const user = userEvent.setup(); + render( + , + ); + + const autocomplete = screen.getByRole('combobox'); + await user.click(autocomplete); + + const listbox = screen.getByRole('listbox'); + const jwksUriOption = within(listbox).getByText('applications:edit.advanced.certificate.type.jwksUri'); + await user.click(jwksUriOption); + + expect(mockOnCertificateChange).toHaveBeenCalledWith({ + type: CertificateTypes.JWKS_URI, + value: 'jwks', + }); + }); + it('should call onCertificateChange with certificate when JWKS is selected', async () => { const user = userEvent.setup(); render(); diff --git a/frontend/apps/console/src/features/applications/components/edit-application/token-settings/EditTokenSettings.tsx b/frontend/apps/console/src/features/applications/components/edit-application/token-settings/EditTokenSettings.tsx index 1d2194fd33..3bdadb78e8 100644 --- a/frontend/apps/console/src/features/applications/components/edit-application/token-settings/EditTokenSettings.tsx +++ b/frontend/apps/console/src/features/applications/components/edit-application/token-settings/EditTokenSettings.tsx @@ -70,6 +70,12 @@ interface EditTokenSettingsProps { * Value shown for `act.sub` in the actor claim preview (the acting agent's ID). */ actorSub?: string; + /** + * Name of the tab where the OAuth client certificate is configured, used in the + * certificate-required hint. Defaults to "Advanced Settings" (applications); agents pass + * "Credentials". + */ + certificateLocation?: string; } const createTokenConfigSchema = (t: (key: string) => string) => { @@ -139,6 +145,7 @@ export default function EditTokenSettings({ showUserInfoTab = true, showActorClaim = false, actorSub = '', + certificateLocation = 'Advanced Settings', }: EditTokenSettingsProps) { const logger = useLogger('EditTokenSettings'); const {t} = useTranslation(); @@ -146,6 +153,9 @@ export default function EditTokenSettings({ const {getServerUrl} = useConfig(); const [userTypes, setUserTypes] = useState([]); + // The algorithm tokens are signed with is determined by the deployment's signing key, not a + // per-application choice. It is surfaced read-only from the OIDC discovery document. + const [signingAlg, setSigningAlg] = useState(undefined); const {data: userTypesData, isLoading: userTypesLoading} = useGetUserTypes(); const [activeTokenType, setActiveTokenType] = useState<'access' | 'id' | 'userinfo'>('access'); @@ -320,6 +330,47 @@ export default function EditTokenSettings({ }); }, [schemaIds, http, getServerUrl, logger]); + /** + * Fetch the deployment's signing algorithm from the OIDC discovery document. Signing is done + * with the server key, so this is informational only and shown read-only in the token sections. + * The discovery document is public, so it is fetched without credentials; sending an + * Authorization header would fail its CORS preflight (only Content-Type is allowed). + */ + useEffect(() => { + if (!isOAuthMode) return undefined; + + let cancelled = false; + + const fetchSigningAlg = async () => { + try { + const response = await fetch(`${getServerUrl()}/.well-known/openid-configuration`); + if (cancelled) return; + if (!response.ok) { + logger.error('Discovery request for signing algorithm returned a non-OK status', { + status: response.status, + }); + return; + } + const data = (await response.json()) as {id_token_signing_alg_values_supported?: string[]}; + if (cancelled) return; + const algs = data?.id_token_signing_alg_values_supported; + if (Array.isArray(algs) && algs.length > 0) { + setSigningAlg(algs[0]); + } + } catch (err) { + if (!cancelled) { + logger.error('Failed to fetch signing algorithm from discovery', {error: err}); + } + } + }; + + void fetchSigningAlg(); + + return () => { + cancelled = true; + }; + }, [isOAuthMode, getServerUrl, logger]); + const userAttributes = useMemo(() => { if (userTypes.length === 0) return []; @@ -468,16 +519,23 @@ export default function EditTokenSettings({ }; const handleIdTokenConfigChange = (field: string, value: string) => { + const nextIdToken = { + ...oauth2Config?.token?.idToken, + userAttributes: oauth2Config?.token?.idToken?.userAttributes ?? [], + validityPeriod: oauth2Config?.token?.idToken?.validityPeriod ?? 3600, + [field]: value, + }; + // Switching to a non-encrypted format must drop the encryption fields, otherwise the backend + // rejects the config (encryption fields require an encrypted response type and a certificate). + if (field === 'responseType' && value !== 'JWE' && value !== 'NESTED_JWT') { + delete nextIdToken.encryptionAlg; + delete nextIdToken.encryptionEnc; + } const updatedConfig = { ...oauth2Config, token: { ...oauth2Config?.token, - idToken: { - ...oauth2Config?.token?.idToken, - userAttributes: oauth2Config?.token?.idToken?.userAttributes ?? [], - validityPeriod: oauth2Config?.token?.idToken?.validityPeriod ?? 3600, - [field]: value, - }, + idToken: nextIdToken, }, }; const updatedInboundAuth = application.inboundAuthConfig?.map((config) => { @@ -490,13 +548,24 @@ export default function EditTokenSettings({ }; const handleUserInfoConfigChange = (field: string, value: string) => { + const nextUserInfo = { + ...oauth2Config?.userInfo, + userAttributes: oauth2Config?.userInfo?.userAttributes ?? oauth2Config?.token?.idToken?.userAttributes ?? [], + [field]: value, + }; + // Switching to a non-encrypted format must drop the encryption fields, otherwise the backend + // rejects the config (encryption fields require an encrypted response type and a certificate). + if (field === 'responseType' && value !== 'JWE' && value !== 'NESTED_JWT') { + delete nextUserInfo.encryptionAlg; + delete nextUserInfo.encryptionEnc; + } + // Signing always uses the server key, so a stale per-app signing algorithm is never sent. + if (field === 'responseType') { + delete nextUserInfo.signingAlg; + } const updatedConfig = { ...oauth2Config, - userInfo: { - ...oauth2Config?.userInfo, - userAttributes: oauth2Config?.userInfo?.userAttributes ?? oauth2Config?.token?.idToken?.userAttributes ?? [], - [field]: value, - }, + userInfo: nextUserInfo, }; const updatedInboundAuth = application.inboundAuthConfig?.map((config) => { if (config.type === 'oauth2') { @@ -729,12 +798,14 @@ export default function EditTokenSettings({ showActorClaim={showActorClaim} actorSub={actorSub} disabled={application.isReadOnly} + signingAlg={signingAlg} + hasCertificate={Boolean(oauth2Config?.certificate?.type)} + certificateLocation={certificateLocation} idTokenResponseType={oauth2Config?.token?.idToken?.responseType} idTokenEncryptionAlg={oauth2Config?.token?.idToken?.encryptionAlg} idTokenEncryptionEnc={oauth2Config?.token?.idToken?.encryptionEnc} onIdTokenConfigChange={handleIdTokenConfigChange} userInfoResponseType={oauth2Config?.userInfo?.responseType} - userInfoSigningAlg={oauth2Config?.userInfo?.signingAlg} userInfoEncryptionAlg={oauth2Config?.userInfo?.encryptionAlg} userInfoEncryptionEnc={oauth2Config?.userInfo?.encryptionEnc} onUserInfoConfigChange={handleUserInfoConfigChange} diff --git a/frontend/apps/console/src/features/applications/components/edit-application/token-settings/TokenUserAttributesSection.tsx b/frontend/apps/console/src/features/applications/components/edit-application/token-settings/TokenUserAttributesSection.tsx index 4e599c3db1..13fdb2762c 100644 --- a/frontend/apps/console/src/features/applications/components/edit-application/token-settings/TokenUserAttributesSection.tsx +++ b/frontend/apps/console/src/features/applications/components/edit-application/token-settings/TokenUserAttributesSection.tsx @@ -114,6 +114,21 @@ interface TokenUserAttributesSectionProps { * Whether inputs should be disabled (e.g. read-only resource). */ disabled?: boolean; + /** + * The algorithm the deployment signs tokens with, from the OIDC discovery document. Shown + * read-only because signing always uses the server key and is not a per-application choice. + */ + signingAlg?: string; + /** + * Whether an OAuth client certificate is configured on the application. Encrypted response + * formats (JWE, NESTED_JWT) require one, so they are disabled in the format dropdowns when false. + */ + hasCertificate?: boolean; + /** + * Name of the tab where the OAuth client certificate is configured, used in the + * certificate-required hint. Applications use "Advanced Settings"; agents use "Credentials". + */ + certificateLocation?: string; /** * Current ID token response type (OAuth mode) */ @@ -134,10 +149,6 @@ interface TokenUserAttributesSectionProps { * Current UserInfo response type (OAuth mode) */ userInfoResponseType?: UserInfoResponseType; - /** - * Current UserInfo signing algorithm (OAuth mode) - */ - userInfoSigningAlg?: string; /** * Current UserInfo encryption key-management algorithm (OAuth mode) */ @@ -200,12 +211,14 @@ export default function TokenUserAttributesSection({ sharedAttributes = undefined, entityLabel = 'application', disabled = false, + signingAlg = undefined, + hasCertificate = false, + certificateLocation = 'Advanced Settings', idTokenResponseType = undefined, idTokenEncryptionAlg = undefined, idTokenEncryptionEnc = undefined, onIdTokenConfigChange = undefined, userInfoResponseType = undefined, - userInfoSigningAlg = undefined, userInfoEncryptionAlg = undefined, userInfoEncryptionEnc = undefined, onUserInfoConfigChange = undefined, @@ -217,6 +230,27 @@ export default function TokenUserAttributesSection({ const isOAuthMode = accessTokenAttributes !== undefined; + /** + * Friendly label and one-line description for a response-format value. The dropdown values are + * the raw JOSE format identifiers sent to the backend; these translations make them readable. + * Signing is always done with the server's signing key, so there is no signing-algorithm choice. + */ + const responseTypeOption = ( + section: 'id_token' | 'user_info', + value: string, + ): {label: string; description: string} => ({ + label: t(`applications:edit.token.${section}.response_type_options.${value}.label`, value), + description: t(`applications:edit.token.${section}.response_type_options.${value}.description`, ''), + }); + + const isEncryptedFormat = (value?: string): boolean => value === 'JWE' || value === 'NESTED_JWT'; + + // Placeholder shown in previews and the read-only signing line until discovery resolves. + const signingAlgDisplay = signingAlg ?? ''; + + // Encrypted formats need a client certificate, so disable them when none is configured. + const isFormatOptionDisabled = (value: string): boolean => isEncryptedFormat(value) && !hasCertificate; + /** * Build the JWT/JSON preview object for a given token type. */ @@ -261,7 +295,7 @@ export default function TokenUserAttributesSection({ const buildIdTokenHeader = (): Record | undefined => { const responseType = idTokenResponseType ?? 'JWT'; if (responseType === 'JWT') { - return {alg: 'RS256', kid: '', typ: 'JWT'}; + return {alg: signingAlgDisplay, kid: '', typ: 'JWT'}; } if (responseType === 'JWE') { return { @@ -288,7 +322,7 @@ export default function TokenUserAttributesSection({ const responseType = userInfoResponseType ?? 'JSON'; if (responseType === 'JSON') return undefined; if (responseType === 'JWS') { - return {alg: userInfoSigningAlg ?? '', kid: '', typ: 'JWT'}; + return {alg: signingAlgDisplay, kid: '', typ: 'JWT'}; } if (responseType === 'JWE') { return { @@ -495,23 +529,57 @@ export default function TokenUserAttributesSection({ renderValue={(selected) => !selected ? ( - {t('applications:edit.token.id_token.response_type_placeholder')} + {t( + 'applications:edit.token.id_token.response_type_placeholder', + 'Select response type', + )} ) : ( - selected + responseTypeOption('id_token', String(selected)).label ) } > - {TokenConstants.ID_TOKEN_RESPONSE_TYPES.map((type) => ( - - {type} - - ))} + {TokenConstants.ID_TOKEN_RESPONSE_TYPES.map((type) => { + const option = responseTypeOption('id_token', type); + return ( + + + {option.label} + {option.description && ( + + {option.description} + + )} + + + ); + })} + {/* Read-only signing algorithm (determined by the server key). Only + shown for signed formats, and only once resolved from discovery. */} + {signingAlg && (idTokenResponseType ?? 'JWT') !== 'JWE' && ( + + {t('applications:edit.token.signed_with', 'Signed with {{alg}}.', { + alg: signingAlg, + })} + + )} + + {/* Certificate requirement for encrypted formats */} + {!hasCertificate && ( + + {t( + 'applications:edit.token.encryption_requires_certificate', + 'Encrypted formats require an OAuth client certificate (JWKS or JWKS URI) configured under the {{location}} tab.', + {location: certificateLocation}, + )} + + )} + {/* Row 2: Encryption fields */} - {(idTokenResponseType === 'JWE' || idTokenResponseType === 'NESTED_JWT') && ( + {isEncryptedFormat(idTokenResponseType) && ( @@ -525,7 +593,10 @@ export default function TokenUserAttributesSection({ renderValue={(selected) => !selected ? ( - {t('applications:edit.token.id_token.encryption_alg_placeholder')} + {t( + 'applications:edit.token.id_token.encryption_alg_placeholder', + 'Select encryption algorithm', + )} ) : ( selected @@ -552,7 +623,10 @@ export default function TokenUserAttributesSection({ renderValue={(selected) => !selected ? ( - {t('applications:edit.token.id_token.encryption_enc_placeholder')} + {t( + 'applications:edit.token.id_token.encryption_enc_placeholder', + 'Select content encryption', + )} ) : ( selected @@ -662,110 +736,118 @@ export default function TokenUserAttributesSection({ renderValue={(selected) => !selected ? ( - {t('applications:edit.token.user_info.response_type_placeholder')} + {t( + 'applications:edit.token.user_info.response_type_placeholder', + 'Select response type', + )} ) : ( - selected + responseTypeOption('user_info', String(selected)).label ) } > - {TokenConstants.USER_INFO_RESPONSE_TYPES.map((type) => ( - - {type} - - ))} + {TokenConstants.USER_INFO_RESPONSE_TYPES.map((type) => { + const option = responseTypeOption('user_info', type); + return ( + + + {option.label} + {option.description && ( + + {option.description} + + )} + + + ); + })} - {/* Row 2: Algorithm fields */} - {userInfoResponseType && userInfoResponseType !== 'JSON' && ( - - {(userInfoResponseType === 'JWS' || userInfoResponseType === 'NESTED_JWT') && ( - - - {t('applications:edit.token.user_info.signing_alg', 'Signing Algorithm')} - - - + {/* Read-only signing algorithm for signed formats (determined by the + server key). Only shown once resolved from discovery. */} + {signingAlg && + (userInfoResponseType === 'JWS' || userInfoResponseType === 'NESTED_JWT') && ( + + {t('applications:edit.token.signed_with', 'Signed with {{alg}}.', { + alg: signingAlg, + })} + + )} + + {/* Certificate requirement for encrypted formats */} + {!hasCertificate && ( + + {t( + 'applications:edit.token.encryption_requires_certificate', + 'Encrypted formats require an OAuth client certificate (JWKS or JWKS URI) configured under the {{location}} tab.', + {location: certificateLocation}, )} + + )} - {(userInfoResponseType === 'JWE' || userInfoResponseType === 'NESTED_JWT') && ( - <> - - - {t('applications:edit.token.user_info.encryption_alg', 'Encryption Algorithm')} - - - - - - - {t('applications:edit.token.user_info.encryption_enc', 'Content Encryption')} - - - - - )} + {/* Row 2: Encryption fields */} + {isEncryptedFormat(userInfoResponseType) && ( + + + + {t('applications:edit.token.user_info.encryption_alg', 'Encryption Algorithm')} + + + + + + + {t('applications:edit.token.user_info.encryption_enc', 'Content Encryption')} + + + )} diff --git a/frontend/apps/console/src/features/applications/components/edit-application/token-settings/__tests__/EditTokenSettings.test.tsx b/frontend/apps/console/src/features/applications/components/edit-application/token-settings/__tests__/EditTokenSettings.test.tsx index a20d430f3c..e267bb46e6 100644 --- a/frontend/apps/console/src/features/applications/components/edit-application/token-settings/__tests__/EditTokenSettings.test.tsx +++ b/frontend/apps/console/src/features/applications/components/edit-application/token-settings/__tests__/EditTokenSettings.test.tsx @@ -16,7 +16,7 @@ * under the License. */ -import {render, screen, waitFor} from '@thunderid/test-utils'; +import {fireEvent, render, screen, waitFor} from '@thunderid/test-utils'; import {describe, it, expect, vi, beforeEach} from 'vitest'; import type {Application} from '../../../../models/application'; import type {OAuth2Config} from '../../../../models/oauth'; @@ -62,12 +62,16 @@ vi.mock('../TokenUserAttributesSection', () => ({ idTokenAttributes, isUserInfoCustomAttributes, onToggleUserInfo, + onIdTokenConfigChange, + onUserInfoConfigChange, userAttributes, }: { accessTokenAttributes?: string[]; idTokenAttributes?: string[]; isUserInfoCustomAttributes?: boolean; onToggleUserInfo?: (checked: boolean) => void; + onIdTokenConfigChange?: (field: string, value: string) => void; + onUserInfoConfigChange?: (field: string, value: string) => void; userAttributes?: string[]; }) => { const isOAuthMode = accessTokenAttributes !== undefined || idTokenAttributes !== undefined; @@ -77,6 +81,12 @@ vi.mock('../TokenUserAttributesSection', () => ({
Access Token Attributes
ID Token Attributes
{userAttributes &&
{userAttributes.join(',')}
} + +