diff --git a/api/discovery.yaml b/api/discovery.yaml index e5e066d58f..1ad9ccaedc 100644 --- a/api/discovery.yaml +++ b/api/discovery.yaml @@ -64,6 +64,8 @@ paths: code_challenge_methods_supported: - S256 authorization_response_iss_parameter_supported: true + authorization_grant_profiles_supported: + - "urn:ietf:params:oauth:grant-profile:id-jag" /.well-known/openid-configuration: get: @@ -116,6 +118,8 @@ paths: - email_verified claims_parameter_supported: true authorization_response_iss_parameter_supported: true + authorization_grant_profiles_supported: + - "urn:ietf:params:oauth:grant-profile:id-jag" "500": description: Internal server error — metadata could not be generated. @@ -187,6 +191,11 @@ components: authorization_response_iss_parameter_supported: type: boolean description: Whether the `iss` parameter is included in authorization responses (RFC 9207). + authorization_grant_profiles_supported: + type: array + items: + type: string + description: Authorization grant profiles supported, e.g. the ID-JAG profile (`urn:ietf:params:oauth:grant-profile:id-jag`). OIDCProviderMetadata: allOf: diff --git a/backend/internal/oauth/oauth2/constants/constants.go b/backend/internal/oauth/oauth2/constants/constants.go index ac86be98ae..0814422e42 100644 --- a/backend/internal/oauth/oauth2/constants/constants.go +++ b/backend/internal/oauth/oauth2/constants/constants.go @@ -335,6 +335,11 @@ const ( CIBAMaxExpiresInSeconds = 600 ) +const ( + // SupportedAuthorizationGrantProfileIDJAG is the constant for supported authorization grant profile ID-JAG. + 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 diff --git a/backend/internal/oauth/oauth2/discovery/discovery_test.go b/backend/internal/oauth/oauth2/discovery/discovery_test.go index 1e42391ebb..a34fae5485 100644 --- a/backend/internal/oauth/oauth2/discovery/discovery_test.go +++ b/backend/internal/oauth/oauth2/discovery/discovery_test.go @@ -498,4 +498,105 @@ func (suite *DiscoveryTestSuite) TestOIDCDiscovery_DeduplicatesAlgorithms() { assert.Contains(suite.T(), algs, "RS256") } +func (suite *DiscoveryTestSuite) TestAuthorizationGrantProfilesSupported_JWTBearerEnabled() { + suite.cryptoMock.EXPECT().GetPublicKeys(mock.Anything, providers.PublicKeyFilter{}). + Return([]providers.PublicKeyInfo{{KeyID: "k1", Algorithm: string(cryptolib.AlgorithmRS256)}}, nil) + + meta, err := suite.discoveryService.GetOIDCMetadata(context.Background()) + assert.NoError(suite.T(), err) + + // Default config allows the JWT Bearer grant type, so the ID-JAG profile should be advertised. + assert.Contains(suite.T(), meta.GrantTypesSupported, string(providers.GrantTypeJWTBearer)) + assert.Contains( + suite.T(), meta.AuthorizationGrantProfilesSupported, constants.SupportedAuthorizationGrantProfileIDJAG) +} + +func (suite *DiscoveryTestSuite) TestAuthorizationGrantProfilesSupported_JWTBearerDisabled() { + testConfig := &config.Config{ + Server: engineconfig.ServerConfig{Hostname: "localhost", Port: 8080}, + JWT: engineconfig.JWTConfig{Issuer: "https://auth.example.com"}, + OAuth: engineconfig.OAuthConfig{ + AllowedGrantTypes: []string{"client_credentials", "refresh_token"}, + }, + } + cryptoMock := cryptomock.NewRuntimeCryptoProviderMock(suite.T()) + cryptoMock.EXPECT().GetSupportedSigningAlgorithms().Return(suite.oauthCfg.OAuth.DPoP.AllowedAlgs).Maybe() + cryptoMock.EXPECT().GetSupportedEncryptionAlgorithms(). + Return([]string{string(cryptolib.AlgorithmRSAOAEP256)}).Maybe() + cryptoMock.EXPECT().GetPublicKeys(mock.Anything, providers.PublicKeyFilter{}). + Return([]providers.PublicKeyInfo{{KeyID: "k1", Algorithm: string(cryptolib.AlgorithmRS256)}}, nil) + + svc := newDiscoveryService(cryptoMock, newTestJWEService(cryptoMock), oauthCfgFromServerConfig(testConfig)) + meta, err := svc.GetOIDCMetadata(context.Background()) + assert.NoError(suite.T(), err) + + assert.NotContains(suite.T(), meta.GrantTypesSupported, string(providers.GrantTypeJWTBearer)) + assert.Empty(suite.T(), meta.AuthorizationGrantProfilesSupported) + + body, err := json.Marshal(meta) + assert.NoError(suite.T(), err) + assert.NotContains(suite.T(), string(body), "authorization_grant_profiles_supported") +} + +func (suite *DiscoveryTestSuite) TestAuthorizationGrantProfilesSupported_AdvertisedOverHTTP() { + suite.cryptoMock.EXPECT().GetPublicKeys(mock.Anything, providers.PublicKeyFilter{}). + Return([]providers.PublicKeyInfo{{KeyID: "k1", Algorithm: string(cryptolib.AlgorithmRS256)}}, nil) + + req := httptest.NewRequest("GET", "/.well-known/openid-configuration", nil) + w := httptest.NewRecorder() + suite.handler.HandleOIDCDiscovery(w, req) + assert.Equal(suite.T(), http.StatusOK, w.Code) + + var metadata OIDCProviderMetadata + err := json.NewDecoder(w.Body).Decode(&metadata) + assert.NoError(suite.T(), err) + assert.Equal( + suite.T(), + []string{constants.SupportedAuthorizationGrantProfileIDJAG}, + metadata.AuthorizationGrantProfilesSupported, + ) +} + +func (suite *DiscoveryTestSuite) TestAuthorizationGrantProfilesSupported_OnOAuth2AuthorizationServerMetadata() { + req := httptest.NewRequest("GET", "/.well-known/oauth-authorization-server", nil) + w := httptest.NewRecorder() + + suite.handler.HandleOAuth2AuthorizationServerMetadata(w, req) + + assert.Equal(suite.T(), http.StatusOK, w.Code) + + var metadata OAuth2AuthorizationServerMetadata + err := json.NewDecoder(w.Body).Decode(&metadata) + assert.NoError(suite.T(), err) + + // Default config allows the JWT Bearer grant type, so the ID-JAG profile should be + // advertised on the OAuth 2.0 Authorization Server Metadata endpoint too. + assert.Contains(suite.T(), metadata.GrantTypesSupported, string(providers.GrantTypeJWTBearer)) + assert.Equal( + suite.T(), + []string{constants.SupportedAuthorizationGrantProfileIDJAG}, + metadata.AuthorizationGrantProfilesSupported, + ) +} + +func (suite *DiscoveryTestSuite) TestAuthorizationGrantProfilesSupported_NotOnOAuth2ServerMetadataWhenUnsupported() { + testConfig := &config.Config{ + Server: engineconfig.ServerConfig{Hostname: "localhost", Port: 8080}, + JWT: engineconfig.JWTConfig{Issuer: "https://auth.example.com"}, + OAuth: engineconfig.OAuthConfig{ + AllowedGrantTypes: []string{"client_credentials", "refresh_token"}, + }, + } + svc := newDiscoveryService( + suite.cryptoMock, newTestJWEService(suite.cryptoMock), oauthCfgFromServerConfig(testConfig)) + handler := newDiscoveryHandler(svc) + + req := httptest.NewRequest("GET", "/.well-known/oauth-authorization-server", nil) + w := httptest.NewRecorder() + handler.HandleOAuth2AuthorizationServerMetadata(w, req) + + assert.Equal(suite.T(), http.StatusOK, w.Code) + assert.NotContains(suite.T(), w.Body.String(), "authorization_grant_profiles_supported") +} + func boolPtr(b bool) *bool { return &b } diff --git a/backend/internal/oauth/oauth2/discovery/model.go b/backend/internal/oauth/oauth2/discovery/model.go index 8ba9391be5..905c700ba4 100644 --- a/backend/internal/oauth/oauth2/discovery/model.go +++ b/backend/internal/oauth/oauth2/discovery/model.go @@ -23,6 +23,7 @@ type OAuth2AuthorizationServerMetadata struct { CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported,omitempty"` AuthorizationResponseIssParameterSupported bool `json:"authorization_response_iss_parameter_supported"` DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"` + AuthorizationGrantProfilesSupported []string `json:"authorization_grant_profiles_supported,omitempty"` } // OIDCProviderMetadata represents OpenID Connect Provider Metadata (OIDC Discovery 1.0) diff --git a/backend/internal/oauth/oauth2/discovery/service.go b/backend/internal/oauth/oauth2/discovery/service.go index 2c1b542d6b..8bcfee265c 100644 --- a/backend/internal/oauth/oauth2/discovery/service.go +++ b/backend/internal/oauth/oauth2/discovery/service.go @@ -59,6 +59,7 @@ func (ds *discoveryService) GetOAuth2AuthorizationServerMetadata( CodeChallengeMethodsSupported: ds.getSupportedCodeChallengeMethods(), AuthorizationResponseIssParameterSupported: true, DPoPSigningAlgValuesSupported: ds.getSupportedDPoPSigningAlgs(), + AuthorizationGrantProfilesSupported: ds.getSupportedAuthorizationGrantProfiles(), } if slices.Contains(metadata.GrantTypesSupported, string(providers.GrantTypeCIBA)) { @@ -244,3 +245,13 @@ func (ds *discoveryService) getSupportedClaims() []string { return uniqueClaims } + +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)) { + supportedProfiles = append(supportedProfiles, string(constants.SupportedAuthorizationGrantProfileIDJAG)) + } + + return supportedProfiles +}