diff --git a/backend/internal/application/service_test.go b/backend/internal/application/service_test.go index e36ab68e4f..03b23e66c0 100644 --- a/backend/internal/application/service_test.go +++ b/backend/internal/application/service_test.go @@ -3327,7 +3327,7 @@ func TestAcrValidationTestSuite(t *testing.T) { func (s *AcrValidationTestSuite) initRegistry(mapping engineconfig.AuthClassConfig) { config.ResetServerRuntime() s.Require().NoError(config.InitializeServerRuntime("", &config.Config{ - OAuth: engineconfig.OAuthConfig{ + OAuth: config.OAuthConfig{ AuthClass: mapping, }, })) diff --git a/backend/internal/application/tools.go b/backend/internal/application/tools.go index 597bea3212..d9da2c495c 100644 --- a/backend/internal/application/tools.go +++ b/backend/internal/application/tools.go @@ -14,7 +14,6 @@ import ( "github.com/thunder-id/thunderid/internal/application/model" oauthconfig "github.com/thunder-id/thunderid/internal/oauth/config" - oauth2const "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" "github.com/thunder-id/thunderid/internal/system/mcp/tool" ) @@ -332,11 +331,9 @@ func (t *applicationTools) getApplicationTemplates( func getCommonSchemaModifiers() []func(*jsonschema.Schema) { oauthCfg := oauthconfig.FromServerRuntime() return []func(*jsonschema.Schema){ - tool.WithEnum("inbound_auth_config.config", "grant_types", oauth2const.GetSupportedGrantTypes(oauthCfg)), - tool.WithEnum("inbound_auth_config.config", "response_types", - oauth2const.GetSupportedResponseTypes(oauthCfg)), - tool.WithEnum("inbound_auth_config.config", "token_endpoint_auth_method", - oauth2const.GetSupportedTokenEndpointAuthMethods(oauthCfg)), + tool.WithEnum("inbound_auth_config.config", "grant_types", oauthCfg.OAuth.AllowedGrantTypes), + tool.WithEnum("inbound_auth_config.config", "response_types", oauthCfg.OAuth.AllowedResponseTypes), + tool.WithEnum("inbound_auth_config.config", "token_endpoint_auth_method", oauthCfg.OAuth.AllowedAuthMethods), tool.WithEnum("inbound_auth_config", "type", []string{string(providers.OAuthInboundAuthType)}), } } diff --git a/backend/internal/oauth/config/config.go b/backend/internal/oauth/config/config.go index 4db5307830..41bee45735 100644 --- a/backend/internal/oauth/config/config.go +++ b/backend/internal/oauth/config/config.go @@ -5,8 +5,10 @@ package oauthconfig import ( + "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" "github.com/thunder-id/thunderid/internal/system/config" engineconfig "github.com/thunder-id/thunderid/pkg/thunderidengine/config" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" ) // Config holds configuration values required by OAuth services. @@ -19,15 +21,91 @@ type Config struct { GateClient engineconfig.GateClientConfig } -// FromServerRuntime builds OAuth configuration from the global server runtime. +// FromServerRuntime builds OAuth configuration from the global server runtime, seeding the +// additional OIDC fields with the server defaults. func FromServerRuntime() Config { runtime := config.GetServerRuntime() + oauth := runtime.Config.OAuth.ToEngineConfig() + applyOIDCDefaults(&oauth) + return Config{ DeploymentID: runtime.Config.Server.Identifier, RuntimeTransientDBType: runtime.Config.Database.RuntimeTransient.Type, BaseURL: config.GetServerURL(&runtime.Config.Server), JWT: runtime.Config.JWT, - OAuth: runtime.Config.OAuth, + OAuth: oauth, GateClient: runtime.Config.GateClient, } } + +// applyOIDCDefaults seeds the default values for the additional OIDC fields on the given OAuthConfig. +func applyOIDCDefaults(oauth *engineconfig.OAuthConfig) { + mapping := make(map[string][]string, len(constants.StandardOIDCScopes)) + scopes := make([]string, 0, len(constants.StandardOIDCScopes)) + + claimSet := make(map[string]struct{}) + for _, c := range constants.GetStandardClaims() { + claimSet[c] = struct{}{} + } + for scope, def := range constants.StandardOIDCScopes { + claims := make([]string, len(def.Claims)) + copy(claims, def.Claims) + mapping[scope] = claims + scopes = append(scopes, scope) + for _, c := range def.Claims { + claimSet[c] = struct{}{} + } + } + + claims := make([]string, 0, len(claimSet)) + for c := range claimSet { + claims = append(claims, c) + } + + oauth.DefaultScopeClaimsMapping = mapping + oauth.AllowedScopes = scopes + oauth.AllowedClaims = claims + + oauth.AllowedSubjectTypes = defaultAllowedSubjectTypes() + if len(oauth.AllowedGrantTypes) == 0 { + oauth.AllowedGrantTypes = defaultAllowedGrantTypes() + } + if len(oauth.AllowedResponseTypes) == 0 { + oauth.AllowedResponseTypes = defaultAllowedResponseTypes() + } + if len(oauth.AllowedAuthMethods) == 0 { + oauth.AllowedAuthMethods = defaultAllowedAuthMethods() + } +} + +// defaultAllowedSubjectTypes returns the default allowed OIDC subject types for the server. +func defaultAllowedSubjectTypes() []string { + return []string{constants.SubjectTypePublic} +} + +// defaultAllowedGrantTypes returns the default allowed grant types for the server. +func defaultAllowedGrantTypes() []string { + result := make([]string, len(providers.SupportedGrantTypes)) + for i, v := range providers.SupportedGrantTypes { + result[i] = string(v) + } + return result +} + +// defaultAllowedResponseTypes returns the default allowed response types for the server. +func defaultAllowedResponseTypes() []string { + result := make([]string, len(providers.SupportedResponseTypes)) + for i, v := range providers.SupportedResponseTypes { + result[i] = string(v) + } + return result +} + +// defaultAllowedAuthMethods returns the default allowed token endpoint authentication methods for the server. +func defaultAllowedAuthMethods() []string { + result := make([]string, len(providers.SupportedTokenEndpointAuthMethods)) + for i, v := range providers.SupportedTokenEndpointAuthMethods { + result[i] = string(v) + } + return result +} diff --git a/backend/internal/oauth/config/config_test.go b/backend/internal/oauth/config/config_test.go index 7e8d575ea4..bd36a248ab 100644 --- a/backend/internal/oauth/config/config_test.go +++ b/backend/internal/oauth/config/config_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/suite" + "github.com/thunder-id/thunderid/internal/oauth/oauth2/constants" "github.com/thunder-id/thunderid/internal/system/config" ) @@ -44,7 +45,7 @@ func (s *OAuthConfigTestSuite) TestFromServerRuntime() { Issuer: "https://thunder.io", ValidityPeriod: 3600, }, - OAuth: engineconfig.OAuthConfig{ + OAuth: config.OAuthConfig{ PAR: engineconfig.PARConfig{ExpiresIn: 600}, }, GateClient: engineconfig.GateClientConfig{ @@ -64,4 +65,61 @@ func (s *OAuthConfigTestSuite) TestFromServerRuntime() { s.Equal("https://thunder.io", result.JWT.Issuer) s.Equal(int64(600), result.OAuth.PAR.ExpiresIn) s.Equal("localhost", result.GateClient.Hostname) + + s.NotEmpty(result.OAuth.DefaultScopeClaimsMapping, "default mapping should be seeded") + s.Len(result.OAuth.DefaultScopeClaimsMapping, len(constants.StandardOIDCScopes)) + for scope, def := range constants.StandardOIDCScopes { + s.ElementsMatch(def.Claims, result.OAuth.DefaultScopeClaimsMapping[scope], "scope %q claims mismatch", scope) + } + standardScopeNames := make([]string, 0, len(constants.StandardOIDCScopes)) + for scope := range constants.StandardOIDCScopes { + standardScopeNames = append(standardScopeNames, scope) + } + s.ElementsMatch(standardScopeNames, result.OAuth.AllowedScopes) + s.ElementsMatch([]string{constants.SubjectTypePublic}, result.OAuth.AllowedSubjectTypes) + for _, c := range constants.GetStandardClaims() { + s.Contains(result.OAuth.AllowedClaims, c, "allowed_claims must include standard JWT claim %q", c) + } + for _, def := range constants.StandardOIDCScopes { + for _, c := range def.Claims { + s.Contains(result.OAuth.AllowedClaims, c, "allowed_claims must include mapped claim %q", c) + } + } +} + +func (s *OAuthConfigTestSuite) TestApplyOIDCDefaults_Idempotent() { + var oauth engineconfig.OAuthConfig + applyOIDCDefaults(&oauth) + first := oauth + applyOIDCDefaults(&oauth) + s.ElementsMatch(first.AllowedScopes, oauth.AllowedScopes) + s.ElementsMatch(first.AllowedClaims, oauth.AllowedClaims) + s.ElementsMatch(first.AllowedSubjectTypes, oauth.AllowedSubjectTypes) + s.Equal(first.DefaultScopeClaimsMapping, oauth.DefaultScopeClaimsMapping) + s.ElementsMatch(first.AllowedGrantTypes, oauth.AllowedGrantTypes) + s.ElementsMatch(first.AllowedResponseTypes, oauth.AllowedResponseTypes) + s.ElementsMatch(first.AllowedAuthMethods, oauth.AllowedAuthMethods) +} + +func (s *OAuthConfigTestSuite) TestApplyOIDCDefaults_SeedsAllowedListsWhenEmpty() { + var oauth engineconfig.OAuthConfig + applyOIDCDefaults(&oauth) + s.NotEmpty(oauth.AllowedGrantTypes) + s.NotEmpty(oauth.AllowedResponseTypes) + s.NotEmpty(oauth.AllowedAuthMethods) + s.Contains(oauth.AllowedGrantTypes, "authorization_code") + s.Contains(oauth.AllowedResponseTypes, "code") + s.Contains(oauth.AllowedAuthMethods, "client_secret_basic") +} + +func (s *OAuthConfigTestSuite) TestApplyOIDCDefaults_PreservesConfiguredAllowedLists() { + oauth := engineconfig.OAuthConfig{ + AllowedGrantTypes: []string{"client_credentials"}, + AllowedResponseTypes: []string{"code"}, + AllowedAuthMethods: []string{"client_secret_post"}, + } + applyOIDCDefaults(&oauth) + s.Equal([]string{"client_credentials"}, oauth.AllowedGrantTypes) + s.Equal([]string{"code"}, oauth.AllowedResponseTypes) + s.Equal([]string{"client_secret_post"}, oauth.AllowedAuthMethods) } diff --git a/backend/internal/oauth/oauth2/authz/handler_test.go b/backend/internal/oauth/oauth2/authz/handler_test.go index 6b2bb22d60..fbb434abc1 100644 --- a/backend/internal/oauth/oauth2/authz/handler_test.go +++ b/backend/internal/oauth/oauth2/authz/handler_test.go @@ -62,7 +62,7 @@ func (suite *AuthorizeHandlerTestSuite) SetupTest() { JWT: engineconfig.JWTConfig{ Issuer: "https://localhost:8090", }, - OAuth: engineconfig.OAuthConfig{ + OAuth: config.OAuthConfig{ AuthorizationCode: engineconfig.AuthorizationCodeConfig{ ValidityPeriod: 600, }, diff --git a/backend/internal/oauth/oauth2/authz/service_test.go b/backend/internal/oauth/oauth2/authz/service_test.go index 8eb87d3ce3..8e2ae07678 100644 --- a/backend/internal/oauth/oauth2/authz/service_test.go +++ b/backend/internal/oauth/oauth2/authz/service_test.go @@ -45,7 +45,7 @@ func authorizeServiceCfgFromRuntime() oauthconfig.Config { runtime := config.GetServerRuntime() return oauthconfig.Config{ JWT: runtime.Config.JWT, - OAuth: runtime.Config.OAuth, + OAuth: runtime.Config.OAuth.ToEngineConfig(), GateClient: runtime.Config.GateClient, } } @@ -120,7 +120,7 @@ func (suite *AuthorizeServiceTestSuite) BeforeTest(suiteName, testName string) { JWT: engineconfig.JWTConfig{ Issuer: "https://localhost:8090", }, - OAuth: engineconfig.OAuthConfig{ + OAuth: config.OAuthConfig{ AuthorizationCode: engineconfig.AuthorizationCodeConfig{ValidityPeriod: 600}, }, } @@ -1822,7 +1822,7 @@ func (suite *AuthorizeServiceTestSuite) TestResolveAttrCacheTTL_RefreshAllowed_U config.ResetServerRuntime() _ = config.InitializeServerRuntime("test", &config.Config{ JWT: engineconfig.JWTConfig{ValidityPeriod: 900}, - OAuth: engineconfig.OAuthConfig{ + OAuth: config.OAuthConfig{ RefreshToken: engineconfig.RefreshTokenConfig{ValidityPeriod: 7200}, AuthorizationCode: engineconfig.AuthorizationCodeConfig{ValidityPeriod: 600}, }, @@ -1848,7 +1848,7 @@ func (suite *AuthorizeServiceTestSuite) TestResolveAttrCacheTTL_RefreshTokenAllo config.ResetServerRuntime() _ = config.InitializeServerRuntime("test", &config.Config{ JWT: engineconfig.JWTConfig{ValidityPeriod: 900}, - OAuth: engineconfig.OAuthConfig{ + OAuth: config.OAuthConfig{ RefreshToken: engineconfig.RefreshTokenConfig{ValidityPeriod: 1800}, AuthorizationCode: engineconfig.AuthorizationCodeConfig{ValidityPeriod: 600}, }, @@ -1874,7 +1874,7 @@ func (suite *AuthorizeServiceTestSuite) TestResolveUserAttributesCacheTTL_Refres config.ResetServerRuntime() _ = config.InitializeServerRuntime("test", &config.Config{ JWT: engineconfig.JWTConfig{ValidityPeriod: 900}, - OAuth: engineconfig.OAuthConfig{ + OAuth: config.OAuthConfig{ // RefreshToken.ValidityPeriod is 0 → ResolveTokenConfig falls back to global JWT validity. RefreshToken: engineconfig.RefreshTokenConfig{ValidityPeriod: 0}, AuthorizationCode: engineconfig.AuthorizationCodeConfig{ValidityPeriod: 600}, @@ -1910,7 +1910,7 @@ func (suite *AuthorizeServiceTestSuite) TestResolveAttrCacheTTL_NoRefreshToken_Z config.ResetServerRuntime() _ = config.InitializeServerRuntime("test", &config.Config{ JWT: engineconfig.JWTConfig{ValidityPeriod: 900}, - OAuth: engineconfig.OAuthConfig{ + OAuth: config.OAuthConfig{ AuthorizationCode: engineconfig.AuthorizationCodeConfig{ValidityPeriod: 600}, }, }) @@ -1933,7 +1933,7 @@ func (suite *AuthorizeServiceTestSuite) TestResolveAttrCacheTTL_NoRefreshToken_N config.ResetServerRuntime() _ = config.InitializeServerRuntime("test", &config.Config{ JWT: engineconfig.JWTConfig{ValidityPeriod: 900}, - OAuth: engineconfig.OAuthConfig{ + OAuth: config.OAuthConfig{ AuthorizationCode: engineconfig.AuthorizationCodeConfig{ValidityPeriod: 600}, }, }) @@ -1951,7 +1951,7 @@ func (suite *AuthorizeServiceTestSuite) TestResolveAttrCacheTTL_NoRefreshToken_N config.ResetServerRuntime() _ = config.InitializeServerRuntime("test", &config.Config{ JWT: engineconfig.JWTConfig{ValidityPeriod: 900}, - OAuth: engineconfig.OAuthConfig{ + OAuth: config.OAuthConfig{ AuthorizationCode: engineconfig.AuthorizationCodeConfig{ValidityPeriod: 600}, }, }) diff --git a/backend/internal/oauth/oauth2/authz/validator_test.go b/backend/internal/oauth/oauth2/authz/validator_test.go index 495f75d307..81e88536ab 100644 --- a/backend/internal/oauth/oauth2/authz/validator_test.go +++ b/backend/internal/oauth/oauth2/authz/validator_test.go @@ -8,7 +8,6 @@ import ( "net/url" "testing" - engineconfig "github.com/thunder-id/thunderid/pkg/thunderidengine/config" "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" "github.com/stretchr/testify/assert" @@ -31,7 +30,7 @@ func TestAuthorizationValidatorTestSuite(t *testing.T) { func (suite *AuthorizationValidatorTestSuite) SetupTest() { sysconfig.ResetServerRuntime() err := sysconfig.InitializeServerRuntime("/tmp/test", &sysconfig.Config{ - OAuth: engineconfig.OAuthConfig{AllowWildcardRedirectURI: true}, + OAuth: sysconfig.OAuthConfig{AllowWildcardRedirectURI: true}, }) suite.Require().NoError(err) diff --git a/backend/internal/oauth/oauth2/constants/constants.go b/backend/internal/oauth/oauth2/constants/constants.go index 5956e65027..59ef8d3c83 100644 --- a/backend/internal/oauth/oauth2/constants/constants.go +++ b/backend/internal/oauth/oauth2/constants/constants.go @@ -7,9 +7,7 @@ package constants import ( "errors" - oauthconfig "github.com/thunder-id/thunderid/internal/oauth/config" "github.com/thunder-id/thunderid/internal/oauth/oauth2/model" - "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" ) // OAuth2 request parameters. @@ -341,50 +339,6 @@ const ( SupportedAuthorizationGrantProfileIDJAG = "urn:ietf:params:oauth:grant-profile:id-jag" ) -// GetSupportedResponseTypes returns all supported OAuth2 response types. -func GetSupportedResponseTypes(oauthConfig oauthconfig.Config) []string { - allowedResponseTypes := oauthConfig.OAuth.AllowedResponseTypes - if len(allowedResponseTypes) > 0 { - return allowedResponseTypes - } - result := make([]string, len(providers.SupportedResponseTypes)) - for i, rt := range providers.SupportedResponseTypes { - result[i] = string(rt) - } - return result -} - -// GetSupportedGrantTypes returns all supported OAuth2 grant types. -func GetSupportedGrantTypes(oauthConfig oauthconfig.Config) []string { - allowedGrantTypes := oauthConfig.OAuth.AllowedGrantTypes - if len(allowedGrantTypes) > 0 { - return allowedGrantTypes - } - result := make([]string, len(providers.SupportedGrantTypes)) - for i, gt := range providers.SupportedGrantTypes { - result[i] = string(gt) - } - return result -} - -// GetSupportedTokenEndpointAuthMethods returns all supported token endpoint authentication methods. -func GetSupportedTokenEndpointAuthMethods(oauthConfig oauthconfig.Config) []string { - allowedAuthMethods := oauthConfig.OAuth.AllowedAuthMethods - if len(allowedAuthMethods) > 0 { - return allowedAuthMethods - } - result := make([]string, len(providers.SupportedTokenEndpointAuthMethods)) - for i, tam := range providers.SupportedTokenEndpointAuthMethods { - result[i] = string(tam) - } - return result -} - -// GetSupportedSubjectTypes returns all supported OIDC subject types. -func GetSupportedSubjectTypes() []string { - return []string{SubjectTypePublic} -} - // GetStandardClaims returns all standard JWT claims that are always included in tokens. func GetStandardClaims() []string { return []string{ diff --git a/backend/internal/oauth/oauth2/dcr/handler_test.go b/backend/internal/oauth/oauth2/dcr/handler_test.go index 7fa9898b98..2b25ff8c36 100644 --- a/backend/internal/oauth/oauth2/dcr/handler_test.go +++ b/backend/internal/oauth/oauth2/dcr/handler_test.go @@ -39,7 +39,7 @@ func TestDCRHandlerTestSuite(t *testing.T) { func (s *DCRHandlerTestSuite) SetupTest() { s.mockService = NewDCRServiceInterfaceMock(s.T()) _ = config.InitializeServerRuntime("test", &config.Config{ - OAuth: engineconfig.OAuthConfig{DCR: engineconfig.DCRConfig{Insecure: true}}, + OAuth: config.OAuthConfig{DCR: engineconfig.DCRConfig{Insecure: true}}, }) cfg := testhelpers.OAuthConfig() cfg.OAuth.DCR.Insecure = true diff --git a/backend/internal/oauth/oauth2/discovery/discovery_test.go b/backend/internal/oauth/oauth2/discovery/discovery_test.go index 17f3896e8e..7267579c73 100644 --- a/backend/internal/oauth/oauth2/discovery/discovery_test.go +++ b/backend/internal/oauth/oauth2/discovery/discovery_test.go @@ -42,12 +42,12 @@ type DiscoveryTestSuite struct { oauthCfg oauthconfig.Config } -func oauthCfgFromServerConfig(cfg *config.Config) oauthconfig.Config { - return oauthconfig.Config{ - BaseURL: config.GetServerURL(&cfg.Server), - JWT: cfg.JWT, - OAuth: cfg.OAuth, - } +// oauthCfgFromServerConfig initializes the global server runtime with the supplied config +// and returns the derived oauth config (with OIDC defaults seeded via FromServerRuntime). +func (suite *DiscoveryTestSuite) oauthCfgFromServerConfig(cfg *config.Config) oauthconfig.Config { + config.ResetServerRuntime() + suite.Require().NoError(config.InitializeServerRuntime("/tmp/test-discovery", cfg)) + return oauthconfig.FromServerRuntime() } func TestDiscoverySuite(t *testing.T) { @@ -65,7 +65,7 @@ func (suite *DiscoveryTestSuite) SetupTest() { Issuer: "https://auth.example.com", ValidityPeriod: 3600, }, - OAuth: engineconfig.OAuthConfig{ + OAuth: config.OAuthConfig{ DPoP: engineconfig.DPoPConfig{ Required: false, IatWindow: 60, @@ -87,7 +87,7 @@ func (suite *DiscoveryTestSuite) SetupTest() { } _ = config.InitializeServerRuntime("test", testConfig) - suite.oauthCfg = oauthCfgFromServerConfig(testConfig) + suite.oauthCfg = suite.oauthCfgFromServerConfig(testConfig) suite.cryptoMock = cryptomock.NewRuntimeCryptoProviderMock(suite.T()) suite.cryptoMock.EXPECT().GetSupportedSigningAlgorithms(). Return(testConfig.OAuth.DPoP.AllowedAlgs).Maybe() @@ -226,7 +226,7 @@ func (suite *DiscoveryTestSuite) TestDPoPSigningAlgValuesOmittedWhenUnconfigured cryptoMock := cryptomock.NewRuntimeCryptoProviderMock(suite.T()) cryptoMock.EXPECT().GetSupportedSigningAlgorithms().Return(nil) - svc := newDiscoveryService(cryptoMock, newTestJWEService(cryptoMock), oauthCfgFromServerConfig(testConfig)) + svc := newDiscoveryService(cryptoMock, newTestJWEService(cryptoMock), suite.oauthCfgFromServerConfig(testConfig)) oauth2Meta := svc.GetOAuth2AuthorizationServerMetadata(context.Background()) assert.Nil(suite.T(), oauth2Meta.DPoPSigningAlgValuesSupported) @@ -245,7 +245,7 @@ func (suite *DiscoveryTestSuite) TestDCRRevocationLogoutEndpointsOmittedWhenDisa defer config.ResetServerRuntime() svc := newDiscoveryService( - suite.cryptoMock, newTestJWEService(suite.cryptoMock), oauthCfgFromServerConfig(testConfig)) + suite.cryptoMock, newTestJWEService(suite.cryptoMock), suite.oauthCfgFromServerConfig(testConfig)) oauth2Meta := svc.GetOAuth2AuthorizationServerMetadata(context.Background()) assert.Empty(suite.T(), oauth2Meta.RegistrationEndpoint) assert.Empty(suite.T(), oauth2Meta.RevocationEndpoint) @@ -305,80 +305,6 @@ func TestTokenEndpointAuthMethodIsValid(t *testing.T) { assert.False(t, providers.TokenEndpointAuthMethod("").IsValid()) } -// TestGetSupportedResponseTypes tests the GetSupportedResponseTypes function -// This is a standalone test for constants - doesn't require discovery service setup -func TestGetSupportedResponseTypes(t *testing.T) { - supported := constants.GetSupportedResponseTypes(oauthconfig.Config{}) - - assert.NotNil(t, supported) - assert.Equal(t, 1, len(supported)) - assert.Contains(t, supported, "code") - assert.Equal(t, []string{"code"}, supported) -} - -func TestGetSupportedResponseTypes_ConfiguredAllowList(t *testing.T) { - cfg := oauthconfig.Config{ - OAuth: engineconfig.OAuthConfig{AllowedResponseTypes: []string{"code"}}, - } - assert.Equal(t, []string{"code"}, constants.GetSupportedResponseTypes(cfg)) -} - -// TestGetSupportedGrantTypes tests the GetSupportedGrantTypes function -// This is a standalone test for constants - doesn't require discovery service setup -func TestGetSupportedGrantTypes(t *testing.T) { - supported := constants.GetSupportedGrantTypes(oauthconfig.Config{}) - - assert.NotNil(t, supported) - assert.Equal(t, 6, len(supported)) - assert.Contains(t, supported, "authorization_code") - assert.Contains(t, supported, "client_credentials") - assert.Contains(t, supported, "refresh_token") - assert.Contains(t, supported, "urn:ietf:params:oauth:grant-type:token-exchange") - assert.Contains(t, supported, "urn:openid:params:grant-type:ciba") - assert.Contains(t, supported, "urn:ietf:params:oauth:grant-type:jwt-bearer") - assert.NotContains(t, supported, "password") - assert.NotContains(t, supported, "implicit") -} - -func TestGetSupportedGrantTypes_ConfiguredAllowList(t *testing.T) { - cfg := oauthconfig.Config{ - OAuth: engineconfig.OAuthConfig{AllowedGrantTypes: []string{"client_credentials", "refresh_token"}}, - } - assert.Equal(t, []string{"client_credentials", "refresh_token"}, constants.GetSupportedGrantTypes(cfg)) -} - -// TestGetSupportedTokenEndpointAuthMethods tests the GetSupportedTokenEndpointAuthMethods function -// This is a standalone test for constants - doesn't require discovery service setup -func TestGetSupportedTokenEndpointAuthMethods(t *testing.T) { - supported := constants.GetSupportedTokenEndpointAuthMethods(oauthconfig.Config{}) - - assert.NotNil(t, supported) - assert.Equal(t, 4, len(supported)) - assert.Contains(t, supported, "client_secret_basic") - assert.Contains(t, supported, "client_secret_post") - assert.Contains(t, supported, "none") - assert.Contains(t, supported, "private_key_jwt") - assert.NotContains(t, supported, "client_secret_jwt") -} - -func TestGetSupportedTokenEndpointAuthMethods_ConfiguredAllowList(t *testing.T) { - cfg := oauthconfig.Config{ - OAuth: engineconfig.OAuthConfig{AllowedAuthMethods: []string{"client_secret_basic"}}, - } - assert.Equal(t, []string{"client_secret_basic"}, constants.GetSupportedTokenEndpointAuthMethods(cfg)) -} - -// TestGetSupportedSubjectTypes tests the GetSupportedSubjectTypes function -// This is a standalone test for constants - doesn't require discovery service setup -func TestGetSupportedSubjectTypes(t *testing.T) { - supported := constants.GetSupportedSubjectTypes() - - assert.NotNil(t, supported) - assert.Equal(t, 1, len(supported)) - assert.Contains(t, supported, constants.SubjectTypePublic) - assert.Equal(t, []string{"public"}, supported) -} - // TestGetStandardClaims tests the GetStandardClaims function // This is a standalone test for constants - doesn't require discovery service setup func TestGetStandardClaims(t *testing.T) { @@ -442,7 +368,7 @@ func (suite *DiscoveryTestSuite) TestGetBaseURL_WithPublicHostname() { _ = config.InitializeServerRuntime("test", testConfig) service := newDiscoveryService( - suite.cryptoMock, newTestJWEService(suite.cryptoMock), oauthCfgFromServerConfig(testConfig)) + suite.cryptoMock, newTestJWEService(suite.cryptoMock), suite.oauthCfgFromServerConfig(testConfig)) metadata := service.GetOAuth2AuthorizationServerMetadata(context.Background()) assert.Contains(suite.T(), metadata.AuthorizationEndpoint, "public.thunder.io") config.ResetServerRuntime() @@ -463,7 +389,7 @@ func (suite *DiscoveryTestSuite) TestGetBaseURL_WithHTTPOnly() { _ = config.InitializeServerRuntime("test", testConfig) service := newDiscoveryService( - suite.cryptoMock, newTestJWEService(suite.cryptoMock), oauthCfgFromServerConfig(testConfig)) + suite.cryptoMock, newTestJWEService(suite.cryptoMock), suite.oauthCfgFromServerConfig(testConfig)) metadata := service.GetOAuth2AuthorizationServerMetadata(context.Background()) assert.Contains(suite.T(), metadata.AuthorizationEndpoint, "http://") config.ResetServerRuntime() @@ -525,7 +451,7 @@ func (suite *DiscoveryTestSuite) TestAuthorizationGrantProfilesSupported_JWTBear testConfig := &config.Config{ Server: engineconfig.ServerConfig{Hostname: "localhost", Port: 8080}, JWT: engineconfig.JWTConfig{Issuer: "https://auth.example.com"}, - OAuth: engineconfig.OAuthConfig{ + OAuth: config.OAuthConfig{ AllowedGrantTypes: []string{"client_credentials", "refresh_token"}, }, } @@ -536,7 +462,7 @@ func (suite *DiscoveryTestSuite) TestAuthorizationGrantProfilesSupported_JWTBear cryptoMock.EXPECT().GetPublicKeys(mock.Anything, providers.PublicKeyFilter{}). Return([]providers.PublicKeyInfo{{KeyID: "k1", Algorithm: string(cryptolib.AlgorithmRS256)}}, nil) - svc := newDiscoveryService(cryptoMock, newTestJWEService(cryptoMock), oauthCfgFromServerConfig(testConfig)) + svc := newDiscoveryService(cryptoMock, newTestJWEService(cryptoMock), suite.oauthCfgFromServerConfig(testConfig)) meta, err := svc.GetOIDCMetadata(context.Background()) assert.NoError(suite.T(), err) @@ -593,12 +519,12 @@ func (suite *DiscoveryTestSuite) TestAuthorizationGrantProfilesSupported_NotOnOA testConfig := &config.Config{ Server: engineconfig.ServerConfig{Hostname: "localhost", Port: 8080}, JWT: engineconfig.JWTConfig{Issuer: "https://auth.example.com"}, - OAuth: engineconfig.OAuthConfig{ + OAuth: config.OAuthConfig{ AllowedGrantTypes: []string{"client_credentials", "refresh_token"}, }, } svc := newDiscoveryService( - suite.cryptoMock, newTestJWEService(suite.cryptoMock), oauthCfgFromServerConfig(testConfig)) + suite.cryptoMock, newTestJWEService(suite.cryptoMock), suite.oauthCfgFromServerConfig(testConfig)) handler := newDiscoveryHandler(svc) req := httptest.NewRequest("GET", "/.well-known/oauth-authorization-server", nil) @@ -609,4 +535,27 @@ func (suite *DiscoveryTestSuite) TestAuthorizationGrantProfilesSupported_NotOnOA assert.NotContains(suite.T(), w.Body.String(), "authorization_grant_profiles_supported") } +func (suite *DiscoveryTestSuite) TestOIDCDiscovery_EngineOverridesLandInWellKnown() { + suite.cryptoMock.EXPECT().GetPublicKeys(mock.Anything, providers.PublicKeyFilter{}). + Return([]providers.PublicKeyInfo{{KeyID: "k1", Algorithm: string(cryptolib.AlgorithmRS256)}}, nil) + + cfg := suite.oauthCfg + cfg.OAuth.AllowedScopes = []string{"openid", "profile", "test"} + cfg.OAuth.AllowedClaims = []string{"sub", "iss", "aud", "exp", "iat", "auth_time", "test_name"} + cfg.OAuth.DefaultScopeClaimsMapping = map[string][]string{ + "openid": {"sub"}, + "profile": {"name"}, + "test": {"test_name"}, + } + cfg.OAuth.AllowedSubjectTypes = []string{"public", "pairwise"} + + svc := newDiscoveryService(suite.cryptoMock, newTestJWEService(suite.cryptoMock), cfg) + meta, err := svc.GetOIDCMetadata(context.Background()) + assert.NoError(suite.T(), err) + + assert.ElementsMatch(suite.T(), cfg.OAuth.AllowedScopes, meta.ScopesSupported) + assert.ElementsMatch(suite.T(), cfg.OAuth.AllowedClaims, meta.ClaimsSupported) + assert.ElementsMatch(suite.T(), cfg.OAuth.AllowedSubjectTypes, meta.SubjectTypesSupported) +} + func boolPtr(b bool) *bool { return &b } diff --git a/backend/internal/oauth/oauth2/discovery/service.go b/backend/internal/oauth/oauth2/discovery/service.go index ca52c87095..d9e046ce19 100644 --- a/backend/internal/oauth/oauth2/discovery/service.go +++ b/backend/internal/oauth/oauth2/discovery/service.go @@ -53,9 +53,9 @@ func (ds *discoveryService) GetOAuth2AuthorizationServerMetadata( IntrospectionEndpoint: ds.getIntrospectionEndpoint(), PushedAuthorizationRequestEndpoint: ds.getPAREndpoint(), RequirePushedAuthorizationRequests: ds.isGlobalPARRequired(), - ResponseTypesSupported: ds.getSupportedResponseTypes(), - GrantTypesSupported: ds.getSupportedGrantTypes(), - TokenEndpointAuthMethodsSupported: ds.getSupportedTokenEndpointAuthMethods(), + ResponseTypesSupported: ds.getAllowedResponseTypes(), + GrantTypesSupported: ds.getAllowedGrantTypes(), + TokenEndpointAuthMethodsSupported: ds.getAllowedTokenEndpointAuthMethods(), TokenEndpointAuthSigningAlgValuesSupported: ds.getSupportedTokenEndpointAuthSigningAlgs(), CodeChallengeMethodsSupported: ds.getSupportedCodeChallengeMethods(), AuthorizationResponseIssParameterSupported: true, @@ -91,15 +91,15 @@ func (ds *discoveryService) GetOIDCMetadata(ctx context.Context) (*OIDCProviderM oidcProviderMetadata := &OIDCProviderMetadata{ OAuth2AuthorizationServerMetadata: *oauth2Meta, UserInfoEndpoint: ds.getUserInfoEndpoint(), - ScopesSupported: ds.getSupportedOIDCScopes(), - SubjectTypesSupported: ds.getSupportedSubjectTypes(), + ScopesSupported: ds.getAllowedScopes(), + SubjectTypesSupported: ds.getAllowedSubjectTypes(), IDTokenSigningAlgValuesSupported: signingAlgs, UserInfoSigningAlgValuesSupported: signingAlgs, UserInfoEncryptionAlgValuesSupported: encryptionAlgs, UserInfoEncryptionEncValuesSupported: encryptionEncs, IDTokenEncryptionAlgValuesSupported: encryptionAlgs, IDTokenEncryptionEncValuesSupported: encryptionEncs, - ClaimsSupported: ds.getSupportedClaims(), + ClaimsSupported: ds.getAllowedClaims(), ClaimsParameterSupported: true, AcrValuesSupported: ds.getSupportedAcrValues(), } @@ -147,24 +147,20 @@ func (ds *discoveryService) getRegistrationEndpoint() string { return ds.cfg.BaseURL + constants.OAuth2DCREndpoint } -func (ds *discoveryService) getSupportedOIDCScopes() []string { - scopes := make([]string, 0, len(constants.StandardOIDCScopes)) - for scope := range constants.StandardOIDCScopes { - scopes = append(scopes, scope) - } - return scopes +func (ds *discoveryService) getAllowedScopes() []string { + return ds.cfg.OAuth.AllowedScopes } -func (ds *discoveryService) getSupportedResponseTypes() []string { - return constants.GetSupportedResponseTypes(ds.cfg) +func (ds *discoveryService) getAllowedResponseTypes() []string { + return ds.cfg.OAuth.AllowedResponseTypes } -func (ds *discoveryService) getSupportedGrantTypes() []string { - return constants.GetSupportedGrantTypes(ds.cfg) +func (ds *discoveryService) getAllowedGrantTypes() []string { + return ds.cfg.OAuth.AllowedGrantTypes } -func (ds *discoveryService) getSupportedTokenEndpointAuthMethods() []string { - return constants.GetSupportedTokenEndpointAuthMethods(ds.cfg) +func (ds *discoveryService) getAllowedTokenEndpointAuthMethods() []string { + return ds.cfg.OAuth.AllowedAuthMethods } func (ds *discoveryService) getSupportedCodeChallengeMethods() []string { @@ -191,8 +187,8 @@ func (ds *discoveryService) getSupportedTokenEndpointAuthSigningAlgs() []string return ds.cryptoProvider.GetSupportedSigningAlgorithms() } -func (ds *discoveryService) getSupportedSubjectTypes() []string { - return constants.GetSupportedSubjectTypes() +func (ds *discoveryService) getAllowedSubjectTypes() []string { + return ds.cfg.OAuth.AllowedSubjectTypes } func (ds *discoveryService) getSupportedSigningAlgorithms(ctx context.Context) ([]string, error) { @@ -229,32 +225,14 @@ func (ds *discoveryService) getSupportedAcrValues() []string { return acrs } -func (ds *discoveryService) getSupportedClaims() []string { - // Extract claims from OIDC scopes - var claims []string - claims = append(claims, constants.GetStandardClaims()...) - - for _, scope := range constants.StandardOIDCScopes { - claims = append(claims, scope.Claims...) - } - - // Remove duplicates - claimMap := make(map[string]bool) - var uniqueClaims []string - for _, claim := range claims { - if !claimMap[claim] { - claimMap[claim] = true - uniqueClaims = append(uniqueClaims, claim) - } - } - - return uniqueClaims +func (ds *discoveryService) getAllowedClaims() []string { + return ds.cfg.OAuth.AllowedClaims } func (ds *discoveryService) getSupportedAuthorizationGrantProfiles() []string { supportedProfiles := make([]string, 0) // support Identity Assertion JWT Authorization Grant profile if the JWT Bearer grant type is supported - if slices.Contains(ds.getSupportedGrantTypes(), string(providers.GrantTypeJWTBearer)) { + if slices.Contains(ds.getAllowedGrantTypes(), string(providers.GrantTypeJWTBearer)) { supportedProfiles = append(supportedProfiles, string(constants.SupportedAuthorizationGrantProfileIDJAG)) } diff --git a/backend/internal/oauth/oauth2/granthandlers/refresh_token_test.go b/backend/internal/oauth/oauth2/granthandlers/refresh_token_test.go index 831f9c0b69..202c4224e0 100644 --- a/backend/internal/oauth/oauth2/granthandlers/refresh_token_test.go +++ b/backend/internal/oauth/oauth2/granthandlers/refresh_token_test.go @@ -82,7 +82,7 @@ func (suite *RefreshTokenGrantHandlerTestSuite) SetupTest() { JWT: engineconfig.JWTConfig{ ValidityPeriod: 3600, }, - OAuth: engineconfig.OAuthConfig{ + OAuth: config.OAuthConfig{ RefreshToken: engineconfig.RefreshTokenConfig{ ValidityPeriod: 86400, RenewOnGrant: false, diff --git a/backend/internal/oauth/oauth2/par/service_test.go b/backend/internal/oauth/oauth2/par/service_test.go index bffed7d566..be665ee76a 100644 --- a/backend/internal/oauth/oauth2/par/service_test.go +++ b/backend/internal/oauth/oauth2/par/service_test.go @@ -43,7 +43,7 @@ func TestServiceTestSuite(t *testing.T) { func (s *ServiceTestSuite) SetupTest() { testConfig := &config.Config{ - OAuth: engineconfig.OAuthConfig{ + OAuth: config.OAuthConfig{ PAR: engineconfig.PARConfig{ ExpiresIn: 60, }, diff --git a/backend/internal/oauth/oauth2/userinfo/init_test.go b/backend/internal/oauth/oauth2/userinfo/init_test.go index 719ab717af..aec312e7d7 100644 --- a/backend/internal/oauth/oauth2/userinfo/init_test.go +++ b/backend/internal/oauth/oauth2/userinfo/init_test.go @@ -52,7 +52,7 @@ func (suite *InitTestSuite) SetupTest() { _ = config.InitializeServerRuntime( "test-home", &config.Config{ - OAuth: engineconfig.OAuthConfig{ + OAuth: config.OAuthConfig{ DPoP: engineconfig.DPoPConfig{ AllowedAlgs: []string{"ES256", "PS256"}, }, diff --git a/backend/internal/system/config/config.go b/backend/internal/system/config/config.go index a1398a48fd..d9bcec3e87 100644 --- a/backend/internal/system/config/config.go +++ b/backend/internal/system/config/config.go @@ -571,6 +571,52 @@ type LogTimeRotationConfig struct { IntervalDays *int `yaml:"interval_days" json:"interval_days"` } +// OAuthConfig is the yaml/json-loaded OAuth section for the server. It mirrors every field +// of engineconfig.OAuthConfig except the configs which are settable only through thunderidengine. +type OAuthConfig struct { + RefreshToken engineconfig.RefreshTokenConfig `yaml:"refresh_token" json:"refresh_token"` + AuthorizationCode engineconfig.AuthorizationCodeConfig `yaml:"authorization_code" json:"authorization_code"` //nolint:lll + AuthorizationRequest engineconfig.AuthorizationRequestConfig `yaml:"authorization_request" json:"authorization_request"` //nolint:lll + DCR engineconfig.DCRConfig `yaml:"dcr" json:"dcr"` + PAR engineconfig.PARConfig `yaml:"par" json:"par"` + DPoP engineconfig.DPoPConfig `yaml:"dpop" json:"dpop"` + AuthClass engineconfig.AuthClassConfig `yaml:"auth_class" json:"auth_class"` + CIBA engineconfig.CIBAConfig `yaml:"ciba" json:"ciba"` + Revocation engineconfig.RevocationConfig `yaml:"revocation" json:"revocation"` + TokenExchange engineconfig.TokenExchangeConfig `yaml:"token_exchange" json:"token_exchange"` + AllowWildcardRedirectURI bool `yaml:"allow_wildcard_redirect_uri" json:"allow_wildcard_redirect_uri"` //nolint:lll + AllowedGrantTypes []string `yaml:"allowed_grant_types" json:"allowed_grant_types"` //nolint:lll + AllowedResponseTypes []string `yaml:"allowed_response_types" json:"allowed_response_types"` //nolint:lll + AllowedAuthMethods []string `yaml:"allowed_auth_methods" json:"allowed_auth_methods"` //nolint:lll + SendServerErrorsToClient *bool `yaml:"send_server_errors_to_client" json:"send_server_errors_to_client"` //nolint:lll + TokenRevocation engineconfig.OAuthTokenRevocationConfig `yaml:"token_revocation" json:"token_revocation"` + Logout engineconfig.LogoutConfig `yaml:"logout" json:"logout"` +} + +// ToEngineConfig copies the yaml-loaded fields into an engineconfig.OAuthConfig value. +// The engine-only fields on the output are left zero. Callers that need these fields seed them separately. +func (c OAuthConfig) ToEngineConfig() engineconfig.OAuthConfig { + return engineconfig.OAuthConfig{ + RefreshToken: c.RefreshToken, + AuthorizationCode: c.AuthorizationCode, + AuthorizationRequest: c.AuthorizationRequest, + DCR: c.DCR, + PAR: c.PAR, + DPoP: c.DPoP, + AuthClass: c.AuthClass, + CIBA: c.CIBA, + Revocation: c.Revocation, + TokenExchange: c.TokenExchange, + AllowWildcardRedirectURI: c.AllowWildcardRedirectURI, + AllowedGrantTypes: c.AllowedGrantTypes, + AllowedResponseTypes: c.AllowedResponseTypes, + AllowedAuthMethods: c.AllowedAuthMethods, + SendServerErrorsToClient: c.SendServerErrorsToClient, + TokenRevocation: c.TokenRevocation, + Logout: c.Logout, + } +} + // Config holds the complete configuration details of the server. type Config struct { Server engineconfig.ServerConfig `yaml:"server" json:"server"` @@ -580,7 +626,7 @@ type Config struct { Database DatabaseConfig `yaml:"database" json:"database"` Cache engineconfig.CacheConfig `yaml:"cache" json:"cache"` JWT engineconfig.JWTConfig `yaml:"jwt" json:"jwt"` - OAuth engineconfig.OAuthConfig `yaml:"oauth" json:"oauth"` + OAuth OAuthConfig `yaml:"oauth" json:"oauth"` Flow engineconfig.FlowConfig `yaml:"flow" json:"flow"` Crypto CryptoConfig `yaml:"crypto" json:"crypto"` User UserConfig `yaml:"user" json:"user"` diff --git a/backend/internal/system/config/config_test.go b/backend/internal/system/config/config_test.go index 255e9c5b27..014947eebb 100644 --- a/backend/internal/system/config/config_test.go +++ b/backend/internal/system/config/config_test.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "reflect" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -525,7 +526,7 @@ func (suite *ConfigTestSuite) TestMergeStructs() { Issuer: "base-issuer", ValidityPeriod: 3600, }, - OAuth: engineconfig.OAuthConfig{ + OAuth: OAuthConfig{ RefreshToken: engineconfig.RefreshTokenConfig{ RenewOnGrant: false, ValidityPeriod: 7200, @@ -572,7 +573,7 @@ func (suite *ConfigTestSuite) TestMergeStructs() { Issuer: "user-issuer", // Override // ValidityPeriod: 0 (zero value, should not override) }, - OAuth: engineconfig.OAuthConfig{ + OAuth: OAuthConfig{ RefreshToken: engineconfig.RefreshTokenConfig{ RenewOnGrant: true, // Override // ValidityPeriod: 0 (zero value, should not override) @@ -863,7 +864,7 @@ func (suite *ConfigTestSuite) TestMergeConfigs_BoolPointerOverride() { return &Config{ Notification: NotificationConfig{OTP: OTPConfig{UseNumericOnly: boolPtr(true)}}, OpenID4VP: OpenID4VPConfig{EnforceKeyBinding: boolPtr(true)}, - OAuth: engineconfig.OAuthConfig{ + OAuth: OAuthConfig{ RefreshToken: engineconfig.RefreshTokenConfig{RevokePreviousOnRenew: boolPtr(true)}, TokenRevocation: engineconfig.OAuthTokenRevocationConfig{Enabled: boolPtr(true)}, Logout: engineconfig.LogoutConfig{Enabled: boolPtr(true)}, @@ -884,7 +885,7 @@ func (suite *ConfigTestSuite) TestMergeConfigs_BoolPointerOverride() { user := &Config{ Notification: NotificationConfig{OTP: OTPConfig{UseNumericOnly: boolPtr(false)}}, OpenID4VP: OpenID4VPConfig{EnforceKeyBinding: boolPtr(false)}, - OAuth: engineconfig.OAuthConfig{ + OAuth: OAuthConfig{ RefreshToken: engineconfig.RefreshTokenConfig{RevokePreviousOnRenew: boolPtr(false)}, TokenRevocation: engineconfig.OAuthTokenRevocationConfig{Enabled: boolPtr(false)}, Logout: engineconfig.LogoutConfig{Enabled: boolPtr(false)}, @@ -1622,3 +1623,85 @@ func (suite *ConfigTestSuite) TestNotificationConfig_Validate_DelegatesToOTP() { assert.Error(suite.T(), err) assert.Contains(suite.T(), err.Error(), "notification.otp.length") } + +func (suite *ConfigTestSuite) TestOAuthConfig_ToEngineConfig_CopiesYAMLFields() { + sendErrs := true + src := OAuthConfig{ + RefreshToken: engineconfig.RefreshTokenConfig{RenewOnGrant: true, ValidityPeriod: 7200}, + AuthorizationCode: engineconfig.AuthorizationCodeConfig{ValidityPeriod: 300}, + AuthorizationRequest: engineconfig.AuthorizationRequestConfig{ValidityPeriod: 60}, + DCR: engineconfig.DCRConfig{Insecure: true}, + PAR: engineconfig.PARConfig{ExpiresIn: 600, RequirePAR: true}, + DPoP: engineconfig.DPoPConfig{Required: true, AllowedAlgs: []string{"ES256"}}, + CIBA: engineconfig.CIBAConfig{IDTokenHintMaxAgeDays: 30}, + TokenExchange: engineconfig.TokenExchangeConfig{TokenFamily: "inherit"}, + AllowWildcardRedirectURI: true, + AllowedGrantTypes: []string{"authorization_code"}, + AllowedResponseTypes: []string{"code"}, + AllowedAuthMethods: []string{"client_secret_basic"}, + SendServerErrorsToClient: &sendErrs, + } + + dst := src.ToEngineConfig() + + assert.Equal(suite.T(), src.RefreshToken, dst.RefreshToken) + assert.Equal(suite.T(), src.AuthorizationCode, dst.AuthorizationCode) + assert.Equal(suite.T(), src.AuthorizationRequest, dst.AuthorizationRequest) + assert.Equal(suite.T(), src.DCR, dst.DCR) + assert.Equal(suite.T(), src.PAR, dst.PAR) + assert.Equal(suite.T(), src.DPoP, dst.DPoP) + assert.Equal(suite.T(), src.CIBA, dst.CIBA) + assert.Equal(suite.T(), src.TokenExchange, dst.TokenExchange) + assert.Equal(suite.T(), src.AllowWildcardRedirectURI, dst.AllowWildcardRedirectURI) + assert.Equal(suite.T(), src.AllowedGrantTypes, dst.AllowedGrantTypes) + assert.Equal(suite.T(), src.AllowedResponseTypes, dst.AllowedResponseTypes) + assert.Equal(suite.T(), src.AllowedAuthMethods, dst.AllowedAuthMethods) + assert.Equal(suite.T(), src.SendServerErrorsToClient, dst.SendServerErrorsToClient) + + // The four engine-only OIDC discovery fields are not settable through the yaml struct + // and must remain zero after conversion. + assert.Empty(suite.T(), dst.AllowedScopes) + assert.Empty(suite.T(), dst.AllowedClaims) + assert.Empty(suite.T(), dst.DefaultScopeClaimsMapping) + assert.Empty(suite.T(), dst.AllowedSubjectTypes) +} + +func (suite *ConfigTestSuite) TestOAuthConfig_YAMLDoesNotBindOIDCFields() { + // The four engine-only OIDC discovery fields must not be bindable through yaml. Under + // strict decoding (KnownFields(true), as used by the production loader) any yaml document + // that tries to set them should fail with an "unknown field" error. + cases := []string{ + "allowed_scopes:\n - openid\n", + "allowed_claims:\n - sub\n", + "default_scope_claims_mapping:\n openid:\n - sub\n", + "allowed_subject_types:\n - public\n", + } + for _, doc := range cases { + var cfg OAuthConfig + dec := yaml.NewDecoder(strings.NewReader(doc)) + dec.KnownFields(true) + err := dec.Decode(&cfg) + suite.Require().Error(err, "yaml %q should be rejected by strict decoder", doc) + suite.Contains(err.Error(), "not found in type") + } + + // Lenient decoding must silently drop the keys rather than populate a struct field. + const combined = ` +allowed_scopes: + - openid +allowed_claims: + - sub +default_scope_claims_mapping: + openid: + - sub +allowed_subject_types: + - public +` + var cfg OAuthConfig + suite.Require().NoError(yaml.Unmarshal([]byte(combined), &cfg)) + dst := cfg.ToEngineConfig() + assert.Empty(suite.T(), dst.AllowedScopes) + assert.Empty(suite.T(), dst.AllowedClaims) + assert.Empty(suite.T(), dst.DefaultScopeClaimsMapping) + assert.Empty(suite.T(), dst.AllowedSubjectTypes) +} diff --git a/backend/pkg/thunderidengine/config/config.go b/backend/pkg/thunderidengine/config/config.go index d0227bf88d..28114cae8a 100644 --- a/backend/pkg/thunderidengine/config/config.go +++ b/backend/pkg/thunderidengine/config/config.go @@ -252,6 +252,14 @@ type OAuthConfig struct { AllowedResponseTypes []string `yaml:"allowed_response_types" json:"allowed_response_types"` // AllowedAuthMethods lists allowed client token endpoint auth methods AllowedAuthMethods []string `yaml:"allowed_auth_methods" json:"allowed_auth_methods"` + // AllowedScopes lists the OAuth scopes advertised as allowed by the server. + AllowedScopes []string `yaml:"allowed_scopes" json:"allowed_scopes"` + // AllowedClaims lists the claims advertised as allowed by the server. + AllowedClaims []string `yaml:"allowed_claims" json:"allowed_claims"` + // DefaultScopeClaimsMapping maps each allowed scope to the claims it implies. + DefaultScopeClaimsMapping map[string][]string `yaml:"default_scope_claims_mapping" json:"default_scope_claims_mapping"` //nolint:lll + // AllowedSubjectTypes lists the OIDC subject types advertised as allowed by the server. + AllowedSubjectTypes []string `yaml:"allowed_subject_types" json:"allowed_subject_types"` //nolint:lll // SendServerErrorsToClient controls whether a flow failure that maps to the OAuth // server_error code is reported to the client. Denials (access_denied) are always // reported and are not affected. Nil means unset; the default lives in default.json. diff --git a/backend/pkg/thunderidengine/providers/oauth_client_test.go b/backend/pkg/thunderidengine/providers/oauth_client_test.go index bf2a560607..90b4aa3ea3 100644 --- a/backend/pkg/thunderidengine/providers/oauth_client_test.go +++ b/backend/pkg/thunderidengine/providers/oauth_client_test.go @@ -33,7 +33,7 @@ func (suite *OAuthClientTestSuite) TearDownTest() { // setupRuntime initializes a minimal runtime config for a specific subtest. // It resets before initializing and registers cleanup via t.Cleanup. -func (suite *OAuthClientTestSuite) setupRuntime(t *testing.T, oauthCfg engineconfig.OAuthConfig) { +func (suite *OAuthClientTestSuite) setupRuntime(t *testing.T, oauthCfg sysconfig.OAuthConfig) { t.Helper() sysconfig.ResetServerRuntime() cfg := &sysconfig.Config{OAuth: oauthCfg} @@ -128,17 +128,17 @@ func (suite *OAuthClientTestSuite) TestOAuthClient_ResolveDefaultAudience() { func (suite *OAuthClientTestSuite) TestOAuthClient_RequiresPAR() { suite.T().Run("client flag forces PAR", func(t *testing.T) { - suite.setupRuntime(t, engineconfig.OAuthConfig{PAR: engineconfig.PARConfig{RequirePAR: false}}) + suite.setupRuntime(t, sysconfig.OAuthConfig{PAR: engineconfig.PARConfig{RequirePAR: false}}) assert.True(t, (&OAuthClient{RequirePushedAuthorizationRequests: true}).RequiresPAR()) }) suite.T().Run("global config forces PAR", func(t *testing.T) { - suite.setupRuntime(t, engineconfig.OAuthConfig{PAR: engineconfig.PARConfig{RequirePAR: true}}) + suite.setupRuntime(t, sysconfig.OAuthConfig{PAR: engineconfig.PARConfig{RequirePAR: true}}) assert.True(t, (&OAuthClient{RequirePushedAuthorizationRequests: false}).RequiresPAR()) }) suite.T().Run("neither forces PAR", func(t *testing.T) { - suite.setupRuntime(t, engineconfig.OAuthConfig{PAR: engineconfig.PARConfig{RequirePAR: false}}) + suite.setupRuntime(t, sysconfig.OAuthConfig{PAR: engineconfig.PARConfig{RequirePAR: false}}) assert.False(t, (&OAuthClient{RequirePushedAuthorizationRequests: false}).RequiresPAR()) }) } @@ -146,21 +146,21 @@ func (suite *OAuthClientTestSuite) TestOAuthClient_RequiresPAR() { // ----- ValidateRedirectURI ----- func (suite *OAuthClientTestSuite) TestValidateRedirectURI_ExactMatch() { - suite.setupRuntime(suite.T(), engineconfig.OAuthConfig{}) + suite.setupRuntime(suite.T(), sysconfig.OAuthConfig{}) err := ValidateRedirectURI(context.Background(), []string{"https://example.com/callback"}, "https://example.com/callback") assert.NoError(suite.T(), err) } func (suite *OAuthClientTestSuite) TestValidateRedirectURI_NoMatch() { - suite.setupRuntime(suite.T(), engineconfig.OAuthConfig{}) + suite.setupRuntime(suite.T(), sysconfig.OAuthConfig{}) err := ValidateRedirectURI(context.Background(), []string{"https://example.com/callback"}, "https://evil.com/callback") assert.Error(suite.T(), err) } func (suite *OAuthClientTestSuite) TestValidateRedirectURI_EmptyURI() { - suite.setupRuntime(suite.T(), engineconfig.OAuthConfig{}) + suite.setupRuntime(suite.T(), sysconfig.OAuthConfig{}) suite.T().Run("single registered URI defaults to it", func(t *testing.T) { err := ValidateRedirectURI(context.Background(), []string{"https://example.com/callback"}, "") @@ -179,41 +179,41 @@ func (suite *OAuthClientTestSuite) TestValidateRedirectURI_EmptyURI() { } func (suite *OAuthClientTestSuite) TestValidateRedirectURI_FragmentRejected() { - suite.setupRuntime(suite.T(), engineconfig.OAuthConfig{}) + suite.setupRuntime(suite.T(), sysconfig.OAuthConfig{}) err := ValidateRedirectURI(context.Background(), []string{"https://example.com/callback#fragment"}, "https://example.com/callback#fragment") assert.Error(suite.T(), err) } func (suite *OAuthClientTestSuite) TestValidateRedirectURI_WildcardDisabled() { - suite.setupRuntime(suite.T(), engineconfig.OAuthConfig{AllowWildcardRedirectURI: false}) + suite.setupRuntime(suite.T(), sysconfig.OAuthConfig{AllowWildcardRedirectURI: false}) err := ValidateRedirectURI(context.Background(), []string{"https://*.example.com/callback"}, "https://sub.example.com/callback") assert.Error(suite.T(), err) } func (suite *OAuthClientTestSuite) TestValidateRedirectURI_WildcardEnabled() { - suite.setupRuntime(suite.T(), engineconfig.OAuthConfig{AllowWildcardRedirectURI: true}) + suite.setupRuntime(suite.T(), sysconfig.OAuthConfig{AllowWildcardRedirectURI: true}) err := ValidateRedirectURI(context.Background(), []string{"https://*.example.com/callback"}, "https://sub.example.com/callback") assert.NoError(suite.T(), err) } func (suite *OAuthClientTestSuite) TestOAuthClient_ValidateRedirectURI() { - suite.setupRuntime(suite.T(), engineconfig.OAuthConfig{}) + suite.setupRuntime(suite.T(), sysconfig.OAuthConfig{}) client := &OAuthClient{RedirectURIs: []string{"https://example.com/callback"}} assert.NoError(suite.T(), client.ValidateRedirectURI(context.Background(), "https://example.com/callback")) assert.Error(suite.T(), client.ValidateRedirectURI(context.Background(), "https://other.com/callback")) } func (suite *OAuthClientTestSuite) TestValidateRedirectURI_InvalidRegisteredURI() { - suite.setupRuntime(suite.T(), engineconfig.OAuthConfig{}) + suite.setupRuntime(suite.T(), sysconfig.OAuthConfig{}) err := ValidateRedirectURI(context.Background(), []string{"/relative/callback"}, "") assert.ErrorContains(suite.T(), err, "not fully qualified") } func (suite *OAuthClientTestSuite) TestValidateRedirectURI_SkipsInvalidWildcardPattern() { - suite.setupRuntime(suite.T(), engineconfig.OAuthConfig{AllowWildcardRedirectURI: true}) + suite.setupRuntime(suite.T(), sysconfig.OAuthConfig{AllowWildcardRedirectURI: true}) err := ValidateRedirectURI(context.Background(), []string{"https://*", "https://example.com/callback"}, "https://example.com/callback") assert.NoError(suite.T(), err)