diff --git a/auth/api/auth/v1/api.go b/auth/api/auth/v1/api.go index f3942cbc6d..a608669f1b 100644 --- a/auth/api/auth/v1/api.go +++ b/auth/api/auth/v1/api.go @@ -311,15 +311,13 @@ func (w Wrapper) RequestAccessToken(ctx context.Context, request RequestAccessTo func (w Wrapper) CreateAccessToken(ctx context.Context, request CreateAccessTokenRequestObject) (CreateAccessTokenResponseObject, error) { if request.Body.GrantType != client.JwtBearerGrantType { - errDesc := fmt.Sprintf("grant_type must be: '%s'", client.JwtBearerGrantType) - errorResponse := oauth.ErrorResponse{Error: errOauthUnsupportedGrant, Description: &errDesc} + errorResponse := oauth.OAuth2Error{Code: errOauthUnsupportedGrant, Description: fmt.Sprintf("grant_type must be: '%s'", client.JwtBearerGrantType)} return CreateAccessToken400JSONResponse(errorResponse), nil } const jwtPattern = `^[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*$` if matched, err := regexp.Match(jwtPattern, []byte(request.Body.Assertion)); !matched || err != nil { - errDesc := "Assertion must be a valid encoded jwt" - errorResponse := AccessTokenRequestFailedResponse{Error: errOauthInvalidGrant, Description: &errDesc} + errorResponse := AccessTokenRequestFailedResponse{Code: errOauthInvalidGrant, Description: "Assertion must be a valid encoded jwt"} return CreateAccessToken400JSONResponse(errorResponse), nil } diff --git a/auth/api/auth/v1/api_test.go b/auth/api/auth/v1/api_test.go index 2ea6a3f2ea..7d4491fc13 100644 --- a/auth/api/auth/v1/api_test.go +++ b/auth/api/auth/v1/api_test.go @@ -55,6 +55,7 @@ type TestContext struct { contractClientMock *services.MockContractNotary authzServerMock *oauth.MockAuthorizationServer relyingPartyMock *oauth.MockRelyingParty + verifierMock *oauth.MockVerifier wrapper Wrapper cedentialResolverMock *vcr.MockResolver audit context.Context @@ -65,6 +66,7 @@ type mockAuthClient struct { contractNotary *services.MockContractNotary authzServer *oauth.MockAuthorizationServer relyingParty *oauth.MockRelyingParty + verifier *oauth.MockVerifier } func (m *mockAuthClient) V2APIEnabled() bool { @@ -79,6 +81,10 @@ func (m *mockAuthClient) RelyingParty() oauth.RelyingParty { return m.relyingParty } +func (m *mockAuthClient) Verifier() oauth.Verifier { + return m.verifier +} + func (m *mockAuthClient) ContractNotary() services.ContractNotary { return m.contractNotary } @@ -97,6 +103,7 @@ func createContext(t *testing.T) *TestContext { contractNotary := services.NewMockContractNotary(ctrl) authzServer := oauth.NewMockAuthorizationServer(ctrl) relyingParty := oauth.NewMockRelyingParty(ctrl) + verifier := oauth.NewMockVerifier(ctrl) mockCredentialResolver := vcr.NewMockResolver(ctrl) authMock := &mockAuthClient{ @@ -104,6 +111,7 @@ func createContext(t *testing.T) *TestContext { contractNotary: contractNotary, authzServer: authzServer, relyingParty: relyingParty, + verifier: verifier, } requestCtx := audit.TestContext() @@ -115,6 +123,7 @@ func createContext(t *testing.T) *TestContext { contractClientMock: contractNotary, authzServerMock: authzServer, relyingPartyMock: relyingParty, + verifierMock: verifier, cedentialResolverMock: mockCredentialResolver, wrapper: Wrapper{Auth: authMock, CredentialResolver: mockCredentialResolver}, audit: requestCtx, @@ -554,7 +563,7 @@ func TestWrapper_CreateAccessToken(t *testing.T) { params := CreateAccessTokenRequest{GrantType: "unknown type"} errorDescription := "grant_type must be: 'urn:ietf:params:oauth:grant-type:jwt-bearer'" - expectedResponse := CreateAccessToken400JSONResponse{Description: &errorDescription, Error: errOauthUnsupportedGrant} + expectedResponse := CreateAccessToken400JSONResponse{Description: errorDescription, Code: errOauthUnsupportedGrant} response, err := ctx.wrapper.CreateAccessToken(ctx.audit, CreateAccessTokenRequestObject{Body: ¶ms}) @@ -568,7 +577,7 @@ func TestWrapper_CreateAccessToken(t *testing.T) { params := CreateAccessTokenRequest{GrantType: "urn:ietf:params:oauth:grant-type:jwt-bearer", Assertion: "invalid jwt"} errorDescription := "Assertion must be a valid encoded jwt" - expectedResponse := CreateAccessToken400JSONResponse{Description: &errorDescription, Error: errOauthInvalidGrant} + expectedResponse := CreateAccessToken400JSONResponse{Description: errorDescription, Code: errOauthInvalidGrant} response, err := ctx.wrapper.CreateAccessToken(ctx.audit, CreateAccessTokenRequestObject{Body: ¶ms}) @@ -582,11 +591,11 @@ func TestWrapper_CreateAccessToken(t *testing.T) { params := CreateAccessTokenRequest{GrantType: "urn:ietf:params:oauth:grant-type:jwt-bearer", Assertion: validJwt} errorDescription := "oh boy" - expectedResponse := CreateAccessToken400JSONResponse{Description: &errorDescription, Error: errOauthInvalidRequest} + expectedResponse := CreateAccessToken400JSONResponse{Description: errorDescription, Code: errOauthInvalidRequest} - ctx.authzServerMock.EXPECT().CreateAccessToken(ctx.audit, services.CreateAccessTokenRequest{RawJwtBearerToken: validJwt}).Return(nil, &oauth2.ErrorResponse{ - Description: &errorDescription, - Error: errOauthInvalidRequest, + ctx.authzServerMock.EXPECT().CreateAccessToken(ctx.audit, services.CreateAccessTokenRequest{RawJwtBearerToken: validJwt}).Return(nil, &oauth2.OAuth2Error{ + Description: errorDescription, + Code: errOauthInvalidRequest, }) response, err := ctx.wrapper.CreateAccessToken(ctx.audit, CreateAccessTokenRequestObject{Body: ¶ms}) diff --git a/auth/api/auth/v1/client/types.go b/auth/api/auth/v1/client/types.go index fdccb9ca60..3fc498f28f 100644 --- a/auth/api/auth/v1/client/types.go +++ b/auth/api/auth/v1/client/types.go @@ -36,4 +36,4 @@ type VerifiablePresentation = vc.VerifiablePresentation type AccessTokenResponse = oauth.TokenResponse // AccessTokenRequestFailedResponse is an alias to use from within the API -type AccessTokenRequestFailedResponse = oauth.ErrorResponse +type AccessTokenRequestFailedResponse = oauth.OAuth2Error diff --git a/auth/api/auth/v1/types.go b/auth/api/auth/v1/types.go index 47da04bf1d..55d2024e2b 100644 --- a/auth/api/auth/v1/types.go +++ b/auth/api/auth/v1/types.go @@ -32,4 +32,4 @@ type VerifiablePresentation = vc.VerifiablePresentation // AccessTokenResponse is an alias to use from within the API type AccessTokenResponse = oauth.TokenResponse -type AccessTokenRequestFailedResponse = oauth.ErrorResponse +type AccessTokenRequestFailedResponse = oauth.OAuth2Error diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 1d95b639b4..9cfe7455c0 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -81,7 +81,7 @@ func New(authInstance auth.AuthenticationServices, vcrInstance vcr.VCR, vdrInsta } } -func (r *Wrapper) Routes(router core.EchoRouter) { +func (r Wrapper) Routes(router core.EchoRouter) { RegisterHandlers(router, NewStrictHandler(r, []StrictMiddlewareFunc{ func(f StrictHandlerFunc, operationID string) StrictHandlerFunc { return func(ctx echo.Context, request interface{}) (response interface{}, err error) { @@ -166,7 +166,7 @@ func (r Wrapper) HandleTokenRequest(ctx context.Context, request HandleTokenRequ } // IntrospectAccessToken allows the resource server (XIS/EHR) to introspect details of an access token issued by this node -func (r Wrapper) IntrospectAccessToken(ctx context.Context, request IntrospectAccessTokenRequestObject) (IntrospectAccessTokenResponseObject, error) { +func (r Wrapper) IntrospectAccessToken(_ context.Context, request IntrospectAccessTokenRequestObject) (IntrospectAccessTokenResponseObject, error) { // Validate token if request.Body.Token == "" { // Return 200 + 'Active = false' when token is invalid or malformed @@ -249,7 +249,6 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho if err != nil { return nil, err } - // Create session object to be passed to handler // Workaround: deepmap codegen doesn't support dynamic query parameters. // See https://github.com/deepmap/oapi-codegen/issues/1129 @@ -268,15 +267,31 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho Description: "redirect_uri is required", } } + // todo: store session in database? switch session.ResponseType { case responseTypeCode: // Options: // - Regular authorization code flow for EHR data access through access token, authentication of end-user using OpenID4VP. // - OpenID4VCI; authorization code flow for credential issuance to (end-user) wallet - // - OpenID4VP, vp_token is sent in Token Response; authorization code flow for presentation exchange (not required a.t.m.) - // TODO: Switch on parameters to right flow - panic("not implemented") + + // TODO: officially flow switching has to be determined by the client_id + // registered client_ids should list which flow they support + // client registration could be done via rfc7591.... + // for now we switch on client_id format. + // when client_id is a did:web, it is a cloud/server wallet + // otherwise it's a normal registered client which we do not support yet + // Note: this is the user facing OpenID4VP flow with a "vp_token" responseType, the demo uses the "vp_token id_token" responseType + clientId := session.ClientID + if strings.HasPrefix(clientId, "did:web:") { + // client is a cloud wallet with user + return r.handleAuthorizeRequestFromHolder(ctx, *ownDID, params) + } else { + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "client_id must be a did:web", + } + } case responseTypeVPToken: // Options: // - OpenID4VP flow, vp_token is sent in Authorization Response @@ -289,28 +304,20 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho return r.handlePresentationRequest(params, session) default: // TODO: This should be a redirect? + redirectURI, _ := url.Parse(session.RedirectURI) return nil, oauth.OAuth2Error{ Code: oauth.UnsupportedResponseType, - RedirectURI: session.RedirectURI, + RedirectURI: redirectURI, } } } // OAuthAuthorizationServerMetadata returns the Authorization Server's metadata func (r Wrapper) OAuthAuthorizationServerMetadata(ctx context.Context, request OAuthAuthorizationServerMetadataRequestObject) (OAuthAuthorizationServerMetadataResponseObject, error) { - ownDID := r.idToDID(request.Id) - owned, err := r.vdr.IsOwner(ctx, ownDID) + _, err := r.idToOwnedDID(ctx, request.Id) if err != nil { - if resolver.IsFunctionalResolveError(err) { - return nil, core.NotFoundError("authz server metadata: %w", err) - } - log.Logger().WithField("did", ownDID.String()).Errorf("authz server metadata: failed to assert ownership of did: %s", err.Error()) - return nil, core.Error(500, "authz server metadata: %w", err) - } - if !owned { - return nil, core.NotFoundError("authz server metadata: did not owned") + return nil, err } - identity := r.auth.PublicURL().JoinPath("iam", request.Id) return OAuthAuthorizationServerMetadata200JSONResponse(authorizationServerMetadata(*identity)), nil @@ -432,6 +439,6 @@ func (r Wrapper) identityURL(id string) *url.URL { return r.auth.PublicURL().JoinPath("iam", id) } -func (r *Wrapper) accessTokenStore() storage.SessionStore { +func (r Wrapper) accessTokenStore() storage.SessionStore { return r.storageEngine.GetSessionDatabase().GetStore(accessTokenValidity, "accesstoken") } diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index f178731e91..4dfa7585cb 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -22,6 +22,7 @@ import ( "context" "encoding/json" "errors" + "github.com/nuts-foundation/nuts-node/test" "net/http" "net/http/httptest" "net/url" @@ -50,6 +51,7 @@ import ( var webDID = did.MustParseDID("did:web:example.com:iam:123") var webIDPart = "123" +var verifierDID = did.MustParseDID("did:web:example.com:iam:verifier") func TestWrapper_OAuthAuthorizationServerMetadata(t *testing.T) { t.Run("ok", func(t *testing.T) { @@ -70,8 +72,8 @@ func TestWrapper_OAuthAuthorizationServerMetadata(t *testing.T) { res, err := ctx.client.OAuthAuthorizationServerMetadata(nil, OAuthAuthorizationServerMetadataRequestObject{Id: webIDPart}) - assert.Equal(t, 404, statusCodeFrom(err)) - assert.EqualError(t, err, "authz server metadata: did not owned") + assert.Equal(t, 400, statusCodeFrom(err)) + assert.EqualError(t, err, "invalid_request - issuer DID not owned by the server") assert.Nil(t, res) }) t.Run("error - did does not exist", func(t *testing.T) { @@ -81,8 +83,8 @@ func TestWrapper_OAuthAuthorizationServerMetadata(t *testing.T) { res, err := ctx.client.OAuthAuthorizationServerMetadata(nil, OAuthAuthorizationServerMetadataRequestObject{Id: webIDPart}) - assert.Equal(t, 404, statusCodeFrom(err)) - assert.EqualError(t, err, "authz server metadata: unable to find the DID document") + assert.Equal(t, 400, statusCodeFrom(err)) + assert.EqualError(t, err, "invalid_request - invalid issuer DID: unable to find the DID document") assert.Nil(t, res) }) t.Run("error - internal error 500", func(t *testing.T) { @@ -93,14 +95,12 @@ func TestWrapper_OAuthAuthorizationServerMetadata(t *testing.T) { res, err := ctx.client.OAuthAuthorizationServerMetadata(nil, OAuthAuthorizationServerMetadataRequestObject{Id: webIDPart}) assert.Equal(t, 500, statusCodeFrom(err)) - assert.EqualError(t, err, "authz server metadata: unknown error") + assert.EqualError(t, err, "DID resolution failed: unknown error") assert.Nil(t, res) }) } func TestWrapper_GetWebDID(t *testing.T) { - webDID := did.MustParseDID("did:web:example.com:iam:123") - id := "123" ctx := audit.TestContext() expectedWebDIDDoc := did.Document{ ID: webDID, @@ -113,7 +113,7 @@ func TestWrapper_GetWebDID(t *testing.T) { test := newTestClient(t) test.vdr.EXPECT().ResolveManaged(webDID).Return(&expectedWebDIDDoc, nil) - response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{id}) + response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{webIDPart}) assert.NoError(t, err) assert.Equal(t, expectedWebDIDDoc, did.Document(response.(GetWebDID200JSONResponse))) @@ -122,7 +122,7 @@ func TestWrapper_GetWebDID(t *testing.T) { test := newTestClient(t) test.vdr.EXPECT().ResolveManaged(webDID).Return(nil, resolver.ErrNotFound) - response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{id}) + response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{webIDPart}) assert.NoError(t, err) assert.IsType(t, GetWebDID404Response{}, response) @@ -131,7 +131,7 @@ func TestWrapper_GetWebDID(t *testing.T) { test := newTestClient(t) test.vdr.EXPECT().ResolveManaged(webDID).Return(nil, errors.New("failed")) - response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{id}) + response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{webIDPart}) assert.EqualError(t, err, "unable to resolve DID") assert.Nil(t, response) @@ -148,6 +148,25 @@ func TestWrapper_GetOAuthClientMetadata(t *testing.T) { require.NoError(t, err) assert.IsType(t, OAuthClientMetadata200JSONResponse{}, res) }) + t.Run("error - did not managed by this node", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(nil, webDID) + + res, err := ctx.client.OAuthClientMetadata(nil, OAuthClientMetadataRequestObject{Id: webIDPart}) + + assert.Equal(t, 400, statusCodeFrom(err)) + assert.Nil(t, res) + }) + t.Run("error - internal error 500", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(nil, webDID).Return(false, errors.New("unknown error")) + + res, err := ctx.client.OAuthClientMetadata(nil, OAuthClientMetadataRequestObject{Id: webIDPart}) + + assert.Equal(t, 500, statusCodeFrom(err)) + assert.EqualError(t, err, "DID resolution failed: unknown error") + assert.Nil(t, res) + }) } func TestWrapper_PresentationDefinition(t *testing.T) { webDID := did.MustParseDID("did:web:example.com:iam:123") @@ -189,6 +208,38 @@ func TestWrapper_PresentationDefinition(t *testing.T) { } func TestWrapper_HandleAuthorizeRequest(t *testing.T) { + metadata := oauth.AuthorizationServerMetadata{ + AuthorizationEndpoint: "https://example.com/holder/authorize", + } + t.Run("ok - 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().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", + scopeParam: "test", + }), HandleAuthorizeRequestRequestObject{ + Id: "verifier", + }) + + require.NoError(t, err) + assert.IsType(t, HandleAuthorizeRequest302Response{}, res) + location := res.(HandleAuthorizeRequest302Response).Headers.Location + assert.Contains(t, location, "https://example.com/holder/authorize") + assert.Contains(t, location, "client_id=did%3Aweb%3Aexample.com%3Aiam%3Averifier") + assert.Contains(t, location, "client_id_scheme=did") + assert.Contains(t, location, "client_metadata_uri=https%3A%2F%2Fexample.com%2F.well-known%2Fauthorization-server%2Fiam%2Fverifier") + assert.Contains(t, location, "nonce=") + assert.Contains(t, location, "presentation_definition_uri=https%3A%2F%2Fexample.com%2Fiam%2Fverifier%2Fpresentation_definition%3Fscope%3Dtest") + assert.Contains(t, location, "response_uri=https%3A%2F%2Fexample.com%2Fiam%2Fverifier%2Fresponse") + assert.Contains(t, location, "response_mode=direct_post") + assert.Contains(t, location, "response_type=vp_token") + + }) t.Run("missing redirect_uri", func(t *testing.T) { ctx := newTestClient(t) ctx.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(true, nil) @@ -346,7 +397,7 @@ func statusCodeFrom(err error) int { if errors.As(err, &SE) { return SE.StatusCode() } - return 0 + return 500 } type testCtx struct { @@ -356,8 +407,9 @@ type testCtx struct { vdr *vdr.MockVDR resolver *resolver.MockDIDResolver relyingParty *oauthServices.MockRelyingParty - verifier *verifier.MockVerifier + vcVerifier *verifier.MockVerifier vcr *vcr.MockVCR + verifierRole *oauthServices.MockVerifier } func newTestClient(t testing.TB) *testCtx { @@ -365,34 +417,34 @@ func newTestClient(t testing.TB) *testCtx { require.NoError(t, err) ctrl := gomock.NewController(t) storageEngine := storage.NewTestStorageEngine(t) - mockVerifier := verifier.NewMockVerifier(ctrl) - mockVCR := vcr.NewMockVCR(ctrl) - mockVCR.EXPECT().Verifier().Return(mockVerifier).AnyTimes() authnServices := auth.NewMockAuthenticationServices(ctrl) authnServices.EXPECT().PublicURL().Return(publicURL).AnyTimes() authnServices.EXPECT().PresentationDefinitions().Return(pe.TestDefinitionResolver(t)).AnyTimes() - resolver := resolver.NewMockDIDResolver(ctrl) + mockResolver := resolver.NewMockDIDResolver(ctrl) relyingPary := oauthServices.NewMockRelyingParty(ctrl) - verifier := verifier.NewMockVerifier(ctrl) - vdr := vdr.NewMockVDR(ctrl) - vcr := vcr.NewMockVCR(ctrl) + vcVerifier := verifier.NewMockVerifier(ctrl) + verifierRole := oauthServices.NewMockVerifier(ctrl) + mockVDR := vdr.NewMockVDR(ctrl) + mockVCR := vcr.NewMockVCR(ctrl) authnServices.EXPECT().PublicURL().Return(publicURL).AnyTimes() authnServices.EXPECT().RelyingParty().Return(relyingPary).AnyTimes() - vcr.EXPECT().Verifier().Return(verifier).AnyTimes() - vdr.EXPECT().Resolver().Return(resolver).AnyTimes() + mockVCR.EXPECT().Verifier().Return(vcVerifier).AnyTimes() + authnServices.EXPECT().Verifier().Return(verifierRole).AnyTimes() + mockVDR.EXPECT().Resolver().Return(mockResolver).AnyTimes() return &testCtx{ ctrl: ctrl, authnServices: authnServices, relyingParty: relyingPary, - resolver: resolver, - vdr: vdr, - verifier: mockVerifier, + vcVerifier: vcVerifier, + resolver: mockResolver, + vdr: mockVDR, + verifierRole: verifierRole, vcr: mockVCR, client: &Wrapper{ auth: authnServices, - vdr: vdr, + vdr: mockVDR, vcr: mockVCR, storageEngine: storageEngine, }, diff --git a/auth/api/iam/metadata.go b/auth/api/iam/metadata.go index 979d3b24b2..12b3d84d7d 100644 --- a/auth/api/iam/metadata.go +++ b/auth/api/iam/metadata.go @@ -25,15 +25,6 @@ import ( "strings" ) -const ( - // authzServerWellKnown is the well-known base path for the oauth authorization server metadata as defined in RFC8414 - authzServerWellKnown = "/.well-known/oauth-authorization-server" - // 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 - openidCredWalletWellKnown = "/.well-known/openid-credential-wallet" -) - // 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) { diff --git a/auth/api/iam/metadata_test.go b/auth/api/iam/metadata_test.go index 1195604e1d..b4d4f35e65 100644 --- a/auth/api/iam/metadata_test.go +++ b/auth/api/iam/metadata_test.go @@ -55,7 +55,7 @@ func TestIssuerIdToWellKnown(t *testing.T) { t.Run("no IP allowed", func(t *testing.T) { issuer := "https://127.0.0.1/iam/id" - u, err := IssuerIdToWellKnown(issuer, authzServerWellKnown, true) + u, err := IssuerIdToWellKnown(issuer, oauth.AuthzServerWellKnown, true) assert.ErrorContains(t, err, "hostname is IP") assert.Nil(t, u) diff --git a/auth/api/iam/openid4vp.go b/auth/api/iam/openid4vp.go index 6480476800..2c92bb7bc6 100644 --- a/auth/api/iam/openid4vp.go +++ b/auth/api/iam/openid4vp.go @@ -27,7 +27,6 @@ import ( "net/http" "net/url" "strings" - "time" "github.com/google/uuid" "github.com/labstack/echo/v4" @@ -35,16 +34,133 @@ import ( "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/crypto" httpNuts "github.com/nuts-foundation/nuts-node/http" + "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/holder" + "github.com/nuts-foundation/nuts-node/vdr/didweb" ) -const sessionExpiry = 5 * time.Minute +var oauthNonceKey = []string{"oauth", "nonce"} + +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 + // redirection URI, or if the client identifier is missing or invalid, + // the authorization server SHOULD inform the resource owner of the + // error and MUST NOT automatically redirect the user-agent to the + // invalid redirection URI. + redirectURI, ok := params[redirectURIParam] + if !ok { + // todo render error page instead of technical error + return nil, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "missing redirect_uri parameter"} + } + redirectURL, err := url.Parse(redirectURI) + if err != nil { + // todo render error page instead of technical error + 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) + } + // the walletDID must be a did:web + walletDID, err := did.ParseDID(walletID) + if err != nil || walletDID.Method != "web" { + return nil, oauthError(oauth.InvalidRequest, "invalid client_id parameter (only did:web is supported)", redirectURL) + } + metadata, err := r.auth.Verifier().AuthorizationServerMetadata(ctx, *walletDID) + if err != nil { + return nil, oauthError(oauth.ServerError, "failed to get metadata from wallet", redirectURL) + } + // own generic endpoint + ownURL, err := didweb.DIDToURL(verifier) + if err != nil { + return nil, oauthError(oauth.ServerError, "invalid verifier DID", redirectURL) + } + // generate presentation_definition_uri based on own presentation_definition endpoint + scope + pdURL := ownURL.JoinPath("presentation_definition") + presentationDefinitionURI := httpNuts.AddQueryParams(*pdURL, map[string]string{ + "scope": params[scopeParam], + }) + + // redirect to wallet authorization endpoint, use direct_post mode + // like this: + // GET /authorize? + // response_type=vp_token + // &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 + // &response_uri=https%3A%2F%2Fexample.com%2Fiam%2F123%2F%2Fresponse + // &presentation_definition_uri=... + // &response_mode=direct_post + // &nonce=n-0S6_WzA2Mj HTTP/1.1 + walletURL, err := url.Parse(metadata.AuthorizationEndpoint) + if err != nil || len(metadata.AuthorizationEndpoint) == 0 { + return nil, oauthError(oauth.InvalidRequest, "invalid wallet endpoint", redirectURL) + } + nonce := crypto.GenerateNonce() + callbackURL := *ownURL + callbackURL.Path, err = url.JoinPath(callbackURL.Path, "response") + if err != nil { + return nil, oauthError(oauth.ServerError, "failed to construct redirect path", redirectURL) + } + + metadataURL, err := r.auth.Verifier().ClientMetadataURL(verifier) + if err != nil { + return nil, oauthError(oauth.ServerError, "failed to construct metadata URL", 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, + responseURIParam: callbackURL.String(), + presentationDefUriParam: presentationDefinitionURI.String(), + clientMetadataURIParam: metadataURL.String(), + responseModeParam: responseModeDirectPost, + nonceParam: nonce, + }) + openid4vpRequest := OAuthSession{ + ClientID: verifier.String(), + Scope: params[scopeParam], + OwnDID: verifier, + ClientState: nonce, + RedirectURI: redirectURL.String(), + } + // use nonce to store authorization request in session store + if err = r.oauthNonceStore().Put(nonce, openid4vpRequest); err != nil { + return nil, oauthError(oauth.ServerError, "failed to store server state", redirectURL) + } + + return HandleAuthorizeRequest302Response{ + Headers: HandleAuthorizeRequest302ResponseHeaders{ + Location: authServerURL.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, +func (r Wrapper) sendPresentationRequest(ctx context.Context, response http.ResponseWriter, scope string, redirectURL url.URL, verifierIdentifier url.URL, walletIdentifier url.URL) error { // TODO: Lookup wallet metadata for correct authorization endpoint. But for Nuts nodes, we derive it from the walletIdentifier authzEndpoint := walletIdentifier.JoinPath("/authorize") @@ -91,7 +207,7 @@ func (r *Wrapper) handlePresentationRequest(params map[string]string, session *O return nil, oauth.OAuth2Error{ Code: oauth.InvalidRequest, Description: "response_mode must be direct_post", - RedirectURI: session.RedirectURI, + RedirectURI: session.redirectURI(), } } @@ -102,7 +218,7 @@ func (r *Wrapper) handlePresentationRequest(params map[string]string, session *O return nil, oauth.OAuth2Error{ Code: oauth.InvalidRequest, Description: fmt.Sprintf("unsupported scope for presentation exchange: %s", params[scopeParam]), - RedirectURI: session.RedirectURI, + RedirectURI: session.redirectURI(), } } @@ -174,7 +290,7 @@ func (r *Wrapper) handlePresentationRequest(params map[string]string, session *O } // handleAuthConsent handles the authorization consent form submission. -func (r *Wrapper) handlePresentationRequestAccept(c echo.Context) error { +func (r Wrapper) handlePresentationRequestAccept(c echo.Context) error { // TODO: Needs authentication? sessionID := c.FormValue("sessionID") if sessionID == "" { @@ -234,7 +350,7 @@ func (r *Wrapper) handlePresentationRequestAccept(c echo.Context) error { return c.Redirect(http.StatusFound, session.CreateRedirectURI(resultParams)) } -func (r *Wrapper) handlePresentationRequestCompleted(ctx echo.Context) error { +func (r Wrapper) handlePresentationRequestCompleted(ctx echo.Context) error { // TODO: response direct_post mode vpToken := ctx.QueryParams()[vpTokenParam] if len(vpToken) == 0 { @@ -262,6 +378,10 @@ func (r *Wrapper) handlePresentationRequestCompleted(ctx echo.Context) error { return ctx.HTML(http.StatusOK, buf.String()) } +func (r Wrapper) oauthNonceStore() storage.SessionStore { + return r.storageEngine.GetSessionDatabase().GetStore(oAuthFlowTimeout, oauthNonceKey...) +} + func assertParamPresent(params map[string]string, param ...string) error { for _, curr := range param { if len(params[curr]) == 0 { @@ -279,3 +399,11 @@ func assertParamNotPresent(params map[string]string, param ...string) error { } return nil } + +func oauthError(code oauth.ErrorCode, description string, redirectURL *url.URL) oauth.OAuth2Error { + return oauth.OAuth2Error{ + Code: code, + Description: description, + RedirectURI: redirectURL, + } +} diff --git a/auth/api/iam/openid4vp_demo.go b/auth/api/iam/openid4vp_demo.go index ddf14d2eae..8ee7290964 100644 --- a/auth/api/iam/openid4vp_demo.go +++ b/auth/api/iam/openid4vp_demo.go @@ -27,7 +27,7 @@ import ( "strings" ) -func (r *Wrapper) handleOpenID4VPDemoLanding(echoCtx echo.Context) error { +func (r Wrapper) handleOpenID4VPDemoLanding(echoCtx echo.Context) error { requestURL := *echoCtx.Request().URL requestURL.Host = echoCtx.Request().Host requestURL.Scheme = "http" @@ -47,7 +47,7 @@ func (r *Wrapper) handleOpenID4VPDemoLanding(echoCtx echo.Context) error { return echoCtx.HTML(http.StatusOK, buf.String()) } -func (r *Wrapper) handleOpenID4VPDemoSendRequest(echoCtx echo.Context) error { +func (r Wrapper) handleOpenID4VPDemoSendRequest(echoCtx echo.Context) error { verifierID := echoCtx.FormValue("verifier_id") if verifierID == "" { return errors.New("missing verifier_id") diff --git a/auth/api/iam/openid4vp_test.go b/auth/api/iam/openid4vp_test.go index a12a8a578d..248e3072fa 100644 --- a/auth/api/iam/openid4vp_test.go +++ b/auth/api/iam/openid4vp_test.go @@ -43,6 +43,63 @@ import ( var holderDID = did.MustParseDID("did:web:example.com:holder") var issuerDID = did.MustParseDID("did:web:example.com:issuer") +func TestWrapper_handleAuthorizeRequestFromHolder(t *testing.T) { + defaultParams := func() map[string]string { + return map[string]string{ + clientIDParam: holderDID.String(), + redirectURIParam: "https://example.com", + responseTypeParam: "code", + scopeParam: "test", + } + } + + t.Run("missing client_id", func(t *testing.T) { + ctx := newTestClient(t) + params := defaultParams() + delete(params, clientIDParam) + + _, err := ctx.client.handleAuthorizeRequestFromHolder(context.Background(), verifierDID, params) + + requireOAuthError(t, err, oauth.InvalidRequest, "missing client_id parameter") + }) + t.Run("invalid client_id", func(t *testing.T) { + ctx := newTestClient(t) + params := defaultParams() + params[clientIDParam] = "did:nuts:1" + + _, err := ctx.client.handleAuthorizeRequestFromHolder(context.Background(), verifierDID, params) + + requireOAuthError(t, err, oauth.InvalidRequest, "invalid client_id parameter (only did:web is supported)") + }) + t.Run("error on authorization server metadata", func(t *testing.T) { + ctx := newTestClient(t) + ctx.verifierRole.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(nil, assert.AnError) + + _, err := ctx.client.handleAuthorizeRequestFromHolder(context.Background(), verifierDID, defaultParams()) + + requireOAuthError(t, err, oauth.ServerError, "failed to get metadata from wallet") + }) + t.Run("failed to generate verifier web url", func(t *testing.T) { + ctx := newTestClient(t) + verifierDID := did.MustParseDID("did:notweb:example.com:verifier") + ctx.verifierRole.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(&oauth.AuthorizationServerMetadata{}, nil) + + _, err := ctx.client.handleAuthorizeRequestFromHolder(context.Background(), verifierDID, defaultParams()) + + requireOAuthError(t, err, oauth.ServerError, "invalid verifier DID") + }) + t.Run("incorrect holder AuthorizationEndpoint URL", func(t *testing.T) { + ctx := newTestClient(t) + ctx.verifierRole.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(&oauth.AuthorizationServerMetadata{ + AuthorizationEndpoint: "://example.com", + }, nil) + + _, err := ctx.client.handleAuthorizeRequestFromHolder(context.Background(), verifierDID, defaultParams()) + + requireOAuthError(t, err, oauth.InvalidRequest, "invalid wallet endpoint") + }) +} + func TestWrapper_sendPresentationRequest(t *testing.T) { instance := New(nil, nil, nil, nil) diff --git a/auth/api/iam/s2s_vptoken.go b/auth/api/iam/s2s_vptoken.go index 36cb1882f5..a66b24457c 100644 --- a/auth/api/iam/s2s_vptoken.go +++ b/auth/api/iam/s2s_vptoken.go @@ -45,7 +45,7 @@ const s2sMaxClockSkew = 5 * time.Second // handleS2SAccessTokenRequest handles the /token request with vp_token bearer grant type, intended for service-to-service exchanges. // It performs cheap checks first (parameter presence and validity, matching VCs to the presentation definition), then the more expensive ones (checking signatures). -func (r *Wrapper) handleS2SAccessTokenRequest(issuer did.DID, scope string, submissionJSON string, assertionJSON string) (HandleTokenRequestResponseObject, error) { +func (r Wrapper) handleS2SAccessTokenRequest(issuer did.DID, scope string, submissionJSON string, assertionJSON string) (HandleTokenRequestResponseObject, error) { pexEnvelope, err := pe.ParseEnvelope([]byte(assertionJSON)) if err != nil { return nil, oauth.OAuth2Error{ @@ -128,7 +128,7 @@ func (r Wrapper) requestServiceAccessToken(ctx context.Context, requestHolder di return RequestAccessToken200JSONResponse(*tokenResult), nil } -func (r *Wrapper) createS2SAccessToken(issuer did.DID, issueTime time.Time, presentations []vc.VerifiablePresentation, submission pe.PresentationSubmission, definition PresentationDefinition, scope string, credentialSubjectDID did.DID, credentialMap map[string]vc.VerifiableCredential) (*oauth.TokenResponse, error) { +func (r Wrapper) createS2SAccessToken(issuer did.DID, issueTime time.Time, presentations []vc.VerifiablePresentation, submission pe.PresentationSubmission, definition PresentationDefinition, scope string, credentialSubjectDID did.DID, credentialMap map[string]vc.VerifiableCredential) (*oauth.TokenResponse, error) { fieldsMap, err := definition.ResolveConstraintsFields(credentialMap) if err != nil { return nil, fmt.Errorf("unable to resolve Presentation Definition Constraints Fields: %w", err) @@ -225,7 +225,7 @@ func validatePresentationSigner(presentation vc.VerifiablePresentation, expected } // validateS2SPresentationNonce checks if the nonce has been used before; 'nonce' claim for JWTs or LDProof's 'nonce' for JSON-LD. -func (r *Wrapper) validateS2SPresentationNonce(presentation vc.VerifiablePresentation) error { +func (r Wrapper) validateS2SPresentationNonce(presentation vc.VerifiablePresentation) error { var nonce string switch presentation.Format() { case vc.JWTPresentationProofFormat: @@ -272,7 +272,7 @@ func (r *Wrapper) validateS2SPresentationNonce(presentation vc.VerifiablePresent } // validatePresentationAudience checks if the presentation audience (aud claim for JWTs, domain property for JSON-LD proofs) contains the issuer DID. -func (r *Wrapper) validatePresentationAudience(presentation vc.VerifiablePresentation, issuer did.DID) error { +func (r Wrapper) validatePresentationAudience(presentation vc.VerifiablePresentation, issuer did.DID) error { var audience []string switch presentation.Format() { case vc.JWTPresentationProofFormat: diff --git a/auth/api/iam/s2s_vptoken_test.go b/auth/api/iam/s2s_vptoken_test.go index fe64d4ed70..a1866469b3 100644 --- a/auth/api/iam/s2s_vptoken_test.go +++ b/auth/api/iam/s2s_vptoken_test.go @@ -24,6 +24,7 @@ import ( "crypto/rand" "encoding/json" "errors" + "go.uber.org/mock/gomock" "net/http" "testing" "time" @@ -41,7 +42,6 @@ import ( "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" ) func TestWrapper_requestServiceAccessToken(t *testing.T) { @@ -116,7 +116,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { t.Run("JSON-LD VP", func(t *testing.T) { ctx := newTestClient(t) - ctx.verifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) + ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) @@ -163,7 +163,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { presentation := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { require.NoError(t, token.Set(jwt.AudienceKey, issuerDID.String())) }, verifiableCredential) - ctx.verifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) + ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) @@ -196,7 +196,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { t.Run("nonce", func(t *testing.T) { t.Run("replay attack (nonce is reused)", func(t *testing.T) { ctx := newTestClient(t) - ctx.verifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) + ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) _, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) require.NoError(t, err) @@ -289,7 +289,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { }) t.Run("VP verification fails", func(t *testing.T) { ctx := newTestClient(t) - ctx.verifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(nil, errors.New("invalid")) + ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(nil, errors.New("invalid")) resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) diff --git a/auth/api/iam/session.go b/auth/api/iam/session.go index d79e161d2f..19311840ba 100644 --- a/auth/api/iam/session.go +++ b/auth/api/iam/session.go @@ -57,3 +57,8 @@ func (s OAuthSession) CreateRedirectURI(params map[string]string) string { r := http.AddQueryParams(*redirectURI, params) return r.String() } + +func (s OAuthSession) redirectURI() *url.URL { + redirectURL, _ := url.Parse(s.RedirectURI) + return redirectURL +} diff --git a/auth/api/iam/types.go b/auth/api/iam/types.go index e699ba0be0..8259046e58 100644 --- a/auth/api/iam/types.go +++ b/auth/api/iam/types.go @@ -24,6 +24,7 @@ import ( "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/resolver" + "time" ) // DIDDocument is an alias @@ -47,6 +48,10 @@ type TokenResponse = oauth.TokenResponse // OAuthAuthorizationServerMetadata is an alias type OAuthAuthorizationServerMetadata = oauth.AuthorizationServerMetadata +const ( + sessionExpiry = 5 * time.Minute +) + const ( // responseTypeParam is the name of the response_type parameter. // Specified by https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.1 @@ -69,11 +74,14 @@ const ( // responseTypeVPIDToken is defined in the OpenID4VP flow that combines its vp_token with SIOPv2's id_token // https://openid.bitbucket.io/connect/openid-4-verifiable-presentations-1_0.html#appendix-B responseTypeVPIDToken = "vp_token id_token" + nonceParam = "nonce" ) var responseTypesSupported = []string{responseTypeCode, responseTypeVPToken, responseTypeVPIDToken} const ( + // didScheme is the client_id_scheme value for DIDs + didScheme = "did" // responseModeParam is the name of the OAuth2 response_mode parameter. // Specified by https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html responseModeParam = "response_mode" @@ -132,6 +140,9 @@ const stateParam = "state" // Specified by https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2 const redirectURIParam = "redirect_uri" +// responseURIParam is the name of the OpenID4VP response_uri parameter. +const responseURIParam = "response_uri" + // presentationDefParam is the name of the OpenID4VP presentation_definition parameter. // Specified by https://openid.bitbucket.io/connect/openid-4-verifiable-presentations-1_0.html#name-presentation_definition-par const presentationDefParam = "presentation_definition" diff --git a/auth/auth.go b/auth/auth.go index c7dbeefd8d..4b7ed93c22 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -49,6 +49,7 @@ type Auth struct { jsonldManager jsonld.JSONLD authzServer oauth.AuthorizationServer relyingParty oauth.RelyingParty + verifier oauth.Verifier contractNotary services.ContractNotary serviceResolver didman.CompoundServiceResolver keyStore crypto.KeyStore @@ -114,6 +115,10 @@ func (auth *Auth) RelyingParty() oauth.RelyingParty { return auth.relyingParty } +func (auth *Auth) Verifier() oauth.Verifier { + return auth.verifier +} + // 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 == "" { @@ -166,6 +171,7 @@ func (auth *Auth) Configure(config core.ServerConfig) error { 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) if err := auth.authzServer.Configure(auth.config.ClockSkew, config.Strictmode); err != nil { return err diff --git a/auth/interface.go b/auth/interface.go index f508d3e958..1749b017a1 100644 --- a/auth/interface.go +++ b/auth/interface.go @@ -34,6 +34,8 @@ type AuthenticationServices interface { AuthzServer() oauth.AuthorizationServer // RelyingParty returns the oauth.RelyingParty RelyingParty() oauth.RelyingParty + // Verifier returns the oauth.Verifier service provider + Verifier() oauth.Verifier // ContractNotary returns an instance of ContractNotary ContractNotary() services.ContractNotary // V2APIEnabled returns true if the V2 API is enabled. diff --git a/auth/mock.go b/auth/mock.go index 36835e7e1d..a1af1bdd49 100644 --- a/auth/mock.go +++ b/auth/mock.go @@ -124,3 +124,17 @@ func (mr *MockAuthenticationServicesMockRecorder) V2APIEnabled() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "V2APIEnabled", reflect.TypeOf((*MockAuthenticationServices)(nil).V2APIEnabled)) } + +// Verifier mocks base method. +func (m *MockAuthenticationServices) Verifier() oauth.Verifier { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Verifier") + ret0, _ := ret[0].(oauth.Verifier) + return ret0 +} + +// Verifier indicates an expected call of Verifier. +func (mr *MockAuthenticationServicesMockRecorder) Verifier() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Verifier", reflect.TypeOf((*MockAuthenticationServices)(nil).Verifier)) +} diff --git a/auth/oauth/error.go b/auth/oauth/error.go index 173992f764..1ea92ae377 100644 --- a/auth/oauth/error.go +++ b/auth/oauth/error.go @@ -62,7 +62,7 @@ type OAuth2Error struct { // It should not be set if the user-agent is not a browser, or there is no redirect_uri (because the request was malformed), this field is empty. // When the field is set, the user-agent is redirected to the specified URI with the error code and description as query parameters. // If it's not set, the error code and description are returned in the response body (plain text or JSON). - RedirectURI string `json:"-"` + RedirectURI *url.URL `json:"-"` } // StatusCode returns the HTTP status code to be returned to the client, in case the user-agent can't be redirected with HTTP 302 - Found. @@ -104,8 +104,7 @@ func (p Oauth2ErrorWriter) Write(echoContext echo.Context, _ int, _ string, err // Somebody forgot to set a code oauthErr.Code = ServerError } - redirectURI, _ := url.Parse(oauthErr.RedirectURI) - if oauthErr.RedirectURI == "" || redirectURI == nil { + if oauthErr.RedirectURI == nil { // Can't redirect the user-agent back, render error as JSON or plain text (depending on accept/content-type) accept := echoContext.Request().Header.Get("Accept") if strings.Contains(accept, "application/json") { @@ -125,13 +124,13 @@ func (p Oauth2ErrorWriter) Write(echoContext echo.Context, _ int, _ string, err return echoContext.String(oauthErr.StatusCode(), strings.Join(parts, " - ")) } // Redirect the user-agent back to the client - query := redirectURI.Query() + query := oauthErr.RedirectURI.Query() query.Set("error", string(oauthErr.Code)) if oauthErr.Description != "" { query.Set("error_description", oauthErr.Description) } - redirectURI.RawQuery = query.Encode() - return echoContext.Redirect(http.StatusFound, redirectURI.String()) + oauthErr.RedirectURI.RawQuery = query.Encode() + return echoContext.Redirect(http.StatusFound, oauthErr.RedirectURI.String()) } // TestOAuthErrorCode tests if the response is an OAuth2 error with the given code. diff --git a/auth/oauth/error_test.go b/auth/oauth/error_test.go index 0e95bf536e..ff175c0b85 100644 --- a/auth/oauth/error_test.go +++ b/auth/oauth/error_test.go @@ -21,6 +21,7 @@ package oauth import ( "errors" "github.com/labstack/echo/v4" + "github.com/nuts-foundation/nuts-node/test" "github.com/stretchr/testify/assert" "io" "net/http" @@ -48,7 +49,7 @@ func Test_oauth2ErrorWriter_Write(t *testing.T) { err := Oauth2ErrorWriter{}.Write(ctx, 0, "", OAuth2Error{ Code: InvalidRequest, Description: "failure", - RedirectURI: "https://example.com", + RedirectURI: test.MustParseURL("https://example.com"), }) assert.NoError(t, err) diff --git a/auth/oauth/types.go b/auth/oauth/types.go index abc14df714..a95422bb85 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -130,9 +130,3 @@ 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"` } - -// ErrorResponse models an error returned from an OAuth flow according to RFC6749 (https://tools.ietf.org/html/rfc6749#page-45) -type ErrorResponse struct { - Description *string `json:"error_description,omitempty"` - Error string `json:"error"` -} diff --git a/auth/services/oauth/authz_server.go b/auth/services/oauth/authz_server.go index 4fd32ae59c..a12763182f 100644 --- a/auth/services/oauth/authz_server.go +++ b/auth/services/oauth/authz_server.go @@ -183,14 +183,13 @@ func (s *authzServer) Configure(clockSkewInMilliseconds int, secureMode bool) er } // CreateAccessToken extracts the claims out of the request, checks the validity and builds the access token -func (s *authzServer) CreateAccessToken(ctx context.Context, request services.CreateAccessTokenRequest) (*oauth.TokenResponse, *oauth.ErrorResponse) { - var oauthError *oauth.ErrorResponse +func (s *authzServer) CreateAccessToken(ctx context.Context, request services.CreateAccessTokenRequest) (*oauth.TokenResponse, *oauth.OAuth2Error) { + var oauthError *oauth.OAuth2Error var result *oauth.TokenResponse validationCtx, err := s.validateAccessTokenRequest(ctx, request.RawJwtBearerToken) if err != nil { - errStr := err.Error() - oauthError = &oauth.ErrorResponse{Error: "invalid_request", Description: &errStr} + oauthError = &oauth.OAuth2Error{Code: "invalid_request", Description: err.Error()} } else { var accessToken string var rawToken services.NutsAccessToken @@ -202,11 +201,10 @@ func (s *authzServer) CreateAccessToken(ctx context.Context, request services.Cr ExpiresIn: &expires, } } else { - oauthError = &oauth.ErrorResponse{Error: "server_error"} + oauthError = &oauth.OAuth2Error{Code: "server_error"} if !s.secureMode { // Only set details when secure mode is disabled - errStr := err.Error() - oauthError.Description = &errStr + oauthError.Description = err.Error() } } } diff --git a/auth/services/oauth/authz_server_test.go b/auth/services/oauth/authz_server_test.go index 4a78c2af73..b9ea959a20 100644 --- a/auth/services/oauth/authz_server_test.go +++ b/auth/services/oauth/authz_server_test.go @@ -117,7 +117,7 @@ func TestAuth_CreateAccessToken(t *testing.T) { assert.Nil(t, response) require.NotNil(t, err.Description) - assert.Contains(t, *err.Description, "jwt bearer token validation failed") + assert.Contains(t, err.Description, "jwt bearer token validation failed") }) t.Run("broken identity token", func(t *testing.T) { @@ -135,7 +135,7 @@ func TestAuth_CreateAccessToken(t *testing.T) { assert.Nil(t, response) require.NotNil(t, err.Description) - assert.Contains(t, *err.Description, "identity validation failed") + assert.Contains(t, err.Description, "identity validation failed") }) t.Run("JWT validity too long", func(t *testing.T) { @@ -151,7 +151,7 @@ func TestAuth_CreateAccessToken(t *testing.T) { assert.Nil(t, response) require.NotNil(t, err.Description) - assert.Contains(t, *err.Description, "JWT validity too long") + assert.Contains(t, err.Description, "JWT validity too long") }) t.Run("invalid identity token", func(t *testing.T) { @@ -170,7 +170,7 @@ func TestAuth_CreateAccessToken(t *testing.T) { assert.Nil(t, response) require.NotNil(t, err.Description) - assert.Contains(t, *err.Description, "identity validation failed: because of reasons") + assert.Contains(t, err.Description, "identity validation failed: because of reasons") }) t.Run("error detail masking", func(t *testing.T) { @@ -201,7 +201,7 @@ func TestAuth_CreateAccessToken(t *testing.T) { assert.Nil(t, response) require.NotNil(t, err.Description) - assert.Contains(t, *err.Description, "could not build accessToken: signing error") + assert.Contains(t, err.Description, "could not build accessToken: signing error") }) t.Run("mask internal errors when secureMode=true", func(t *testing.T) { ctx := setup(createContext(t)) @@ -213,8 +213,8 @@ func TestAuth_CreateAccessToken(t *testing.T) { response, err := ctx.oauthService.CreateAccessToken(ctx.audit, services.CreateAccessTokenRequest{RawJwtBearerToken: tokenCtx.rawJwtBearerToken}) assert.Nil(t, response) - assert.Nil(t, err.Description) - assert.Equal(t, err.Error, "server_error") + assert.Empty(t, err.Description) + assert.Equal(t, "server_error", string(err.Code)) }) }) diff --git a/auth/services/oauth/interface.go b/auth/services/oauth/interface.go index 8006684314..28c237c7a6 100644 --- a/auth/services/oauth/interface.go +++ b/auth/services/oauth/interface.go @@ -44,6 +44,14 @@ type AuthorizationServer interface { // CreateAccessToken is called by remote Nuts nodes to create an access token, // which can be used to access the local organization's XIS resources. // It returns an oauth.ErrorResponse rather than a regular Go error, because the errors that may be returned are tightly specified. - CreateAccessToken(ctx context.Context, request services.CreateAccessTokenRequest) (*oauth.TokenResponse, *oauth.ErrorResponse) + CreateAccessToken(ctx context.Context, request services.CreateAccessTokenRequest) (*oauth.TokenResponse, *oauth.OAuth2Error) IntrospectAccessToken(ctx context.Context, token string) (*services.NutsAccessToken, error) } + +// Verifier implements the OpenID4VP Verifier role. +type Verifier interface { + // AuthorizationServerMetadata returns the metadata of the remote wallet. + AuthorizationServerMetadata(ctx context.Context, webdid did.DID) (*oauth.AuthorizationServerMetadata, error) + // ClientMetadataURL constructs the URL to the client metadata of the local verifier. + ClientMetadataURL(webdid did.DID) (*url.URL, error) +} diff --git a/auth/services/oauth/mock.go b/auth/services/oauth/mock.go index c817d302c9..babd76913b 100644 --- a/auth/services/oauth/mock.go +++ b/auth/services/oauth/mock.go @@ -140,11 +140,11 @@ func (mr *MockAuthorizationServerMockRecorder) Configure(clockSkewInMilliseconds } // CreateAccessToken mocks base method. -func (m *MockAuthorizationServer) CreateAccessToken(ctx context.Context, request services.CreateAccessTokenRequest) (*oauth.TokenResponse, *oauth.ErrorResponse) { +func (m *MockAuthorizationServer) CreateAccessToken(ctx context.Context, request services.CreateAccessTokenRequest) (*oauth.TokenResponse, *oauth.OAuth2Error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateAccessToken", ctx, request) ret0, _ := ret[0].(*oauth.TokenResponse) - ret1, _ := ret[1].(*oauth.ErrorResponse) + ret1, _ := ret[1].(*oauth.OAuth2Error) return ret0, ret1 } @@ -168,3 +168,56 @@ func (mr *MockAuthorizationServerMockRecorder) IntrospectAccessToken(ctx, token mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IntrospectAccessToken", reflect.TypeOf((*MockAuthorizationServer)(nil).IntrospectAccessToken), ctx, token) } + +// MockVerifier is a mock of Verifier interface. +type MockVerifier struct { + ctrl *gomock.Controller + recorder *MockVerifierMockRecorder +} + +// MockVerifierMockRecorder is the mock recorder for MockVerifier. +type MockVerifierMockRecorder struct { + mock *MockVerifier +} + +// NewMockVerifier creates a new mock instance. +func NewMockVerifier(ctrl *gomock.Controller) *MockVerifier { + mock := &MockVerifier{ctrl: ctrl} + mock.recorder = &MockVerifierMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockVerifier) EXPECT() *MockVerifierMockRecorder { + return m.recorder +} + +// AuthorizationServerMetadata mocks base method. +func (m *MockVerifier) AuthorizationServerMetadata(ctx context.Context, webdid did.DID) (*oauth.AuthorizationServerMetadata, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AuthorizationServerMetadata", ctx, webdid) + ret0, _ := ret[0].(*oauth.AuthorizationServerMetadata) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AuthorizationServerMetadata indicates an expected call of AuthorizationServerMetadata. +func (mr *MockVerifierMockRecorder) AuthorizationServerMetadata(ctx, webdid any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthorizationServerMetadata", reflect.TypeOf((*MockVerifier)(nil).AuthorizationServerMetadata), ctx, webdid) +} + +// ClientMetadataURL mocks base method. +func (m *MockVerifier) ClientMetadataURL(webdid did.DID) (*url.URL, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ClientMetadataURL", webdid) + ret0, _ := ret[0].(*url.URL) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ClientMetadataURL indicates an expected call of ClientMetadataURL. +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) +} diff --git a/auth/services/oauth/relying_party.go b/auth/services/oauth/relying_party.go index c2cd0afe43..9da8482646 100644 --- a/auth/services/oauth/relying_party.go +++ b/auth/services/oauth/relying_party.go @@ -38,7 +38,7 @@ import ( "github.com/nuts-foundation/nuts-node/core" nutsCrypto "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/didman" - http2 "github.com/nuts-foundation/nuts-node/http" + 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/signature/proof" @@ -127,7 +127,7 @@ func (s *relyingParty) CreateAuthorizationRequest(ctx context.Context, requestHo return nil, fmt.Errorf("failed to parse authorization endpoint URL: %w", err) } // todo: redirect_uri - redirectURL := http2.AddQueryParams(*endpoint, map[string]string{ + redirectURL := nutsHttp.AddQueryParams(*endpoint, map[string]string{ "client_id": requestHolder.String(), "response_type": "code", "scope": scopes, @@ -234,6 +234,15 @@ func (s *relyingParty) RequestRFC021AccessToken(ctx context.Context, requester d }, nil } +func (s *relyingParty) authorizationServerMetadata(ctx context.Context, webdid did.DID) (*oauth.AuthorizationServerMetadata, error) { + iamClient := iam.NewHTTPClient(s.strictMode, s.httpClientTimeout, s.httpClientTLS) + metadata, err := iamClient.OAuthAuthorizationServerMetadata(ctx, webdid) + if err != nil { + return nil, fmt.Errorf("failed to retrieve remote OAuth Authorization Server metadata: %w", err) + } + return metadata, nil +} + func chooseVPFormat(formats map[string]map[string][]string) string { // They are in preferred order if _, ok := formats[vc.JWTPresentationProofFormat]; ok { diff --git a/auth/services/oauth/relying_party_test.go b/auth/services/oauth/relying_party_test.go index 34fb2042e5..385b9c66fe 100644 --- a/auth/services/oauth/relying_party_test.go +++ b/auth/services/oauth/relying_party_test.go @@ -24,21 +24,21 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/audit" - "github.com/nuts-foundation/nuts-node/auth/oauth" - http2 "github.com/nuts-foundation/nuts-node/test/http" "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/audit" + "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/auth/services" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/didman" + "github.com/nuts-foundation/nuts-node/test" + 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" @@ -61,7 +61,7 @@ func TestRelyingParty_RequestRFC003AccessToken(t *testing.T) { httpServer := httptest.NewServer(httpHandler) t.Cleanup(httpServer.Close) - response, err := ctx.relyingParty.RequestRFC003AccessToken(context.Background(), bearerToken, mustParseURL(httpServer.URL)) + response, err := ctx.relyingParty.RequestRFC003AccessToken(context.Background(), bearerToken, *test.MustParseURL(httpServer.URL)) assert.NoError(t, err) assert.NotNil(t, response) @@ -74,7 +74,7 @@ func TestRelyingParty_RequestRFC003AccessToken(t *testing.T) { }) t.Cleanup(server.Close) - response, err := ctx.relyingParty.RequestRFC003AccessToken(context.Background(), bearerToken, mustParseURL(server.URL)) + response, err := ctx.relyingParty.RequestRFC003AccessToken(context.Background(), bearerToken, *test.MustParseURL(server.URL)) assert.Nil(t, response) assert.EqualError(t, err, "remote server/nuts node returned error creating access token: server returned HTTP 502 (expected: 200)") @@ -94,7 +94,7 @@ func TestRelyingParty_RequestRFC003AccessToken(t *testing.T) { t.Run("HTTPS in strict mode", func(t *testing.T) { ctx.relyingParty.strictMode = true - response, err := ctx.relyingParty.RequestRFC003AccessToken(context.Background(), bearerToken, mustParseURL(httpsServer.URL)) + response, err := ctx.relyingParty.RequestRFC003AccessToken(context.Background(), bearerToken, *test.MustParseURL(httpsServer.URL)) assert.NoError(t, err) assert.NotNil(t, response) @@ -102,7 +102,7 @@ func TestRelyingParty_RequestRFC003AccessToken(t *testing.T) { t.Run("HTTP allowed in non-strict mode", func(t *testing.T) { ctx.relyingParty.strictMode = false - response, err := ctx.relyingParty.RequestRFC003AccessToken(context.Background(), bearerToken, mustParseURL(httpServer.URL)) + response, err := ctx.relyingParty.RequestRFC003AccessToken(context.Background(), bearerToken, *test.MustParseURL(httpServer.URL)) assert.NoError(t, err) assert.NotNil(t, response) @@ -110,7 +110,7 @@ func TestRelyingParty_RequestRFC003AccessToken(t *testing.T) { t.Run("HTTP not allowed in strict mode", func(t *testing.T) { ctx.relyingParty.strictMode = true - response, err := ctx.relyingParty.RequestRFC003AccessToken(context.Background(), bearerToken, mustParseURL(httpServer.URL)) + response, err := ctx.relyingParty.RequestRFC003AccessToken(context.Background(), bearerToken, *test.MustParseURL(httpServer.URL)) assert.EqualError(t, err, fmt.Sprintf("authorization server endpoint must be HTTPS when in strict mode: %s", httpServer.URL)) assert.Nil(t, response) @@ -560,11 +560,3 @@ func createOAuthRPContext(t *testing.T) *rpOAuthTestContext { return ctx } - -func mustParseURL(str string) url.URL { - u, err := url.Parse(str) - if err != nil { - panic(err) - } - return *u -} diff --git a/auth/services/oauth/verifier.go b/auth/services/oauth/verifier.go new file mode 100644 index 0000000000..cf9d029fdb --- /dev/null +++ b/auth/services/oauth/verifier.go @@ -0,0 +1,71 @@ +/* + * 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" + "fmt" + "github.com/nuts-foundation/nuts-node/auth/client/iam" + "github.com/nuts-foundation/nuts-node/vdr/didweb" + "net/url" + "time" + + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/auth/oauth" +) + +var _ Verifier = (*VerifierServiceProvider)(nil) + +type VerifierServiceProvider struct { + strictMode bool + httpClientTimeout time.Duration + httpClientTLS *tls.Config +} + +// NewVerifier returns an implementation of Verifier +func NewVerifier(strictMode bool, httpClientTimeout time.Duration, httpClientTLS *tls.Config) *VerifierServiceProvider { + return &VerifierServiceProvider{ + strictMode: strictMode, + httpClientTimeout: httpClientTimeout, + httpClientTLS: httpClientTLS, + } +} + +func (v *VerifierServiceProvider) AuthorizationServerMetadata(ctx context.Context, webdid did.DID) (*oauth.AuthorizationServerMetadata, error) { + iamClient := iam.NewHTTPClient(v.strictMode, v.httpClientTimeout, v.httpClientTLS) + // the wallet/holder acts as authorization server + metadata, err := iamClient.OAuthAuthorizationServerMetadata(ctx, webdid) + if err != nil { + return nil, fmt.Errorf("failed to retrieve remote OAuth Authorization Server metadata: %w", err) + } + return metadata, nil +} + +func (v *VerifierServiceProvider) ClientMetadataURL(webdid did.DID) (*url.URL, error) { + didURL, err := didweb.DIDToURL(webdid) + if err != nil { + return nil, fmt.Errorf("failed to convert DID to URL: %w", err) + } + // 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 +} diff --git a/auth/services/oauth/verifier_test.go b/auth/services/oauth/verifier_test.go new file mode 100644 index 0000000000..b05d7e17e0 --- /dev/null +++ b/auth/services/oauth/verifier_test.go @@ -0,0 +1,118 @@ +/* + * 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" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/auth/oauth" + http2 "github.com/nuts-foundation/nuts-node/test/http" + "github.com/nuts-foundation/nuts-node/vdr/didweb" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestVerifier_AuthorizationServerMetadata(t *testing.T) { + t.Run("ok", func(t *testing.T) { + ctx := createVContext(t) + + metadata, err := ctx.verifier.AuthorizationServerMetadata(context.Background(), ctx.verifierDID) + + require.NoError(t, err) + require.NotNil(t, metadata) + assert.Equal(t, ctx.authzServerMetadata, *metadata) + }) + t.Run("error - failed to get metadata", func(t *testing.T) { + ctx := createVContext(t) + ctx.metadata = nil + + _, err := ctx.verifier.AuthorizationServerMetadata(context.Background(), ctx.verifierDID) + + require.Error(t, err) + assert.EqualError(t, err, "failed to retrieve remote OAuth Authorization Server metadata: server returned HTTP 404 (expected: 200)") + }) +} + +func TestVerifierServiceProvider_ClientMetadataURL(t *testing.T) { + verifier := NewVerifier(false, 0, &tls.Config{InsecureSkipVerify: true}) + webdid := did.MustParseDID("did:web:example.com:iam:holder") + + t.Run("ok", func(t *testing.T) { + url, err := verifier.ClientMetadataURL(webdid) + + require.NoError(t, err) + require.NotNil(t, url) + assert.Equal(t, "https://example.com/.well-known/oauth-authorization-server/iam/holder", url.String()) + }) + t.Run("error - invalid DID", func(t *testing.T) { + _, err := verifier.ClientMetadataURL(did.DID{}) + + require.Error(t, err) + assert.EqualError(t, err, "failed to convert DID to URL: URL does not represent a Web DID\nunsupported DID method: ") + }) +} + +type vTestContext struct { + ctrl *gomock.Controller + verifier Verifier + authzServerMetadata oauth.AuthorizationServerMetadata + handler http.HandlerFunc + tlsServer *httptest.Server + verifierDID did.DID + metadata func(writer http.ResponseWriter) +} + +func createVContext(t *testing.T) *vTestContext { + ctrl := gomock.NewController(t) + authzServerMetadata := oauth.AuthorizationServerMetadata{} + ctx := &vTestContext{ + ctrl: ctrl, + metadata: func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(authzServerMetadata) + _, _ = 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 + } + } + writer.WriteHeader(http.StatusNotFound) + } + ctx.tlsServer = http2.TestTLSServer(t, ctx.handler) + ctx.verifierDID = didweb.ServerURLToDIDWeb(t, ctx.tlsServer.URL) + ctx.authzServerMetadata = authzServerMetadata + ctx.verifier = NewVerifier(false, 0, &tls.Config{InsecureSkipVerify: true}) + + return ctx +} diff --git a/e2e-tests/oauth-flow/rfc021/docker-compose.yml b/e2e-tests/oauth-flow/rfc021/docker-compose.yml index c34f0bad59..eea54d2446 100644 --- a/e2e-tests/oauth-flow/rfc021/docker-compose.yml +++ b/e2e-tests/oauth-flow/rfc021/docker-compose.yml @@ -14,6 +14,7 @@ services: # did:web resolver uses the OS CA bundle, but e2e tests use a self-signed CA which can be found in truststore.pem # So we need to mount that file to the OS CA bundle location, otherwise did:web resolving will fail due to untrusted certs. - "../../tls-certs/truststore.pem:/etc/ssl/certs/Nuts_RootCA.pem:ro" + - "../../tls-certs/truststore.pem:/etc/ssl/certs/truststore.pem:ro" - "./node-A/presentationexchangemapping.json:/opt/nuts/presentationexchangemapping.json:ro" healthcheck: interval: 1s # Make test run quicker by checking health status more often diff --git a/echo/echo_integration_test.go b/echo/echo_integration_test.go index cc1e2c06e9..4bf6c51814 100644 --- a/echo/echo_integration_test.go +++ b/echo/echo_integration_test.go @@ -66,9 +66,6 @@ func TestStatusCodes(t *testing.T) { } }) t.Run("400s", func(t *testing.T) { - hook := logTest.NewGlobal() - baseUrl, _ := node.StartServer(t) - testCases := []operation{ {module: "Crypto", operation: "SignJwt", url: "/internal/crypto/v1/sign_jwt", body: map[string]interface{}{"kid": "fpp", "claims": map[string]interface{}{"foo": "bar"}}}, {module: "Network", operation: "GetTransaction", url: "/internal/network/v1/transaction/invalidhash"}, @@ -76,6 +73,8 @@ func TestStatusCodes(t *testing.T) { } for _, testCase := range testCases { + hook := logTest.NewGlobal() + baseUrl, _ := node.StartServer(t) var resp *http.Response var err error if testCase.body != nil { diff --git a/golden_hammer/module_test.go b/golden_hammer/module_test.go index 45c8a975f3..e6033532c0 100644 --- a/golden_hammer/module_test.go +++ b/golden_hammer/module_test.go @@ -30,7 +30,6 @@ import ( "github.com/nuts-foundation/nuts-node/test/pki" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vdr" - "github.com/nuts-foundation/nuts-node/vdr/didnuts/didstore" "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -108,59 +107,51 @@ func TestGoldenHammer_Fix(t *testing.T) { t.Run("nothing to fix", func(t *testing.T) { // vendor and care organization DIDs already have the required service, so there's nothing to fix - ctrl := gomock.NewController(t) - didResolver := resolver.NewMockDIDResolver(ctrl) - didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(&vendorDocumentWithBaseURL, nil, nil).MinTimes(1) - didResolver.EXPECT().Resolve(clientADID, gomock.Any()).Return(&clientDocumentWithBaseURL, nil, nil).MinTimes(1) - didResolver.EXPECT().Resolve(clientBDID, gomock.Any()).Return(&clientDocumentWithBaseURL, nil, nil).MinTimes(1) - vdr := vdr.NewMockVDR(ctrl) - vdr.EXPECT().Resolver().Return(didResolver).MinTimes(1) - vdr.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{vendorDID, clientADID, clientBDID}, nil) - service := New(vdr, nil) + ctx := newMockContext(t) + ctx.didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(&vendorDocumentWithBaseURL, nil, nil).MinTimes(1) + ctx.didResolver.EXPECT().Resolve(clientADID, gomock.Any()).Return(&clientDocumentWithBaseURL, nil, nil).MinTimes(1) + ctx.didResolver.EXPECT().Resolve(clientBDID, gomock.Any()).Return(&clientDocumentWithBaseURL, nil, nil).MinTimes(1) + ctx.vdr.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{vendorDID, clientADID, clientBDID}, nil) - err := service.registerServiceBaseURLs() + err := ctx.hammer.registerServiceBaseURLs() assert.NoError(t, err) t.Run("second time list of fixed DIDs is cached (no DID resolving)", func(t *testing.T) { - vdr.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{vendorDID, clientADID, clientBDID}, nil) + ctx.vdr.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{vendorDID, clientADID, clientBDID}, nil) - err := service.registerServiceBaseURLs() + err := ctx.hammer.registerServiceBaseURLs() assert.NoError(t, err) }) }) t.Run("to be registered on vendor DID and client DIDs", func(t *testing.T) { - ctrl := gomock.NewController(t) - didResolver := didstore.NewMockStore(ctrl) + ctx := newMockContext(t) docClientA := clientDocumentWithoutBaseURL docClientA.ID = clientADID docClientB := clientDocumentWithoutBaseURL docClientB.ID = clientBDID - vdr := vdr.NewMockVDR(ctrl) - vdr.EXPECT().Resolver().Return(didResolver).MinTimes(1) // Order DIDs such that care organization DID is first, to test ordering - vdr.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{clientADID, vendorDID, clientBDID}, nil) - didmanAPI := didman.NewMockDidman(ctrl) + ctx.vdr.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{clientADID, vendorDID, clientBDID}, nil) gomock.InOrder( // DID documents are listed first to check if they should be fixed - didResolver.EXPECT().Resolve(clientADID, gomock.Any()).Return(&docClientA, nil, nil), - didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(&vendorDocumentWithoutBaseURL, nil, nil), - didResolver.EXPECT().Resolve(clientBDID, gomock.Any()).Return(&docClientB, nil, nil), + ctx.didResolver.EXPECT().Resolve(clientADID, gomock.Any()).Return(&docClientA, nil, nil), + ctx.didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(&vendorDocumentWithoutBaseURL, nil, nil), + ctx.didResolver.EXPECT().Resolve(clientBDID, gomock.Any()).Return(&docClientB, nil, nil), // Vendor document is fixed first - didmanAPI.EXPECT().AddEndpoint(gomock.Any(), vendorDID, resolver.BaseURLServiceType, *expectedBaseURL).Return(nil, nil), + ctx.didmanAPI.EXPECT().AddEndpoint(gomock.Any(), vendorDID, resolver.BaseURLServiceType, *expectedBaseURL).Return(nil, nil), // Then client A - didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(&vendorDocumentWithBaseURL, nil, nil), - didmanAPI.EXPECT().AddEndpoint(gomock.Any(), clientADID, resolver.BaseURLServiceType, *serviceRef).Return(nil, nil), + ctx.didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(&vendorDocumentWithBaseURL, nil, nil), + ctx.didmanAPI.EXPECT().AddEndpoint(gomock.Any(), clientADID, resolver.BaseURLServiceType, *serviceRef).Return(nil, nil), // Then client B - didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(&vendorDocumentWithBaseURL, nil, nil), - didmanAPI.EXPECT().AddEndpoint(gomock.Any(), clientBDID, resolver.BaseURLServiceType, *serviceRef).Return(nil, nil), + ctx.didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(&vendorDocumentWithBaseURL, nil, nil), + ctx.didmanAPI.EXPECT().AddEndpoint(gomock.Any(), clientBDID, resolver.BaseURLServiceType, *serviceRef).Return(nil, nil), ) - service := New(vdr, didmanAPI) + service := ctx.hammer service.tlsConfig = tlsServer.TLS service.tlsConfig.InsecureSkipVerify = true service.tlsConfig.Certificates = []tls.Certificate{pki.Certificate()} @@ -170,13 +161,10 @@ func TestGoldenHammer_Fix(t *testing.T) { assert.NoError(t, err) }) t.Run("vendor identifier can't be resolved from TLS", func(t *testing.T) { - ctrl := gomock.NewController(t) - didResolver := didstore.NewMockStore(ctrl) - didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(&vendorDocumentWithoutBaseURL, nil, nil).MinTimes(1) - vdr := vdr.NewMockVDR(ctrl) - vdr.EXPECT().Resolver().Return(didResolver).MinTimes(1) - vdr.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{vendorDID}, nil) - service := New(vdr, nil) + ctx := newMockContext(t) + ctx.didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(&vendorDocumentWithoutBaseURL, nil, nil).MinTimes(1) + ctx.vdr.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{vendorDID}, nil) + service := ctx.hammer service.tlsConfig = &tls.Config{ Certificates: []tls.Certificate{pki.Certificate()}, } @@ -188,26 +176,22 @@ func TestGoldenHammer_Fix(t *testing.T) { t.Run("to be registered on client DIDs", func(t *testing.T) { // vendor DID document already contains the service, but its care organization DID documents not yet, // so they need to be registered. - ctrl := gomock.NewController(t) - didResolver := didstore.NewMockStore(ctrl) - didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(&vendorDocumentWithBaseURL, nil, nil).MinTimes(1) + ctx := newMockContext(t) + ctx.didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(&vendorDocumentWithBaseURL, nil, nil).MinTimes(1) docClientA := clientDocumentWithoutBaseURL docClientA.ID = clientADID docClientB := clientDocumentWithoutBaseURL docClientB.ID = clientBDID - didResolver.EXPECT().Resolve(clientADID, gomock.Any()).Return(&docClientA, nil, nil).MinTimes(1) - didResolver.EXPECT().Resolve(clientBDID, gomock.Any()).Return(&docClientB, nil, nil).MinTimes(1) + ctx.didResolver.EXPECT().Resolve(clientADID, gomock.Any()).Return(&docClientA, nil, nil).MinTimes(1) + ctx.didResolver.EXPECT().Resolve(clientBDID, gomock.Any()).Return(&docClientB, nil, nil).MinTimes(1) // Client C is owned, but not linked to the vendor (via NutsComm service), so do not register the service on that one - didResolver.EXPECT().Resolve(clientCDID, gomock.Any()).Return(&did.Document{ID: clientCDID}, nil, nil).MinTimes(1) - vdr := vdr.NewMockVDR(ctrl) - vdr.EXPECT().Resolver().Return(didResolver).MinTimes(1) - vdr.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{vendorDID, clientADID, clientBDID, clientCDID}, nil) - didmanAPI := didman.NewMockDidman(ctrl) + ctx.didResolver.EXPECT().Resolve(clientCDID, gomock.Any()).Return(&did.Document{ID: clientCDID}, nil, nil).MinTimes(1) + ctx.vdr.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{vendorDID, clientADID, clientBDID, clientCDID}, nil) // AddEndpoint is not called for vendor DID (URL already present), but for client DIDs. // Not for clientC, since it's not linked to the vendor (doesn't have a NutsComm endpoint). - didmanAPI.EXPECT().AddEndpoint(gomock.Any(), clientADID, resolver.BaseURLServiceType, *serviceRef).Return(nil, nil) - didmanAPI.EXPECT().AddEndpoint(gomock.Any(), clientBDID, resolver.BaseURLServiceType, *serviceRef).Return(nil, nil) - service := New(vdr, didmanAPI) + ctx.didmanAPI.EXPECT().AddEndpoint(gomock.Any(), clientADID, resolver.BaseURLServiceType, *serviceRef).Return(nil, nil) + ctx.didmanAPI.EXPECT().AddEndpoint(gomock.Any(), clientBDID, resolver.BaseURLServiceType, *serviceRef).Return(nil, nil) + service := ctx.hammer service.tlsConfig = tlsServer.TLS service.tlsConfig.InsecureSkipVerify = true service.tlsConfig.Certificates = []tls.Certificate{pki.Certificate()} @@ -217,13 +201,10 @@ func TestGoldenHammer_Fix(t *testing.T) { assert.NoError(t, err) }) t.Run("resolve error", func(t *testing.T) { - ctrl := gomock.NewController(t) - didResolver := resolver.NewMockDIDResolver(ctrl) - didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(nil, nil, fmt.Errorf("resolve error")) - vdr := vdr.NewMockVDR(ctrl) - vdr.EXPECT().Resolver().Return(didResolver) - vdr.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{vendorDID}, nil) - service := New(vdr, nil) + ctx := newMockContext(t) + ctx.didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(nil, nil, fmt.Errorf("resolve error")) + ctx.vdr.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{vendorDID}, nil) + service := ctx.hammer service.tlsConfig = &tls.Config{ Certificates: []tls.Certificate{pki.Certificate()}, } @@ -240,13 +221,12 @@ func TestGoldenHammer_Lifecycle(t *testing.T) { defer goleak.VerifyNone(t, goleak.IgnoreCurrent()) fixCalled := &atomic.Int64{} - ctrl := gomock.NewController(t) - vdr := vdr.NewMockVDR(ctrl) - vdr.EXPECT().ListOwned(gomock.Any()).DoAndReturn(func(_ context.Context) ([]did.DID, error) { + ctx := newMockContext(t) + ctx.vdr.EXPECT().ListOwned(gomock.Any()).DoAndReturn(func(_ context.Context) ([]did.DID, error) { fixCalled.Add(1) return []did.DID{}, nil }).MinTimes(1) - service := New(vdr, nil) + service := ctx.hammer service.config.Interval = time.Millisecond service.config.Enabled = true @@ -271,6 +251,29 @@ func TestGoldenHammer_Lifecycle(t *testing.T) { }) } +type mockContext struct { + ctrl *gomock.Controller + didmanAPI *didman.MockDidman + didResolver *resolver.MockDIDResolver + hammer *GoldenHammer + vdr *vdr.MockVDR +} + +func newMockContext(t *testing.T) mockContext { + ctrl := gomock.NewController(t) + mockVdr := vdr.NewMockVDR(ctrl) + mockDidmanAPI := didman.NewMockDidman(ctrl) + didResolver := resolver.NewMockDIDResolver(ctrl) + mockVdr.EXPECT().Resolver().Return(didResolver).AnyTimes() + + return mockContext{ + ctrl: ctrl, + didmanAPI: mockDidmanAPI, + didResolver: didResolver, + hammer: New(mockVdr, mockDidmanAPI), + vdr: mockVdr, + } +} func TestGoldenHammer_Name(t *testing.T) { service := New(nil, nil) diff --git a/test/url.go b/test/url.go new file mode 100644 index 0000000000..200f6d23b4 --- /dev/null +++ b/test/url.go @@ -0,0 +1,29 @@ +/* + * 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 test + +import "net/url" + +func MustParseURL(str string) *url.URL { + u, err := url.Parse(str) + if err != nil { + panic(err) + } + return u +}