diff --git a/auth/api/auth/v1/api_test.go b/auth/api/auth/v1/api_test.go index a56cf91a45..63e2f033bc 100644 --- a/auth/api/auth/v1/api_test.go +++ b/auth/api/auth/v1/api_test.go @@ -66,6 +66,7 @@ type mockAuthClient struct { authzServer *oauth.MockAuthorizationServer relyingParty *oauth.MockRelyingParty verifier *oauth.MockVerifier + holder *oauth.MockHolder } func (m *mockAuthClient) V2APIEnabled() bool { @@ -84,6 +85,10 @@ func (m *mockAuthClient) Verifier() oauth.Verifier { return m.verifier } +func (m *mockAuthClient) Holder() oauth.Holder { + return m.holder +} + func (m *mockAuthClient) ContractNotary() services.ContractNotary { return m.contractNotary } @@ -100,6 +105,7 @@ func createContext(t *testing.T) *TestContext { relyingParty := oauth.NewMockRelyingParty(ctrl) verifier := oauth.NewMockVerifier(ctrl) mockCredentialResolver := vcr.NewMockResolver(ctrl) + holder := oauth.NewMockHolder(ctrl) authMock := &mockAuthClient{ ctrl: ctrl, @@ -107,6 +113,7 @@ func createContext(t *testing.T) *TestContext { authzServer: authzServer, relyingParty: relyingParty, verifier: verifier, + holder: holder, } requestCtx := audit.TestContext() diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 910fee1470..2ae5483358 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -265,17 +265,8 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho for key, value := range httpRequest.URL.Query() { params[key] = value[0] } + // todo: store session in database? Isn't session specific for a particular flow? session := createSession(params, *ownDID) - if session.RedirectURI == "" { - // TODO: Spec says that the redirect URI is optional, but it's not clear what to do if it's not provided. - // Threat models say it's unsafe to omit redirect_uri. - // See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 - return nil, oauth.OAuth2Error{ - Code: oauth.InvalidRequest, - Description: "redirect_uri is required", - } - } - // todo: store session in database? switch session.ResponseType { case responseTypeCode: @@ -303,9 +294,7 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho case responseTypeVPToken: // Options: // - OpenID4VP flow, vp_token is sent in Authorization Response - // TODO: Check parameters for right flow - // TODO: Do we actually need this? (probably not) - panic("not implemented") + return r.handleAuthorizeRequestFromVerifier(ctx, *ownDID, params) case responseTypeVPIDToken: // Options: // - OpenID4VP+SIOP flow, vp_token is sent in Authorization Response diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 7bd85af959..a661584075 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -223,19 +223,23 @@ func TestWrapper_PresentationDefinition(t *testing.T) { } func TestWrapper_HandleAuthorizeRequest(t *testing.T) { - metadata := oauth.AuthorizationServerMetadata{ - AuthorizationEndpoint: "https://example.com/holder/authorize", + serverMetadata := oauth.AuthorizationServerMetadata{ + AuthorizationEndpoint: "https://example.com/holder/authorize", + ClientIdSchemesSupported: []string{"did"}, } - t.Run("ok - from holder", func(t *testing.T) { + clientMetadata := oauth.OAuthClientMetadata{ + VPFormats: oauth.DefaultOpenIDSupportedFormats(), + } + t.Run("ok - code response type - from holder", func(t *testing.T) { ctx := newTestClient(t) ctx.vdr.EXPECT().IsOwner(gomock.Any(), verifierDID).Return(true, nil) - ctx.verifierRole.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(&metadata, nil) + ctx.verifierRole.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(&serverMetadata, nil) ctx.verifierRole.EXPECT().ClientMetadataURL(verifierDID).Return(test.MustParseURL("https://example.com/.well-known/authorization-server/iam/verifier"), nil) res, err := ctx.client.HandleAuthorizeRequest(requestContext(map[string]string{ clientIDParam: holderDID.String(), redirectURIParam: "https://example.com", - responseTypeParam: "code", + responseTypeParam: responseTypeCode, scopeParam: "test", }), HandleAuthorizeRequestRequestObject{ Id: "verifier", @@ -255,16 +259,42 @@ func TestWrapper_HandleAuthorizeRequest(t *testing.T) { assert.Contains(t, location, "response_type=vp_token") }) - t.Run("missing redirect_uri", func(t *testing.T) { + t.Run("ok - vp_token response type - from verifier", func(t *testing.T) { ctx := newTestClient(t) - ctx.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(true, nil) + _ = ctx.client.storageEngine.GetSessionDatabase().GetStore(oAuthFlowTimeout, oauthClientStateKey...).Put("state", OAuthSession{ + // this is the state from the holder that was stored at the creation of the first authorization request to the verifier + ClientID: holderDID.String(), + Scope: "test", + OwnDID: holderDID, + ClientState: "state", + RedirectURI: "https://example.com/iam/holder/cb", + ResponseType: "code", + }) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), holderDID).Return(true, nil) + ctx.holderRole.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/iam/verifier").Return(&clientMetadata, nil) + ctx.holderRole.EXPECT().PresentationDefinition(gomock.Any(), "https://example.com/iam/verifier/presentation_definition?scope=test").Return(&pe.PresentationDefinition{}, nil) + ctx.holderRole.EXPECT().BuildPresentation(gomock.Any(), holderDID, pe.PresentationDefinition{}, clientMetadata.VPFormats, "nonce").Return(&vc.VerifiablePresentation{}, &pe.PresentationSubmission{}, nil) + ctx.holderRole.EXPECT().PostAuthorizationResponse(gomock.Any(), vc.VerifiablePresentation{}, pe.PresentationSubmission{}, "https://example.com/iam/verifier/response", "state").Return("https://example.com/iam/holder/redirect", nil) - res, err := ctx.client.HandleAuthorizeRequest(requestContext(map[string]string{}), HandleAuthorizeRequestRequestObject{ - Id: webIDPart, + res, err := ctx.client.HandleAuthorizeRequest(requestContext(map[string]string{ + clientIDParam: verifierDID.String(), + clientIDSchemeParam: didScheme, + clientMetadataURIParam: "https://example.com/.well-known/authorization-server/iam/verifier", + nonceParam: "nonce", + presentationDefUriParam: "https://example.com/iam/verifier/presentation_definition?scope=test", + responseURIParam: "https://example.com/iam/verifier/response", + responseModeParam: responseModeDirectPost, + responseTypeParam: responseTypeVPToken, + scopeParam: "test", + stateParam: "state", + }), HandleAuthorizeRequestRequestObject{ + Id: "holder", }) - requireOAuthError(t, err, oauth.InvalidRequest, "redirect_uri is required") - assert.Nil(t, res) + require.NoError(t, err) + assert.IsType(t, HandleAuthorizeRequest302Response{}, res) + location := res.(HandleAuthorizeRequest302Response).Headers.Location + assert.Equal(t, location, "https://example.com/iam/holder/redirect") }) t.Run("unsupported response type", func(t *testing.T) { ctx := newTestClient(t) @@ -420,12 +450,13 @@ type testCtx struct { client *Wrapper authnServices *auth.MockAuthenticationServices vdr *vdr.MockVDR - policy *policy.MockBackend + policy *policy.MockPDPBackend resolver *resolver.MockDIDResolver relyingParty *oauthServices.MockRelyingParty vcVerifier *verifier.MockVerifier vcr *vcr.MockVCR verifierRole *oauthServices.MockVerifier + holderRole *oauthServices.MockHolder } func newTestClient(t testing.TB) *testCtx { @@ -435,11 +466,12 @@ func newTestClient(t testing.TB) *testCtx { storageEngine := storage.NewTestStorageEngine(t) authnServices := auth.NewMockAuthenticationServices(ctrl) authnServices.EXPECT().PublicURL().Return(publicURL).AnyTimes() - policyInstance := policy.NewMockBackend(ctrl) + policyInstance := policy.NewMockPDPBackend(ctrl) mockResolver := resolver.NewMockDIDResolver(ctrl) relyingPary := oauthServices.NewMockRelyingParty(ctrl) vcVerifier := verifier.NewMockVerifier(ctrl) verifierRole := oauthServices.NewMockVerifier(ctrl) + holderRole := oauthServices.NewMockHolder(ctrl) mockVDR := vdr.NewMockVDR(ctrl) mockVCR := vcr.NewMockVCR(ctrl) @@ -447,6 +479,7 @@ func newTestClient(t testing.TB) *testCtx { authnServices.EXPECT().RelyingParty().Return(relyingPary).AnyTimes() mockVCR.EXPECT().Verifier().Return(vcVerifier).AnyTimes() authnServices.EXPECT().Verifier().Return(verifierRole).AnyTimes() + authnServices.EXPECT().Holder().Return(holderRole).AnyTimes() mockVDR.EXPECT().Resolver().Return(mockResolver).AnyTimes() return &testCtx{ @@ -458,6 +491,7 @@ func newTestClient(t testing.TB) *testCtx { resolver: mockResolver, vdr: mockVDR, verifierRole: verifierRole, + holderRole: holderRole, vcr: mockVCR, client: &Wrapper{ auth: authnServices, diff --git a/auth/api/iam/openid4vp.go b/auth/api/iam/openid4vp.go index cef85def7f..1354e27438 100644 --- a/auth/api/iam/openid4vp.go +++ b/auth/api/iam/openid4vp.go @@ -26,6 +26,7 @@ import ( "fmt" "net/http" "net/url" + "slices" "strings" "github.com/google/uuid" @@ -34,8 +35,10 @@ import ( "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" + oauthServices "github.com/nuts-foundation/nuts-node/auth/services/oauth" "github.com/nuts-foundation/nuts-node/crypto" httpNuts "github.com/nuts-foundation/nuts-node/http" + "github.com/nuts-foundation/nuts-node/network/log" "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/holder" @@ -45,18 +48,20 @@ import ( var oauthNonceKey = []string{"oauth", "nonce"} +// handleAuthorizeRequestFromHolder handles an Authorization Request as specified by OpenID4VP: https://openid.net/specs/openid-4-verifiable-presentations-1_0.html. +// we expect a generic OAuth2 request like this: +// GET /iam/123/authorize?response_type=token&client_id=did:web:example.com:iam:456&state=xyz +// +// &redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1 +// Host: server.com +// +// The following parameters are expected +// response_type, REQUIRED. Value MUST be set to "code". (Already checked by caller) +// client_id, REQUIRED. This must be a did:web +// redirect_uri, REQUIRED. This must be the client or other node url (client for regular flow, node for popup) +// scope, OPTIONAL. The scope that maps to a presentation definition, if not set we just want an empty VP +// state, RECOMMENDED. Opaque value used to maintain state between the request and the callback. func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, verifier did.DID, params map[string]string) (HandleAuthorizeRequestResponseObject, error) { - // we expect a generic OAuth2 request like this: - // GET /iam/123/authorize?response_type=token&client_id=did:web:example.com:iam:456&state=xyz - // &redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1 - // Host: server.com - // The following parameters are expected - // response_type, REQUIRED. Value MUST be set to "token". - // client_id, REQUIRED. This must be a did:web - // redirect_uri, REQUIRED. This must be the client or other node url (client for regular flow, node for popup) - // scope, OPTIONAL. The scope that maps to a presentation definition, if not set we just want an empty VP - // state, RECOMMENDED. Opaque value used to maintain state between the request and the callback. - // first we check the redirect URL because later errors will redirect to this URL // from RFC6749: // If the request fails due to a missing, invalid, or mismatching @@ -71,16 +76,13 @@ func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, verifier } redirectURL, err := url.Parse(redirectURI) if err != nil { - // todo render error page instead of technical error + // todo render error page instead of technical error (via errorWriter) return nil, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "invalid redirect_uri parameter"} } // now we have a valid redirectURL, so all future errors will redirect to this URL using the Oauth2ErrorWriter // GET authorization server metadata for wallet - walletID, ok := params[clientIDParam] - if !ok { - return nil, oauthError(oauth.InvalidRequest, "missing client_id parameter", redirectURL) - } + walletID := params[clientIDParam] // the walletDID must be a did:web walletDID, err := did.ParseDID(walletID) if err != nil || walletDID.Method != "web" { @@ -105,6 +107,8 @@ func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, verifier // like this: // GET /authorize? // response_type=vp_token + // &client_id_scheme=did + // &client_metadata_uri=https%3A%2F%2Fexample.com%2Fiam%2F123%2F%2Fclient_metadata // &client_id=did:web:example.com:iam:123 // &client_id_scheme=did // &client_metadata_uri=https%3A%2F%2Fexample.com%2F.well-known%2Fauthorization-server%2Fiam%2F123%2F%2F @@ -128,12 +132,17 @@ func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, verifier return nil, oauthError(oauth.ServerError, "failed to construct metadata URL", redirectURL) } + // check metadata for supported client_id_schemes + if !slices.Contains(metadata.ClientIdSchemesSupported, didScheme) { + return nil, oauthError(oauth.InvalidRequest, "wallet metadata does not contain did in client_id_schemes_supported", redirectURL) + } + // todo: because of the did scheme, the request needs to be signed using JAR according to ยง5.7 of the openid4vp spec authServerURL := httpNuts.AddQueryParams(*walletURL, map[string]string{ responseTypeParam: responseTypeVPToken, - clientIDParam: verifier.String(), clientIDSchemeParam: didScheme, + clientIDParam: verifier.String(), responseURIParam: callbackURL.String(), presentationDefUriParam: presentationDefinitionURI.String(), clientMetadataURIParam: metadataURL.String(), @@ -159,6 +168,124 @@ func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, verifier }, nil } +// handleAuthorizeRequestFromVerifier handles an Authorization Request for a wallet from a verifier as specified by OpenID4VP: https://openid.net/specs/openid-4-verifiable-presentations-1_0.html. +// we expect an OpenID4VP request like this +// GET /iam/456/authorize?response_type=vp_token&client_id=did:web:example.com:iam:123&nonce=xyz +// &response_mode=direct_post&response_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb&presentation_definition_uri=example.com%2Fiam%2F123%2Fpresentation_definition?scope=a+b HTTP/1.1 +// Host: server.com +// The following parameters are expected +// response_type, REQUIRED. Value MUST be set to "vp_token". +// client_id, REQUIRED. This must be a did:web +// response_uri, REQUIRED. This must be the verifier node url +// response_mode, REQUIRED. Value MUST be "direct_post" +// presentation_definition_uri, REQUIRED. For getting the presentation definition + +// there are way more error conditions that listed at: https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#name-error-response +// missing or invalid parameters are all mapped to invalid_request +// any operation that fails is mapped to server_error, this includes unreachable or broken backends. +func (r Wrapper) handleAuthorizeRequestFromVerifier(ctx context.Context, walletDID did.DID, params map[string]string) (HandleAuthorizeRequestResponseObject, error) { + responseMode := params[responseModeParam] + if responseMode != responseModeDirectPost { + return nil, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "invalid response_mode parameter"} + } + // check the response URL because later errors will redirect to this URL + responseURI, responseOK := params[responseURIParam] + if !responseOK { + return nil, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "missing response_uri parameter"} + } + clientIDScheme := params[clientIDSchemeParam] + if clientIDScheme != didScheme { + return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "invalid client_id_scheme parameter"}, responseURI) + } + verifierID := params[clientIDParam] + // the verifier must be a did:web + verifierDID, err := did.ParseDID(verifierID) + if err != nil || verifierDID.Method != "web" { + return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "invalid client_id parameter (only did:web is supported)"}, responseURI) + } + nonce, ok := params[nonceParam] + if !ok { + return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "missing nonce parameter"}, responseURI) + } + // get verifier metadata + clientMetadataURI := params[clientMetadataURIParam] + // we ignore any client_metadata, but officially an error must be returned when that param is present. + metadata, err := r.auth.Holder().ClientMetadata(ctx, clientMetadataURI) + if err != nil { + return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.ServerError, Description: "failed to get client metadata (verifier)"}, responseURI) + } + // get presentation_definition from presentation_definition_uri + presentationDefinitionURI := params[presentationDefUriParam] + presentationDefinition, err := r.auth.Holder().PresentationDefinition(ctx, presentationDefinitionURI) + if err != nil { + return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidPresentationDefinitionURI, Description: fmt.Sprintf("failed to retrieve presentation definition on %s", presentationDefinitionURI)}, responseURI) + } + + // at this point in the flow it would be possible to ask the user to confirm the credentials to use + + // all params checked, delegate responsibility to the holder + vp, submission, err := r.auth.Holder().BuildPresentation(ctx, walletDID, *presentationDefinition, metadata.VPFormats, nonce) + if err != nil { + if errors.Is(err, oauthServices.ErrNoCredentials) { + return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "no credentials available"}, responseURI) + } + return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.ServerError, Description: err.Error()}, responseURI) + } + + // any error here is a server error, might need a fixup to prevent exposing to a user + return r.sendAndHandleDirectPost(ctx, *vp, *submission, responseURI, params[stateParam]) +} + +// sendAndHandleDirectPost sends OpenID4VP direct_post to the verifier. The verifier responds with a redirect to the client (including error fields if needed). +// If the direct post fails, the user-agent will be redirected back to the client with an error. (Original redirect_uri). +func (r Wrapper) sendAndHandleDirectPost(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI string, state string) (HandleAuthorizeRequestResponseObject, error) { + redirectURI, err := r.auth.Holder().PostAuthorizationResponse(ctx, vp, presentationSubmission, verifierResponseURI, state) + if err != nil { + return nil, err + } + + return HandleAuthorizeRequest302Response{ + HandleAuthorizeRequest302ResponseHeaders{ + Location: redirectURI, + }, + }, nil +} + +// sendAndHandleDirectPostError sends errors from handleAuthorizeRequestFromVerifier as direct_post to the verifier. The verifier responds with a redirect to the client (including error fields). +// If the direct post fails, the user-agent will be redirected back to the client with an error. (Original redirect_uri). +// If no redirect_uri is present, the user-agent will be redirected to the error page. +func (r Wrapper) sendAndHandleDirectPostError(ctx context.Context, auth2Error oauth.OAuth2Error, verifierResponseURI string) (HandleAuthorizeRequestResponseObject, error) { + redirectURI, err := r.auth.Holder().PostError(ctx, auth2Error, verifierResponseURI) + if err == nil { + return HandleAuthorizeRequest302Response{ + HandleAuthorizeRequest302ResponseHeaders{ + Location: redirectURI, + }, + }, nil + } + + msg := fmt.Sprintf("failed to post error to verifier @ %s", verifierResponseURI) + log.Logger().WithError(err).Error(msg) + + if auth2Error.RedirectURI == nil { + // render error page because all else failed, in a correct flow this should never happen + // it could be the case that the client state has just expired, so no redirectURI is present and the verifier is not responding + log.Logger().WithError(err).Error("failed to post error to verifier and no clientRedirectURI present") + return nil, oauth.OAuth2Error{Code: oauth.ServerError, Description: "something went wrong"} + } + + // clientRedirectURL has been checked earlier in te process. + clientRedirectURL := httpNuts.AddQueryParams(*auth2Error.RedirectURI, map[string]string{ + oauth.ErrorParam: string(oauth.ServerError), + oauth.ErrorDescriptionParam: msg, + }) + return HandleAuthorizeRequest302Response{ + HandleAuthorizeRequest302ResponseHeaders{ + Location: clientRedirectURL.String(), + }, + }, nil +} + // createPresentationRequest creates a new Authorization Request as specified by OpenID4VP: https://openid.net/specs/openid-4-verifiable-presentations-1_0.html. // It is sent by a verifier to a wallet, to request one or more verifiable credentials as verifiable presentation from the wallet. func (r Wrapper) sendPresentationRequest(ctx context.Context, response http.ResponseWriter, scope string, diff --git a/auth/api/iam/openid4vp_test.go b/auth/api/iam/openid4vp_test.go index db20b8db81..40ef2227b7 100644 --- a/auth/api/iam/openid4vp_test.go +++ b/auth/api/iam/openid4vp_test.go @@ -26,22 +26,26 @@ import ( "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth" "github.com/nuts-foundation/nuts-node/auth/oauth" + "net/http" + "net/url" + "testing" + + oauth2 "github.com/nuts-foundation/nuts-node/auth/services/oauth" "github.com/nuts-foundation/nuts-node/policy" "github.com/nuts-foundation/nuts-node/storage" + "github.com/nuts-foundation/nuts-node/test" "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/holder" + "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" - "net/http" - "net/url" - "testing" ) -var holderDID = did.MustParseDID("did:web:example.com:holder") -var issuerDID = did.MustParseDID("did:web:example.com:issuer") +var holderDID = did.MustParseDID("did:web:example.com:iam:holder") +var issuerDID = did.MustParseDID("did:web:example.com:iam:issuer") func TestWrapper_handleAuthorizeRequestFromHolder(t *testing.T) { defaultParams := func() map[string]string { @@ -53,23 +57,27 @@ func TestWrapper_handleAuthorizeRequestFromHolder(t *testing.T) { } } - t.Run("missing client_id", func(t *testing.T) { + t.Run("invalid client_id", func(t *testing.T) { ctx := newTestClient(t) params := defaultParams() - delete(params, clientIDParam) + params[clientIDParam] = "did:nuts:1" _, err := ctx.client.handleAuthorizeRequestFromHolder(context.Background(), verifierDID, params) - requireOAuthError(t, err, oauth.InvalidRequest, "missing client_id parameter") + requireOAuthError(t, err, oauth.InvalidRequest, "invalid client_id parameter (only did:web is supported)") }) - t.Run("invalid client_id", func(t *testing.T) { + t.Run("missing did in supported_client_id_schemes", func(t *testing.T) { ctx := newTestClient(t) params := defaultParams() - params[clientIDParam] = "did:nuts:1" + ctx.verifierRole.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(&oauth.AuthorizationServerMetadata{ + AuthorizationEndpoint: "http://example.com", + ClientIdSchemesSupported: []string{"not_did"}, + }, nil) + ctx.verifierRole.EXPECT().ClientMetadataURL(verifierDID).Return(test.MustParseURL("https://example.com/.well-known/authorization-server/iam/verifier"), nil) _, err := ctx.client.handleAuthorizeRequestFromHolder(context.Background(), verifierDID, params) - requireOAuthError(t, err, oauth.InvalidRequest, "invalid client_id parameter (only did:web is supported)") + requireOAuthError(t, err, oauth.InvalidRequest, "wallet metadata does not contain did in client_id_schemes_supported") }) t.Run("error on authorization server metadata", func(t *testing.T) { ctx := newTestClient(t) @@ -100,6 +108,189 @@ func TestWrapper_handleAuthorizeRequestFromHolder(t *testing.T) { }) } +func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { + responseURI := "https://example.com/iam/verifier/response" + clientMetadata := oauth.OAuthClientMetadata{ + VPFormats: oauth.DefaultOpenIDSupportedFormats(), + } + defaultParams := func() map[string]string { + return map[string]string{ + clientIDParam: verifierDID.String(), + clientIDSchemeParam: didScheme, + clientMetadataURIParam: "https://example.com/.well-known/authorization-server/iam/verifier", + nonceParam: "nonce", + presentationDefUriParam: "https://example.com/iam/verifier/presentation_definition?scope=test", + responseModeParam: responseModeDirectPost, + responseURIParam: responseURI, + responseTypeParam: responseTypeVPToken, + scopeParam: "test", + } + } + + t.Run("invalid client_id", func(t *testing.T) { + ctx := newTestClient(t) + params := defaultParams() + params[clientIDParam] = "did:nuts:1" + expectPostError(t, ctx, oauth.InvalidRequest, "invalid client_id parameter (only did:web is supported)", responseURI) + + _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params) + + require.NoError(t, err) + }) + t.Run("invalid client_id_scheme", func(t *testing.T) { + ctx := newTestClient(t) + params := defaultParams() + params[clientIDSchemeParam] = "other" + expectPostError(t, ctx, oauth.InvalidRequest, "invalid client_id_scheme parameter", responseURI) + + _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params) + + require.NoError(t, err) + }) + t.Run("missing client_metadata_uri", func(t *testing.T) { + ctx := newTestClient(t) + params := defaultParams() + delete(params, clientMetadataURIParam) + ctx.holderRole.EXPECT().ClientMetadata(gomock.Any(), "").Return(nil, assert.AnError) + expectPostError(t, ctx, oauth.ServerError, "failed to get client metadata (verifier)", responseURI) + + _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params) + + require.NoError(t, err) + }) + t.Run("missing nonce", func(t *testing.T) { + ctx := newTestClient(t) + params := defaultParams() + delete(params, nonceParam) + expectPostError(t, ctx, oauth.InvalidRequest, "missing nonce parameter", responseURI) + + _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params) + + require.NoError(t, err) + }) + t.Run("invalid presentation_definition_uri", func(t *testing.T) { + ctx := newTestClient(t) + params := defaultParams() + params[presentationDefUriParam] = "://example.com" + ctx.holderRole.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/iam/verifier").Return(nil, assert.AnError) + expectPostError(t, ctx, oauth.ServerError, "failed to get client metadata (verifier)", responseURI) + + _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params) + + require.NoError(t, err) + }) + t.Run("missing response_mode", func(t *testing.T) { + ctx := newTestClient(t) + params := defaultParams() + delete(params, responseModeParam) + + _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params) + + assert.EqualError(t, err, "invalid_request - invalid response_mode parameter") + }) + t.Run("missing response_uri", func(t *testing.T) { + ctx := newTestClient(t) + params := defaultParams() + delete(params, responseURIParam) + + _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params) + + assert.EqualError(t, err, "invalid_request - missing response_uri parameter") + }) + t.Run("missing state and missing response_uri", func(t *testing.T) { + ctx := newTestClient(t) + params := defaultParams() + delete(params, responseURIParam) + + _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params) + + require.Error(t, err) + }) + t.Run("invalid presentation_definition_uri", func(t *testing.T) { + ctx := newTestClient(t) + params := defaultParams() + ctx.holderRole.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/iam/verifier").Return(&clientMetadata, nil) + ctx.holderRole.EXPECT().PresentationDefinition(gomock.Any(), "https://example.com/iam/verifier/presentation_definition?scope=test").Return(nil, assert.AnError) + expectPostError(t, ctx, oauth.InvalidPresentationDefinitionURI, "failed to retrieve presentation definition on https://example.com/iam/verifier/presentation_definition?scope=test", responseURI) + + _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params) + + require.NoError(t, err) + }) + t.Run("failed to create verifiable presentation", func(t *testing.T) { + ctx := newTestClient(t) + params := defaultParams() + ctx.holderRole.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/iam/verifier").Return(&clientMetadata, nil) + ctx.holderRole.EXPECT().PresentationDefinition(gomock.Any(), "https://example.com/iam/verifier/presentation_definition?scope=test").Return(&pe.PresentationDefinition{}, nil) + ctx.holderRole.EXPECT().BuildPresentation(gomock.Any(), holderDID, pe.PresentationDefinition{}, clientMetadata.VPFormats, "nonce").Return(nil, nil, assert.AnError) + expectPostError(t, ctx, oauth.ServerError, assert.AnError.Error(), responseURI) + + _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params) + + require.NoError(t, err) + }) + t.Run("missing credentials in wallet", func(t *testing.T) { + ctx := newTestClient(t) + params := defaultParams() + ctx.holderRole.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/iam/verifier").Return(&clientMetadata, nil) + ctx.holderRole.EXPECT().PresentationDefinition(gomock.Any(), "https://example.com/iam/verifier/presentation_definition?scope=test").Return(&pe.PresentationDefinition{}, nil) + ctx.holderRole.EXPECT().BuildPresentation(gomock.Any(), holderDID, pe.PresentationDefinition{}, clientMetadata.VPFormats, "nonce").Return(nil, nil, oauth2.ErrNoCredentials) + expectPostError(t, ctx, oauth.InvalidRequest, "no credentials available", responseURI) + + _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params) + + require.NoError(t, err) + }) +} + +// expectPostError is a convenience method to add an expectation to the holderRole mock. +// it checks if the right error is posted to the verifier. +func expectPostError(t *testing.T, ctx *testCtx, errorCode oauth.ErrorCode, description string, expectedResponseURI string) { + ctx.holderRole.EXPECT().PostError(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, err oauth.OAuth2Error, responseURI string) (string, error) { + assert.Equal(t, errorCode, err.Code) + assert.Equal(t, description, err.Description) + assert.Equal(t, expectedResponseURI, responseURI) + return "redirect", nil + }) +} + +func TestWrapper_sendAndHandleDirectPost(t *testing.T) { + t.Run("failed to post response", func(t *testing.T) { + ctx := newTestClient(t) + ctx.holderRole.EXPECT().PostAuthorizationResponse(gomock.Any(), gomock.Any(), gomock.Any(), "response", "").Return("", assert.AnError) + _, err := ctx.client.sendAndHandleDirectPost(context.Background(), vc.VerifiablePresentation{}, pe.PresentationSubmission{}, "response", "") + + require.Error(t, err) + }) +} + +func TestWrapper_sendAndHandleDirectPostError(t *testing.T) { + t.Run("failed to post error with redirect available", func(t *testing.T) { + ctx := newTestClient(t) + ctx.holderRole.EXPECT().PostError(gomock.Any(), gomock.Any(), "response").Return("", assert.AnError) + redirectURI := test.MustParseURL("https://example.com/redirect") + expected := HandleAuthorizeRequest302Response{ + Headers: HandleAuthorizeRequest302ResponseHeaders{ + Location: "https://example.com/redirect?error=server_error&error_description=failed+to+post+error+to+verifier+%40+response", + }, + } + + redirect, err := ctx.client.sendAndHandleDirectPostError(context.Background(), oauth.OAuth2Error{RedirectURI: redirectURI}, "response") + + require.NoError(t, err) + assert.Equal(t, expected, redirect) + }) + t.Run("failed to post error without redirect available", func(t *testing.T) { + ctx := newTestClient(t) + ctx.holderRole.EXPECT().PostError(gomock.Any(), gomock.Any(), "response").Return("", assert.AnError) + + _, err := ctx.client.sendAndHandleDirectPostError(context.Background(), oauth.OAuth2Error{}, "response") + + require.Error(t, err) + require.Equal(t, "server_error - something went wrong", err.Error()) + }) +} + func TestWrapper_sendPresentationRequest(t *testing.T) { instance := New(nil, nil, nil, nil, nil) @@ -153,7 +344,7 @@ func TestWrapper_handlePresentationRequest(t *testing.T) { mockVDR := vdr.NewMockVDR(ctrl) mockVCR := vcr.NewMockVCR(ctrl) mockWallet := holder.NewMockWallet(ctrl) - mockPolicy := policy.NewMockBackend(ctrl) + mockPolicy := policy.NewMockPDPBackend(ctrl) mockVCR.EXPECT().Wallet().Return(mockWallet) mockAuth := auth.NewMockAuthenticationServices(ctrl) mockWallet.EXPECT().List(gomock.Any(), holderDID).Return(walletCredentials, nil) diff --git a/auth/api/iam/types.go b/auth/api/iam/types.go index 8259046e58..95093b5262 100644 --- a/auth/api/iam/types.go +++ b/auth/api/iam/types.go @@ -48,6 +48,9 @@ type TokenResponse = oauth.TokenResponse // OAuthAuthorizationServerMetadata is an alias type OAuthAuthorizationServerMetadata = oauth.AuthorizationServerMetadata +// OAuthClientMetadata is an alias +type OAuthClientMetadata = oauth.OAuthClientMetadata + const ( sessionExpiry = 5 * time.Minute ) @@ -158,74 +161,3 @@ const presentationSubmissionParam = "presentation_submission" // vpTokenParam is the name of the OpenID4VP vp_token parameter. // Specified by https://openid.bitbucket.io/connect/openid-4-verifiable-presentations-1_0.html#name-response-type-vp_token const vpTokenParam = "vp_token" - -// OAuthClientMetadata defines the OAuth Client metadata. -// Specified by https://www.rfc-editor.org/rfc/rfc7591.html and elsewhere. -type OAuthClientMetadata struct { - // RedirectURIs lists all URIs that the client may use in any redirect-based flow. - // From https://www.rfc-editor.org/rfc/rfc7591.html - RedirectURIs []string `json:"redirect_uris,omitempty"` - - // TODO: What do we use? Must provide a value if its not "client_secret_basic" - // TokenEndpointAuthMethod indicator of the requested authentication method for the token endpoint. - // If unspecified or omitted, the default is "client_secret_basic", denoting the HTTP Basic authentication scheme as specified in Section 2.3.1 of OAuth 2.0. - // Examples are: none, client_secret_post, client_secret_basic, tls_client_auth. - // From https://www.rfc-editor.org/rfc/rfc7591.html - // TODO: Can "tls_client_auth" replace /n2n/ for pre-authorized_code flow? https://www.rfc-editor.org/rfc/rfc8705.html - TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` - - // GrantTypes lists all supported grant_types. Defaults to "authorization_code" if omitted. - // From https://www.rfc-editor.org/rfc/rfc7591.html - GrantTypes []string `json:"grant_types,omitempty"` - - // ResponseTypes lists all supported response_types. Defaults to "code". Must contain the values corresponding to listed GrantTypes. - // From https://www.rfc-editor.org/rfc/rfc7591.html - ResponseTypes []string `json:"response_types,omitempty"` - - // Scope contains a space-separated list of scopes the client can request. - // From https://www.rfc-editor.org/rfc/rfc7591.html - // TODO: I don't see the use for this. The idea is that an AS does not assign scopes to a client that it does not support (or wants to request at any time), but seems like unnecessary complexity for minimal safety. - Scope string `json:"scope,omitempty"` - - // Contacts contains an array of strings representing ways to contact people responsible for this client, typically email addresses. - // From https://www.rfc-editor.org/rfc/rfc7591.html - // TODO: remove? Can plug DID docs contact info. - Contacts []string `json:"contacts,omitempty"` - - // JwksURI URL string referencing the client's JSON Web Key (JWK) Set [RFC7517] document, which contains the client's public keys. - // From https://www.rfc-editor.org/rfc/rfc7591.html - // TODO: remove? Can list the DID's keys. Could be useful if authorization without DIDs/VCs is needed. - // TODO: In EBSI it is a required field for the Service Wallet Metadata https://api-conformance.ebsi.eu/docs/ct/providers-and-wallets-metadata#service-wallet-metadata - JwksURI string `json:"jwks_uri,omitempty"` - // Jwks includes the JWK Set of a client. Mutually exclusive with JwksURI. - // From https://www.rfc-editor.org/rfc/rfc7591.html - Jwks any `json:"jwks,omitempty"` - - // SoftwareID is a unique identifier string (e.g., a Universally Unique Identifier (UUID)) assigned by the client developer. - // From https://www.rfc-editor.org/rfc/rfc7591.html - SoftwareID string `json:"software_id,omitempty"` - // SoftwareVersion is a version identifier string for the client software identified by "software_id". - // From https://www.rfc-editor.org/rfc/rfc7591.html - // TODO: Including a software_id + software_version could provide us with some upgrade paths in the future. - SoftwareVersion string `json:"software_version,omitempty"` - - // TODO: ignored values: client_name, client_uri, logo_uri, tos_uri, policy_uri. - // TODO: Things like client_name and logo may enhance the user experience when asking to accept authorization requests, but this should probably be added on the server size for that? - - /*********** OpenID4VCI ***********/ - - // CredentialOfferEndpoint contains a URL where the pre-authorized_code flow offers a credential. - // https://openid.bitbucket.io/connect/openid-4-verifiable-credential-issuance-1_0.html#name-client-metadata - // TODO: openid4vci duplicate. Also defined on /.well-known/openid-credential-wallet to be /n2n/identity/{did}/openid4vci/credential_offer - CredentialOfferEndpoint string `json:"credential_offer_endpoint,omitempty"` - - /*********** OpenID4VP ***********/ - // VPFormats lists the vp_formats supported by the client. See additional comments on vpFormatsSupported. - // https://openid.bitbucket.io/connect/openid-4-verifiable-presentations-1_0.html#name-verifier-metadata-client-me - VPFormats any `json:"vp_formats,omitempty"` - - // ClientIdScheme is a string identifying the Client Identifier scheme. The value range defined by this specification is - // pre-registered, redirect_uri, entity_id, did. If omitted, the default value is pre-registered. - // https://openid.bitbucket.io/connect/openid-4-verifiable-presentations-1_0.html#name-verifier-metadata-client-me - ClientIdScheme string `json:"client_id_scheme,omitempty"` -} diff --git a/auth/api/iam/user.go b/auth/api/iam/user.go index 1cfda26719..d89d859818 100644 --- a/auth/api/iam/user.go +++ b/auth/api/iam/user.go @@ -48,7 +48,7 @@ var oauthClientStateKey = []string{"oauth", "client_state"} var userRedirectSessionKey = []string{"user", "redirect"} var userSessionKey = []string{"user", "session"} -func (r *Wrapper) requestUserAccessToken(_ context.Context, requester did.DID, request RequestAccessTokenRequestObject) (RequestAccessTokenResponseObject, error) { +func (r Wrapper) requestUserAccessToken(_ context.Context, requester did.DID, request RequestAccessTokenRequestObject) (RequestAccessTokenResponseObject, error) { // generate a redirect token valid for 5 seconds token := crypto.GenerateNonce() store := r.userRedirectStore() @@ -78,7 +78,7 @@ func (r *Wrapper) requestUserAccessToken(_ context.Context, requester did.DID, r // handleUserLanding is the handler for the landing page of the user. // It renders the page with the correct context based on the token. -func (r *Wrapper) handleUserLanding(echoCtx echo.Context) error { +func (r Wrapper) handleUserLanding(echoCtx echo.Context) error { // todo: user authentication is currently not implemented, user consent is not implemented // This means that this handler succeeds if the token is valid // It only checks for an existing RequestAccessTokenRequestObject in the store @@ -136,14 +136,14 @@ func (r *Wrapper) handleUserLanding(echoCtx echo.Context) error { return echoCtx.Redirect(http.StatusFound, redirectURL.String()) } -func (r *Wrapper) userRedirectStore() storage.SessionStore { +func (r Wrapper) userRedirectStore() storage.SessionStore { return r.storageEngine.GetSessionDatabase().GetStore(userRedirectTimeout, userRedirectSessionKey...) } -func (r *Wrapper) userSessionStore() storage.SessionStore { +func (r Wrapper) userSessionStore() storage.SessionStore { return r.storageEngine.GetSessionDatabase().GetStore(userSessionTimeout, userSessionKey...) } -func (r *Wrapper) oauthClientStateStore() storage.SessionStore { +func (r Wrapper) oauthClientStateStore() storage.SessionStore { return r.storageEngine.GetSessionDatabase().GetStore(oAuthFlowTimeout, oauthClientStateKey...) } diff --git a/auth/auth.go b/auth/auth.go index db9d23668d..070f757992 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -48,6 +48,7 @@ type Auth struct { authzServer oauth.AuthorizationServer relyingParty oauth.RelyingParty verifier oauth.Verifier + holder oauth.Holder contractNotary services.ContractNotary serviceResolver didman.CompoundServiceResolver keyStore crypto.KeyStore @@ -111,6 +112,10 @@ func (auth *Auth) Verifier() oauth.Verifier { return auth.verifier } +func (auth *Auth) Holder() oauth.Holder { + return auth.holder +} + // 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 == "" { @@ -151,12 +156,14 @@ func (auth *Auth) Configure(config core.ServerConfig) error { return err } + clientTimeout := time.Duration(auth.config.HTTPTimeout) * time.Second accessTokenLifeSpan := time.Duration(auth.config.AccessTokenLifeSpan) * time.Second auth.authzServer = oauth.NewAuthorizationServer(auth.vdrInstance.Resolver(), auth.vcr, auth.vcr.Verifier(), auth.serviceResolver, auth.keyStore, auth.contractNotary, auth.jsonldManager, accessTokenLifeSpan) auth.relyingParty = oauth.NewRelyingParty(auth.vdrInstance.Resolver(), auth.serviceResolver, - auth.keyStore, auth.vcr.Wallet(), time.Duration(auth.config.HTTPTimeout)*time.Second, tlsConfig, config.Strictmode) - auth.verifier = oauth.NewVerifier(config.Strictmode, time.Duration(auth.config.HTTPTimeout)*time.Second, tlsConfig) + auth.keyStore, auth.vcr.Wallet(), clientTimeout, tlsConfig, config.Strictmode) + auth.verifier = oauth.NewVerifier(config.Strictmode, clientTimeout, tlsConfig) + auth.holder = oauth.NewHolder(auth.vcr.Wallet(), config.Strictmode, clientTimeout, tlsConfig) if err := auth.authzServer.Configure(auth.config.ClockSkew, config.Strictmode); err != nil { return err diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index 508306205d..b47375a145 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -90,43 +90,32 @@ func (hb HTTPClient) OAuthAuthorizationServerMetadata(ctx context.Context, webDI return &metadata, nil } -// PresentationDefinition retrieves the presentation definition from the presentation definition endpoint (as specified by RFC021) for the given scope. -func (hb HTTPClient) PresentationDefinition(ctx context.Context, definitionEndpoint string, scopes string) (*pe.PresentationDefinition, error) { - presentationDefinitionURL, err := core.ParsePublicURL(definitionEndpoint, hb.strictMode) +// ClientMetadata retrieves the client metadata from the client metadata endpoint given in the authorization request. +// We use the AuthorizationServerMetadata struct since it overlaps greatly with the client metadata. +func (hb HTTPClient) ClientMetadata(ctx context.Context, endpoint string) (*oauth.OAuthClientMetadata, error) { + _, err := core.ParsePublicURL(endpoint, hb.strictMode) + if err != nil { + return nil, err + } + // create a GET request + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } - presentationDefinitionURL.RawQuery = url.Values{"scope": []string{scopes}}.Encode() + var metadata oauth.OAuthClientMetadata + return &metadata, hb.doRequest(request, &metadata) +} +// PresentationDefinition retrieves the presentation definition from the presentation definition endpoint (as specified by RFC021) for the given scope. +func (hb HTTPClient) PresentationDefinition(ctx context.Context, presentationDefinitionURL url.URL) (*pe.PresentationDefinition, error) { // create a GET request with scope query param request, err := http.NewRequestWithContext(ctx, http.MethodGet, presentationDefinitionURL.String(), nil) if err != nil { return nil, err } - response, err := hb.httpClient.Do(request.WithContext(ctx)) - if err != nil { - return nil, fmt.Errorf("failed to call endpoint: %w", err) - } - if httpErr := core.TestResponseCode(http.StatusOK, response); httpErr != nil { - rse := httpErr.(core.HttpError) - if ok, oauthErr := oauth.TestOAuthErrorCode(rse.ResponseBody, oauth.InvalidScope); ok { - return nil, oauthErr - } - return nil, httpErr - } - var presentationDefinition pe.PresentationDefinition - var data []byte - - if data, err = io.ReadAll(response.Body); err != nil { - return nil, fmt.Errorf("unable to read response: %w", err) - } - if err = json.Unmarshal(data, &presentationDefinition); err != nil { - return nil, fmt.Errorf("unable to unmarshal response: %w", err) - } - - return &presentationDefinition, nil + return &presentationDefinition, hb.doRequest(request, &presentationDefinition) } func (hb HTTPClient) AccessToken(ctx context.Context, tokenEndpoint string, vp vc.VerifiablePresentation, submission pe.PresentationSubmission, scopes string) (oauth.TokenResponse, error) { @@ -185,3 +174,64 @@ func (hb HTTPClient) AccessToken(ctx context.Context, tokenEndpoint string, vp v } return token, nil } + +// PostError posts an OAuth error to the redirect URL and returns the redirect URL with the error as query parameter. +func (hb HTTPClient) PostError(ctx context.Context, err oauth.OAuth2Error, verifierCallbackURL url.URL) (string, error) { + // initiate http client, create a POST request with x-www-form-urlencoded body and send it to the redirect URL + data := url.Values{} + data.Set(oauth.ErrorParam, string(err.Code)) + data.Set(oauth.ErrorDescriptionParam, err.Description) + + return hb.postFormExpectRedirect(ctx, data, verifierCallbackURL) +} + +// PostAuthorizationResponse posts the authorization response to the verifier response URL and returns the callback URL. +func (hb HTTPClient) PostAuthorizationResponse(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI url.URL, state string) (string, error) { + // initiate http client, create a POST request with x-www-form-urlencoded body and send it to the redirect URL + psBytes, _ := json.Marshal(presentationSubmission) + data := url.Values{} + data.Set(oauth.VpTokenParam, vp.Raw()) + data.Set(oauth.PresentationSubmissionParam, string(psBytes)) + data.Set(oauth.StateParam, state) + + return hb.postFormExpectRedirect(ctx, data, verifierResponseURI) +} + +func (hb HTTPClient) postFormExpectRedirect(ctx context.Context, form url.Values, redirectURL url.URL) (string, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, redirectURL.String(), strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + request.Header.Add("Accept", "application/json") + request.Header.Add("Content-Type", "application/x-www-form-urlencoded") + var redirect oauth.Redirect + if err := hb.doRequest(request, &redirect); err != nil { + return "", err + } + return redirect.RedirectURI, nil +} + +func (hb HTTPClient) doRequest(request *http.Request, target interface{}) error { + response, err := hb.httpClient.Do(request) + if err != nil { + return fmt.Errorf("failed to call endpoint: %w", err) + } + if httpErr := core.TestResponseCode(http.StatusOK, response); httpErr != nil { + rse := httpErr.(core.HttpError) + if ok, oauthErr := oauth.TestOAuthErrorCode(rse.ResponseBody, oauth.InvalidScope); ok { + return oauthErr + } + return httpErr + } + + var data []byte + + if data, err = io.ReadAll(response.Body); err != nil { + return fmt.Errorf("unable to read response: %w", err) + } + if err = json.Unmarshal(data, &target); err != nil { + return fmt.Errorf("unable to unmarshal response: %w", err) + } + + return nil +} diff --git a/auth/client/iam/client_test.go b/auth/client/iam/client_test.go index 6228c7b2dd..2ec42f7971 100644 --- a/auth/client/iam/client_test.go +++ b/auth/client/iam/client_test.go @@ -20,6 +20,14 @@ package iam import ( "context" + "github.com/nuts-foundation/nuts-node/test" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" @@ -29,11 +37,6 @@ import ( "github.com/nuts-foundation/nuts-node/vdr/didweb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "net/http" - "net/http/httptest" - "net/url" - "testing" - "time" ) func TestHTTPClient_OAuthAuthorizationServerMetadata(t *testing.T) { @@ -110,75 +113,63 @@ func TestHTTPClient_PresentationDefinition(t *testing.T) { t.Run("ok", func(t *testing.T) { handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: definition} tlsServer, client := testServerAndClient(t, &handler) + pdUrl := test.MustParseURL(tlsServer.URL) - response, err := client.PresentationDefinition(ctx, tlsServer.URL, "test") - - require.NoError(t, err) - require.NotNil(t, definition) - assert.Equal(t, definition, *response) - require.NotNil(t, handler.Request) - }) - t.Run("ok - multiple scopes", func(t *testing.T) { - handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: definition} - tlsServer, client := testServerAndClient(t, &handler) - - response, err := client.PresentationDefinition(ctx, tlsServer.URL, "first second") + response, err := client.PresentationDefinition(ctx, *pdUrl) require.NoError(t, err) require.NotNil(t, definition) assert.Equal(t, definition, *response) require.NotNil(t, handler.Request) - assert.Equal(t, url.Values{"scope": []string{"first second"}}, handler.Request.URL.Query()) - }) - t.Run("error - invalid_scope", func(t *testing.T) { - handler := http2.Handler{StatusCode: http.StatusBadRequest, ResponseData: oauth.OAuth2Error{Code: oauth.InvalidScope}} - tlsServer, client := testServerAndClient(t, &handler) - - response, err := client.PresentationDefinition(ctx, tlsServer.URL, "test") - - require.Error(t, err) - assert.EqualError(t, err, "invalid_scope") - assert.Nil(t, response) }) t.Run("error - not found", func(t *testing.T) { handler := http2.Handler{StatusCode: http.StatusNotFound} tlsServer, client := testServerAndClient(t, &handler) + pdUrl := test.MustParseURL(tlsServer.URL) - response, err := client.PresentationDefinition(ctx, tlsServer.URL, "test") + _, err := client.PresentationDefinition(ctx, *pdUrl) require.Error(t, err) assert.EqualError(t, err, "server returned HTTP 404 (expected: 200)") - assert.Nil(t, response) }) - t.Run("error - invalid URL", func(t *testing.T) { - handler := http2.Handler{StatusCode: http.StatusNotFound} - _, client := testServerAndClient(t, &handler) + t.Run("error - invalid response", func(t *testing.T) { + handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: "}"} + tlsServer, client := testServerAndClient(t, &handler) + pdUrl := test.MustParseURL(tlsServer.URL) - response, err := client.PresentationDefinition(ctx, ":", "test") + _, err := client.PresentationDefinition(ctx, *pdUrl) require.Error(t, err) - assert.EqualError(t, err, "parse \":\": missing protocol scheme") - assert.Nil(t, response) + assert.EqualError(t, err, "unable to unmarshal response: invalid character '}' looking for beginning of value") }) - t.Run("error - unknown host", func(t *testing.T) { - handler := http2.Handler{StatusCode: http.StatusNotFound} - _, client := testServerAndClient(t, &handler) +} - response, err := client.PresentationDefinition(ctx, "http://localhost", "test") +func TestHTTPClient_ClientMetadata(t *testing.T) { + ctx := context.Background() + metadata := oauth.OAuthClientMetadata{ + SoftwareID: "id", + } - require.Error(t, err) - assert.ErrorContains(t, err, "connection refused") - assert.Nil(t, response) - }) - t.Run("error - invalid response", func(t *testing.T) { - handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: "}"} + t.Run("ok", func(t *testing.T) { + handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: metadata} tlsServer, client := testServerAndClient(t, &handler) - response, err := client.PresentationDefinition(ctx, tlsServer.URL, "test") + response, err := client.ClientMetadata(ctx, tlsServer.URL) + + require.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, metadata, *response) + require.NotNil(t, handler.Request) + }) + + t.Run("error - incorrect url", func(t *testing.T) { + handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: metadata} + _, client := testServerAndClient(t, &handler) + + _, err := client.ClientMetadata(ctx, ":") require.Error(t, err) - assert.EqualError(t, err, "unable to unmarshal response: invalid character '}' looking for beginning of value") - assert.Nil(t, response) + assert.EqualError(t, err, "parse \":\": missing protocol scheme") }) } @@ -239,6 +230,88 @@ func TestHTTPClient_AccessToken(t *testing.T) { }) } +func TestHTTPClient_PostError(t *testing.T) { + redirectReturn := oauth.Redirect{ + RedirectURI: "http://test.test", + } + //bytes, _ := json.Marshal(redirectReturn) + t.Run("ok", func(t *testing.T) { + ctx := context.Background() + handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: redirectReturn} + tlsServer, client := testServerAndClient(t, &handler) + tlsServerURL := test.MustParseURL(tlsServer.URL) + + redirectURI, err := client.PostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "test"}, *tlsServerURL) + + require.NoError(t, err) + assert.Equal(t, redirectReturn.RedirectURI, redirectURI) + }) +} + +func TestHTTPClient_PostAuthorizationResponse(t *testing.T) { + presentation := vc.VerifiablePresentation{ID: &ssi.URI{URL: url.URL{Scheme: "https", Host: "test.test"}}} + submission := pe.PresentationSubmission{Id: "id"} + redirectReturn := oauth.Redirect{ + RedirectURI: "http://test.test", + } + t.Run("ok", func(t *testing.T) { + ctx := context.Background() + handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: redirectReturn} + tlsServer, client := testServerAndClient(t, &handler) + tlsServerURL := test.MustParseURL(tlsServer.URL) + + redirectURI, err := client.PostAuthorizationResponse(ctx, presentation, submission, *tlsServerURL, "") + + require.NoError(t, err) + assert.Equal(t, redirectReturn.RedirectURI, redirectURI) + }) +} + +func TestHTTPClient_postFormExpectRedirect(t *testing.T) { + redirectReturn := oauth.Redirect{ + RedirectURI: "http://test.test", + } + data := url.Values{} + data.Set("test", "test") + + t.Run("error - unknown host", func(t *testing.T) { + ctx := context.Background() + handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: redirectReturn} + _, client := testServerAndClient(t, &handler) + tlsServerURL := test.MustParseURL("http://localhost") + + redirectURI, err := client.postFormExpectRedirect(ctx, data, *tlsServerURL) + + require.Error(t, err) + assert.ErrorContains(t, err, "connection refused") + assert.Empty(t, redirectURI) + }) + t.Run("error - invalid response", func(t *testing.T) { + ctx := context.Background() + handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: "}"} + tlsServer, client := testServerAndClient(t, &handler) + tlsServerURL := test.MustParseURL(tlsServer.URL) + + redirectURI, err := client.postFormExpectRedirect(ctx, data, *tlsServerURL) + + require.Error(t, err) + assert.EqualError(t, err, "unable to unmarshal response: invalid character '}' looking for beginning of value") + assert.Empty(t, redirectURI) + }) + t.Run("error - server error", func(t *testing.T) { + ctx := context.Background() + handler := http2.Handler{StatusCode: http.StatusBadGateway, ResponseData: "offline"} + tlsServer, client := testServerAndClient(t, &handler) + tlsServerURL := test.MustParseURL(tlsServer.URL) + + redirectURI, err := client.postFormExpectRedirect(ctx, data, *tlsServerURL) + + require.Error(t, err) + assert.EqualError(t, err, "server returned HTTP 502 (expected: 200)") + assert.Empty(t, redirectURI) + }) +} + func testServerAndClient(t *testing.T, handler http.Handler) (*httptest.Server, *HTTPClient) { tlsServer := http2.TestTLSServer(t, handler) return tlsServer, &HTTPClient{ diff --git a/auth/interface.go b/auth/interface.go index c19f897d44..6f2fbb6b94 100644 --- a/auth/interface.go +++ b/auth/interface.go @@ -31,6 +31,8 @@ const ModuleName = "Auth" type AuthenticationServices interface { // AuthzServer returns the oauth.AuthorizationServer AuthzServer() oauth.AuthorizationServer + // Holder returens the oauth.Holder + Holder() oauth.Holder // RelyingParty returns the oauth.RelyingParty RelyingParty() oauth.RelyingParty // Verifier returns the oauth.Verifier service provider diff --git a/auth/mock.go b/auth/mock.go index 89ef4f95c4..c6bb8acc10 100644 --- a/auth/mock.go +++ b/auth/mock.go @@ -69,6 +69,20 @@ func (mr *MockAuthenticationServicesMockRecorder) ContractNotary() *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContractNotary", reflect.TypeOf((*MockAuthenticationServices)(nil).ContractNotary)) } +// Holder mocks base method. +func (m *MockAuthenticationServices) Holder() oauth.Holder { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Holder") + ret0, _ := ret[0].(oauth.Holder) + return ret0 +} + +// Holder indicates an expected call of Holder. +func (mr *MockAuthenticationServicesMockRecorder) Holder() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Holder", reflect.TypeOf((*MockAuthenticationServices)(nil).Holder)) +} + // PublicURL mocks base method. func (m *MockAuthenticationServices) PublicURL() *url.URL { m.ctrl.T.Helper() diff --git a/auth/oauth/error.go b/auth/oauth/error.go index 1ea92ae377..cbf89dbaed 100644 --- a/auth/oauth/error.go +++ b/auth/oauth/error.go @@ -44,6 +44,8 @@ const ( ServerError ErrorCode = "server_error" // InvalidScope is returned when the requested scope is invalid, unknown or malformed. InvalidScope = ErrorCode("invalid_scope") + // InvalidPresentationDefinitionURI is returned when the requested presentation definition URI is invalid or can't be reached. + InvalidPresentationDefinitionURI = ErrorCode("invalid_presentation_definition_uri") ) // Make sure the error implements core.HTTPStatusCodeError, so the HTTP request logger can log the correct status code. diff --git a/auth/oauth/types.go b/auth/oauth/types.go index a95422bb85..fbff181d01 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -38,6 +38,8 @@ type TokenResponse struct { const ( // AuthzServerWellKnown is the well-known base path for the oauth authorization server metadata as defined in RFC8414 AuthzServerWellKnown = "/.well-known/oauth-authorization-server" + // ClientMetadataPath is the path to the client metadata relative to the complete did:web URL + ClientMetadataPath = "/oauth-client" // openidCredIssuerWellKnown is the well-known base path for the openID credential issuer metadata as defined in OpenID4VCI specification openidCredIssuerWellKnown = "/.well-known/openid-credential-issuer" // openidCredWalletWellKnown is the well-known path element we created for openid4vci to retrieve the oauth client metadata @@ -48,12 +50,23 @@ const ( AssertionParam = "assertion" // ScopeParam is the parameter name for the scope parameter ScopeParam = "scope" + // StateParam is the parameter name for the state parameter + StateParam = "state" // PresentationSubmissionParam is the parameter name for the presentation_submission parameter PresentationSubmissionParam = "presentation_submission" + // VpTokenParam is the parameter name for the vp_token parameter + VpTokenParam = "vp_token" // VpTokenGrantType is the grant_type for the vp_token-bearer grant type VpTokenGrantType = "vp_token-bearer" ) +const ( + // ErrorParam is the parameter name for the error parameter + ErrorParam = "error" + // ErrorDescriptionParam is the parameter name for the error_description parameter + ErrorDescriptionParam = "error_description" +) + // IssuerIdToWellKnown converts the OAuth2 Issuer identity to the specified well-known endpoint by inserting the well-known at the root of the path. // It returns no url and an error when issuer is not a valid URL. func IssuerIdToWellKnown(issuer string, wellKnown string, strictmode bool) (*url.URL, error) { @@ -130,3 +143,80 @@ type AuthorizationServerMetadata struct { // If omitted, the default value is `pre-registered` (referring to the client), which is currently not supported. ClientIdSchemesSupported []string `json:"client_id_schemes_supported,omitempty"` } + +// OAuthClientMetadata defines the OAuth Client metadata. +// Specified by https://www.rfc-editor.org/rfc/rfc7591.html and elsewhere. +type OAuthClientMetadata struct { + // RedirectURIs lists all URIs that the client may use in any redirect-based flow. + // From https://www.rfc-editor.org/rfc/rfc7591.html + RedirectURIs []string `json:"redirect_uris,omitempty"` + + // TODO: What do we use? Must provide a value if its not "client_secret_basic" + // TokenEndpointAuthMethod indicator of the requested authentication method for the token endpoint. + // If unspecified or omitted, the default is "client_secret_basic", denoting the HTTP Basic authentication scheme as specified in Section 2.3.1 of OAuth 2.0. + // Examples are: none, client_secret_post, client_secret_basic, tls_client_auth. + // From https://www.rfc-editor.org/rfc/rfc7591.html + // TODO: Can "tls_client_auth" replace /n2n/ for pre-authorized_code flow? https://www.rfc-editor.org/rfc/rfc8705.html + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"` + + // GrantTypes lists all supported grant_types. Defaults to "authorization_code" if omitted. + // From https://www.rfc-editor.org/rfc/rfc7591.html + GrantTypes []string `json:"grant_types,omitempty"` + + // ResponseTypes lists all supported response_types. Defaults to "code". Must contain the values corresponding to listed GrantTypes. + // From https://www.rfc-editor.org/rfc/rfc7591.html + ResponseTypes []string `json:"response_types,omitempty"` + + // Scope contains a space-separated list of scopes the client can request. + // From https://www.rfc-editor.org/rfc/rfc7591.html + // TODO: I don't see the use for this. The idea is that an AS does not assign scopes to a client that it does not support (or wants to request at any time), but seems like unnecessary complexity for minimal safety. + Scope string `json:"scope,omitempty"` + + // Contacts contains an array of strings representing ways to contact people responsible for this client, typically email addresses. + // From https://www.rfc-editor.org/rfc/rfc7591.html + // TODO: remove? Can plug DID docs contact info. + Contacts []string `json:"contacts,omitempty"` + + // JwksURI URL string referencing the client's JSON Web Key (JWK) Set [RFC7517] document, which contains the client's public keys. + // From https://www.rfc-editor.org/rfc/rfc7591.html + // TODO: remove? Can list the DID's keys. Could be useful if authorization without DIDs/VCs is needed. + // TODO: In EBSI it is a required field for the Service Wallet Metadata https://api-conformance.ebsi.eu/docs/ct/providers-and-wallets-metadata#service-wallet-metadata + JwksURI string `json:"jwks_uri,omitempty"` + // Jwks includes the JWK Set of a client. Mutually exclusive with JwksURI. + // From https://www.rfc-editor.org/rfc/rfc7591.html + Jwks any `json:"jwks,omitempty"` + + // SoftwareID is a unique identifier string (e.g., a Universally Unique Identifier (UUID)) assigned by the client developer. + // From https://www.rfc-editor.org/rfc/rfc7591.html + SoftwareID string `json:"software_id,omitempty"` + // SoftwareVersion is a version identifier string for the client software identified by "software_id". + // From https://www.rfc-editor.org/rfc/rfc7591.html + // TODO: Including a software_id + software_version could provide us with some upgrade paths in the future. + SoftwareVersion string `json:"software_version,omitempty"` + + // TODO: ignored values: client_name, client_uri, logo_uri, tos_uri, policy_uri. + // TODO: Things like client_name and logo may enhance the user experience when asking to accept authorization requests, but this should probably be added on the server size for that? + + /*********** OpenID4VCI ***********/ + + // CredentialOfferEndpoint contains a URL where the pre-authorized_code flow offers a credential. + // https://openid.bitbucket.io/connect/openid-4-verifiable-credential-issuance-1_0.html#name-client-metadata + // TODO: openid4vci duplicate. Also defined on /.well-known/openid-credential-wallet to be /n2n/identity/{did}/openid4vci/credential_offer + CredentialOfferEndpoint string `json:"credential_offer_endpoint,omitempty"` + + /*********** OpenID4VP ***********/ + // VPFormats lists the vp_formats supported by the client. See additional comments on vpFormatsSupported. + // https://openid.bitbucket.io/connect/openid-4-verifiable-presentations-1_0.html#name-verifier-metadata-client-me + VPFormats map[string]map[string][]string `json:"vp_formats,omitempty"` + + // ClientIdScheme is a string identifying the Client Identifier scheme. The value range defined by this specification is + // pre-registered, redirect_uri, entity_id, did. If omitted, the default value is pre-registered. + // https://openid.bitbucket.io/connect/openid-4-verifiable-presentations-1_0.html#name-verifier-metadata-client-me + ClientIdScheme string `json:"client_id_scheme,omitempty"` +} + +// Redirect is the response from the verifier on the direct_post authorization response. +type Redirect struct { + // RedirectURI is the URI to redirect the user-agent to. + RedirectURI string `json:"redirect_uri"` +} diff --git a/auth/services/oauth/holder.go b/auth/services/oauth/holder.go new file mode 100644 index 0000000000..5f77e208ec --- /dev/null +++ b/auth/services/oauth/holder.go @@ -0,0 +1,147 @@ +/* + * Nuts node + * Copyright (C) 2023 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 oauth + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/auth/client/iam" + "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/vcr/holder" + "github.com/nuts-foundation/nuts-node/vcr/pe" + "github.com/nuts-foundation/nuts-node/vcr/signature/proof" + "time" +) + +var _ Holder = (*HolderService)(nil) + +// ErrNoCredentials is returned when no matching credentials are found in the wallet based on a PresentationDefinition +var ErrNoCredentials = errors.New("no matching credentials") + +type HolderService struct { + strictMode bool + httpClientTimeout time.Duration + httpClientTLS *tls.Config + wallet holder.Wallet +} + +// NewHolder returns an implementation of Holder +func NewHolder(wallet holder.Wallet, strictMode bool, httpClientTimeout time.Duration, httpClientTLS *tls.Config) *HolderService { + return &HolderService{ + wallet: wallet, + strictMode: strictMode, + httpClientTimeout: httpClientTimeout, + httpClientTLS: httpClientTLS, + } +} + +func (v *HolderService) BuildPresentation(ctx context.Context, walletDID did.DID, presentationDefinition pe.PresentationDefinition, acceptedFormats map[string]map[string][]string, nonce string) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { + // get VCs from own wallet + credentials, err := v.wallet.List(ctx, walletDID) + if err != nil { + return nil, nil, fmt.Errorf("failed to retrieve wallet credentials: %w", err) + } + + expires := time.Now().Add(time.Minute * 15) //todo + // build VP + submissionBuilder := presentationDefinition.PresentationSubmissionBuilder() + submissionBuilder.AddWallet(walletDID, credentials) + format := pe.ChooseVPFormat(acceptedFormats) + presentationSubmission, signInstructions, err := submissionBuilder.Build(format) + if err != nil { + return nil, nil, fmt.Errorf("failed to build presentation submission: %w", err) + } + if signInstructions.Empty() { + return nil, nil, ErrNoCredentials + } + + // todo: support multiple wallets + vp, err := v.wallet.BuildPresentation(ctx, signInstructions[0].VerifiableCredentials, holder.PresentationOptions{ + Format: format, + ProofOptions: proof.ProofOptions{ + Created: time.Now(), + Challenge: &nonce, + Expires: &expires, + }, + }, &walletDID, false) + if err != nil { + return nil, nil, fmt.Errorf("failed to create verifiable presentation: %w", err) + } + return vp, &presentationSubmission, nil +} + +func (v *HolderService) ClientMetadata(ctx context.Context, endpoint string) (*oauth.OAuthClientMetadata, error) { + iamClient := iam.NewHTTPClient(v.strictMode, v.httpClientTimeout, v.httpClientTLS) + + metadata, err := iamClient.ClientMetadata(ctx, endpoint) + if err != nil { + return nil, fmt.Errorf("failed to retrieve remote OAuth Authorization Server metadata: %w", err) + } + return metadata, nil +} + +func (v *HolderService) PostError(ctx context.Context, auth2Error oauth.OAuth2Error, verifierResponseURI string) (string, error) { + iamClient := iam.NewHTTPClient(v.strictMode, v.httpClientTimeout, v.httpClientTLS) + + responseURL, err := core.ParsePublicURL(verifierResponseURI, v.strictMode) + if err != nil { + return "", fmt.Errorf("failed to post error to verifier: %w", err) + } + redirectURL, err := iamClient.PostError(ctx, auth2Error, *responseURL) + if err != nil { + return "", fmt.Errorf("failed to post error to verifier: %w", err) + } + + return redirectURL, nil +} + +func (v *HolderService) PostAuthorizationResponse(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI string, state string) (string, error) { + iamClient := iam.NewHTTPClient(v.strictMode, v.httpClientTimeout, v.httpClientTLS) + + responseURL, err := core.ParsePublicURL(verifierResponseURI, v.strictMode) + if err != nil { + return "", fmt.Errorf("failed to post error to verifier: %w", err) + } + redirectURL, err := iamClient.PostAuthorizationResponse(ctx, vp, presentationSubmission, *responseURL, state) + if err == nil { + return redirectURL, nil + } + + return "", fmt.Errorf("failed to post authorization response to verifier: %w", err) +} + +func (s *HolderService) PresentationDefinition(ctx context.Context, presentationDefinitionParam string) (*pe.PresentationDefinition, error) { + presentationDefinitionURL, err := core.ParsePublicURL(presentationDefinitionParam, s.strictMode) + if err != nil { + return nil, err + } + + iamClient := iam.NewHTTPClient(s.strictMode, s.httpClientTimeout, s.httpClientTLS) + presentationDefinition, err := iamClient.PresentationDefinition(ctx, *presentationDefinitionURL) + if err != nil { + return nil, fmt.Errorf("failed to retrieve presentation definition: %w", err) + } + return presentationDefinition, nil +} diff --git a/auth/services/oauth/holder_test.go b/auth/services/oauth/holder_test.go new file mode 100644 index 0000000000..0d7696046a --- /dev/null +++ b/auth/services/oauth/holder_test.go @@ -0,0 +1,310 @@ +/* + * Copyright (C) 2023 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 oauth + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + ssi "github.com/nuts-foundation/go-did" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/audit" + "github.com/nuts-foundation/nuts-node/auth/oauth" + http2 "github.com/nuts-foundation/nuts-node/test/http" + vcr "github.com/nuts-foundation/nuts-node/vcr/api/vcr/v2" + "github.com/nuts-foundation/nuts-node/vcr/credential" + "github.com/nuts-foundation/nuts-node/vcr/holder" + "github.com/nuts-foundation/nuts-node/vcr/pe" + "github.com/nuts-foundation/nuts-node/vdr/didweb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestHolderService_ClientMetadata(t *testing.T) { + t.Run("ok", func(t *testing.T) { + ctx := createOAuthHolderContext(t) + endpoint := fmt.Sprintf("%s/.well-known/oauth-authorization-server", ctx.tlsServer.URL) + + clientMetadata, err := ctx.holder.ClientMetadata(ctx.audit, endpoint) + + require.NoError(t, err) + assert.NotNil(t, clientMetadata) + }) + t.Run("error", func(t *testing.T) { + ctx := createOAuthHolderContext(t) + endpoint := fmt.Sprintf("%s/.well-known/oauth-authorization-server", ctx.tlsServer.URL) + ctx.metadata = nil + + clientMetadata, err := ctx.holder.ClientMetadata(ctx.audit, endpoint) + + assert.Error(t, err) + assert.Nil(t, clientMetadata) + }) +} + +func TestHolderService_PostError(t *testing.T) { + t.Run("ok", func(t *testing.T) { + ctx := createOAuthHolderContext(t) + endpoint := fmt.Sprintf("%s/error", ctx.tlsServer.URL) + oauthError := oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "missing required parameter", + } + + redirect, err := ctx.holder.PostError(ctx.audit, oauthError, endpoint) + + require.NoError(t, err) + assert.Equal(t, "redirect", redirect) + }) + t.Run("error", func(t *testing.T) { + ctx := createOAuthHolderContext(t) + endpoint := fmt.Sprintf("%s/error", ctx.tlsServer.URL) + ctx.errorResponse = nil + + redirect, err := ctx.holder.PostError(ctx.audit, oauth.OAuth2Error{}, endpoint) + + assert.Error(t, err) + assert.Empty(t, redirect) + }) +} + +func TestHolderService_PostResponse(t *testing.T) { + t.Run("ok", func(t *testing.T) { + ctx := createOAuthHolderContext(t) + endpoint := fmt.Sprintf("%s/response", ctx.tlsServer.URL) + vp := vc.VerifiablePresentation{Type: []ssi.URI{ssi.MustParseURI("VerifiablePresentation")}} + // marshal and unmarshal to make sure Raw() works + bytes, _ := json.Marshal(vp) + _ = json.Unmarshal(bytes, &vp) + + redirect, err := ctx.holder.PostAuthorizationResponse( + ctx.audit, + vp, + pe.PresentationSubmission{Id: "id"}, + endpoint, + "state", + ) + + require.NoError(t, err) + assert.Equal(t, "redirect", redirect) + }) + t.Run("error", func(t *testing.T) { + ctx := createOAuthHolderContext(t) + endpoint := fmt.Sprintf("%s/response", ctx.tlsServer.URL) + ctx.response = nil + + redirect, err := ctx.holder.PostAuthorizationResponse(ctx.audit, vc.VerifiablePresentation{}, pe.PresentationSubmission{}, endpoint, "") + + assert.Error(t, err) + assert.Empty(t, redirect) + }) +} + +func TestHolderService_BuildPresentation(t *testing.T) { + credentials := []vcr.VerifiableCredential{credential.ValidNutsOrganizationCredential(t)} + walletDID := did.MustParseDID("did:web:example.com:iam:wallet") + presentationDefinition := pe.PresentationDefinition{InputDescriptors: []*pe.InputDescriptor{{Constraints: &pe.Constraints{Fields: []pe.Field{{Path: []string{"$.type"}}}}}}} + vpFormats := oauth.DefaultOpenIDSupportedFormats() + + t.Run("ok", func(t *testing.T) { + ctx := createHolderContext(t, nil) + ctx.wallet.EXPECT().List(gomock.Any(), walletDID).Return(credentials, nil) + ctx.wallet.EXPECT().BuildPresentation(gomock.Any(), credentials, gomock.Any(), &walletDID, false).Return(&vc.VerifiablePresentation{}, nil) + + vp, submission, err := ctx.holder.BuildPresentation(context.Background(), walletDID, presentationDefinition, vpFormats, "") + + assert.NoError(t, err) + require.NotNil(t, vp) + require.NotNil(t, submission) + }) + // wallet failure, build failure, no credentials + t.Run("error - wallet failure", func(t *testing.T) { + ctx := createHolderContext(t, nil) + ctx.wallet.EXPECT().List(gomock.Any(), walletDID).Return(nil, assert.AnError) + + vp, submission, err := ctx.holder.BuildPresentation(context.Background(), walletDID, presentationDefinition, vpFormats, "") + + assert.Error(t, err) + assert.Nil(t, vp) + assert.Nil(t, submission) + }) + t.Run("error - build failure", func(t *testing.T) { + ctx := createHolderContext(t, nil) + ctx.wallet.EXPECT().List(gomock.Any(), walletDID).Return(credentials, nil) + ctx.wallet.EXPECT().BuildPresentation(gomock.Any(), credentials, gomock.Any(), &walletDID, false).Return(nil, assert.AnError) + + vp, submission, err := ctx.holder.BuildPresentation(context.Background(), walletDID, presentationDefinition, vpFormats, "") + + assert.Error(t, err) + assert.Nil(t, vp) + assert.Nil(t, submission) + }) + t.Run("error - no matching credentials", func(t *testing.T) { + ctx := createHolderContext(t, nil) + ctx.wallet.EXPECT().List(gomock.Any(), walletDID).Return(credentials, nil) + + vp, submission, err := ctx.holder.BuildPresentation(context.Background(), walletDID, pe.PresentationDefinition{}, vpFormats, "") + + assert.Equal(t, ErrNoCredentials, err) + assert.Nil(t, vp) + assert.Nil(t, submission) + }) +} + +func TestHolderService_PresentationDefinition(t *testing.T) { + t.Run("ok", func(t *testing.T) { + ctx := createOAuthHolderContext(t) + endpoint := fmt.Sprintf("%s/presentation_definition", ctx.tlsServer.URL) + + pd, err := ctx.holder.PresentationDefinition(context.Background(), endpoint) + + assert.NoError(t, err) + assert.NotNil(t, pd) + }) + t.Run("error", func(t *testing.T) { + ctx := createOAuthHolderContext(t) + endpoint := fmt.Sprintf("%s/presentation_definition", ctx.tlsServer.URL) + ctx.presentationDefinition = nil + + pd, err := ctx.holder.PresentationDefinition(context.Background(), endpoint) + + assert.Error(t, err) + assert.Nil(t, pd) + }) +} + +type holderTestContext struct { + ctrl *gomock.Controller + audit context.Context + holder Holder + wallet *holder.MockWallet +} + +func createHolderContext(t *testing.T, tlsConfig *tls.Config) *holderTestContext { + ctrl := gomock.NewController(t) + + wallet := holder.NewMockWallet(ctrl) + + if tlsConfig == nil { + tlsConfig = &tls.Config{} + } + tlsConfig.InsecureSkipVerify = true + + return &holderTestContext{ + audit: audit.TestContext(), + ctrl: ctrl, + holder: &HolderService{ + httpClientTLS: tlsConfig, + wallet: wallet, + }, + wallet: wallet, + } +} + +type holderOAuthTestContext struct { + *holderTestContext + authzServerMetadata *oauth.AuthorizationServerMetadata + handler http.HandlerFunc + tlsServer *httptest.Server + verifierDID did.DID + metadata func(writer http.ResponseWriter) + errorResponse func(writer http.ResponseWriter) + response func(writer http.ResponseWriter) + presentationDefinition func(writer http.ResponseWriter) +} + +func createOAuthHolderContext(t *testing.T) *holderOAuthTestContext { + clientMetadata := &oauth.AuthorizationServerMetadata{VPFormats: oauth.DefaultOpenIDSupportedFormats()} + ctx := &holderOAuthTestContext{ + holderTestContext: createHolderContext(t, nil), + metadata: func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(*clientMetadata) + _, _ = writer.Write(bytes) + return + }, + errorResponse: func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(oauth.Redirect{ + RedirectURI: "redirect", + }) + _, _ = writer.Write(bytes) + return + }, + presentationDefinition: func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(pe.PresentationDefinition{}) + _, _ = writer.Write(bytes) + return + }, + response: func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(oauth.Redirect{ + RedirectURI: "redirect", + }) + _, _ = writer.Write(bytes) + return + }, + } + + ctx.handler = func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/.well-known/oauth-authorization-server": + if ctx.metadata != nil { + ctx.metadata(writer) + return + } + case "/error": + if ctx.errorResponse != nil { + assert.Equal(t, string(oauth.InvalidRequest), request.FormValue("error")) + ctx.errorResponse(writer) + return + } + case "/presentation_definition": + if ctx.presentationDefinition != nil { + ctx.presentationDefinition(writer) + return + } + case "/response": + if ctx.response != nil { + assert.NotEmpty(t, request.FormValue(oauth.VpTokenParam)) + assert.NotEmpty(t, request.FormValue(oauth.PresentationSubmissionParam)) + assert.NotEmpty(t, request.FormValue(oauth.StateParam)) + ctx.errorResponse(writer) + return + } + } + writer.WriteHeader(http.StatusNotFound) + } + ctx.tlsServer = http2.TestTLSServer(t, ctx.handler) + ctx.verifierDID = didweb.ServerURLToDIDWeb(t, ctx.tlsServer.URL) + + return ctx +} diff --git a/auth/services/oauth/interface.go b/auth/services/oauth/interface.go index 28c237c7a6..e7de3fa5e0 100644 --- a/auth/services/oauth/interface.go +++ b/auth/services/oauth/interface.go @@ -20,10 +20,13 @@ package oauth import ( "context" + "github.com/nuts-foundation/go-did/vc" + "net/url" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/auth/services" - "net/url" + "github.com/nuts-foundation/nuts-node/vcr/pe" ) // RelyingParty implements the OAuth2 relying party role. @@ -31,6 +34,7 @@ type RelyingParty interface { CreateJwtGrant(ctx context.Context, request services.CreateJwtGrantRequest) (*services.JwtBearerTokenResult, error) // CreateAuthorizationRequest creates an OAuth2.0 authorizationRequest redirect URL that redirects to the authorization server. CreateAuthorizationRequest(ctx context.Context, requestHolder did.DID, verifier did.DID, scopes string, clientState string) (*url.URL, error) + // RequestRFC003AccessToken is called by the local EHR node to request an access token from a remote Nuts node using Nuts RFC003. RequestRFC003AccessToken(ctx context.Context, jwtGrantToken string, authServerEndpoint url.URL) (*oauth.TokenResponse, error) // RequestRFC021AccessToken is called by the local EHR node to request an access token from a remote Nuts node using Nuts RFC021. @@ -55,3 +59,17 @@ type Verifier interface { // ClientMetadataURL constructs the URL to the client metadata of the local verifier. ClientMetadataURL(webdid did.DID) (*url.URL, error) } + +// Holder implements the OpenID4VP Holder role which acts as Authorization server in the OpenID4VP flow. +type Holder interface { + // BuildPresentation builds a Verifiable Presentation based on the given presentation definition. + BuildPresentation(ctx context.Context, walletDID did.DID, presentationDefinition pe.PresentationDefinition, acceptedFormats map[string]map[string][]string, nonce string) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) + // ClientMetadata returns the metadata of the remote verifier. + ClientMetadata(ctx context.Context, endpoint string) (*oauth.OAuthClientMetadata, error) + // PostError posts an error to the verifier. If it fails, an error is returned. + PostError(ctx context.Context, auth2Error oauth.OAuth2Error, verifierResponseURI string) (string, error) + // PostAuthorizationResponse posts the authorization response to the verifier. If it fails, an error is returned. + PostAuthorizationResponse(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI string, state string) (string, error) + // PresentationDefinition returns the presentation definition from the given endpoint. + PresentationDefinition(ctx context.Context, presentationDefinitionURI string) (*pe.PresentationDefinition, error) +} diff --git a/auth/services/oauth/mock.go b/auth/services/oauth/mock.go index 9825de4f95..3f8ecce34a 100644 --- a/auth/services/oauth/mock.go +++ b/auth/services/oauth/mock.go @@ -15,8 +15,10 @@ import ( reflect "reflect" did "github.com/nuts-foundation/go-did/did" + vc "github.com/nuts-foundation/go-did/vc" oauth "github.com/nuts-foundation/nuts-node/auth/oauth" services "github.com/nuts-foundation/nuts-node/auth/services" + pe "github.com/nuts-foundation/nuts-node/vcr/pe" gomock "go.uber.org/mock/gomock" ) @@ -222,3 +224,102 @@ func (mr *MockVerifierMockRecorder) ClientMetadataURL(webdid any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClientMetadataURL", reflect.TypeOf((*MockVerifier)(nil).ClientMetadataURL), webdid) } + +// MockHolder is a mock of Holder interface. +type MockHolder struct { + ctrl *gomock.Controller + recorder *MockHolderMockRecorder +} + +// MockHolderMockRecorder is the mock recorder for MockHolder. +type MockHolderMockRecorder struct { + mock *MockHolder +} + +// NewMockHolder creates a new mock instance. +func NewMockHolder(ctrl *gomock.Controller) *MockHolder { + mock := &MockHolder{ctrl: ctrl} + mock.recorder = &MockHolderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockHolder) EXPECT() *MockHolderMockRecorder { + return m.recorder +} + +// BuildPresentation mocks base method. +func (m *MockHolder) BuildPresentation(ctx context.Context, walletDID did.DID, presentationDefinition pe.PresentationDefinition, acceptedFormats map[string]map[string][]string, nonce string) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "BuildPresentation", ctx, walletDID, presentationDefinition, acceptedFormats, nonce) + ret0, _ := ret[0].(*vc.VerifiablePresentation) + ret1, _ := ret[1].(*pe.PresentationSubmission) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// BuildPresentation indicates an expected call of BuildPresentation. +func (mr *MockHolderMockRecorder) BuildPresentation(ctx, walletDID, presentationDefinition, acceptedFormats, nonce any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BuildPresentation", reflect.TypeOf((*MockHolder)(nil).BuildPresentation), ctx, walletDID, presentationDefinition, acceptedFormats, nonce) +} + +// ClientMetadata mocks base method. +func (m *MockHolder) ClientMetadata(ctx context.Context, endpoint string) (*oauth.OAuthClientMetadata, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClientMetadata", ctx, endpoint) + ret0, _ := ret[0].(*oauth.OAuthClientMetadata) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ClientMetadata indicates an expected call of ClientMetadata. +func (mr *MockHolderMockRecorder) ClientMetadata(ctx, endpoint any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClientMetadata", reflect.TypeOf((*MockHolder)(nil).ClientMetadata), ctx, endpoint) +} + +// PostAuthorizationResponse mocks base method. +func (m *MockHolder) PostAuthorizationResponse(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI, state string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PostAuthorizationResponse", ctx, vp, presentationSubmission, verifierResponseURI, state) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PostAuthorizationResponse indicates an expected call of PostAuthorizationResponse. +func (mr *MockHolderMockRecorder) PostAuthorizationResponse(ctx, vp, presentationSubmission, verifierResponseURI, state any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostAuthorizationResponse", reflect.TypeOf((*MockHolder)(nil).PostAuthorizationResponse), ctx, vp, presentationSubmission, verifierResponseURI, state) +} + +// PostError mocks base method. +func (m *MockHolder) PostError(ctx context.Context, auth2Error oauth.OAuth2Error, verifierResponseURI string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PostError", ctx, auth2Error, verifierResponseURI) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PostError indicates an expected call of PostError. +func (mr *MockHolderMockRecorder) PostError(ctx, auth2Error, verifierResponseURI any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostError", reflect.TypeOf((*MockHolder)(nil).PostError), ctx, auth2Error, verifierResponseURI) +} + +// PresentationDefinition mocks base method. +func (m *MockHolder) PresentationDefinition(ctx context.Context, presentationDefinitionURI string) (*pe.PresentationDefinition, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PresentationDefinition", ctx, presentationDefinitionURI) + ret0, _ := ret[0].(*pe.PresentationDefinition) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// PresentationDefinition indicates an expected call of PresentationDefinition. +func (mr *MockHolderMockRecorder) PresentationDefinition(ctx, presentationDefinitionURI any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PresentationDefinition", reflect.TypeOf((*MockHolder)(nil).PresentationDefinition), ctx, presentationDefinitionURI) +} diff --git a/auth/services/oauth/relying_party.go b/auth/services/oauth/relying_party.go index 9da8482646..3505b8d24f 100644 --- a/auth/services/oauth/relying_party.go +++ b/auth/services/oauth/relying_party.go @@ -23,14 +23,13 @@ import ( "crypto/tls" "errors" "fmt" + "github.com/lestrrat-go/jwx/v2/jwt" "net/http" "net/url" "strings" "time" - "github.com/lestrrat-go/jwx/v2/jwt" "github.com/nuts-foundation/go-did/did" - "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/api/auth/v1/client" "github.com/nuts-foundation/nuts-node/auth/client/iam" "github.com/nuts-foundation/nuts-node/auth/oauth" @@ -41,6 +40,7 @@ import ( nutsHttp "github.com/nuts-foundation/nuts-node/http" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/holder" + "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vcr/signature/proof" "github.com/nuts-foundation/nuts-node/vdr/resolver" ) @@ -165,7 +165,7 @@ func (s *relyingParty) RequestRFC021AccessToken(ctx context.Context, requester d } // get the presentation definition from the verifier - presentationDefinition, err := iamClient.PresentationDefinition(ctx, metadata.PresentationDefinitionEndpoint, scopes) + presentationDefinition, err := s.presentationDefinition(ctx, metadata.PresentationDefinitionEndpoint, scopes) if err != nil { return nil, fmt.Errorf("failed to retrieve presentation definition: %w", err) } @@ -193,7 +193,7 @@ func (s *relyingParty) RequestRFC021AccessToken(ctx context.Context, requester d if presentationDefinition.Format != nil { formatCandidates = formatCandidates.Match(credential.DIFClaimFormats(*presentationDefinition.Format)) } - format := chooseVPFormat(formatCandidates.Map) + format := pe.ChooseVPFormat(formatCandidates.Map) if format == "" { return nil, errors.New("requester, verifier (authorization server metadata) and presentation definition don't share a supported VP format") } @@ -243,18 +243,18 @@ func (s *relyingParty) authorizationServerMetadata(ctx context.Context, webdid d return metadata, nil } -func chooseVPFormat(formats map[string]map[string][]string) string { - // They are in preferred order - if _, ok := formats[vc.JWTPresentationProofFormat]; ok { - return vc.JWTPresentationProofFormat - } - if _, ok := formats["jwt_vp_json"]; ok { - return vc.JWTPresentationProofFormat +func (s *relyingParty) presentationDefinition(ctx context.Context, presentationDefinitionURL string, scopes string) (*pe.PresentationDefinition, error) { + parsedURL, err := url.Parse(presentationDefinitionURL) + if err != nil { + return nil, err } - if _, ok := formats[vc.JSONLDPresentationProofFormat]; ok { - return vc.JSONLDPresentationProofFormat + parsedURL.RawQuery = url.Values{"scope": []string{scopes}}.Encode() + iamClient := iam.NewHTTPClient(s.strictMode, s.httpClientTimeout, s.httpClientTLS) + presentationDefinition, err := iamClient.PresentationDefinition(ctx, *parsedURL) + if err != nil { + return nil, err } - return "" + return presentationDefinition, nil } var timeFunc = time.Now diff --git a/auth/services/oauth/relying_party_test.go b/auth/services/oauth/relying_party_test.go index 385b9c66fe..5ed0d484c6 100644 --- a/auth/services/oauth/relying_party_test.go +++ b/auth/services/oauth/relying_party_test.go @@ -271,37 +271,6 @@ func TestRelyingParty_AuthorizationRequest(t *testing.T) { assert.ErrorContains(t, err, "no authorization endpoint found in metadata for") }) } -func Test_chooseVPFormat(t *testing.T) { - t.Run("ok", func(t *testing.T) { - formats := map[string]map[string][]string{ - "jwt_vp": { - "alg": {"ES256K"}, - }, - } - - format := chooseVPFormat(formats) - - assert.Equal(t, "jwt_vp", format) - }) - t.Run("no supported format", func(t *testing.T) { - formats := map[string]map[string][]string{} - - format := chooseVPFormat(formats) - - assert.Empty(t, format) - }) - t.Run(" jwt_vp_json returns jwt_vp", func(t *testing.T) { - formats := map[string]map[string][]string{ - "jwt_vp_json": { - "alg": {"ES256K"}, - }, - } - - format := chooseVPFormat(formats) - - assert.Equal(t, "jwt_vp", format) - }) -} func TestService_CreateJwtBearerToken(t *testing.T) { usi := vc.VerifiablePresentation{} @@ -540,6 +509,11 @@ func createOAuthRPContext(t *testing.T) *rpOAuthTestContext { } case "/presentation_definition": if ctx.presentationDefinition != nil { + scopes := request.URL.Query().Get("scope") + if scopes == "" { + writer.WriteHeader(http.StatusBadRequest) + return + } ctx.presentationDefinition(writer) return } diff --git a/auth/services/oauth/verifier.go b/auth/services/oauth/verifier.go index cf9d029fdb..2bfca568b7 100644 --- a/auth/services/oauth/verifier.go +++ b/auth/services/oauth/verifier.go @@ -66,6 +66,5 @@ func (v *VerifierServiceProvider) ClientMetadataURL(webdid did.DID) (*url.URL, e } // we use the authorization server endpoint as the client metadata endpoint, contents are the same // coming from a did:web, it's impossible to get a false URL - metadataURL, _ := oauth.IssuerIdToWellKnown(didURL.String(), oauth.AuthzServerWellKnown, v.strictMode) - return metadataURL, nil + return didURL.JoinPath(oauth.ClientMetadataPath), nil } diff --git a/auth/services/oauth/verifier_test.go b/auth/services/oauth/verifier_test.go index b05d7e17e0..16dc4195ab 100644 --- a/auth/services/oauth/verifier_test.go +++ b/auth/services/oauth/verifier_test.go @@ -66,7 +66,7 @@ func TestVerifierServiceProvider_ClientMetadataURL(t *testing.T) { require.NoError(t, err) require.NotNil(t, url) - assert.Equal(t, "https://example.com/.well-known/oauth-authorization-server/iam/holder", url.String()) + assert.Equal(t, "https://example.com/iam/holder/oauth-client", url.String()) }) t.Run("error - invalid DID", func(t *testing.T) { _, err := verifier.ClientMetadataURL(did.DID{}) diff --git a/docs/_static/auth/iam.yaml b/docs/_static/auth/iam.yaml index 7bbdd3b049..bed2533975 100644 --- a/docs/_static/auth/iam.yaml +++ b/docs/_static/auth/iam.yaml @@ -303,7 +303,7 @@ paths: example: did:web:example.com scope: type: string - description: The scope that will be The service for which this access token can be used. + description: The scope that will be the service for which this access token can be used. example: eOverdracht-sender userID: type: string diff --git a/policy/mock.go b/policy/mock.go index cc32c7811f..26c7e4987e 100644 --- a/policy/mock.go +++ b/policy/mock.go @@ -5,6 +5,7 @@ // // mockgen -destination=policy/mock.go -package=policy -source=policy/interface.go // + // Package policy is a generated GoMock package. package policy @@ -18,31 +19,31 @@ import ( gomock "go.uber.org/mock/gomock" ) -// MockBackend is a mock of PDPBackend interface. -type MockBackend struct { +// MockPDPBackend is a mock of PDPBackend interface. +type MockPDPBackend struct { ctrl *gomock.Controller - recorder *MockBackendMockRecorder + recorder *MockPDPBackendMockRecorder } -// MockBackendMockRecorder is the mock recorder for MockBackend. -type MockBackendMockRecorder struct { - mock *MockBackend +// MockPDPBackendMockRecorder is the mock recorder for MockPDPBackend. +type MockPDPBackendMockRecorder struct { + mock *MockPDPBackend } -// NewMockBackend creates a new mock instance. -func NewMockBackend(ctrl *gomock.Controller) *MockBackend { - mock := &MockBackend{ctrl: ctrl} - mock.recorder = &MockBackendMockRecorder{mock} +// NewMockPDPBackend creates a new mock instance. +func NewMockPDPBackend(ctrl *gomock.Controller) *MockPDPBackend { + mock := &MockPDPBackend{ctrl: ctrl} + mock.recorder = &MockPDPBackendMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockBackend) EXPECT() *MockBackendMockRecorder { +func (m *MockPDPBackend) EXPECT() *MockPDPBackendMockRecorder { return m.recorder } // Authorized mocks base method. -func (m *MockBackend) Authorized(ctx context.Context, requestInfo client.AuthorizedRequest) (bool, error) { +func (m *MockPDPBackend) Authorized(ctx context.Context, requestInfo client.AuthorizedRequest) (bool, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "Authorized", ctx, requestInfo) ret0, _ := ret[0].(bool) @@ -51,13 +52,13 @@ func (m *MockBackend) Authorized(ctx context.Context, requestInfo client.Authori } // Authorized indicates an expected call of Authorized. -func (mr *MockBackendMockRecorder) Authorized(ctx, requestInfo any) *gomock.Call { +func (mr *MockPDPBackendMockRecorder) Authorized(ctx, requestInfo any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Authorized", reflect.TypeOf((*MockBackend)(nil).Authorized), ctx, requestInfo) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Authorized", reflect.TypeOf((*MockPDPBackend)(nil).Authorized), ctx, requestInfo) } // PresentationDefinition mocks base method. -func (m *MockBackend) PresentationDefinition(ctx context.Context, authorizer did.DID, scope string) (*pe.PresentationDefinition, error) { +func (m *MockPDPBackend) PresentationDefinition(ctx context.Context, authorizer did.DID, scope string) (*pe.PresentationDefinition, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "PresentationDefinition", ctx, authorizer, scope) ret0, _ := ret[0].(*pe.PresentationDefinition) @@ -66,7 +67,7 @@ func (m *MockBackend) PresentationDefinition(ctx context.Context, authorizer did } // PresentationDefinition indicates an expected call of PresentationDefinition. -func (mr *MockBackendMockRecorder) PresentationDefinition(ctx, authorizer, scope any) *gomock.Call { +func (mr *MockPDPBackendMockRecorder) PresentationDefinition(ctx, authorizer, scope any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PresentationDefinition", reflect.TypeOf((*MockBackend)(nil).PresentationDefinition), ctx, authorizer, scope) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PresentationDefinition", reflect.TypeOf((*MockPDPBackend)(nil).PresentationDefinition), ctx, authorizer, scope) } diff --git a/policy/policy_test.go b/policy/policy_test.go index 1cfb00be5e..54bedc44dc 100644 --- a/policy/policy_test.go +++ b/policy/policy_test.go @@ -104,11 +104,11 @@ func TestRouterForwarding(t *testing.T) { testDID := did.MustParseDID("did:web:example.com:test") presentationDefinition := pe.PresentationDefinition{} router := Router{ - backend: NewMockBackend(ctrl), + backend: NewMockPDPBackend(ctrl), } t.Run("Authorized", func(t *testing.T) { - router.backend.(*MockBackend).EXPECT().Authorized(ctx, gomock.Any()).Return(true, nil) + router.backend.(*MockPDPBackend).EXPECT().Authorized(ctx, gomock.Any()).Return(true, nil) result, err := router.Authorized(ctx, client.AuthorizedRequest{}) @@ -117,7 +117,7 @@ func TestRouterForwarding(t *testing.T) { }) t.Run("PresentationDefinition", func(t *testing.T) { - router.backend.(*MockBackend).EXPECT().PresentationDefinition(ctx, testDID, "test").Return(&presentationDefinition, nil) + router.backend.(*MockPDPBackend).EXPECT().PresentationDefinition(ctx, testDID, "test").Return(&presentationDefinition, nil) result, err := router.PresentationDefinition(ctx, testDID, "test") diff --git a/vcr/pe/format.go b/vcr/pe/format.go new file mode 100644 index 0000000000..e0986e43a1 --- /dev/null +++ b/vcr/pe/format.go @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2023 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 pe + +import "github.com/nuts-foundation/go-did/vc" + +// ChooseVPFormat determines the format of the Verifiable Presentation based on the authorization server metadata. +func ChooseVPFormat(formats map[string]map[string][]string) string { + // They are in preferred order + if _, ok := formats[vc.JWTPresentationProofFormat]; ok { + return vc.JWTPresentationProofFormat + } + if _, ok := formats["jwt_vp_json"]; ok { + return vc.JWTPresentationProofFormat + } + if _, ok := formats[vc.JSONLDPresentationProofFormat]; ok { + return vc.JSONLDPresentationProofFormat + } + return "" +}