From 6519fdf88d8bd4e0dce0d546336eefa1d16c2c75 Mon Sep 17 00:00:00 2001 From: Wout Slakhorst Date: Fri, 8 Dec 2023 11:43:16 +0100 Subject: [PATCH] finish up --- auth/api/auth/v1/api.go | 6 +- auth/api/auth/v1/api_test.go | 21 ++-- auth/api/auth/v1/client/types.go | 2 +- auth/api/auth/v1/types.go | 2 +- auth/api/iam/api.go | 3 +- auth/api/iam/api_test.go | 10 +- auth/api/iam/metadata.go | 9 -- auth/api/iam/metadata_test.go | 2 +- auth/api/iam/openid4vp.go | 63 +++++++++--- auth/api/iam/openid4vp_test.go | 6 +- auth/api/iam/session.go | 5 + auth/api/iam/types.go | 2 + auth/auth.go | 6 ++ auth/interface.go | 2 + auth/mock.go | 14 +++ auth/oauth/error.go | 11 +- auth/oauth/error_test.go | 11 +- auth/oauth/types.go | 6 -- auth/services/oauth/authz_server.go | 12 +-- auth/services/oauth/authz_server_test.go | 14 +-- auth/services/oauth/interface.go | 12 ++- auth/services/oauth/mock.go | 72 +++++++++---- auth/services/oauth/relying_party.go | 6 +- auth/services/oauth/relying_party_test.go | 26 ++--- auth/services/oauth/verifier.go | 71 +++++++++++++ auth/services/oauth/verifier_test.go | 118 ++++++++++++++++++++++ echo/echo_integration_test.go | 5 +- test/url.go | 29 ++++++ 28 files changed, 432 insertions(+), 114 deletions(-) create mode 100644 auth/services/oauth/verifier.go create mode 100644 auth/services/oauth/verifier_test.go create mode 100644 test/url.go diff --git a/auth/api/auth/v1/api.go b/auth/api/auth/v1/api.go index 6d564fcd5e..c966af6864 100644 --- a/auth/api/auth/v1/api.go +++ b/auth/api/auth/v1/api.go @@ -310,15 +310,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 c4d6d6dcaf..7d5b165efa 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -32,6 +32,7 @@ import ( "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/core" "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/vdr" "github.com/nuts-foundation/nuts-node/vdr/didweb" @@ -293,7 +294,7 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho // TODO: This should be a redirect? return nil, oauth.OAuth2Error{ Code: oauth.UnsupportedResponseType, - RedirectURI: session.RedirectURI, + RedirectURI: test.MustParseURL(session.RedirectURI), } } } diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index ecc47e4805..e39f8b9d67 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" @@ -213,7 +214,8 @@ func TestWrapper_HandleAuthorizeRequest(t *testing.T) { t.Run("ok - from holder", func(t *testing.T) { ctx := newTestClient(t) ctx.vdr.EXPECT().IsOwner(gomock.Any(), verifierDID).Return(true, nil) - ctx.relyingParty.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(&metadata, nil) + ctx.verifier.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(&metadata, nil) + ctx.verifier.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(), @@ -229,6 +231,8 @@ func TestWrapper_HandleAuthorizeRequest(t *testing.T) { 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") @@ -401,6 +405,7 @@ type testCtx struct { vdr *vdr.MockVDR resolver *resolver.MockDIDResolver relyingParty *oauthServices.MockRelyingParty + verifier *oauthServices.MockVerifier } func newTestClient(t testing.TB) *testCtx { @@ -412,15 +417,18 @@ func newTestClient(t testing.TB) *testCtx { authnServices.EXPECT().PublicURL().Return(publicURL).AnyTimes() resolver := resolver.NewMockDIDResolver(ctrl) relyingPary := oauthServices.NewMockRelyingParty(ctrl) + verifier := oauthServices.NewMockVerifier(ctrl) vdr := vdr.NewMockVDR(ctrl) authnServices.EXPECT().PublicURL().Return(publicURL).AnyTimes() authnServices.EXPECT().RelyingParty().Return(relyingPary).AnyTimes() + authnServices.EXPECT().Verifier().Return(verifier).AnyTimes() vdr.EXPECT().Resolver().Return(resolver).AnyTimes() return &testCtx{ authnServices: authnServices, relyingParty: relyingPary, + verifier: verifier, resolver: resolver, vdr: vdr, client: &Wrapper{ diff --git a/auth/api/iam/metadata.go b/auth/api/iam/metadata.go index df5025ff9b..10aa6892ff 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 925fa41319..2ad741c704 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 65e1630d40..f8c8e8a7ae 100644 --- a/auth/api/iam/openid4vp.go +++ b/auth/api/iam/openid4vp.go @@ -55,28 +55,47 @@ func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, verifier // 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, OPTIONAL. This must be the client or other node url (client for regular flow, node for popup) + // 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") + 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") + return nil, oauthError(oauth.InvalidRequest, "invalid client_id parameter", redirectURL) } - metadata, err := r.auth.RelyingParty().AuthorizationServerMetadata(ctx, *walletDID) + metadata, err := r.auth.Verifier().AuthorizationServerMetadata(ctx, *walletDID) if err != nil { - return nil, oauthError(oauth.ServerError, "failed to get authorization server metadata (holder)") + return nil, oauthError(oauth.ServerError, "failed to get authorization server metadata (holder)", redirectURL) } // own generic endpoint ownURL, err := didweb.DIDToURL(verifier) if err != nil { - return nil, oauthError(oauth.ServerError, "failed to translate own did to URL") + return nil, oauthError(oauth.ServerError, "failed to translate own did to URL", redirectURL) } // generate presentation_definition_uri based on own presentation_definition endpoint + scope pdURL := ownURL.JoinPath("presentation_definition") @@ -89,26 +108,37 @@ func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, verifier // GET /authorize? // response_type=vp_token // &client_id=did:web:example.com:iam:123 - // &redirect_uri=https%3A%2F%2Fexample.com%2Fiam%2F123%2F%2Fresponse + // &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 authorization_endpoint (holder)") + return nil, oauthError(oauth.InvalidRequest, "invalid authorization_endpoint (holder)", redirectURL) } nonce := crypto.GenerateNonce() - callbackURL := ownURL + callbackURL := *ownURL callbackURL.Path, err = url.JoinPath(callbackURL.Path, "response") if err != nil { - return nil, oauthError(oauth.ServerError, "failed to construct redirect path") + return nil, oauthError(oauth.ServerError, "failed to construct redirect path", redirectURL) } - redirectURL := AddQueryParams(*walletURL, map[string]string{ + 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 := 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, }) @@ -121,12 +151,12 @@ func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, verifier } // 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") + return nil, oauthError(oauth.ServerError, "failed to store server state", redirectURL) } return HandleAuthorizeRequest302Response{ Headers: HandleAuthorizeRequest302ResponseHeaders{ - Location: redirectURL.String(), + Location: authServerURL.String(), }, }, nil } @@ -180,7 +210,7 @@ func (r Wrapper) handlePresentationRequest(params map[string]string, session *Se return nil, oauth.OAuth2Error{ Code: oauth.InvalidRequest, Description: "response_mode must be direct_post", - RedirectURI: session.RedirectURI, + RedirectURI: session.redirectURI(), } } @@ -191,7 +221,7 @@ func (r Wrapper) handlePresentationRequest(params map[string]string, session *Se return nil, oauth.OAuth2Error{ Code: oauth.InvalidRequest, Description: fmt.Sprintf("unsupported scope for presentation exchange: %s", params[scopeParam]), - RedirectURI: session.RedirectURI, + RedirectURI: session.redirectURI(), } } @@ -373,9 +403,10 @@ func assertParamNotPresent(params map[string]string, param ...string) error { return nil } -func oauthError(code oauth.ErrorCode, description string) oauth.OAuth2Error { +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_test.go b/auth/api/iam/openid4vp_test.go index 467b3e1648..9a13a2d238 100644 --- a/auth/api/iam/openid4vp_test.go +++ b/auth/api/iam/openid4vp_test.go @@ -73,7 +73,7 @@ func TestWrapper_handleAuthorizeRequestFromHolder(t *testing.T) { }) t.Run("error on authorization server metadata", func(t *testing.T) { ctx := newTestClient(t) - ctx.relyingParty.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(nil, assert.AnError) + ctx.verifier.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(nil, assert.AnError) _, err := ctx.client.handleAuthorizeRequestFromHolder(context.Background(), verifierDID, defaultParams()) @@ -82,7 +82,7 @@ func TestWrapper_handleAuthorizeRequestFromHolder(t *testing.T) { t.Run("failed to generate verifier web url", func(t *testing.T) { ctx := newTestClient(t) verifierDID := did.MustParseDID("did:notweb:example.com:verifier") - ctx.relyingParty.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(&oauth.AuthorizationServerMetadata{}, nil) + ctx.verifier.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(&oauth.AuthorizationServerMetadata{}, nil) _, err := ctx.client.handleAuthorizeRequestFromHolder(context.Background(), verifierDID, defaultParams()) @@ -90,7 +90,7 @@ func TestWrapper_handleAuthorizeRequestFromHolder(t *testing.T) { }) t.Run("incorrect holder AuthorizationEndpoint URL", func(t *testing.T) { ctx := newTestClient(t) - ctx.relyingParty.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(&oauth.AuthorizationServerMetadata{ + ctx.verifier.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(&oauth.AuthorizationServerMetadata{ AuthorizationEndpoint: "://example.com", }, nil) diff --git a/auth/api/iam/session.go b/auth/api/iam/session.go index f72a4c1d56..a854ac44a8 100644 --- a/auth/api/iam/session.go +++ b/auth/api/iam/session.go @@ -47,3 +47,8 @@ func (s Session) CreateRedirectURI(params map[string]string) string { r := AddQueryParams(*redirectURI, params) return r.String() } + +func (s Session) 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 759894b01d..77eec42c8f 100644 --- a/auth/api/iam/types.go +++ b/auth/api/iam/types.go @@ -80,6 +80,8 @@ const ( 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" 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..1308c4a87b 100644 --- a/auth/oauth/error_test.go +++ b/auth/oauth/error_test.go @@ -25,6 +25,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "strings" "testing" ) @@ -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: mustParseURL("https://example.com"), }) assert.NoError(t, err) @@ -121,3 +122,11 @@ func Test_oauth2ErrorWriter_Write(t *testing.T) { assert.Equal(t, `server_error`, strings.TrimSpace(string(body))) }) } + +func mustParseURL(theURL string) *url.URL { + parsed, err := url.Parse(theURL) + if err != nil { + panic(err) + } + return parsed +} 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 70cfe707be..9851144b29 100644 --- a/auth/services/oauth/interface.go +++ b/auth/services/oauth/interface.go @@ -28,9 +28,6 @@ import ( // RelyingParty implements the OAuth2 relying party role. type RelyingParty interface { - // AuthorizationServerMetadata returns the metadata of the remote authorization server. - AuthorizationServerMetadata(ctx context.Context, webdid did.DID) (*oauth.AuthorizationServerMetadata, error) - CreateJwtGrant(ctx context.Context, request services.CreateJwtGrantRequest) (*services.JwtBearerTokenResult, error) // RequestRFC003AccessToken is called by the local EHR node to request an access token from a remote Nuts node using Nuts RFC003. @@ -47,6 +44,13 @@ 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) } + +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 1463612dfa..c446c7dbbc 100644 --- a/auth/services/oauth/mock.go +++ b/auth/services/oauth/mock.go @@ -42,21 +42,6 @@ func (m *MockRelyingParty) EXPECT() *MockRelyingPartyMockRecorder { return m.recorder } -// AuthorizationServerMetadata mocks base method. -func (m *MockRelyingParty) 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 *MockRelyingPartyMockRecorder) AuthorizationServerMetadata(ctx, webdid any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthorizationServerMetadata", reflect.TypeOf((*MockRelyingParty)(nil).AuthorizationServerMetadata), ctx, webdid) -} - // CreateJwtGrant mocks base method. func (m *MockRelyingParty) CreateJwtGrant(ctx context.Context, request services.CreateJwtGrantRequest) (*services.JwtBearerTokenResult, error) { m.ctrl.T.Helper() @@ -140,11 +125,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 +153,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 13999529b4..617f4a9c37 100644 --- a/auth/services/oauth/relying_party.go +++ b/auth/services/oauth/relying_party.go @@ -24,6 +24,7 @@ import ( "errors" "fmt" "github.com/nuts-foundation/nuts-node/openid4vc" + "github.com/nuts-foundation/nuts-node/vcr/holder" "net/http" "net/url" "strings" @@ -39,7 +40,6 @@ import ( nutsCrypto "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/didman" "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" "github.com/nuts-foundation/nuts-node/vdr/resolver" ) @@ -133,7 +133,7 @@ func (s *relyingParty) RequestRFC003AccessToken(ctx context.Context, jwtGrantTok func (s *relyingParty) RequestRFC021AccessToken(ctx context.Context, requester did.DID, verifier did.DID, scopes string) (*oauth.TokenResponse, error) { iamClient := iam.NewHTTPClient(s.strictMode, s.httpClientTimeout, s.httpClientTLS) - metadata, err := s.AuthorizationServerMetadata(ctx, verifier) + metadata, err := s.authorizationServerMetadata(ctx, verifier) if err != nil { return nil, err } @@ -193,7 +193,7 @@ func (s *relyingParty) RequestRFC021AccessToken(ctx context.Context, requester d }, nil } -func (s *relyingParty) AuthorizationServerMetadata(ctx context.Context, webdid did.DID) (*oauth.AuthorizationServerMetadata, error) { +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 { diff --git a/auth/services/oauth/relying_party_test.go b/auth/services/oauth/relying_party_test.go index 50a6aaa7f4..873635b4a4 100644 --- a/auth/services/oauth/relying_party_test.go +++ b/auth/services/oauth/relying_party_test.go @@ -26,10 +26,10 @@ import ( "fmt" "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/test" http2 "github.com/nuts-foundation/nuts-node/test/http" "net/http" "net/http/httptest" - "net/url" "testing" "time" @@ -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) @@ -371,11 +371,11 @@ func TestService_CreateJwtBearerToken(t *testing.T) { }) } -func TestRelyingParty_AuthorizationServerMetadata(t *testing.T) { +func TestRelyingParty_authorizationServerMetadata(t *testing.T) { t.Run("ok", func(t *testing.T) { ctx := createOAuthRPContext(t) - metadata, err := ctx.relyingParty.AuthorizationServerMetadata(context.Background(), ctx.verifierDID) + metadata, err := ctx.relyingParty.authorizationServerMetadata(context.Background(), ctx.verifierDID) require.NoError(t, err) require.NotNil(t, metadata) @@ -385,7 +385,7 @@ func TestRelyingParty_AuthorizationServerMetadata(t *testing.T) { ctx := createOAuthRPContext(t) ctx.metadata = nil - _, err := ctx.relyingParty.AuthorizationServerMetadata(context.Background(), ctx.verifierDID) + _, err := ctx.relyingParty.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)") @@ -523,11 +523,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..975f75d33f --- /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/vdr/didweb" + "net/url" + "time" + + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/auth/client/iam" + "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/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/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 +}