diff --git a/auth/api/auth/v1/api_test.go b/auth/api/auth/v1/api_test.go index a4970f89ad..2ae2a7b29f 100644 --- a/auth/api/auth/v1/api_test.go +++ b/auth/api/auth/v1/api_test.go @@ -91,6 +91,10 @@ func (m *mockAuthClient) OpenID4VCIClient() openid4vci.Client { return nil } +func (m *mockAuthClient) OAuthClientCredentials(_ string) (*pkg2.OAuthClientConfig, bool) { + return nil, false +} + func (m *mockAuthClient) ContractNotary() services.ContractNotary { return m.contractNotary } diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index b3af7d5e17..744385b961 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -478,7 +478,7 @@ func TestWrapper_Callback(t *testing.T) { putState(ctx, "state", withDPoP) putToken(ctx, token) codeVerifier := getState(ctx, state).PKCEParams.Verifier - ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, session.TokenEndpoint, "https://example.com/oauth2/holder/callback", holderSubjectID, holderClientID, codeVerifier, true).Return(&oauth.TokenResponse{AccessToken: "access"}, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, session.TokenEndpoint, "https://example.com/oauth2/holder/callback", holderSubjectID, holderClientID, "", codeVerifier, true).Return(&oauth.TokenResponse{AccessToken: "access"}, nil) res, err := ctx.client.Callback(nil, CallbackRequestObject{ SubjectID: holderSubjectID, @@ -512,7 +512,7 @@ func TestWrapper_Callback(t *testing.T) { }) putToken(ctx, token) codeVerifier := getState(ctx, state).PKCEParams.Verifier - ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, session.TokenEndpoint, "https://example.com/oauth2/holder/callback", holderSubjectID, holderClientID, codeVerifier, false).Return(&oauth.TokenResponse{AccessToken: "access"}, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, session.TokenEndpoint, "https://example.com/oauth2/holder/callback", holderSubjectID, holderClientID, "", codeVerifier, false).Return(&oauth.TokenResponse{AccessToken: "access"}, nil) res, err := ctx.client.Callback(nil, CallbackRequestObject{ SubjectID: holderSubjectID, @@ -1649,6 +1649,8 @@ type testCtx struct { subjectManager *didsubject.MockManager jar *MockJAR openid4vciClient *openid4vci.MockClient + // oauthClientCredentials, when set, is returned by the auth mock's OAuthClientCredentials for a matching ServerURL. + oauthClientCredentials *auth.OAuthClientConfig } func newTestClient(t testing.TB) *testCtx { @@ -1704,7 +1706,7 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b jwtSigner: jwtSigner, jar: mockJAR, } - return &testCtx{ + result := &testCtx{ ctrl: ctrl, authnServices: authnServices, policy: policyInstance, @@ -1724,4 +1726,13 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b client: client, openid4vciClient: openid4vciClient, } + // By default no OAuth client credentials are configured (preserving did:web + entity_id behavior). A test can set + // result.oauthClientCredentials to exercise the configured-client path; it matches by exact ServerURL. + authnServices.EXPECT().OAuthClientCredentials(gomock.Any()).DoAndReturn(func(authServerIssuer string) (*auth.OAuthClientConfig, bool) { + if result.oauthClientCredentials != nil && result.oauthClientCredentials.ServerURL == authServerIssuer { + return result.oauthClientCredentials, true + } + return nil, false + }).AnyTimes() + return result } diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 1f9fd19feb..24aef0dd4b 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -128,7 +128,7 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques if err != nil { return nil, fmt.Errorf("failed to parse the authorization_endpoint: %w", err) } - redirectUrl := nutsHttp.AddQueryParams(*authorizationEndpoint, map[string]string{ + authzParams := map[string]string{ oauth.ResponseTypeParam: oauth.CodeResponseType, oauth.StateParam: state, oauth.ClientIDParam: clientID.String(), @@ -137,7 +137,14 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques oauth.RedirectURIParam: redirectUri.String(), oauth.CodeChallengeParam: pkceParams.Challenge, oauth.CodeChallengeMethodParam: pkceParams.ChallengeMethod, - }) + } + // EXPERIMENTAL: when client credentials are configured for this authorization server, present the configured + // client_id and drop the Nuts-specific entity_id client_id scheme (which external servers don't understand). + if clientConfig, ok := r.auth.OAuthClientCredentials(authzServerMetadata.Issuer); ok { + authzParams[oauth.ClientIDParam] = clientConfig.ClientID + delete(authzParams, oauth.ClientIDSchemeParam) + } + redirectUrl := nutsHttp.AddQueryParams(*authorizationEndpoint, authzParams) return RequestOpenid4VCICredentialIssuance200JSONResponse{ RedirectURI: redirectUrl.String(), @@ -155,8 +162,16 @@ func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode return nil, withCallbackURI(oauthError(oauth.ServerError, "missing wallet DID in session"), appCallbackURI) } + // EXPERIMENTAL: when client credentials are configured for this authorization server, present the configured + // client_id and authenticate with the client_secret (client_secret_post) instead of the did:web client_id. + var clientSecret string + if clientConfig, ok := r.auth.OAuthClientCredentials(oauthSession.IssuerURL); ok { + clientID = clientConfig.ClientID + clientSecret = clientConfig.ClientSecret + } + // use code to request access token from remote token endpoint - tokenResponse, err := r.auth.IAMClient().AccessToken(ctx, authorizationCode, oauthSession.TokenEndpoint, checkURL.String(), *oauthSession.OwnSubject, clientID, oauthSession.PKCEParams.Verifier, false) + tokenResponse, err := r.auth.IAMClient().AccessToken(ctx, authorizationCode, oauthSession.TokenEndpoint, checkURL.String(), *oauthSession.OwnSubject, clientID, clientSecret, oauthSession.PKCEParams.Verifier, false) if err != nil { return nil, withCallbackURI(oauthError(oauth.AccessDenied, fmt.Sprintf("error while fetching the access_token from endpoint: %s, error: %s", oauthSession.TokenEndpoint, err.Error())), appCallbackURI) } diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index d70844d7d6..50fa095b46 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -28,6 +28,7 @@ import ( "github.com/nuts-foundation/nuts-node/core/to" + "github.com/nuts-foundation/nuts-node/auth" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/crypto" @@ -204,6 +205,35 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { }) } +func TestWrapper_RequestOpenid4VCICredentialIssuance_configuredClient(t *testing.T) { + redirectURI := "https://test.test/iam/123/cb" + authServer := "https://auth.server/" + metadata := openid4vci.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "issuer", + CredentialEndpoint: "endpoint", + AuthorizationServers: []string{authServer}, + } + authzMetadata := oauth.AuthorizationServerMetadata{ + Issuer: "https://auth.server", + AuthorizationEndpoint: "https://auth.server/authorize", + TokenEndpoint: "https://auth.server/token", + ClientIdSchemesSupported: clientIdSchemesSupported, + } + ctx := newTestClient(t) + ctx.oauthClientCredentials = &auth.OAuthClientConfig{ServerURL: "https://auth.server", ClientID: "configured-client", ClientSecret: "secret"} + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) + + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, requestCredentials(holderSubjectID, issuerClientID, redirectURI)) + + require.NoError(t, err) + redirectUri, err := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) + require.NoError(t, err) + // The configured client_id replaces the did:web client_id, and the Nuts-specific entity_id scheme is dropped. + assert.Equal(t, "configured-client", redirectUri.Query().Get("client_id")) + assert.False(t, redirectUri.Query().Has("client_id_scheme"), "entity_id scheme should be omitted for configured clients") +} + func requestCredentials(subjectID string, issuer string, redirectURI string) RequestOpenid4VCICredentialIssuanceRequestObject { return RequestOpenid4VCICredentialIssuanceRequestObject{ SubjectID: subjectID, @@ -260,7 +290,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { t.Run("ok - with nonce endpoint", func(t *testing.T) { ctx := newTestClient(t) require.NoError(t, ctx.client.oauthClientStateStore().Put(state, &session)) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, "", pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").DoAndReturn(func(_ context.Context, claims map[string]interface{}, headers map[string]interface{}, key interface{}) (string, error) { @@ -293,7 +323,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { }) t.Run("ok - no nonce endpoint", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, "", pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").DoAndReturn(func(_ context.Context, claims map[string]interface{}, headers map[string]interface{}, key interface{}) (string, error) { _, hasNonce := claims["nonce"] @@ -309,6 +339,24 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { require.NoError(t, err) assert.NotNil(t, callback) }) + t.Run("ok - configured client credentials sent to token endpoint", func(t *testing.T) { + ctx := newTestClient(t) + ctx.oauthClientCredentials = &auth.OAuthClientConfig{ServerURL: authServer, ClientID: "configured-client", ClientSecret: "secret"} + sessionConfigured := sessionWithoutNonce + sessionConfigured.IssuerURL = authServer + // The configured client_id and client_secret are sent instead of the did:web client_id and empty secret. + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, "configured-client", "secret", pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof", nil) + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{CredentialEndpoint: credEndpoint, AccessToken: accessToken, CredentialConfigurationID: credentialConfigID, ProofJWT: "signed-proof"}).Return(&credentialResponse, nil) + ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) + ctx.wallet.EXPECT().Put(nil, *verifiableCredential) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &sessionConfigured) + + require.NoError(t, err) + assert.NotNil(t, callback) + }) t.Run("ok - credential_request_params from session forwarded to credential endpoint", func(t *testing.T) { ctx := newTestClient(t) params := map[string]any{ @@ -317,7 +365,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { } sessionWithParams := session sessionWithParams.CredentialRequestParams = params - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, "", pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof", nil) @@ -341,7 +389,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { freshNonce := "fresh-nonce" invalidNonceErr := oauth.OAuth2Error{Code: oauth.InvalidNonce} - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, "", pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) // first attempt fails with invalid_nonce ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil).Times(2) @@ -363,7 +411,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx := newTestClient(t) invalidNonceErr := oauth.OAuth2Error{Code: oauth.InvalidNonce} - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, "", pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil).Times(2) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof-1", nil) @@ -382,7 +430,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { ctx := newTestClient(t) invalidNonceErr := oauth.OAuth2Error{Code: oauth.InvalidNonce} - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, "", pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof", nil) @@ -406,7 +454,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { "credential_configuration_id": credentialConfigID, "credential_identifiers": []string{"CivilEngineeringDegree-2023"}, }}) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponseWithAuthDetails, nil) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, "", pkceParams.Verifier, false).Return(tokenResponseWithAuthDetails, nil) ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof", nil) @@ -437,7 +485,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { "credential_configuration_id": credentialConfigID, // credential_identifiers omitted }}) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponseWithBadDetails, nil) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, "", pkceParams.Verifier, false).Return(tokenResponseWithBadDetails, nil) _, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) require.Error(t, err) @@ -445,7 +493,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { }) t.Run("error - initial nonce request fails", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, "", pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return("", errors.New("nonce endpoint unavailable")) callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) @@ -455,7 +503,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { }) t.Run("fail_access_token", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(nil, errors.New("FAIL")) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, "", pkceParams.Verifier, false).Return(nil, errors.New("FAIL")) callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &session) @@ -465,7 +513,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { }) t.Run("fail_credential_response", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, "", pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) @@ -478,7 +526,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { }) t.Run("err - invalid credential", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, "", pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) @@ -493,7 +541,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { }) t.Run("fail_verify", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, "", pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) @@ -507,7 +555,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { }) t.Run("error - key not found", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, "", pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("", nil, resolver.ErrKeyNotFound) @@ -518,7 +566,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { }) t.Run("error - signature failure", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, "", pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("", errors.New("signature failed")) @@ -540,7 +588,7 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { }) t.Run("error - empty credentials array", func(t *testing.T) { ctx := newTestClient(t) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, "", pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) diff --git a/auth/api/iam/openid4vp.go b/auth/api/iam/openid4vp.go index 231149fc9b..08e45e303a 100644 --- a/auth/api/iam/openid4vp.go +++ b/auth/api/iam/openid4vp.go @@ -736,7 +736,8 @@ func (r Wrapper) handleCallback(ctx context.Context, authorizationCode string, o checkURL := baseURL.JoinPath(oauth.CallbackPath) // use code to request access token from remote token endpoint - tokenResponse, err := r.auth.IAMClient().AccessToken(ctx, authorizationCode, oauthSession.TokenEndpoint, checkURL.String(), *oauthSession.OwnSubject, clientID, oauthSession.PKCEParams.Verifier, oauthSession.UseDPoP) + // Configured client authentication (client_secret) applies only to the OpenID4VCI flow; the OpenID4VP wallet authenticates via did:web. + tokenResponse, err := r.auth.IAMClient().AccessToken(ctx, authorizationCode, oauthSession.TokenEndpoint, checkURL.String(), *oauthSession.OwnSubject, clientID, "", oauthSession.PKCEParams.Verifier, oauthSession.UseDPoP) if err != nil { return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("failed to retrieve access token: %s", err.Error())), appCallbackURI) } diff --git a/auth/api/iam/openid4vp_test.go b/auth/api/iam/openid4vp_test.go index 86fa35e4c4..478ed46bad 100644 --- a/auth/api/iam/openid4vp_test.go +++ b/auth/api/iam/openid4vp_test.go @@ -753,7 +753,7 @@ func Test_handleCallback(t *testing.T) { callbackURI := "https://example.com/oauth2/holder/callback" codeVerifier := session.PKCEParams.Verifier - ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, session.TokenEndpoint, callbackURI, holderSubjectID, holderClientID, codeVerifier, false).Return(nil, assert.AnError) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, session.TokenEndpoint, callbackURI, holderSubjectID, holderClientID, "", codeVerifier, false).Return(nil, assert.AnError) _, err := ctx.client.handleCallback(nil, code, &session) diff --git a/auth/auth.go b/auth/auth.go index c64235dfcb..9fac0af19d 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -21,6 +21,9 @@ package auth import ( "crypto/tls" "errors" + "fmt" + "strings" + "github.com/nuts-foundation/nuts-node/auth/client/iam" "github.com/nuts-foundation/nuts-node/auth/openid4vci" "github.com/nuts-foundation/nuts-node/policy" @@ -150,6 +153,45 @@ func (auth *Auth) OpenID4VCIClient() openid4vci.Client { return auth.openID4VCIClient } +// OAuthClientCredentials returns the configured OAuth client credentials for the given Authorization Server issuer, +// if an entry was configured under auth.experimental.clients. EXPERIMENTAL. +func (auth *Auth) OAuthClientCredentials(authServerIssuer string) (*OAuthClientConfig, bool) { + target := normalizeServerURL(authServerIssuer) + for i := range auth.config.Experimental.Clients { + if normalizeServerURL(auth.config.Experimental.Clients[i].ServerURL) == target { + return &auth.config.Experimental.Clients[i], true + } + } + return nil, false +} + +// validateOAuthClients validates the auth.experimental.clients configuration. +func (auth *Auth) validateOAuthClients(strictMode bool) error { + seen := make(map[string]struct{}, len(auth.config.Experimental.Clients)) + for i, client := range auth.config.Experimental.Clients { + if client.ServerURL == "" { + return fmt.Errorf("auth.experimental.clients[%d]: serverurl is required", i) + } + if _, err := core.ParsePublicURL(client.ServerURL, strictMode); err != nil { + return fmt.Errorf("auth.experimental.clients[%d]: invalid serverurl: %w", i, err) + } + if client.ClientID == "" { + return fmt.Errorf("auth.experimental.clients[%d]: clientid is required", i) + } + key := normalizeServerURL(client.ServerURL) + if _, duplicate := seen[key]; duplicate { + return fmt.Errorf("auth.experimental.clients[%d]: duplicate serverurl %q", i, client.ServerURL) + } + seen[key] = struct{}{} + } + return nil +} + +// normalizeServerURL strips a single trailing slash so equivalent issuer identifiers match. +func normalizeServerURL(serverURL string) string { + return strings.TrimSuffix(serverURL, "/") +} + // Configure the Auth struct by creating a validator and create an Irma server func (auth *Auth) Configure(config core.ServerConfig) error { if auth.config.Irma.SchemeManager == "" { @@ -160,6 +202,10 @@ func (auth *Auth) Configure(config core.ServerConfig) error { return errors.New("in strictmode the only valid irma-scheme-manager is 'pbdf'") } + if err := auth.validateOAuthClients(config.Strictmode); err != nil { + return err + } + var err error auth.publicURL, err = config.ServerURL() if err != nil { diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index 1517b83bfa..634c81980b 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -31,7 +31,8 @@ type Client interface { // AccessToken requests an access token at the oauth2 token endpoint. // The token endpoint can be a regular OAuth2 token endpoint or OpenID4VCI-related endpoint. // The response will be unmarshalled into the given tokenResponseOut parameter. - AccessToken(ctx context.Context, code string, tokenURI, callbackURI string, subject string, clientID string, codeVerifier string, useDPoP bool) (*oauth.TokenResponse, error) + // clientSecret, when non-empty, authenticates the client at the token endpoint using client_secret_post; an empty value means public client. + AccessToken(ctx context.Context, code string, tokenURI, callbackURI string, subject string, clientID string, clientSecret string, codeVerifier string, useDPoP bool) (*oauth.TokenResponse, error) // AuthorizationServerMetadata returns the metadata of the remote wallet. // oauthIssuer is the URL of the issuer as specified by RFC 8414 (OAuth 2.0 Authorization Server Metadata). // For client_id's used by Nuts nodes, these are constructed as https://example.com/oauth2/ diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go index dac35b1be8..3f8b53fd4e 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -44,18 +44,18 @@ func (m *MockClient) EXPECT() *MockClientMockRecorder { } // AccessToken mocks base method. -func (m *MockClient) AccessToken(ctx context.Context, code, tokenURI, callbackURI, subject, clientID, codeVerifier string, useDPoP bool) (*oauth.TokenResponse, error) { +func (m *MockClient) AccessToken(ctx context.Context, code, tokenURI, callbackURI, subject, clientID, clientSecret, codeVerifier string, useDPoP bool) (*oauth.TokenResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "AccessToken", ctx, code, tokenURI, callbackURI, subject, clientID, codeVerifier, useDPoP) + ret := m.ctrl.Call(m, "AccessToken", ctx, code, tokenURI, callbackURI, subject, clientID, clientSecret, codeVerifier, useDPoP) ret0, _ := ret[0].(*oauth.TokenResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // AccessToken indicates an expected call of AccessToken. -func (mr *MockClientMockRecorder) AccessToken(ctx, code, tokenURI, callbackURI, subject, clientID, codeVerifier, useDPoP any) *gomock.Call { +func (mr *MockClientMockRecorder) AccessToken(ctx, code, tokenURI, callbackURI, subject, clientID, clientSecret, codeVerifier, useDPoP any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AccessToken", reflect.TypeOf((*MockClient)(nil).AccessToken), ctx, code, tokenURI, callbackURI, subject, clientID, codeVerifier, useDPoP) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AccessToken", reflect.TypeOf((*MockClient)(nil).AccessToken), ctx, code, tokenURI, callbackURI, subject, clientID, clientSecret, codeVerifier, useDPoP) } // AuthorizationServerMetadata mocks base method. diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 34522f3191..4c04e96494 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -224,7 +224,7 @@ func (c *OpenID4VPClient) RequestObjectByPost(ctx context.Context, requestURI st return requestObject, nil } -func (c *OpenID4VPClient) AccessToken(ctx context.Context, code string, tokenEndpoint string, callbackURI string, subject string, clientID string, codeVerifier string, useDPoP bool) (*oauth.TokenResponse, error) { +func (c *OpenID4VPClient) AccessToken(ctx context.Context, code string, tokenEndpoint string, callbackURI string, subject string, clientID string, clientSecret string, codeVerifier string, useDPoP bool) (*oauth.TokenResponse, error) { iamClient := c.httpClient // validate tokenEndpoint parsedURL, err := core.ParsePublicURL(tokenEndpoint, c.strictMode) @@ -235,6 +235,10 @@ func (c *OpenID4VPClient) AccessToken(ctx context.Context, code string, tokenEnd // call token endpoint data := url.Values{} data.Set(oauth.ClientIDParam, clientID) + // When a client secret is configured, authenticate the client using client_secret_post (RFC6749 §2.3.1). + if clientSecret != "" { + data.Set(oauth.ClientSecretParam, clientSecret) + } data.Set(oauth.GrantTypeParam, oauth.AuthorizationCodeGrantType) data.Set(oauth.CodeParam, code) data.Set(oauth.RedirectURIParam, callbackURI) diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index 081617e082..4ec44148dd 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -64,20 +64,52 @@ func TestIAMClient_AccessToken(t *testing.T) { t.Run("ok", func(t *testing.T) { ctx := createClientServerTestContext(t) - response, err := ctx.client.AccessToken(context.Background(), code, ctx.authzServerMetadata.TokenEndpoint, callbackURI, subject, clientID, codeVerifier, false) + response, err := ctx.client.AccessToken(context.Background(), code, ctx.authzServerMetadata.TokenEndpoint, callbackURI, subject, clientID, "", codeVerifier, false) require.NoError(t, err) require.NotNil(t, response) assert.Equal(t, "token", response.AccessToken) assert.Equal(t, "bearer", response.TokenType) }) + t.Run("ok - client_secret_post when a client secret is configured", func(t *testing.T) { + ctx := createClientServerTestContext(t) + var gotClientID, gotClientSecret string + ctx.token = func(writer http.ResponseWriter, request *http.Request) { + _ = request.ParseForm() + gotClientID = request.PostFormValue("client_id") + gotClientSecret = request.PostFormValue("client_secret") + writer.Header().Add("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"access_token": "token", "token_type": "bearer"}`)) + } + + _, err := ctx.client.AccessToken(context.Background(), code, ctx.authzServerMetadata.TokenEndpoint, callbackURI, subject, clientID, "secret", codeVerifier, false) + + require.NoError(t, err) + assert.Equal(t, clientID, gotClientID) + assert.Equal(t, "secret", gotClientSecret) + }) + t.Run("ok - no client_secret sent for a public client", func(t *testing.T) { + ctx := createClientServerTestContext(t) + var hasSecret bool + ctx.token = func(writer http.ResponseWriter, request *http.Request) { + _ = request.ParseForm() + _, hasSecret = request.PostForm["client_secret"] + writer.Header().Add("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"access_token": "token", "token_type": "bearer"}`)) + } + + _, err := ctx.client.AccessToken(context.Background(), code, ctx.authzServerMetadata.TokenEndpoint, callbackURI, subject, clientID, "", codeVerifier, false) + + require.NoError(t, err) + assert.False(t, hasSecret, "public client must not send client_secret") + }) t.Run("ok - with DPoP", func(t *testing.T) { ctx := createClientServerTestContext(t) ctx.keyResolver.EXPECT().ResolveKey(clientDID, nil, resolver.NutsSigningKeyType).Return(kid, nil, nil) ctx.jwtSigner.EXPECT().SignDPoP(context.Background(), gomock.Any(), kid).Return("dpop", nil) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subject).Return([]did.DID{clientDID}, nil) - response, err := ctx.client.AccessToken(context.Background(), code, ctx.authzServerMetadata.TokenEndpoint, callbackURI, subject, clientID, codeVerifier, true) + response, err := ctx.client.AccessToken(context.Background(), code, ctx.authzServerMetadata.TokenEndpoint, callbackURI, subject, clientID, "", codeVerifier, true) require.NoError(t, err) require.NotNil(t, response) @@ -88,7 +120,7 @@ func TestIAMClient_AccessToken(t *testing.T) { ctx := createClientServerTestContext(t) ctx.token = nil - response, err := ctx.client.AccessToken(context.Background(), code, ctx.authzServerMetadata.TokenEndpoint, callbackURI, subject, clientID, codeVerifier, false) + response, err := ctx.client.AccessToken(context.Background(), code, ctx.authzServerMetadata.TokenEndpoint, callbackURI, subject, clientID, "", codeVerifier, false) assert.EqualError(t, err, "remote server: error creating access token: server returned HTTP 404 (expected: 200)") assert.Nil(t, response) @@ -99,7 +131,7 @@ func TestIAMClient_AccessToken(t *testing.T) { ctx.jwtSigner.EXPECT().SignDPoP(context.Background(), gomock.Any(), kid).Return("", assert.AnError) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subject).Return([]did.DID{clientDID}, nil) - response, err := ctx.client.AccessToken(context.Background(), code, ctx.authzServerMetadata.TokenEndpoint, callbackURI, subject, clientID, codeVerifier, true) + response, err := ctx.client.AccessToken(context.Background(), code, ctx.authzServerMetadata.TokenEndpoint, callbackURI, subject, clientID, "", codeVerifier, true) assert.Error(t, err) assert.Nil(t, response) diff --git a/auth/config.go b/auth/config.go index 78cf9ea95f..b06c1e76a7 100644 --- a/auth/config.go +++ b/auth/config.go @@ -41,6 +41,27 @@ type ExperimentalConfig struct { // JwtBearerClient enables the RFC 7523 jwt-bearer two-VP token request flow. // While disabled (the default), requests carrying a service-provider subject identifier are rejected. JwtBearerClient bool `koanf:"jwtbearerclient"` + // Clients configures OAuth client authentication for outbound flows against external authorization servers + // (currently only the OpenID4VCI authorization code flow). When the node initiates a flow against an + // authorization server whose identifier matches an entry's ServerURL, it presents the configured client_id + // (and client_secret, if set) instead of the did:web + entity_id defaults. + // + // EXPERIMENTAL: this configuration may change or be removed without further notice. + Clients []OAuthClientConfig `koanf:"clients"` +} + +// OAuthClientConfig holds client credentials the node presents to a specific external OAuth authorization server. +// +// EXPERIMENTAL: this configuration may change or be removed without further notice. +type OAuthClientConfig struct { + // ServerURL is the OAuth Authorization Server identifier (issuer) to match against. For OpenID4VCI this is + // the entry from the Credential Issuer Metadata's authorization_servers, not the credential_issuer URL. + ServerURL string `koanf:"serverurl"` + // ClientID is the client identifier registered at the authorization server. + ClientID string `koanf:"clientid"` + // ClientSecret authenticates the client at the token endpoint using client_secret_post. Optional: when empty + // the node acts as a public client (relying on PKCE). + ClientSecret string `koanf:"clientsecret"` } type AuthorizationEndpointConfig struct { diff --git a/auth/interface.go b/auth/interface.go index 894911652a..09a54cf5e2 100644 --- a/auth/interface.go +++ b/auth/interface.go @@ -37,6 +37,9 @@ type AuthenticationServices interface { IAMClient() iam.Client // OpenID4VCIClient returns the OpenID4VCI 1.0 HTTP client. OpenID4VCIClient() openid4vci.Client + // OAuthClientCredentials returns the configured OAuth client credentials for the given Authorization Server + // issuer, if an entry was configured under auth.experimental.clients. EXPERIMENTAL. + OAuthClientCredentials(authServerIssuer string) (*OAuthClientConfig, bool) // RelyingParty returns the oauth.RelyingParty RelyingParty() oauth.RelyingParty // ContractNotary returns an instance of ContractNotary diff --git a/auth/mock.go b/auth/mock.go index a04365018a..7e7a9e5b0b 100644 --- a/auth/mock.go +++ b/auth/mock.go @@ -100,6 +100,21 @@ func (mr *MockAuthenticationServicesMockRecorder) IAMClient() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IAMClient", reflect.TypeOf((*MockAuthenticationServices)(nil).IAMClient)) } +// OAuthClientCredentials mocks base method. +func (m *MockAuthenticationServices) OAuthClientCredentials(authServerIssuer string) (*OAuthClientConfig, bool) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "OAuthClientCredentials", authServerIssuer) + ret0, _ := ret[0].(*OAuthClientConfig) + ret1, _ := ret[1].(bool) + return ret0, ret1 +} + +// OAuthClientCredentials indicates an expected call of OAuthClientCredentials. +func (mr *MockAuthenticationServicesMockRecorder) OAuthClientCredentials(authServerIssuer any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OAuthClientCredentials", reflect.TypeOf((*MockAuthenticationServices)(nil).OAuthClientCredentials), authServerIssuer) +} + // OpenID4VCIClient mocks base method. func (m *MockAuthenticationServices) OpenID4VCIClient() openid4vci.Client { m.ctrl.T.Helper() diff --git a/auth/oauth/types.go b/auth/oauth/types.go index 380909c0ec..8cc5d95997 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -156,6 +156,8 @@ const ( AuthorizationDetailsParam = "authorization_details" // ClientIDParam is the parameter name for the client_id parameter. (RFC6749) ClientIDParam = "client_id" + // ClientSecretParam is the parameter name for the client_secret parameter, used for client_secret_post client authentication. (RFC6749 §2.3.1) + ClientSecretParam = "client_secret" // ClientIDSchemeParam is the parameter name for the client_id_scheme parameter. (OpenID4VP) ClientIDSchemeParam = "client_id_scheme" // ClientMetadataParam is the parameter name for the client_metadata parameter. (OpenID4VP) diff --git a/auth/oauth_clients_test.go b/auth/oauth_clients_test.go new file mode 100644 index 0000000000..364ab80cd9 --- /dev/null +++ b/auth/oauth_clients_test.go @@ -0,0 +1,86 @@ +/* + * Nuts node + * Copyright (C) 2026 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package auth + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func authWithClients(clients ...OAuthClientConfig) *Auth { + return &Auth{config: Config{Experimental: ExperimentalConfig{Clients: clients}}} +} + +func TestAuth_OAuthClientCredentials(t *testing.T) { + cfg := OAuthClientConfig{ServerURL: "https://issuer.example.com/oauth", ClientID: "client-1", ClientSecret: "secret"} + a := authWithClients(cfg) + + t.Run("match", func(t *testing.T) { + got, ok := a.OAuthClientCredentials("https://issuer.example.com/oauth") + require.True(t, ok) + assert.Equal(t, "client-1", got.ClientID) + assert.Equal(t, "secret", got.ClientSecret) + }) + t.Run("match ignores a trailing slash difference", func(t *testing.T) { + got, ok := a.OAuthClientCredentials("https://issuer.example.com/oauth/") + require.True(t, ok) + assert.Equal(t, "client-1", got.ClientID) + }) + t.Run("no match returns false", func(t *testing.T) { + got, ok := a.OAuthClientCredentials("https://other.example.com/oauth") + assert.False(t, ok) + assert.Nil(t, got) + }) + t.Run("no configured clients returns false", func(t *testing.T) { + got, ok := (&Auth{}).OAuthClientCredentials("https://issuer.example.com/oauth") + assert.False(t, ok) + assert.Nil(t, got) + }) +} + +func TestAuth_validateOAuthClients(t *testing.T) { + t.Run("valid", func(t *testing.T) { + a := authWithClients(OAuthClientConfig{ServerURL: "https://nuts-services.nl/oauth", ClientID: "client-1"}) + assert.NoError(t, a.validateOAuthClients(true)) + }) + t.Run("empty (no clients) is valid", func(t *testing.T) { + assert.NoError(t, (&Auth{}).validateOAuthClients(true)) + }) + t.Run("missing serverurl", func(t *testing.T) { + a := authWithClients(OAuthClientConfig{ClientID: "client-1"}) + assert.EqualError(t, a.validateOAuthClients(true), "auth.experimental.clients[0]: serverurl is required") + }) + t.Run("missing clientid", func(t *testing.T) { + a := authWithClients(OAuthClientConfig{ServerURL: "https://nuts-services.nl/oauth"}) + assert.EqualError(t, a.validateOAuthClients(true), "auth.experimental.clients[0]: clientid is required") + }) + t.Run("invalid serverurl in strict mode (not HTTPS)", func(t *testing.T) { + a := authWithClients(OAuthClientConfig{ServerURL: "http://nuts-services.nl/oauth", ClientID: "client-1"}) + assert.ErrorContains(t, a.validateOAuthClients(true), "auth.experimental.clients[0]: invalid serverurl") + }) + t.Run("duplicate serverurl", func(t *testing.T) { + a := authWithClients( + OAuthClientConfig{ServerURL: "https://nuts-services.nl/oauth", ClientID: "client-1"}, + OAuthClientConfig{ServerURL: "https://nuts-services.nl/oauth/", ClientID: "client-2"}, + ) + assert.ErrorContains(t, a.validateOAuthClients(true), "duplicate serverurl") + }) +} diff --git a/core/server_config.go b/core/server_config.go index 434300e72b..8216f604f7 100644 --- a/core/server_config.go +++ b/core/server_config.go @@ -53,6 +53,9 @@ var redactedConfigKeys = []string{ "storage.session.redis.password", "storage.session.redis.sentinel.password", "storage.sql.connection", + // auth.experimental.clients holds OAuth client_secret values. The whole subtree is redacted because the slice + // is logged as a single value (YAML) or as indexed keys (env vars), so a leaf-only match would miss one form. + "auth.experimental.clients", } // ServerConfig has global server settings. @@ -283,7 +286,8 @@ func FlagSet() *pflag.FlagSet { func (ngc *ServerConfig) PrintConfig() string { redacted := func(k string) bool { for _, key := range redactedConfigKeys { - if key == k { + // Match the key itself and any descendant (e.g. indexed array entries like ".0.clientsecret"). + if key == k || strings.HasPrefix(k, key+".") { return true } } diff --git a/core/server_config_test.go b/core/server_config_test.go index ab00a13339..8609287633 100644 --- a/core/server_config_test.go +++ b/core/server_config_test.go @@ -176,6 +176,24 @@ func TestNewNutsConfig_PrintConfig(t *testing.T) { assert.Contains(t, bs, "redactedKey -> (redacted)") assert.NotContains(t, bs, "redacted-value") }) + t.Run("redacts descendant keys (e.g. array entries)", func(t *testing.T) { + old := redactedConfigKeys + defer func() { + redactedConfigKeys = old + }() + redactedConfigKeys = []string{"auth.experimental.clients"} + + fs2 := pflag.FlagSet{} + fs2.String("auth.experimental.clients.0.clientsecret", "super-secret", "description") + cmd2 := testCommand() + cmd2.Flags().AddFlagSet(&fs2) + cfg2 := NewServerConfig() + cfg2.Load(cmd2.Flags()) + + bs := cfg2.PrintConfig() + assert.Contains(t, bs, "auth.experimental.clients.0.clientsecret -> (redacted)") + assert.NotContains(t, bs, "super-secret") + }) } func TestNewNutsConfig_InjectIntoEngine(t *testing.T) {