From 06ea85a5d057d04736cce86484ee40c6a447e9e8 Mon Sep 17 00:00:00 2001 From: Wout Slakhorst Date: Fri, 10 Nov 2023 15:47:59 +0100 Subject: [PATCH] add s2s access token request for relying party (#2568) --- auth/api/auth/v1/api.go | 10 +- auth/api/auth/v1/api_test.go | 25 +- auth/api/auth/v1/client/generated.go | 18 -- auth/api/auth/v1/client/types.go | 7 +- auth/api/auth/v1/generated.go | 18 -- auth/api/auth/v1/types.go | 6 +- auth/api/iam/api.go | 33 +-- auth/api/iam/api_test.go | 26 ++- auth/api/iam/client.go | 122 ---------- auth/api/iam/generated.go | 62 +++-- auth/api/iam/metadata.go | 5 +- auth/api/iam/metadata_test.go | 15 +- auth/api/iam/openid4vp.go | 9 +- auth/api/iam/openid4vp_test.go | 5 +- auth/api/iam/s2s_vptoken.go | 36 ++- auth/api/iam/s2s_vptoken_test.go | 28 ++- auth/api/iam/types.go | 77 +------ auth/auth.go | 2 +- auth/client/iam/client.go | 179 +++++++++++++++ auth/{api => client}/iam/client_test.go | 104 ++++++--- auth/interface.go | 3 + auth/{api/iam => oauth}/error.go | 7 +- auth/{api/iam => oauth}/error_test.go | 12 +- auth/oauth/types.go | 138 ++++++++++++ auth/services/messages.go | 10 - auth/services/oauth/authz_server.go | 39 ++-- auth/services/oauth/authz_server_test.go | 22 +- auth/services/oauth/interface.go | 12 +- auth/services/oauth/mock.go | 37 ++- auth/services/oauth/relying_party.go | 142 +++++++++--- auth/services/oauth/relying_party_test.go | 263 ++++++++++++++++++---- codegen/configs/auth_client_v1.yaml | 1 + codegen/configs/auth_iam.yaml | 2 +- codegen/configs/auth_v1.yaml | 1 + core/http_client.go | 36 ++- core/http_client_test.go | 3 +- crypto/random.go | 34 +++ auth/types.go => crypto/random_test.go | 21 +- docs/_static/auth/iam.yaml | 37 +-- openid4vc/types.go | 29 +++ vcr/ambassador_test.go | 4 +- vcr/api/openid4vci/v0/api.go | 3 +- vcr/api/openid4vci/v0/holder_test.go | 3 +- vcr/api/openid4vci/v0/issuer.go | 8 +- vcr/api/openid4vci/v0/issuer_test.go | 2 +- vcr/{test => assets/test_assets}/vc.json | 0 vcr/context_test.go | 4 +- vcr/credential/resolver_test.go | 4 +- vcr/credential/revocation_test.go | 5 +- vcr/credential/test.go | 17 +- vcr/credential/validator_test.go | 70 +++--- vcr/holder/openid.go | 12 +- vcr/holder/openid_test.go | 19 +- vcr/issuer/openid.go | 27 +-- vcr/issuer/openid_test.go | 3 +- vcr/openid4vci/issuer_client.go | 7 +- vcr/openid4vci/issuer_client_mock.go | 9 +- vcr/openid4vci/issuer_client_test.go | 3 +- vcr/openid4vci/test.go | 6 +- vcr/openid4vci/types.go | 22 -- vcr/pe/presentation_definition_test.go | 13 +- vcr/pe/presentation_submission.go | 20 +- vcr/store_test.go | 9 +- vcr/test/openid4vci_integration_test.go | 3 +- vcr/vcr.go | 16 +- vcr/vcr_test.go | 5 +- vcr/verifier/verifier_test.go | 4 +- vdr/didweb/test.go | 36 +++ 68 files changed, 1253 insertions(+), 717 deletions(-) delete mode 100644 auth/api/iam/client.go create mode 100644 auth/client/iam/client.go rename auth/{api => client}/iam/client_test.go (67%) rename auth/{api/iam => oauth}/error.go (97%) rename auth/{api/iam => oauth}/error_test.go (92%) create mode 100644 auth/oauth/types.go create mode 100644 crypto/random.go rename auth/types.go => crypto/random_test.go (69%) create mode 100644 openid4vc/types.go rename vcr/{test => assets/test_assets}/vc.json (100%) create mode 100644 vdr/didweb/test.go diff --git a/auth/api/auth/v1/api.go b/auth/api/auth/v1/api.go index 1d7acb9105..6d564fcd5e 100644 --- a/auth/api/auth/v1/api.go +++ b/auth/api/auth/v1/api.go @@ -23,6 +23,7 @@ import ( "encoding/json" "fmt" "github.com/nuts-foundation/nuts-node/audit" + "github.com/nuts-foundation/nuts-node/auth/oauth" "net/http" "net/url" "regexp" @@ -295,7 +296,7 @@ func (w Wrapper) RequestAccessToken(ctx context.Context, request RequestAccessTo return nil, core.InvalidInputError("invalid authorization server endpoint: %s", jwtGrant.AuthorizationServerEndpoint) } - accessTokenResult, err := w.Auth.RelyingParty().RequestAccessToken(ctx, jwtGrant.BearerToken, *authServerEndpoint) + accessTokenResult, err := w.Auth.RelyingParty().RequestRFC003AccessToken(ctx, jwtGrant.BearerToken, *authServerEndpoint) if err != nil { return nil, core.Error(http.StatusServiceUnavailable, err.Error()) } @@ -310,22 +311,21 @@ func (w Wrapper) CreateAccessToken(ctx context.Context, request CreateAccessToke if request.Body.GrantType != client.JwtBearerGrantType { errDesc := fmt.Sprintf("grant_type must be: '%s'", client.JwtBearerGrantType) - errorResponse := AccessTokenRequestFailedResponse{Error: errOauthUnsupportedGrant, ErrorDescription: errDesc} + errorResponse := oauth.ErrorResponse{Error: errOauthUnsupportedGrant, Description: &errDesc} 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, ErrorDescription: errDesc} + errorResponse := AccessTokenRequestFailedResponse{Error: errOauthInvalidGrant, Description: &errDesc} return CreateAccessToken400JSONResponse(errorResponse), nil } catRequest := services.CreateAccessTokenRequest{RawJwtBearerToken: request.Body.Assertion} acResponse, oauthError := w.Auth.AuthzServer().CreateAccessToken(ctx, catRequest) if oauthError != nil { - errorResponse := AccessTokenRequestFailedResponse{Error: AccessTokenRequestFailedResponseError(oauthError.Code), ErrorDescription: oauthError.Error()} - return CreateAccessToken400JSONResponse(errorResponse), nil + return CreateAccessToken400JSONResponse(*oauthError), nil } response := AccessTokenResponse{ AccessToken: acResponse.AccessToken, diff --git a/auth/api/auth/v1/api_test.go b/auth/api/auth/v1/api_test.go index fd1b7a7b78..3e7e1b8604 100644 --- a/auth/api/auth/v1/api_test.go +++ b/auth/api/auth/v1/api_test.go @@ -28,6 +28,7 @@ import ( "github.com/nuts-foundation/nuts-node/audit" pkg2 "github.com/nuts-foundation/nuts-node/auth" "github.com/nuts-foundation/nuts-node/auth/contract" + oauth2 "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/auth/services" "github.com/nuts-foundation/nuts-node/auth/services/dummy" "github.com/nuts-foundation/nuts-node/auth/services/oauth" @@ -475,7 +476,7 @@ func TestWrapper_RequestAccessToken(t *testing.T) { BearerToken: bearerToken, AuthorizationServerEndpoint: authEndpointURL.String(), }, nil) - ctx.relyingPartyMock.EXPECT().RequestAccessToken(gomock.Any(), bearerToken, *authEndpointURL).Return(nil, errors.New("random error")) + ctx.relyingPartyMock.EXPECT().RequestRFC003AccessToken(gomock.Any(), bearerToken, *authEndpointURL).Return(nil, errors.New("random error")) response, err := ctx.wrapper.RequestAccessToken(ctx.audit, RequestAccessTokenRequestObject{Body: &fakeRequest}) @@ -513,9 +514,10 @@ func TestWrapper_RequestAccessToken(t *testing.T) { request := fakeRequest request.Credentials = credentials + in10 := 10 expectedResponse := AccessTokenResponse{ TokenType: "token-type", - ExpiresIn: 10, + ExpiresIn: &in10, AccessToken: "actual-token", } @@ -532,7 +534,7 @@ func TestWrapper_RequestAccessToken(t *testing.T) { AuthorizationServerEndpoint: authEndpointURL.String(), }, nil) ctx.relyingPartyMock.EXPECT(). - RequestAccessToken(gomock.Any(), bearerToken, *authEndpointURL). + RequestRFC003AccessToken(gomock.Any(), bearerToken, *authEndpointURL). Return(&expectedResponse, nil) response, err := ctx.wrapper.RequestAccessToken(ctx.audit, RequestAccessTokenRequestObject{Body: &request}) @@ -551,7 +553,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{ErrorDescription: errorDescription, Error: errOauthUnsupportedGrant} + expectedResponse := CreateAccessToken400JSONResponse{Description: &errorDescription, Error: errOauthUnsupportedGrant} response, err := ctx.wrapper.CreateAccessToken(ctx.audit, CreateAccessTokenRequestObject{Body: ¶ms}) @@ -565,7 +567,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{ErrorDescription: errorDescription, Error: errOauthInvalidGrant} + expectedResponse := CreateAccessToken400JSONResponse{Description: &errorDescription, Error: errOauthInvalidGrant} response, err := ctx.wrapper.CreateAccessToken(ctx.audit, CreateAccessTokenRequestObject{Body: ¶ms}) @@ -579,11 +581,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{ErrorDescription: errorDescription, Error: errOauthInvalidRequest} + expectedResponse := CreateAccessToken400JSONResponse{Description: &errorDescription, Error: errOauthInvalidRequest} - ctx.authzServerMock.EXPECT().CreateAccessToken(ctx.audit, services.CreateAccessTokenRequest{RawJwtBearerToken: validJwt}).Return(nil, &oauth.ErrorResponse{ - Description: errors.New(errorDescription), - Code: errOauthInvalidRequest, + ctx.authzServerMock.EXPECT().CreateAccessToken(ctx.audit, services.CreateAccessTokenRequest{RawJwtBearerToken: validJwt}).Return(nil, &oauth2.ErrorResponse{ + Description: &errorDescription, + Error: errOauthInvalidRequest, }) response, err := ctx.wrapper.CreateAccessToken(ctx.audit, CreateAccessTokenRequestObject{Body: ¶ms}) @@ -597,12 +599,13 @@ func TestWrapper_CreateAccessToken(t *testing.T) { params := CreateAccessTokenRequest{GrantType: "urn:ietf:params:oauth:grant-type:jwt-bearer", Assertion: validJwt} - pkgResponse := &services.AccessTokenResult{AccessToken: "foo", ExpiresIn: 800000} + in800000 := 800000 + pkgResponse := &oauth2.TokenResponse{AccessToken: "foo", ExpiresIn: &in800000} ctx.authzServerMock.EXPECT().CreateAccessToken(gomock.Any(), services.CreateAccessTokenRequest{RawJwtBearerToken: validJwt}).Return(pkgResponse, nil) expectedResponse := CreateAccessToken200JSONResponse{ AccessToken: pkgResponse.AccessToken, - ExpiresIn: 800000, + ExpiresIn: &in800000, TokenType: "bearer", } diff --git a/auth/api/auth/v1/client/generated.go b/auth/api/auth/v1/client/generated.go index 3c34151aca..c2466dc85b 100644 --- a/auth/api/auth/v1/client/generated.go +++ b/auth/api/auth/v1/client/generated.go @@ -20,13 +20,6 @@ const ( JwtBearerAuthScopes = "jwtBearerAuth.Scopes" ) -// Defines values for AccessTokenRequestFailedResponseError. -const ( - InvalidGrant AccessTokenRequestFailedResponseError = "invalid_grant" - InvalidRequest AccessTokenRequestFailedResponseError = "invalid_request" - UnsupportedGrantType AccessTokenRequestFailedResponseError = "unsupported_grant_type" -) - // Defines values for SignSessionRequestMeans. const ( SignSessionRequestMeansDummy SignSessionRequestMeans = "dummy" @@ -48,17 +41,6 @@ const ( Substantial TokenIntrospectionResponseAssuranceLevel = "substantial" ) -// AccessTokenRequestFailedResponse Error response when access token request fails as described in rfc6749 section 5.2 -type AccessTokenRequestFailedResponse struct { - Error AccessTokenRequestFailedResponseError `json:"error"` - - // ErrorDescription Human-readable ASCII text providing additional information, used to assist the client developer in understanding the error that occurred. - ErrorDescription string `json:"error_description"` -} - -// AccessTokenRequestFailedResponseError defines model for AccessTokenRequestFailedResponse.Error. -type AccessTokenRequestFailedResponseError string - // Contract defines model for Contract. type Contract struct { // Language Language of the contract in all caps. diff --git a/auth/api/auth/v1/client/types.go b/auth/api/auth/v1/client/types.go index 17d6e99b5e..fdccb9ca60 100644 --- a/auth/api/auth/v1/client/types.go +++ b/auth/api/auth/v1/client/types.go @@ -20,7 +20,7 @@ package client import ( "github.com/nuts-foundation/go-did/vc" - "github.com/nuts-foundation/nuts-node/auth/services" + "github.com/nuts-foundation/nuts-node/auth/oauth" ) // JwtBearerGrantType defines the grant-type to use in the access token request @@ -33,4 +33,7 @@ type VerifiableCredential = vc.VerifiableCredential type VerifiablePresentation = vc.VerifiablePresentation // AccessTokenResponse is an alias to use from within the API -type AccessTokenResponse = services.AccessTokenResult +type AccessTokenResponse = oauth.TokenResponse + +// AccessTokenRequestFailedResponse is an alias to use from within the API +type AccessTokenRequestFailedResponse = oauth.ErrorResponse diff --git a/auth/api/auth/v1/generated.go b/auth/api/auth/v1/generated.go index 18b18e6ec6..f04ed9d8e0 100644 --- a/auth/api/auth/v1/generated.go +++ b/auth/api/auth/v1/generated.go @@ -18,13 +18,6 @@ const ( JwtBearerAuthScopes = "jwtBearerAuth.Scopes" ) -// Defines values for AccessTokenRequestFailedResponseError. -const ( - InvalidGrant AccessTokenRequestFailedResponseError = "invalid_grant" - InvalidRequest AccessTokenRequestFailedResponseError = "invalid_request" - UnsupportedGrantType AccessTokenRequestFailedResponseError = "unsupported_grant_type" -) - // Defines values for SignSessionRequestMeans. const ( SignSessionRequestMeansDummy SignSessionRequestMeans = "dummy" @@ -46,17 +39,6 @@ const ( Substantial TokenIntrospectionResponseAssuranceLevel = "substantial" ) -// AccessTokenRequestFailedResponse Error response when access token request fails as described in rfc6749 section 5.2 -type AccessTokenRequestFailedResponse struct { - Error AccessTokenRequestFailedResponseError `json:"error"` - - // ErrorDescription Human-readable ASCII text providing additional information, used to assist the client developer in understanding the error that occurred. - ErrorDescription string `json:"error_description"` -} - -// AccessTokenRequestFailedResponseError defines model for AccessTokenRequestFailedResponse.Error. -type AccessTokenRequestFailedResponseError string - // Contract defines model for Contract. type Contract struct { // Language Language of the contract in all caps. diff --git a/auth/api/auth/v1/types.go b/auth/api/auth/v1/types.go index 90f203be59..47da04bf1d 100644 --- a/auth/api/auth/v1/types.go +++ b/auth/api/auth/v1/types.go @@ -20,7 +20,7 @@ package v1 import ( "github.com/nuts-foundation/go-did/vc" - "github.com/nuts-foundation/nuts-node/auth/services" + "github.com/nuts-foundation/nuts-node/auth/oauth" ) // VerifiableCredential is an alias to use from within the API @@ -30,4 +30,6 @@ type VerifiableCredential = vc.VerifiableCredential type VerifiablePresentation = vc.VerifiablePresentation // AccessTokenResponse is an alias to use from within the API -type AccessTokenResponse = services.AccessTokenResult +type AccessTokenResponse = oauth.TokenResponse + +type AccessTokenRequestFailedResponse = oauth.ErrorResponse diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 9f94cf722a..f00af3399f 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -27,6 +27,7 @@ import ( "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/auth" "github.com/nuts-foundation/nuts-node/auth/log" + "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/vcr" @@ -105,7 +106,7 @@ func (r Wrapper) middleware(ctx echo.Context, request interface{}, operationID s requestCtx := context.WithValue(ctx.Request().Context(), httpRequestContextKey, ctx.Request()) ctx.SetRequest(ctx.Request().WithContext(requestCtx)) if strings.HasPrefix(ctx.Request().URL.Path, "/iam/") { - ctx.Set(core.ErrorWriterContextKey, &oauth2ErrorWriter{}) + ctx.Set(core.ErrorWriterContextKey, &oauth.Oauth2ErrorWriter{}) } audit.StrictMiddleware(f, apiModuleName, operationID) return f(ctx, request) @@ -118,27 +119,27 @@ func (r Wrapper) HandleTokenRequest(ctx context.Context, request HandleTokenRequ // Options: // - OpenID4VCI // - OpenID4VP, vp_token is sent in Token Response - return nil, OAuth2Error{ - Code: UnsupportedGrantType, + return nil, oauth.OAuth2Error{ + Code: oauth.UnsupportedGrantType, Description: "not implemented yet", } case "vp_token": // Options: // - service-to-service vp_token flow - return nil, OAuth2Error{ - Code: UnsupportedGrantType, + return nil, oauth.OAuth2Error{ + Code: oauth.UnsupportedGrantType, Description: "not implemented yet", } case "urn:ietf:params:oauth:grant-type:pre-authorized_code": // Options: // - OpenID4VCI - return nil, OAuth2Error{ - Code: UnsupportedGrantType, + return nil, oauth.OAuth2Error{ + Code: oauth.UnsupportedGrantType, Description: "not implemented yet", } default: - return nil, OAuth2Error{ - Code: UnsupportedGrantType, + return nil, oauth.OAuth2Error{ + Code: oauth.UnsupportedGrantType, } } } @@ -160,8 +161,8 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho // TODO: Spec says that the redirect URI is optional, but it's not clear what to do if it's not provided. // Threat models say it's unsafe to omit redirect_uri. // See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1 - return nil, OAuth2Error{ - Code: InvalidRequest, + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidRequest, Description: "redirect_uri is required", } } @@ -186,8 +187,8 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho return r.handlePresentationRequest(params, session) default: // TODO: This should be a redirect? - return nil, OAuth2Error{ - Code: UnsupportedResponseType, + return nil, oauth.OAuth2Error{ + Code: oauth.UnsupportedResponseType, RedirectURI: session.RedirectURI, } } @@ -254,9 +255,9 @@ func (r Wrapper) PresentationDefinition(_ context.Context, request PresentationD scopes := strings.Split(request.Params.Scope, " ") presentationDefinition := r.auth.PresentationDefinitions().ByScope(scopes[0]) if presentationDefinition == nil { - return PresentationDefinition400JSONResponse{ - Code: "invalid_scope", - }, nil + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidScope, + } } return PresentationDefinition200JSONResponse(*presentationDefinition), nil diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index fa9119ce76..171108af15 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -31,6 +31,8 @@ import ( "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/auth" + "github.com/nuts-foundation/nuts-node/auth/oauth" + oauthServices "github.com/nuts-foundation/nuts-node/auth/services/oauth" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr/pe" @@ -196,9 +198,9 @@ func TestWrapper_PresentationDefinition(t *testing.T) { response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{Did: webDID.ID, Params: PresentationDefinitionParams{Scope: "unknown"}}) - require.NoError(t, err) - require.NotNil(t, response) - assert.Equal(t, InvalidScope, (response.(PresentationDefinition400JSONResponse)).Code) + require.Error(t, err) + assert.Nil(t, response) + assert.Equal(t, string(oauth.InvalidScope), err.Error()) }) } @@ -210,7 +212,7 @@ func TestWrapper_HandleAuthorizeRequest(t *testing.T) { Id: nutsDID.String(), }) - requireOAuthError(t, err, InvalidRequest, "redirect_uri is required") + requireOAuthError(t, err, oauth.InvalidRequest, "redirect_uri is required") assert.Nil(t, res) }) t.Run("unsupported response type", func(t *testing.T) { @@ -223,7 +225,7 @@ func TestWrapper_HandleAuthorizeRequest(t *testing.T) { Id: nutsDID.String(), }) - requireOAuthError(t, err, UnsupportedResponseType, "") + requireOAuthError(t, err, oauth.UnsupportedResponseType, "") assert.Nil(t, res) }) } @@ -239,13 +241,13 @@ func TestWrapper_HandleTokenRequest(t *testing.T) { }, }) - requireOAuthError(t, err, UnsupportedGrantType, "") + requireOAuthError(t, err, oauth.UnsupportedGrantType, "") assert.Nil(t, res) }) } -func requireOAuthError(t *testing.T, err error, errorCode ErrorCode, errorDescription string) { - var oauthErr OAuth2Error +func requireOAuthError(t *testing.T, err error, errorCode oauth.ErrorCode, errorDescription string) { + var oauthErr oauth.OAuth2Error require.ErrorAs(t, err, &oauthErr) assert.Equal(t, errorCode, oauthErr.Code) assert.Equal(t, errorDescription, oauthErr.Description) @@ -278,6 +280,7 @@ type testCtx struct { authnServices *auth.MockAuthenticationServices vdr *vdr.MockVDR resolver *resolver.MockDIDResolver + relyingParty *oauthServices.MockRelyingParty } func newTestClient(t testing.TB) *testCtx { @@ -288,11 +291,16 @@ func newTestClient(t testing.TB) *testCtx { authnServices := auth.NewMockAuthenticationServices(ctrl) authnServices.EXPECT().PublicURL().Return(publicURL).AnyTimes() resolver := resolver.NewMockDIDResolver(ctrl) + relyingPary := oauthServices.NewMockRelyingParty(ctrl) vdr := vdr.NewMockVDR(ctrl) + + authnServices.EXPECT().PublicURL().Return(publicURL).AnyTimes() + authnServices.EXPECT().RelyingParty().Return(relyingPary).AnyTimes() vdr.EXPECT().Resolver().Return(resolver).AnyTimes() return &testCtx{ authnServices: authnServices, + relyingParty: relyingPary, resolver: resolver, vdr: vdr, client: &Wrapper{ @@ -347,7 +355,7 @@ func TestWrapper_middleware(t *testing.T) { ctx := server.NewContext(httptest.NewRequest("GET", "/iam/foo", nil), httptest.NewRecorder()) _, _ = Wrapper{auth: authService}.middleware(ctx, nil, "Test", handler.handle) - assert.IsType(t, &oauth2ErrorWriter{}, ctx.Get(core.ErrorWriterContextKey)) + assert.IsType(t, &oauth.Oauth2ErrorWriter{}, ctx.Get(core.ErrorWriterContextKey)) }) t.Run("other path", func(t *testing.T) { ctx := server.NewContext(httptest.NewRequest("GET", "/internal/foo", nil), httptest.NewRecorder()) diff --git a/auth/api/iam/client.go b/auth/api/iam/client.go deleted file mode 100644 index 2478da77fa..0000000000 --- a/auth/api/iam/client.go +++ /dev/null @@ -1,122 +0,0 @@ -/* - * 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 iam - -import ( - "context" - "encoding/json" - "fmt" - "github.com/nuts-foundation/go-did/did" - "github.com/nuts-foundation/nuts-node/core" - "github.com/nuts-foundation/nuts-node/vdr/didweb" - "io" - "net/http" - "net/url" - "strings" -) - -// HTTPClient holds the server address and other basic settings for the http client -type HTTPClient struct { - config core.ClientConfig - httpClient core.HTTPRequestDoer -} - -// NewHTTPClient creates a new api client. -func NewHTTPClient(config core.ClientConfig) HTTPClient { - return HTTPClient{ - config: config, - httpClient: core.MustCreateHTTPClient(config, nil), - } -} - -// OAuthAuthorizationServerMetadata retrieves the OAuth authorization server metadata for the given web DID. -func (hb HTTPClient) OAuthAuthorizationServerMetadata(ctx context.Context, webDID did.DID) (*OAuthAuthorizationServerMetadata, error) { - serverURL, err := didweb.DIDToURL(webDID) - if err != nil { - return nil, err - } - - metadataURL, err := IssuerIdToWellKnown(serverURL.String(), authzServerWellKnown, hb.config.Strictmode) - if err != nil { - return nil, err - } - - request, err := http.NewRequest(http.MethodGet, metadataURL.String(), nil) - if err != nil { - return nil, err - } - response, err := hb.httpClient.Do(request.WithContext(ctx)) - if err != nil { - return nil, err - } - - if err = core.TestResponseCode(http.StatusOK, response); err != nil { - return nil, err - } - - var metadata OAuthAuthorizationServerMetadata - var data []byte - - if data, err = io.ReadAll(response.Body); err != nil { - return nil, fmt.Errorf("unable to read response: %w", err) - } - if err = json.Unmarshal(data, &metadata); err != nil { - return nil, fmt.Errorf("unable to unmarshal response: %w, %s", err, string(data)) - } - - return &metadata, nil -} - -// PresentationDefinition retrieves the presentation definition from the presentation definition endpoint (as specified by RFC021) for the given scope. -func (hb HTTPClient) PresentationDefinition(ctx context.Context, definitionEndpoint string, scopes []string) (*PresentationDefinition, error) { - presentationDefinitionURL, err := core.ParsePublicURL(definitionEndpoint, hb.config.Strictmode) - if err != nil { - return nil, err - } - presentationDefinitionURL.RawQuery = url.Values{"scope": []string{strings.Join(scopes, " ")}}.Encode() - - // create a GET request with scope query param - request, err := http.NewRequest(http.MethodGet, presentationDefinitionURL.String(), nil) - if err != nil { - return nil, err - } - response, err := hb.httpClient.Do(request.WithContext(ctx)) - if err != nil { - return nil, fmt.Errorf("failed to call endpoint: %w", err) - } - if httpErr := core.TestResponseCode(http.StatusOK, response); httpErr != nil { - rse := httpErr.(core.HttpError) - if ok, oauthErr := TestOAuthErrorCode(rse.ResponseBody, InvalidScope); ok { - return nil, oauthErr - } - return nil, httpErr - } - - var presentationDefinition PresentationDefinition - var data []byte - - if data, err = io.ReadAll(response.Body); err != nil { - return nil, fmt.Errorf("unable to read response: %w", err) - } - if err = json.Unmarshal(data, &presentationDefinition); err != nil { - return nil, fmt.Errorf("unable to unmarshal response: %w", err) - } - - return &presentationDefinition, nil -} diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 0af21dc2ab..9549917595 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -15,19 +15,6 @@ import ( strictecho "github.com/oapi-codegen/runtime/strictmiddleware/echo" ) -// TokenResponse Token Responses are made as defined in (RFC6749)[https://datatracker.ietf.org/doc/html/rfc6749#section-5.1] -type TokenResponse struct { - // AccessToken The access token issued by the authorization server. - AccessToken string `json:"access_token"` - - // ExpiresIn The lifetime in seconds of the access token. - ExpiresIn *int `json:"expires_in,omitempty"` - Scope *string `json:"scope,omitempty"` - - // TokenType The type of the token issued as described in [RFC6749]. - TokenType string `json:"token_type"` -} - // PresentationDefinitionParams defines parameters for PresentationDefinition. type PresentationDefinitionParams struct { Scope string `form:"scope" json:"scope"` @@ -391,21 +378,25 @@ func (response PresentationDefinition200JSONResponse) VisitPresentationDefinitio return json.NewEncoder(w).Encode(response) } -type PresentationDefinition400JSONResponse ErrorResponse +type PresentationDefinitiondefaultApplicationProblemPlusJSONResponse struct { + Body struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` -func (response PresentationDefinition400JSONResponse) VisitPresentationDefinitionResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(400) + // Status HTTP statuscode + Status float32 `json:"status"` - return json.NewEncoder(w).Encode(response) + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + StatusCode int } -type PresentationDefinition404Response struct { -} +func (response PresentationDefinitiondefaultApplicationProblemPlusJSONResponse) VisitPresentationDefinitionResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) -func (response PresentationDefinition404Response) VisitPresentationDefinitionResponse(w http.ResponseWriter) error { - w.WriteHeader(404) - return nil + return json.NewEncoder(w).Encode(response.Body) } type HandleAuthorizeRequestRequestObject struct { @@ -531,22 +522,25 @@ func (response HandleTokenRequest200JSONResponse) VisitHandleTokenRequestRespons return json.NewEncoder(w).Encode(response) } -type HandleTokenRequest400JSONResponse ErrorResponse +type HandleTokenRequestdefaultApplicationProblemPlusJSONResponse struct { + Body struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` -func (response HandleTokenRequest400JSONResponse) VisitHandleTokenRequestResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(400) + // Status HTTP statuscode + Status float32 `json:"status"` - return json.NewEncoder(w).Encode(response) + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + StatusCode int } -type HandleTokenRequest404JSONResponse ErrorResponse - -func (response HandleTokenRequest404JSONResponse) VisitHandleTokenRequestResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(404) +func (response HandleTokenRequestdefaultApplicationProblemPlusJSONResponse) VisitHandleTokenRequestResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) - return json.NewEncoder(w).Encode(response) + return json.NewEncoder(w).Encode(response.Body) } type RequestAccessTokenRequestObject struct { diff --git a/auth/api/iam/metadata.go b/auth/api/iam/metadata.go index 1dae09d602..df5025ff9b 100644 --- a/auth/api/iam/metadata.go +++ b/auth/api/iam/metadata.go @@ -19,6 +19,7 @@ package iam import ( + "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/core" "net/url" "strings" @@ -43,8 +44,8 @@ func IssuerIdToWellKnown(issuer string, wellKnown string, strictmode bool) (*url return issuerURL.Parse(wellKnown + issuerURL.EscapedPath()) } -func authorizationServerMetadata(identity url.URL) OAuthAuthorizationServerMetadata { - return OAuthAuthorizationServerMetadata{ +func authorizationServerMetadata(identity url.URL) oauth.AuthorizationServerMetadata { + return oauth.AuthorizationServerMetadata{ Issuer: identity.String(), AuthorizationEndpoint: identity.JoinPath("authorize").String(), ResponseTypesSupported: responseTypesSupported, diff --git a/auth/api/iam/metadata_test.go b/auth/api/iam/metadata_test.go index c0e9e5fa8e..925fa41319 100644 --- a/auth/api/iam/metadata_test.go +++ b/auth/api/iam/metadata_test.go @@ -19,6 +19,7 @@ package iam import ( + "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -29,37 +30,39 @@ import ( func TestIssuerIdToWellKnown(t *testing.T) { t.Run("ok", func(t *testing.T) { issuer := "https://nuts.nl/iam/id" - u, err := IssuerIdToWellKnown(issuer, authzServerWellKnown, true) + u, err := oauth.IssuerIdToWellKnown(issuer, oauth.AuthzServerWellKnown, true) require.NoError(t, err) assert.Equal(t, "https://nuts.nl/.well-known/oauth-authorization-server/iam/id", u.String()) }) t.Run("no path in issuer", func(t *testing.T) { issuer := "https://nuts.nl" - u, err := IssuerIdToWellKnown(issuer, authzServerWellKnown, true) + u, err := oauth.IssuerIdToWellKnown(issuer, oauth.AuthzServerWellKnown, true) require.NoError(t, err) assert.Equal(t, "https://nuts.nl/.well-known/oauth-authorization-server", u.String()) }) t.Run("don't unescape path", func(t *testing.T) { issuer := "https://nuts.nl/iam/%2E%2E/still-has-iam" - u, err := IssuerIdToWellKnown(issuer, authzServerWellKnown, true) + u, err := oauth.IssuerIdToWellKnown(issuer, oauth.AuthzServerWellKnown, true) require.NoError(t, err) assert.Equal(t, "https://nuts.nl/.well-known/oauth-authorization-server/iam/%2E%2E/still-has-iam", u.String()) }) t.Run("https in strictmode", func(t *testing.T) { issuer := "http://nuts.nl/iam/id" - u, err := IssuerIdToWellKnown(issuer, authzServerWellKnown, true) + u, err := oauth.IssuerIdToWellKnown(issuer, oauth.AuthzServerWellKnown, true) assert.ErrorContains(t, err, "scheme must be https") assert.Nil(t, u) }) t.Run("no IP allowed", func(t *testing.T) { issuer := "https://127.0.0.1/iam/id" + u, err := IssuerIdToWellKnown(issuer, authzServerWellKnown, true) + assert.ErrorContains(t, err, "hostname is IP") assert.Nil(t, u) }) t.Run("invalid URL", func(t *testing.T) { issuer := "http:// /iam/id" - u, err := IssuerIdToWellKnown(issuer, authzServerWellKnown, true) + u, err := oauth.IssuerIdToWellKnown(issuer, oauth.AuthzServerWellKnown, true) assert.ErrorContains(t, err, "invalid character \" \" in host name") assert.Nil(t, u) }) @@ -75,7 +78,7 @@ var vpFormats = map[string]map[string][]string{ func Test_authorizationServerMetadata(t *testing.T) { identity := "https://example.com/iam/did:nuts:123" identityURL, _ := url.Parse(identity) - expected := OAuthAuthorizationServerMetadata{ + expected := oauth.AuthorizationServerMetadata{ Issuer: identity, AuthorizationEndpoint: identity + "/authorize", ResponseTypesSupported: []string{"code", "vp_token", "vp_token id_token"}, diff --git a/auth/api/iam/openid4vp.go b/auth/api/iam/openid4vp.go index 5cfe9f6915..dfeb007d66 100644 --- a/auth/api/iam/openid4vp.go +++ b/auth/api/iam/openid4vp.go @@ -29,6 +29,7 @@ import ( ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/holder" "net/http" @@ -85,8 +86,8 @@ func (r *Wrapper) handlePresentationRequest(params map[string]string, session *S } // Response mode is always direct_post for now if params[responseModeParam] != responseModeDirectPost { - return nil, OAuth2Error{ - Code: InvalidRequest, + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidRequest, Description: "response_mode must be direct_post", RedirectURI: session.RedirectURI, } @@ -96,8 +97,8 @@ func (r *Wrapper) handlePresentationRequest(params map[string]string, session *S // For compatibility, we probably need to support presentation_definition and/or presentation_definition_uri. presentationDefinition := r.auth.PresentationDefinitions().ByScope(params[scopeParam]) if presentationDefinition == nil { - return nil, OAuth2Error{ - Code: InvalidRequest, + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidRequest, Description: fmt.Sprintf("unsupported scope for presentation exchange: %s", params[scopeParam]), RedirectURI: session.RedirectURI, } diff --git a/auth/api/iam/openid4vp_test.go b/auth/api/iam/openid4vp_test.go index 9da7f5b192..eb55988dfc 100644 --- a/auth/api/iam/openid4vp_test.go +++ b/auth/api/iam/openid4vp_test.go @@ -25,6 +25,7 @@ import ( "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth" + "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/credential" @@ -136,7 +137,7 @@ func TestWrapper_handlePresentationRequest(t *testing.T) { response, err := instance.handlePresentationRequest(params, createSession(params, holderDID)) - requireOAuthError(t, err, InvalidRequest, "unsupported scope for presentation exchange: unsupported") + requireOAuthError(t, err, oauth.InvalidRequest, "unsupported scope for presentation exchange: unsupported") assert.Nil(t, response) }) t.Run("invalid response_mode", func(t *testing.T) { @@ -150,7 +151,7 @@ func TestWrapper_handlePresentationRequest(t *testing.T) { response, err := instance.handlePresentationRequest(params, createSession(params, holderDID)) - requireOAuthError(t, err, InvalidRequest, "response_mode must be direct_post") + requireOAuthError(t, err, oauth.InvalidRequest, "response_mode must be direct_post") assert.Nil(t, response) }) } diff --git a/auth/api/iam/s2s_vptoken.go b/auth/api/iam/s2s_vptoken.go index 7c16066277..81048adcc7 100644 --- a/auth/api/iam/s2s_vptoken.go +++ b/auth/api/iam/s2s_vptoken.go @@ -20,23 +20,21 @@ package iam import ( "context" - "crypto/rand" - "encoding/base64" "errors" "fmt" + "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/crypto" + "net/http" + "time" + "github.com/labstack/echo/v4" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vdr/resolver" - "net/http" - "time" ) -// secretSizeBits is the size of the generated random secrets (access tokens, pre-authorized codes) in bits. -const secretSizeBits = 128 - // accessTokenValidity defines how long access tokens are valid. // TODO: Might want to make this configurable at some point const accessTokenValidity = 15 * time.Minute @@ -108,14 +106,17 @@ func (r Wrapper) RequestAccessToken(ctx context.Context, request RequestAccessTo return nil, err } - // todo fetch metadata using didDocument service data or .well-known path - - return RequestAccessToken200JSONResponse{}, nil + tokenResult, err := r.auth.RelyingParty().RequestRFC021AccessToken(ctx, *requestHolder, *requestVerifier, request.Body.Scope) + if err != nil { + // this can be an internal server error, a 400 oauth error or a 412 precondition failed if the wallet does not contain the required credentials + return nil, err + } + return RequestAccessToken200JSONResponse(*tokenResult), nil } -func (r Wrapper) createAccessToken(issuer did.DID, issueTime time.Time, presentation vc.VerifiablePresentation, scope string) (*TokenResponse, error) { +func (r Wrapper) createAccessToken(issuer did.DID, issueTime time.Time, presentation vc.VerifiablePresentation, scope string) (*oauth.TokenResponse, error) { accessToken := AccessToken{ - Token: generateCode(), + Token: crypto.GenerateNonce(), Issuer: issuer.String(), Expiration: issueTime.Add(accessTokenValidity), Presentation: presentation, @@ -125,7 +126,7 @@ func (r Wrapper) createAccessToken(issuer did.DID, issueTime time.Time, presenta return nil, fmt.Errorf("unable to store access token: %w", err) } expiresIn := int(accessTokenValidity.Seconds()) - return &TokenResponse{ + return &oauth.TokenResponse{ AccessToken: accessToken.Token, ExpiresIn: &expiresIn, Scope: &scope, @@ -137,15 +138,6 @@ func (r Wrapper) accessTokenStore(issuer did.DID) storage.SessionStore { return r.storageEngine.GetSessionDatabase().GetStore(accessTokenValidity, "s2s", issuer.String(), "accesstoken") } -func generateCode() string { - buf := make([]byte, secretSizeBits/8) - _, err := rand.Read(buf) - if err != nil { - panic(err) - } - return base64.URLEncoding.EncodeToString(buf) -} - type AccessToken struct { Token string Issuer string diff --git a/auth/api/iam/s2s_vptoken_test.go b/auth/api/iam/s2s_vptoken_test.go index 858aab47e4..c53a46c8dd 100644 --- a/auth/api/iam/s2s_vptoken_test.go +++ b/auth/api/iam/s2s_vptoken_test.go @@ -19,25 +19,30 @@ package iam import ( + "net/http" + "testing" + "time" + "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/core" "github.com/nuts-foundation/nuts-node/jsonld" "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "testing" - "time" ) func TestWrapper_RequestAccessToken(t *testing.T) { walletDID := did.MustParseDID("did:test:123") verifierDID := did.MustParseDID("did:test:456") - body := &RequestAccessTokenJSONRequestBody{Verifier: verifierDID.String()} + body := &RequestAccessTokenJSONRequestBody{Verifier: verifierDID.String(), Scope: "first second"} t.Run("ok", func(t *testing.T) { ctx := newTestClient(t) ctx.vdr.EXPECT().IsOwner(nil, walletDID).Return(true, nil) ctx.resolver.EXPECT().Resolve(verifierDID, nil).Return(&did.Document{}, &resolver.DocumentMetadata{}, nil) + ctx.relyingParty.EXPECT().RequestRFC021AccessToken(nil, walletDID, verifierDID, "first second").Return(&oauth.TokenResponse{}, nil) _, err := ctx.client.RequestAccessToken(nil, RequestAccessTokenRequestObject{Did: walletDID.String(), Body: body}) @@ -59,7 +64,7 @@ func TestWrapper_RequestAccessToken(t *testing.T) { require.EqualError(t, err, "did not found: invalid DID") }) - t.Run("missing request body", func(t *testing.T) { + t.Run("error - missing request body", func(t *testing.T) { ctx := newTestClient(t) _, err := ctx.client.RequestAccessToken(nil, RequestAccessTokenRequestObject{Did: walletDID.String()}) @@ -67,7 +72,7 @@ func TestWrapper_RequestAccessToken(t *testing.T) { require.Error(t, err) assert.EqualError(t, err, "missing request body") }) - t.Run("invalid verifier did", func(t *testing.T) { + t.Run("error - invalid verifier did", func(t *testing.T) { ctx := newTestClient(t) body := &RequestAccessTokenJSONRequestBody{Verifier: "invalid"} ctx.vdr.EXPECT().IsOwner(nil, walletDID).Return(true, nil) @@ -77,7 +82,7 @@ func TestWrapper_RequestAccessToken(t *testing.T) { require.Error(t, err) assert.EqualError(t, err, "invalid verifier: invalid DID") }) - t.Run("verifier not found", func(t *testing.T) { + t.Run("error - verifier not found", func(t *testing.T) { ctx := newTestClient(t) ctx.vdr.EXPECT().IsOwner(nil, walletDID).Return(true, nil) ctx.resolver.EXPECT().Resolve(verifierDID, nil).Return(nil, nil, resolver.ErrNotFound) @@ -87,6 +92,17 @@ func TestWrapper_RequestAccessToken(t *testing.T) { require.Error(t, err) assert.EqualError(t, err, "verifier not found: unable to find the DID document") }) + t.Run("error - verifier error", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(nil, walletDID).Return(true, nil) + ctx.resolver.EXPECT().Resolve(verifierDID, nil).Return(&did.Document{}, &resolver.DocumentMetadata{}, nil) + ctx.relyingParty.EXPECT().RequestRFC021AccessToken(nil, walletDID, verifierDID, "first second").Return(nil, core.Error(http.StatusPreconditionFailed, "no matching credentials")) + + _, err := ctx.client.RequestAccessToken(nil, RequestAccessTokenRequestObject{Did: walletDID.String(), Body: body}) + + require.Error(t, err) + assert.EqualError(t, err, "no matching credentials") + }) } func TestWrapper_createAccessToken(t *testing.T) { diff --git a/auth/api/iam/types.go b/auth/api/iam/types.go index e633488398..d805d6a070 100644 --- a/auth/api/iam/types.go +++ b/auth/api/iam/types.go @@ -20,6 +20,7 @@ package iam import ( "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/resolver" ) @@ -30,12 +31,15 @@ type DIDDocument = did.Document // DIDDocumentMetadata is an alias type DIDDocumentMetadata = resolver.DocumentMetadata -// ErrorResponse is an alias -type ErrorResponse = OAuth2Error - // PresentationDefinition is an alias type PresentationDefinition = pe.PresentationDefinition +// TokenResponse is an alias +type TokenResponse = oauth.TokenResponse + +// OAuthAuthorizationServerMetadata is an alias +type OAuthAuthorizationServerMetadata = oauth.AuthorizationServerMetadata + const ( // responseTypeParam is the name of the response_type parameter. // Specified by https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.1 @@ -159,73 +163,6 @@ const presentationSubmissionParam = "presentation_submission" // Specified by https://openid.bitbucket.io/connect/openid-4-verifiable-presentations-1_0.html#name-response-type-vp_token const vpTokenParam = "vp_token" -// OAuthAuthorizationServerMetadata defines the OAuth Authorization Server metadata. -// Specified by https://www.rfc-editor.org/rfc/rfc8414.txt -type OAuthAuthorizationServerMetadata struct { - // Issuer defines the authorization server's identifier, which is a URL that uses the "https" scheme and has no query or fragment components. - Issuer string `json:"issuer"` - - /* ******** /authorize ******** */ - - // AuthorizationEndpoint defines the URL of the authorization server's authorization endpoint [RFC6749] - AuthorizationEndpoint string `json:"authorization_endpoint"` - - // ResponseTypesSupported defines what response types a client can request - ResponseTypesSupported []string `json:"response_types_supported,omitempty"` - - // ResponseModesSupported defines what response modes a client can request - // Currently supports - // - query for response_type=code - // - direct_post for response_type=["vp_token", "vp_token id_token"] - // TODO: is `form_post` something we want in the future? - ResponseModesSupported []string `json:"response_modes_supported,omitempty"` - - /* ******** /token ******** */ - - // TokenEndpoint defines the URL of the authorization server's token endpoint [RFC6749]. - TokenEndpoint string `json:"token_endpoint"` - - // GrantTypesSupported is a list of the OAuth 2.0 grant type values that this authorization server supports. - GrantTypesSupported []string `json:"grant_types_supported,omitempty"` - - //// TODO: what do we support? - //// TokenEndpointAuthMethodsSupported is a JSON array containing a list of client authentication methods supported by this token endpoint. - //// Client authentication method values are used in the "token_endpoint_auth_method" parameter defined in Section 2 of [RFC7591]. - //// If omitted, the default is "client_secret_basic" -- the HTTP Basic Authentication Scheme specified in Section 2.3.1 of OAuth 2.0 [RFC6749]. - //TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"` - // - //// TODO: May be needed depending on TokenEndpointAuthMethodsSupported - //// TokenEndpointAuthSigningAlgValuesSupported is a JSON array containing a list of the JWS signing algorithms ("alg" values) supported by the token endpoint - //// for the signature on the JWT [JWT] used to authenticate the client at the token endpoint for the "private_key_jwt" and "client_secret_jwt" authentication methods. - //// This metadata entry MUST be present if either of these authentication methods are specified in the "token_endpoint_auth_methods_supported" entry. - //// No default algorithms are implied if this entry is omitted. Servers SHOULD support "RS256". The value "none" MUST NOT be used. - //TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported,omitempty"` - - /* ******** openid4vc ******** */ - - // PreAuthorizedGrantAnonymousAccessSupported indicates whether anonymous access (requests without client_id) for pre-authorized code grant flows. - // See https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-oauth-20-authorization-serv - PreAuthorizedGrantAnonymousAccessSupported bool `json:"pre-authorized_grant_anonymous_access_supported,omitempty"` - - // PresentationDefinitionEndpoint defines the URL of the authorization server's presentation definition endpoint. - // See https://nuts-foundation.gitbook.io/drafts/rfc/rfc021-vp_token-grant-type - PresentationDefinitionEndpoint string `json:"presentation_definition_endpoint,omitempty"` - - // PresentationDefinitionUriSupported specifies whether the Wallet supports the transfer of presentation_definition by reference, with true indicating support. - // If omitted, the default value is true. (hence pointer, or add custom unmarshalling) - PresentationDefinitionUriSupported *bool `json:"presentation_definition_uri_supported,omitempty"` - - // VPFormatsSupported is an object containing a list of key value pairs, where the key is a string identifying a Credential format supported by the Wallet. - VPFormatsSupported map[string]map[string][]string `json:"vp_formats_supported,omitempty"` - - // VPFormats is an object containing a list of key value pairs, where the key is a string identifying a Credential format supported by the Verifier. - VPFormats map[string]map[string][]string `json:"vp_formats,omitempty"` - - // ClientIdSchemesSupported defines the `client_id_schemes` currently supported. - // If omitted, the default value is `pre-registered` (referring to the client), which is currently not supported. - ClientIdSchemesSupported []string `json:"client_id_schemes_supported,omitempty"` -} - // OAuthClientMetadata defines the OAuth Client metadata. // Specified by https://www.rfc-editor.org/rfc/rfc7591.html and elsewhere. type OAuthClientMetadata struct { diff --git a/auth/auth.go b/auth/auth.go index 0db58e7484..f77a801d31 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -168,7 +168,7 @@ func (auth *Auth) Configure(config core.ServerConfig) error { auth.authzServer = oauth.NewAuthorizationServer(auth.vdrInstance.Resolver(), auth.vcr, auth.vcr.Verifier(), auth.serviceResolver, auth.keyStore, auth.contractNotary, auth.jsonldManager, accessTokenLifeSpan) auth.relyingParty = oauth.NewRelyingParty(auth.vdrInstance.Resolver(), auth.serviceResolver, - auth.keyStore, time.Duration(auth.config.HTTPTimeout)*time.Second, tlsConfig) + auth.keyStore, auth.vcr.Wallet(), time.Duration(auth.config.HTTPTimeout)*time.Second, tlsConfig, config.Strictmode) if err := auth.authzServer.Configure(auth.config.ClockSkew, config.Strictmode); err != nil { return err diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go new file mode 100644 index 0000000000..0bb167fc3c --- /dev/null +++ b/auth/client/iam/client.go @@ -0,0 +1,179 @@ +/* + * 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 iam + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "github.com/nuts-foundation/nuts-node/auth/log" + "io" + "net/http" + "net/url" + "strings" + "time" + + "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/core" + "github.com/nuts-foundation/nuts-node/vcr/pe" + "github.com/nuts-foundation/nuts-node/vdr/didweb" +) + +// HTTPClient holds the server address and other basic settings for the http client +type HTTPClient struct { + strictMode bool + httpClient core.HTTPRequestDoer +} + +// NewHTTPClient creates a new api client. +func NewHTTPClient(strictMode bool, timeout time.Duration, tlsConfig *tls.Config) HTTPClient { + return HTTPClient{ + strictMode: strictMode, + httpClient: core.NewStrictHTTPClient(strictMode, timeout, tlsConfig), + } +} + +// OAuthAuthorizationServerMetadata retrieves the OAuth authorization server metadata for the given web DID. +func (hb HTTPClient) OAuthAuthorizationServerMetadata(ctx context.Context, webDID did.DID) (*oauth.AuthorizationServerMetadata, error) { + serverURL, err := didweb.DIDToURL(webDID) + if err != nil { + return nil, err + } + + metadataURL, err := oauth.IssuerIdToWellKnown(serverURL.String(), oauth.AuthzServerWellKnown, hb.strictMode) + if err != nil { + return nil, err + } + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL.String(), nil) + if err != nil { + return nil, err + } + response, err := hb.httpClient.Do(request.WithContext(ctx)) + if err != nil { + return nil, err + } + + if err = core.TestResponseCode(http.StatusOK, response); err != nil { + return nil, err + } + + var metadata oauth.AuthorizationServerMetadata + var data []byte + + if data, err = io.ReadAll(response.Body); err != nil { + return nil, fmt.Errorf("unable to read response: %w", err) + } + if err = json.Unmarshal(data, &metadata); err != nil { + return nil, fmt.Errorf("unable to unmarshal response: %w, %s", err, string(data)) + } + + return &metadata, nil +} + +// PresentationDefinition retrieves the presentation definition from the presentation definition endpoint (as specified by RFC021) for the given scope. +func (hb HTTPClient) PresentationDefinition(ctx context.Context, definitionEndpoint string, scopes string) (*pe.PresentationDefinition, error) { + presentationDefinitionURL, err := core.ParsePublicURL(definitionEndpoint, hb.strictMode) + + if err != nil { + return nil, err + } + presentationDefinitionURL.RawQuery = url.Values{"scope": []string{scopes}}.Encode() + + // create a GET request with scope query param + request, err := http.NewRequestWithContext(ctx, http.MethodGet, presentationDefinitionURL.String(), nil) + if err != nil { + return nil, err + } + response, err := hb.httpClient.Do(request.WithContext(ctx)) + if err != nil { + return nil, fmt.Errorf("failed to call endpoint: %w", err) + } + if httpErr := core.TestResponseCode(http.StatusOK, response); httpErr != nil { + rse := httpErr.(core.HttpError) + if ok, oauthErr := oauth.TestOAuthErrorCode(rse.ResponseBody, oauth.InvalidScope); ok { + return nil, oauthErr + } + return nil, httpErr + } + + var presentationDefinition pe.PresentationDefinition + var data []byte + + if data, err = io.ReadAll(response.Body); err != nil { + return nil, fmt.Errorf("unable to read response: %w", err) + } + if err = json.Unmarshal(data, &presentationDefinition); err != nil { + return nil, fmt.Errorf("unable to unmarshal response: %w", err) + } + + return &presentationDefinition, nil +} + +func (hb HTTPClient) AccessToken(ctx context.Context, tokenEndpoint string, vp vc.VerifiablePresentation, submission pe.PresentationSubmission, scopes string) (oauth.TokenResponse, error) { + var token oauth.TokenResponse + presentationDefinitionURL, err := url.Parse(tokenEndpoint) + if err != nil { + return token, err + } + + // create a POST request with x-www-form-urlencoded body + assertion, _ := json.Marshal(vp) + presentationSubmission, _ := json.Marshal(submission) + data := url.Values{} + data.Set(oauth.GrantTypeParam, oauth.VpTokenGrantType) + data.Set(oauth.AssertionParam, string(assertion)) + data.Set(oauth.PresentationSubmissionParam, string(presentationSubmission)) + data.Set(oauth.ScopeParam, scopes) + request, err := http.NewRequestWithContext(ctx, http.MethodPost, presentationDefinitionURL.String(), strings.NewReader(data.Encode())) + request.Header.Add("Content-Type", "application/x-www-form-urlencoded") + if err != nil { + return token, err + } + response, err := hb.httpClient.Do(request.WithContext(ctx)) + if err != nil { + return token, fmt.Errorf("failed to call endpoint: %w", err) + } + if err = core.TestResponseCode(http.StatusOK, response); err != nil { + // check for oauth error + if innerErr := core.TestResponseCode(http.StatusBadRequest, response); innerErr != nil { + // a non oauth error, the response body could contain a lot of stuff. We'll log and return the entire error + log.Logger().Debugf("authorization server token endpoint returned non oauth error (statusCode=%d)", response.StatusCode) + } + + return token, err + } + + var responseData []byte + if responseData, err = io.ReadAll(response.Body); err != nil { + return token, fmt.Errorf("unable to read response: %w", err) + } + if err = json.Unmarshal(responseData, &token); err != nil { + // Cut off the response body to 100 characters max to prevent logging of large responses + responseBodyString := string(responseData) + if len(responseBodyString) > 100 { + responseBodyString = responseBodyString[:100] + "...(clipped)" + } + return token, fmt.Errorf("unable to unmarshal response: %w, %s", err, string(responseData)) + } + return token, nil +} diff --git a/auth/api/iam/client_test.go b/auth/client/iam/client_test.go similarity index 67% rename from auth/api/iam/client_test.go rename to auth/client/iam/client_test.go index cff6e5d166..5df91fbfcd 100644 --- a/auth/api/iam/client_test.go +++ b/auth/client/iam/client_test.go @@ -21,25 +21,29 @@ package iam import ( "context" "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/core" http2 "github.com/nuts-foundation/nuts-node/test/http" + "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/didweb" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "net/http" "net/http/httptest" "net/url" - "strings" "testing" + "time" ) func TestHTTPClient_OAuthAuthorizationServerMetadata(t *testing.T) { ctx := context.Background() t.Run("ok using root web:did", func(t *testing.T) { - result := OAuthAuthorizationServerMetadata{TokenEndpoint: "/token"} + result := oauth.AuthorizationServerMetadata{TokenEndpoint: "/token"} handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: result} tlsServer, client := testServerAndClient(t, &handler) - testDID := stringURLToDID(t, tlsServer.URL) + testDID := didweb.ServerURLToDIDWeb(t, tlsServer.URL) metadata, err := client.OAuthAuthorizationServerMetadata(ctx, testDID) @@ -51,10 +55,10 @@ func TestHTTPClient_OAuthAuthorizationServerMetadata(t *testing.T) { assert.Equal(t, "/.well-known/oauth-authorization-server", handler.Request.URL.Path) }) t.Run("ok using user web:did", func(t *testing.T) { - result := OAuthAuthorizationServerMetadata{TokenEndpoint: "/token"} + result := oauth.AuthorizationServerMetadata{TokenEndpoint: "/token"} handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: result} tlsServer, client := testServerAndClient(t, &handler) - testDID := stringURLToDID(t, tlsServer.URL) + testDID := didweb.ServerURLToDIDWeb(t, tlsServer.URL) testDID = did.MustParseDID(testDID.String() + ":iam:123") metadata, err := client.OAuthAuthorizationServerMetadata(ctx, testDID) @@ -69,7 +73,7 @@ func TestHTTPClient_OAuthAuthorizationServerMetadata(t *testing.T) { t.Run("error - non 200 return value", func(t *testing.T) { handler := http2.Handler{StatusCode: http.StatusBadRequest} tlsServer, client := testServerAndClient(t, &handler) - testDID := stringURLToDID(t, tlsServer.URL) + testDID := didweb.ServerURLToDIDWeb(t, tlsServer.URL) metadata, err := client.OAuthAuthorizationServerMetadata(ctx, testDID) @@ -79,7 +83,7 @@ func TestHTTPClient_OAuthAuthorizationServerMetadata(t *testing.T) { t.Run("error - bad contents", func(t *testing.T) { handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: "not json"} tlsServer, client := testServerAndClient(t, &handler) - testDID := stringURLToDID(t, tlsServer.URL) + testDID := didweb.ServerURLToDIDWeb(t, tlsServer.URL) metadata, err := client.OAuthAuthorizationServerMetadata(ctx, testDID) @@ -88,7 +92,7 @@ func TestHTTPClient_OAuthAuthorizationServerMetadata(t *testing.T) { }) t.Run("error - server not responding", func(t *testing.T) { _, client := testServerAndClient(t, nil) - testDID := stringURLToDID(t, "https://localhost:1234") + testDID := didweb.ServerURLToDIDWeb(t, "https://localhost:1234") metadata, err := client.OAuthAuthorizationServerMetadata(ctx, testDID) @@ -99,7 +103,7 @@ func TestHTTPClient_OAuthAuthorizationServerMetadata(t *testing.T) { func TestHTTPClient_PresentationDefinition(t *testing.T) { ctx := context.Background() - definition := PresentationDefinition{ + definition := pe.PresentationDefinition{ Id: "123", } @@ -107,7 +111,7 @@ func TestHTTPClient_PresentationDefinition(t *testing.T) { handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: definition} tlsServer, client := testServerAndClient(t, &handler) - response, err := client.PresentationDefinition(ctx, tlsServer.URL, []string{"test"}) + response, err := client.PresentationDefinition(ctx, tlsServer.URL, "test") require.NoError(t, err) require.NotNil(t, definition) @@ -118,7 +122,7 @@ func TestHTTPClient_PresentationDefinition(t *testing.T) { handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: definition} tlsServer, client := testServerAndClient(t, &handler) - response, err := client.PresentationDefinition(ctx, tlsServer.URL, []string{"first", "second"}) + response, err := client.PresentationDefinition(ctx, tlsServer.URL, "first second") require.NoError(t, err) require.NotNil(t, definition) @@ -127,10 +131,10 @@ func TestHTTPClient_PresentationDefinition(t *testing.T) { assert.Equal(t, url.Values{"scope": []string{"first second"}}, handler.Request.URL.Query()) }) t.Run("error - invalid_scope", func(t *testing.T) { - handler := http2.Handler{StatusCode: http.StatusBadRequest, ResponseData: OAuth2Error{Code: InvalidScope}} + handler := http2.Handler{StatusCode: http.StatusBadRequest, ResponseData: oauth.OAuth2Error{Code: oauth.InvalidScope}} tlsServer, client := testServerAndClient(t, &handler) - response, err := client.PresentationDefinition(ctx, tlsServer.URL, []string{"test"}) + response, err := client.PresentationDefinition(ctx, tlsServer.URL, "test") require.Error(t, err) assert.EqualError(t, err, "invalid_scope") @@ -140,7 +144,7 @@ func TestHTTPClient_PresentationDefinition(t *testing.T) { handler := http2.Handler{StatusCode: http.StatusNotFound} tlsServer, client := testServerAndClient(t, &handler) - response, err := client.PresentationDefinition(ctx, tlsServer.URL, []string{"test"}) + response, err := client.PresentationDefinition(ctx, tlsServer.URL, "test") require.Error(t, err) assert.EqualError(t, err, "server returned HTTP 404 (expected: 200)") @@ -150,7 +154,7 @@ func TestHTTPClient_PresentationDefinition(t *testing.T) { handler := http2.Handler{StatusCode: http.StatusNotFound} _, client := testServerAndClient(t, &handler) - response, err := client.PresentationDefinition(ctx, ":", []string{"test"}) + response, err := client.PresentationDefinition(ctx, ":", "test") require.Error(t, err) assert.EqualError(t, err, "parse \":\": missing protocol scheme") @@ -160,7 +164,7 @@ func TestHTTPClient_PresentationDefinition(t *testing.T) { handler := http2.Handler{StatusCode: http.StatusNotFound} _, client := testServerAndClient(t, &handler) - response, err := client.PresentationDefinition(ctx, "http://localhost", []string{"test"}) + response, err := client.PresentationDefinition(ctx, "http://localhost", "test") require.Error(t, err) assert.ErrorContains(t, err, "connection refused") @@ -170,7 +174,7 @@ func TestHTTPClient_PresentationDefinition(t *testing.T) { handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: "}"} tlsServer, client := testServerAndClient(t, &handler) - response, err := client.PresentationDefinition(ctx, tlsServer.URL, []string{"test"}) + response, err := client.PresentationDefinition(ctx, tlsServer.URL, "test") require.Error(t, err) assert.EqualError(t, err, "unable to unmarshal response: invalid character '}' looking for beginning of value") @@ -178,18 +182,66 @@ func TestHTTPClient_PresentationDefinition(t *testing.T) { }) } +func TestHTTPClient_AccessToken(t *testing.T) { + t.Run("ok", func(t *testing.T) { + ctx := context.Background() + now := int(time.Now().Unix()) + scope := "test" + accessToken := oauth.TokenResponse{ + AccessToken: "token", + TokenType: "bearer", + Scope: &scope, + ExpiresIn: &now, + } + vp := vc.VerifiablePresentation{} + + t.Run("ok", func(t *testing.T) { + handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: accessToken} + tlsServer, client := testServerAndClient(t, &handler) + + response, err := client.AccessToken(ctx, tlsServer.URL, vp, pe.PresentationSubmission{}, "test") + + require.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, "token", response.AccessToken) + assert.Equal(t, "bearer", response.TokenType) + require.NotNil(t, response.Scope) + assert.Equal(t, "test", *response.Scope) + require.NotNil(t, response.ExpiresIn) + assert.Equal(t, now, *response.ExpiresIn) + }) + }) + t.Run("error - oauth error", func(t *testing.T) { + ctx := context.Background() + handler := http2.Handler{StatusCode: http.StatusBadRequest, ResponseData: oauth.OAuth2Error{Code: oauth.InvalidScope}} + tlsServer, client := testServerAndClient(t, &handler) + + _, err := client.AccessToken(ctx, tlsServer.URL, vc.VerifiablePresentation{}, pe.PresentationSubmission{}, "test") + + require.Error(t, err) + // check if the error is a http error + httpError, ok := err.(core.HttpError) + require.True(t, ok) + assert.Equal(t, "{\"error\":\"invalid_scope\"}", string(httpError.ResponseBody)) + }) + t.Run("error - generic server error", func(t *testing.T) { + ctx := context.Background() + handler := http2.Handler{StatusCode: http.StatusBadGateway, ResponseData: "offline"} + tlsServer, client := testServerAndClient(t, &handler) + + _, err := client.AccessToken(ctx, tlsServer.URL, vc.VerifiablePresentation{}, pe.PresentationSubmission{}, "test") + + require.Error(t, err) + // check if the error is a http error + httpError, ok := err.(core.HttpError) + require.True(t, ok) + assert.Equal(t, "offline", string(httpError.ResponseBody)) + }) +} + func testServerAndClient(t *testing.T, handler http.Handler) (*httptest.Server, *HTTPClient) { tlsServer := http2.TestTLSServer(t, handler) return tlsServer, &HTTPClient{ httpClient: tlsServer.Client(), } } - -func stringURLToDID(t *testing.T, stringUrl string) did.DID { - stringUrl = strings.ReplaceAll(stringUrl, "127.0.0.1", "localhost") - asURL, err := url.Parse(stringUrl) - require.NoError(t, err) - testDID, err := didweb.URLToDID(*asURL) - require.NoError(t, err) - return *testDID -} diff --git a/auth/interface.go b/auth/interface.go index 2ad318a603..f508d3e958 100644 --- a/auth/interface.go +++ b/auth/interface.go @@ -25,6 +25,9 @@ import ( "net/url" ) +// ModuleName contains the name of this module +const ModuleName = "Auth" + // AuthenticationServices is the interface which should be implemented for clients or mocks type AuthenticationServices interface { // AuthzServer returns the oauth.AuthorizationServer diff --git a/auth/api/iam/error.go b/auth/oauth/error.go similarity index 97% rename from auth/api/iam/error.go rename to auth/oauth/error.go index 41001fd554..23e98d8f48 100644 --- a/auth/api/iam/error.go +++ b/auth/oauth/error.go @@ -16,7 +16,7 @@ * */ -package iam +package oauth import ( "encoding/json" @@ -88,9 +88,10 @@ func (e OAuth2Error) Error() string { return strings.Join(parts, " - ") } -type oauth2ErrorWriter struct{} +// Oauth2ErrorWriter is a HTTP response writer for OAuth errors +type Oauth2ErrorWriter struct{} -func (p oauth2ErrorWriter) Write(echoContext echo.Context, _ int, _ string, err error) error { +func (p Oauth2ErrorWriter) Write(echoContext echo.Context, _ int, _ string, err error) error { var oauthErr OAuth2Error if !errors.As(err, &oauthErr) { // Internal error, wrap it in an OAuth2 error diff --git a/auth/api/iam/error_test.go b/auth/oauth/error_test.go similarity index 92% rename from auth/api/iam/error_test.go rename to auth/oauth/error_test.go index 9225282c92..0e95bf536e 100644 --- a/auth/api/iam/error_test.go +++ b/auth/oauth/error_test.go @@ -16,7 +16,7 @@ * */ -package iam +package oauth import ( "errors" @@ -45,7 +45,7 @@ func Test_oauth2ErrorWriter_Write(t *testing.T) { rec := httptest.NewRecorder() ctx := server.NewContext(httpRequest, rec) - err := oauth2ErrorWriter{}.Write(ctx, 0, "", OAuth2Error{ + err := Oauth2ErrorWriter{}.Write(ctx, 0, "", OAuth2Error{ Code: InvalidRequest, Description: "failure", RedirectURI: "https://example.com", @@ -61,7 +61,7 @@ func Test_oauth2ErrorWriter_Write(t *testing.T) { rec := httptest.NewRecorder() ctx := server.NewContext(httpRequest, rec) - err := oauth2ErrorWriter{}.Write(ctx, 0, "", OAuth2Error{ + err := Oauth2ErrorWriter{}.Write(ctx, 0, "", OAuth2Error{ Code: InvalidRequest, Description: "failure", }) @@ -80,7 +80,7 @@ func Test_oauth2ErrorWriter_Write(t *testing.T) { rec := httptest.NewRecorder() ctx := server.NewContext(httpRequest, rec) - err := oauth2ErrorWriter{}.Write(ctx, 0, "", OAuth2Error{ + err := Oauth2ErrorWriter{}.Write(ctx, 0, "", OAuth2Error{ Code: InvalidRequest, Description: "failure", }) @@ -98,7 +98,7 @@ func Test_oauth2ErrorWriter_Write(t *testing.T) { rec := httptest.NewRecorder() ctx := server.NewContext(httpRequest, rec) - err := oauth2ErrorWriter{}.Write(ctx, 0, "", OAuth2Error{ + err := Oauth2ErrorWriter{}.Write(ctx, 0, "", OAuth2Error{ Description: "failure", }) @@ -113,7 +113,7 @@ func Test_oauth2ErrorWriter_Write(t *testing.T) { rec := httptest.NewRecorder() ctx := server.NewContext(httpRequest, rec) - err := oauth2ErrorWriter{}.Write(ctx, 0, "", errors.New("catastrophic")) + err := Oauth2ErrorWriter{}.Write(ctx, 0, "", errors.New("catastrophic")) assert.NoError(t, err) body, _ := io.ReadAll(rec.Body) diff --git a/auth/oauth/types.go b/auth/oauth/types.go new file mode 100644 index 0000000000..abc14df714 --- /dev/null +++ b/auth/oauth/types.go @@ -0,0 +1,138 @@ +/* + * 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 contains generic OAuth related functionality, variables and constants +package oauth + +import ( + "github.com/nuts-foundation/nuts-node/core" + "net/url" +) + +// this file contains constants, variables and helper functions for OAuth related code + +// TokenResponse is the OAuth access token response +type TokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn *int `json:"expires_in,omitempty"` + TokenType string `json:"token_type"` + CNonce *string `json:"c_nonce,omitempty"` + Scope *string `json:"scope,omitempty"` +} + +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" + // GrantTypeParam is the parameter name for the grant_type parameter + GrantTypeParam = "grant_type" + // AssertionParam is the parameter name for the assertion parameter + AssertionParam = "assertion" + // ScopeParam is the parameter name for the scope parameter + ScopeParam = "scope" + // PresentationSubmissionParam is the parameter name for the presentation_submission parameter + PresentationSubmissionParam = "presentation_submission" + // VpTokenGrantType is the grant_type for the vp_token-bearer grant type + VpTokenGrantType = "vp_token-bearer" +) + +// 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) { + issuerURL, err := core.ParsePublicURL(issuer, strictmode) + if err != nil { + return nil, err + } + return issuerURL.Parse(wellKnown + issuerURL.EscapedPath()) +} + +// AuthorizationServerMetadata defines the OAuth Authorization Server metadata. +// Specified by https://www.rfc-editor.org/rfc/rfc8414.txt +type AuthorizationServerMetadata struct { + // Issuer defines the authorization server's identifier, which is a URL that uses the "https" scheme and has no query or fragment components. + Issuer string `json:"issuer"` + + /* ******** /authorize ******** */ + + // AuthorizationEndpoint defines the URL of the authorization server's authorization endpoint [RFC6749] + AuthorizationEndpoint string `json:"authorization_endpoint"` + + // ResponseTypesSupported defines what response types a client can request + ResponseTypesSupported []string `json:"response_types_supported,omitempty"` + + // ResponseModesSupported defines what response modes a client can request + // Currently supports + // - query for response_type=code + // - direct_post for response_type=["vp_token", "vp_token id_token"] + // TODO: is `form_post` something we want in the future? + ResponseModesSupported []string `json:"response_modes_supported,omitempty"` + + /* ******** /token ******** */ + + // TokenEndpoint defines the URL of the authorization server's token endpoint [RFC6749]. + TokenEndpoint string `json:"token_endpoint"` + + // GrantTypesSupported is a list of the OAuth 2.0 grant type values that this authorization server supports. + GrantTypesSupported []string `json:"grant_types_supported,omitempty"` + + //// TODO: what do we support? + //// TokenEndpointAuthMethodsSupported is a JSON array containing a list of client authentication methods supported by this token endpoint. + //// Client authentication method values are used in the "token_endpoint_auth_method" parameter defined in Section 2 of [RFC7591]. + //// If omitted, the default is "client_secret_basic" -- the HTTP Basic Authentication Scheme specified in Section 2.3.1 of OAuth 2.0 [RFC6749]. + //TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported,omitempty"` + // + //// TODO: May be needed depending on TokenEndpointAuthMethodsSupported + //// TokenEndpointAuthSigningAlgValuesSupported is a JSON array containing a list of the JWS signing algorithms ("alg" values) supported by the token endpoint + //// for the signature on the JWT [JWT] used to authenticate the client at the token endpoint for the "private_key_jwt" and "client_secret_jwt" authentication methods. + //// This metadata entry MUST be present if either of these authentication methods are specified in the "token_endpoint_auth_methods_supported" entry. + //// No default algorithms are implied if this entry is omitted. Servers SHOULD support "RS256". The value "none" MUST NOT be used. + //TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported,omitempty"` + + /* ******** openid4vc ******** */ + + // PreAuthorizedGrantAnonymousAccessSupported indicates whether anonymous access (requests without client_id) for pre-authorized code grant flows. + // See https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-oauth-20-authorization-serv + PreAuthorizedGrantAnonymousAccessSupported bool `json:"pre-authorized_grant_anonymous_access_supported,omitempty"` + + // PresentationDefinitionEndpoint defines the URL of the authorization server's presentation definition endpoint. + // See https://nuts-foundation.gitbook.io/drafts/rfc/rfc021-vp_token-grant-type + PresentationDefinitionEndpoint string `json:"presentation_definition_endpoint,omitempty"` + + // PresentationDefinitionUriSupported specifies whether the Wallet supports the transfer of presentation_definition by reference, with true indicating support. + // If omitted, the default value is true. (hence pointer, or add custom unmarshalling) + PresentationDefinitionUriSupported *bool `json:"presentation_definition_uri_supported,omitempty"` + + // VPFormatsSupported is an object containing a list of key value pairs, where the key is a string identifying a Credential format supported by the Wallet. + VPFormatsSupported map[string]map[string][]string `json:"vp_formats_supported,omitempty"` + + // VPFormats is an object containing a list of key value pairs, where the key is a string identifying a Credential format supported by the Verifier. + VPFormats map[string]map[string][]string `json:"vp_formats,omitempty"` + + // ClientIdSchemesSupported defines the `client_id_schemes` currently supported. + // 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/messages.go b/auth/services/messages.go index 7a19a288fa..e6d9858219 100644 --- a/auth/services/messages.go +++ b/auth/services/messages.go @@ -54,16 +54,6 @@ type CreateJwtGrantRequest struct { Credentials []vc.VerifiableCredential } -// AccessTokenResult defines the return value back to the api for the CreateAccessToken method -type AccessTokenResult struct { - // AccessToken contains the JWT in compact serialization form - AccessToken string `json:"access_token"` - // ExpiresIn defines the expiration in seconds - ExpiresIn int `json:"expires_in"` - // TokenType The type of the token issued - TokenType string `json:"token_type"` -} - // JwtBearerTokenResult defines the return value back to the api for the createJwtBearerToken method type JwtBearerTokenResult struct { BearerToken string diff --git a/auth/services/oauth/authz_server.go b/auth/services/oauth/authz_server.go index 8db30970ef..4fd32ae59c 100644 --- a/auth/services/oauth/authz_server.go +++ b/auth/services/oauth/authz_server.go @@ -24,7 +24,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/vdr/resolver" "time" "github.com/lestrrat-go/jwx/v2/jwt" @@ -32,6 +31,7 @@ import ( vc2 "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/contract" "github.com/nuts-foundation/nuts-node/auth/log" + "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/auth/services" "github.com/nuts-foundation/nuts-node/core" nutsCrypto "github.com/nuts-foundation/nuts-node/crypto" @@ -40,6 +40,7 @@ import ( "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/verifier" + "github.com/nuts-foundation/nuts-node/vdr/resolver" ) const errInvalidIssuerFmt = "invalid jwt.issuer: %w" @@ -57,21 +58,6 @@ const secureAccessTokenLifeSpan = time.Minute var _ AuthorizationServer = (*authzServer)(nil) -// ErrorResponse models an error returned from an OAuth flow according to RFC6749 (https://tools.ietf.org/html/rfc6749#page-45) -type ErrorResponse struct { - Description error - Code string -} - -// Error returns the error detail, if any. If there's no detailed error message, it returns a generic error message. -// This aids hiding internal errors from clients. -func (e ErrorResponse) Error() string { - if e.Description != nil { - return e.Description.Error() - } - return "failed" -} - type authzServer struct { vcFinder vcr.Finder vcVerifier verifier.Verifier @@ -165,7 +151,7 @@ func (c validationContext) verifiableCredentials() ([]vc2.VerifiableCredential, return vcs, nil } -// NewAuthorizationServer accepts a vendorID, and several Nuts engines and returns an implementation of services.AuthorizationServer +// NewAuthorizationServer accepts a vendorID, and several Nuts engines and returns an implementation of services.OAuthAuthorizationServer func NewAuthorizationServer( didResolver resolver.DIDResolver, vcFinder vcr.Finder, vcVerifier verifier.Verifier, serviceResolver didman.CompoundServiceResolver, privateKeyStore nutsCrypto.KeyStore, @@ -197,27 +183,30 @@ 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) (*services.AccessTokenResult, *ErrorResponse) { - var oauthError *ErrorResponse - var result *services.AccessTokenResult +func (s *authzServer) CreateAccessToken(ctx context.Context, request services.CreateAccessTokenRequest) (*oauth.TokenResponse, *oauth.ErrorResponse) { + var oauthError *oauth.ErrorResponse + var result *oauth.TokenResponse validationCtx, err := s.validateAccessTokenRequest(ctx, request.RawJwtBearerToken) if err != nil { - oauthError = &ErrorResponse{Code: "invalid_request", Description: err} + errStr := err.Error() + oauthError = &oauth.ErrorResponse{Error: "invalid_request", Description: &errStr} } else { var accessToken string var rawToken services.NutsAccessToken accessToken, rawToken, err = s.buildAccessToken(ctx, *validationCtx.requester, *validationCtx.authorizer, validationCtx.purposeOfUse, validationCtx.contractVerificationResult, validationCtx.credentialIDs) if err == nil { - result = &services.AccessTokenResult{ + expires := int(rawToken.Expiration - rawToken.IssuedAt) + result = &oauth.TokenResponse{ AccessToken: accessToken, - ExpiresIn: int(rawToken.Expiration - rawToken.IssuedAt), + ExpiresIn: &expires, } } else { - oauthError = &ErrorResponse{Code: "server_error"} + oauthError = &oauth.ErrorResponse{Error: "server_error"} if !s.secureMode { // Only set details when secure mode is disabled - oauthError.Description = err + errStr := err.Error() + oauthError.Description = &errStr } } } diff --git a/auth/services/oauth/authz_server_test.go b/auth/services/oauth/authz_server_test.go index c6373b28ff..4a78c2af73 100644 --- a/auth/services/oauth/authz_server_test.go +++ b/auth/services/oauth/authz_server_test.go @@ -116,7 +116,8 @@ func TestAuth_CreateAccessToken(t *testing.T) { response, err := ctx.oauthService.CreateAccessToken(ctx.audit, services.CreateAccessTokenRequest{RawJwtBearerToken: "foo"}) assert.Nil(t, response) - require.ErrorContains(t, err, "jwt bearer token validation failed") + require.NotNil(t, err.Description) + assert.Contains(t, *err.Description, "jwt bearer token validation failed") }) t.Run("broken identity token", func(t *testing.T) { @@ -133,7 +134,8 @@ func TestAuth_CreateAccessToken(t *testing.T) { response, err := ctx.oauthService.CreateAccessToken(ctx.audit, services.CreateAccessTokenRequest{RawJwtBearerToken: tokenCtx.rawJwtBearerToken}) assert.Nil(t, response) - require.ErrorContains(t, err, "identity validation failed") + require.NotNil(t, err.Description) + assert.Contains(t, *err.Description, "identity validation failed") }) t.Run("JWT validity too long", func(t *testing.T) { @@ -148,7 +150,8 @@ func TestAuth_CreateAccessToken(t *testing.T) { response, err := ctx.oauthService.CreateAccessToken(ctx.audit, services.CreateAccessTokenRequest{RawJwtBearerToken: tokenCtx.rawJwtBearerToken}) assert.Nil(t, response) - assert.ErrorContains(t, err, "JWT validity too long") + require.NotNil(t, err.Description) + assert.Contains(t, *err.Description, "JWT validity too long") }) t.Run("invalid identity token", func(t *testing.T) { @@ -166,7 +169,8 @@ func TestAuth_CreateAccessToken(t *testing.T) { response, err := ctx.oauthService.CreateAccessToken(ctx.audit, services.CreateAccessTokenRequest{RawJwtBearerToken: tokenCtx.rawJwtBearerToken}) assert.Nil(t, response) - assert.ErrorContains(t, err, "identity validation failed: because of reasons") + require.NotNil(t, err.Description) + assert.Contains(t, *err.Description, "identity validation failed: because of reasons") }) t.Run("error detail masking", func(t *testing.T) { @@ -195,9 +199,9 @@ func TestAuth_CreateAccessToken(t *testing.T) { response, err := ctx.oauthService.CreateAccessToken(ctx.audit, services.CreateAccessTokenRequest{RawJwtBearerToken: tokenCtx.rawJwtBearerToken}) - require.Error(t, err) assert.Nil(t, response) - assert.EqualError(t, err, "could not build accessToken: signing error") + require.NotNil(t, err.Description) + 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)) @@ -208,9 +212,9 @@ func TestAuth_CreateAccessToken(t *testing.T) { response, err := ctx.oauthService.CreateAccessToken(ctx.audit, services.CreateAccessTokenRequest{RawJwtBearerToken: tokenCtx.rawJwtBearerToken}) - require.Error(t, err) assert.Nil(t, response) - assert.EqualError(t, err, "failed") + assert.Nil(t, err.Description) + assert.Equal(t, err.Error, "server_error") }) }) @@ -272,7 +276,7 @@ func TestAuth_CreateAccessToken(t *testing.T) { signToken(tokenCtx) _, err := ctx.oauthService.CreateAccessToken(ctx.audit, services.CreateAccessTokenRequest{RawJwtBearerToken: tokenCtx.rawJwtBearerToken}) - require.Error(t, err) + require.NotNil(t, err) }) } diff --git a/auth/services/oauth/interface.go b/auth/services/oauth/interface.go index e0d1dcf1d7..f393a2c4f2 100644 --- a/auth/services/oauth/interface.go +++ b/auth/services/oauth/interface.go @@ -20,15 +20,21 @@ package oauth import ( "context" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/auth/services" "net/url" ) // RelyingParty implements the OAuth2 relying party role. type RelyingParty interface { - // RequestAccessToken is called by the local EHR node to request an access token from a remote Nuts node. - RequestAccessToken(ctx context.Context, jwtGrantToken string, authServerEndpoint url.URL) (*services.AccessTokenResult, 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. + RequestRFC003AccessToken(ctx context.Context, jwtGrantToken string, authServerEndpoint url.URL) (*oauth.TokenResponse, error) + + // RequestRFC021AccessToken is called by the local EHR node to request an access token from a remote Nuts node using Nuts RFC021. + RequestRFC021AccessToken(ctx context.Context, requestHolder did.DID, verifier did.DID, scopes string) (*oauth.TokenResponse, error) } // AuthorizationServer implements the OAuth2 authorization server role. @@ -38,6 +44,6 @@ 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) (*services.AccessTokenResult, *ErrorResponse) + CreateAccessToken(ctx context.Context, request services.CreateAccessTokenRequest) (*oauth.TokenResponse, *oauth.ErrorResponse) IntrospectAccessToken(ctx context.Context, token string) (*services.NutsAccessToken, error) } diff --git a/auth/services/oauth/mock.go b/auth/services/oauth/mock.go index f65f49dc9c..8db473762a 100644 --- a/auth/services/oauth/mock.go +++ b/auth/services/oauth/mock.go @@ -13,6 +13,8 @@ import ( url "net/url" reflect "reflect" + did "github.com/nuts-foundation/go-did/did" + oauth "github.com/nuts-foundation/nuts-node/auth/oauth" services "github.com/nuts-foundation/nuts-node/auth/services" gomock "go.uber.org/mock/gomock" ) @@ -55,19 +57,34 @@ func (mr *MockRelyingPartyMockRecorder) CreateJwtGrant(ctx, request any) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateJwtGrant", reflect.TypeOf((*MockRelyingParty)(nil).CreateJwtGrant), ctx, request) } -// RequestAccessToken mocks base method. -func (m *MockRelyingParty) RequestAccessToken(ctx context.Context, jwtGrantToken string, authServerEndpoint url.URL) (*services.AccessTokenResult, error) { +// RequestRFC003AccessToken mocks base method. +func (m *MockRelyingParty) RequestRFC003AccessToken(ctx context.Context, jwtGrantToken string, authServerEndpoint url.URL) (*oauth.TokenResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RequestAccessToken", ctx, jwtGrantToken, authServerEndpoint) - ret0, _ := ret[0].(*services.AccessTokenResult) + ret := m.ctrl.Call(m, "RequestRFC003AccessToken", ctx, jwtGrantToken, authServerEndpoint) + ret0, _ := ret[0].(*oauth.TokenResponse) ret1, _ := ret[1].(error) return ret0, ret1 } -// RequestAccessToken indicates an expected call of RequestAccessToken. -func (mr *MockRelyingPartyMockRecorder) RequestAccessToken(ctx, jwtGrantToken, authServerEndpoint any) *gomock.Call { +// RequestRFC003AccessToken indicates an expected call of RequestRFC003AccessToken. +func (mr *MockRelyingPartyMockRecorder) RequestRFC003AccessToken(ctx, jwtGrantToken, authServerEndpoint any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestAccessToken", reflect.TypeOf((*MockRelyingParty)(nil).RequestAccessToken), ctx, jwtGrantToken, authServerEndpoint) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestRFC003AccessToken", reflect.TypeOf((*MockRelyingParty)(nil).RequestRFC003AccessToken), ctx, jwtGrantToken, authServerEndpoint) +} + +// RequestRFC021AccessToken mocks base method. +func (m *MockRelyingParty) RequestRFC021AccessToken(ctx context.Context, requestHolder, verifier did.DID, scopes string) (*oauth.TokenResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RequestRFC021AccessToken", ctx, requestHolder, verifier, scopes) + ret0, _ := ret[0].(*oauth.TokenResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RequestRFC021AccessToken indicates an expected call of RequestRFC021AccessToken. +func (mr *MockRelyingPartyMockRecorder) RequestRFC021AccessToken(ctx, requestHolder, verifier, scopes any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestRFC021AccessToken", reflect.TypeOf((*MockRelyingParty)(nil).RequestRFC021AccessToken), ctx, requestHolder, verifier, scopes) } // MockAuthorizationServer is a mock of AuthorizationServer interface. @@ -108,11 +125,11 @@ func (mr *MockAuthorizationServerMockRecorder) Configure(clockSkewInMilliseconds } // CreateAccessToken mocks base method. -func (m *MockAuthorizationServer) CreateAccessToken(ctx context.Context, request services.CreateAccessTokenRequest) (*services.AccessTokenResult, *ErrorResponse) { +func (m *MockAuthorizationServer) CreateAccessToken(ctx context.Context, request services.CreateAccessTokenRequest) (*oauth.TokenResponse, *oauth.ErrorResponse) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateAccessToken", ctx, request) - ret0, _ := ret[0].(*services.AccessTokenResult) - ret1, _ := ret[1].(*ErrorResponse) + ret0, _ := ret[0].(*oauth.TokenResponse) + ret1, _ := ret[1].(*oauth.ErrorResponse) return ret0, ret1 } diff --git a/auth/services/oauth/relying_party.go b/auth/services/oauth/relying_party.go index 98d41b204b..6960bf6d21 100644 --- a/auth/services/oauth/relying_party.go +++ b/auth/services/oauth/relying_party.go @@ -21,8 +21,9 @@ package oauth import ( "context" "crypto/tls" + "errors" "fmt" - "github.com/nuts-foundation/nuts-node/vdr/resolver" + "github.com/nuts-foundation/nuts-node/openid4vc" "net/http" "net/url" "strings" @@ -31,11 +32,16 @@ import ( "github.com/lestrrat-go/jwx/v2/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/auth/api/auth/v1/client" + "github.com/nuts-foundation/nuts-node/auth/client/iam" + "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/auth/services" "github.com/nuts-foundation/nuts-node/core" 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" ) var _ RelyingParty = (*relyingParty)(nil) @@ -44,51 +50,27 @@ type relyingParty struct { keyResolver resolver.KeyResolver privateKeyStore nutsCrypto.KeyStore serviceResolver didman.CompoundServiceResolver - secureMode bool + strictMode bool httpClientTimeout time.Duration httpClientTLS *tls.Config + wallet holder.Wallet } // NewRelyingParty returns an implementation of RelyingParty func NewRelyingParty( didResolver resolver.DIDResolver, serviceResolver didman.CompoundServiceResolver, privateKeyStore nutsCrypto.KeyStore, - httpClientTimeout time.Duration, httpClientTLS *tls.Config) RelyingParty { + wallet holder.Wallet, httpClientTimeout time.Duration, httpClientTLS *tls.Config, strictMode bool) RelyingParty { return &relyingParty{ keyResolver: resolver.DIDKeyResolver{Resolver: didResolver}, serviceResolver: serviceResolver, privateKeyStore: privateKeyStore, httpClientTimeout: httpClientTimeout, httpClientTLS: httpClientTLS, + strictMode: strictMode, + wallet: wallet, } } -// Configure the service -func (s *relyingParty) Configure(secureMode bool) { - s.secureMode = secureMode -} - -// RequestAccessToken is called by the local EHR node to request an access token from a remote Nuts node. -func (s *relyingParty) RequestAccessToken(ctx context.Context, jwtGrantToken string, authorizationServerEndpoint url.URL) (*services.AccessTokenResult, error) { - if s.secureMode && strings.ToLower(authorizationServerEndpoint.Scheme) != "https" { - return nil, fmt.Errorf("authorization server endpoint must be HTTPS when in strict mode: %s", authorizationServerEndpoint.String()) - } - httpClient := &http.Client{} - if s.httpClientTLS != nil { - httpClient.Transport = &http.Transport{ - TLSClientConfig: s.httpClientTLS, - } - } - authClient, err := client.NewHTTPClient("", s.httpClientTimeout, client.WithHTTPClient(httpClient), client.WithRequestEditorFn(core.UserAgentRequestEditor)) - if err != nil { - return nil, fmt.Errorf("unable to create HTTP client: %w", err) - } - accessTokenResponse, err := authClient.CreateAccessToken(ctx, authorizationServerEndpoint, jwtGrantToken) - if err != nil { - return nil, fmt.Errorf("remote server/nuts node returned error creating access token: %w", err) - } - return accessTokenResponse, nil -} - // CreateJwtGrant creates a JWT Grant from the given CreateJwtGrantRequest func (s *relyingParty) CreateJwtGrant(ctx context.Context, request services.CreateJwtGrantRequest) (*services.JwtBearerTokenResult, error) { requester, err := did.ParseDID(request.Requester) @@ -128,6 +110,106 @@ func (s *relyingParty) CreateJwtGrant(ctx context.Context, request services.Crea return &services.JwtBearerTokenResult{BearerToken: signingString, AuthorizationServerEndpoint: endpointURL}, nil } +func (s *relyingParty) RequestRFC003AccessToken(ctx context.Context, jwtGrantToken string, authorizationServerEndpoint url.URL) (*oauth.TokenResponse, error) { + if s.strictMode && strings.ToLower(authorizationServerEndpoint.Scheme) != "https" { + return nil, fmt.Errorf("authorization server endpoint must be HTTPS when in strict mode: %s", authorizationServerEndpoint.String()) + } + httpClient := &http.Client{} + if s.httpClientTLS != nil { + httpClient.Transport = &http.Transport{ + TLSClientConfig: s.httpClientTLS, + } + } + authClient, err := client.NewHTTPClient("", s.httpClientTimeout, client.WithHTTPClient(httpClient), client.WithRequestEditorFn(core.UserAgentRequestEditor)) + if err != nil { + return nil, fmt.Errorf("unable to create HTTP client: %w", err) + } + accessTokenResponse, err := authClient.CreateAccessToken(ctx, authorizationServerEndpoint, jwtGrantToken) + if err != nil { + return nil, fmt.Errorf("remote server/nuts node returned error creating access token: %w", err) + } + return accessTokenResponse, nil +} + +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 := iamClient.OAuthAuthorizationServerMetadata(ctx, verifier) + if err != nil { + return nil, fmt.Errorf("failed to retrieve remote OAuth Authorization Server metadata: %w", err) + } + + // get the presentation definition from the verifier + presentationDefinition, err := iamClient.PresentationDefinition(ctx, metadata.PresentationDefinitionEndpoint, scopes) + if err != nil { + return nil, fmt.Errorf("failed to retrieve presentation definition: %w", err) + } + + walletCredentials, err := s.wallet.List(ctx, requester) + if err != nil { + return nil, fmt.Errorf("failed to retrieve wallet credentials: %w", err) + } + + // match against the wallet's credentials + // if there's a match, create a VP and call the token endpoint + // If the token endpoint succeeds, return the access token + // If no presentation definition matches, return a 412 "no matching credentials" error + builder := presentationDefinition.PresentationSubmissionBuilder() + builder.AddWallet(requester, walletCredentials) + format, err := determineFormat(metadata.VPFormats) + if err != nil { + return nil, err + } + submission, signInstructions, err := builder.Build(format) + if err != nil { + return nil, fmt.Errorf("failed to match presentation definition: %w", err) + } + if signInstructions.Empty() { + return nil, core.Error(http.StatusPreconditionFailed, "no matching credentials") + } + expires := time.Now().Add(time.Minute * 15) //todo + nonce := nutsCrypto.GenerateNonce() + // todo: support multiple wallets + vp, err := s.wallet.BuildPresentation(ctx, signInstructions[0].VerifiableCredentials, holder.PresentationOptions{ + Format: format, + ProofOptions: proof.ProofOptions{ + Created: time.Now(), + Challenge: &nonce, + Expires: &expires, + }, + }, &requester, false) + if err != nil { + return nil, fmt.Errorf("failed to create verifiable presentation: %w", err) + } + token, err := iamClient.AccessToken(ctx, metadata.TokenEndpoint, *vp, submission, scopes) + if err != nil { + // the error could be a http error, we just relay it here to make use of any 400 status codes. + return nil, err + } + return &oauth.TokenResponse{ + AccessToken: token.AccessToken, + ExpiresIn: token.ExpiresIn, + TokenType: token.TokenType, + Scope: &scopes, + }, nil +} + +func determineFormat(formats map[string]map[string][]string) (string, error) { + for format := range formats { + switch format { + case openid4vc.VerifiablePresentationJWTFormat: + fallthrough + case openid4vc.VerifiablePresentationJSONLDFormat: + fallthrough + case "jwt_vp_json": + return format, nil + default: + continue + } + } + + return "", errors.New("authorization server metadata does not contain any supported VP formats") +} + var timeFunc = time.Now // standalone func for easier testing diff --git a/auth/services/oauth/relying_party_test.go b/auth/services/oauth/relying_party_test.go index 7cd31b6837..2f0e828400 100644 --- a/auth/services/oauth/relying_party_test.go +++ b/auth/services/oauth/relying_party_test.go @@ -21,11 +21,13 @@ package oauth import ( "context" "crypto/tls" + "encoding/json" "errors" "fmt" "github.com/nuts-foundation/nuts-node/audit" + "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/core" http2 "github.com/nuts-foundation/nuts-node/test/http" - "github.com/nuts-foundation/nuts-node/vdr/resolver" "net/http" "net/http/httptest" "net/url" @@ -38,45 +40,49 @@ import ( "github.com/nuts-foundation/nuts-node/auth/services" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/didman" + vcr "github.com/nuts-foundation/nuts-node/vcr/api/vcr/v2" "github.com/nuts-foundation/nuts-node/vcr/credential" + "github.com/nuts-foundation/nuts-node/vcr/holder" "github.com/nuts-foundation/nuts-node/vdr" + "github.com/nuts-foundation/nuts-node/vdr/didweb" + "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" ) -func TestRelyingParty_RequestAccessToken(t *testing.T) { +func TestRelyingParty_RequestRFC003AccessToken(t *testing.T) { const bearerToken = "jwt-bearer-token" t.Run("ok", func(t *testing.T) { - ctx := createRPContext(t) + ctx := createRPContext(t, nil) httpHandler := &http2.Handler{ StatusCode: http.StatusOK, } httpServer := httptest.NewServer(httpHandler) t.Cleanup(httpServer.Close) - response, err := ctx.relyingParty.RequestAccessToken(context.Background(), bearerToken, mustParseURL(httpServer.URL)) + response, err := ctx.relyingParty.RequestRFC003AccessToken(context.Background(), bearerToken, mustParseURL(httpServer.URL)) assert.NoError(t, err) assert.NotNil(t, response) assert.Equal(t, "nuts-node-refimpl/unknown", httpHandler.RequestHeaders.Get("User-Agent")) }) t.Run("returns error when HTTP create access token fails", func(t *testing.T) { - ctx := createRPContext(t) + ctx := createRPContext(t, nil) server := httptest.NewServer(&http2.Handler{ StatusCode: http.StatusBadGateway, }) t.Cleanup(server.Close) - response, err := ctx.relyingParty.RequestAccessToken(context.Background(), bearerToken, mustParseURL(server.URL)) + response, err := ctx.relyingParty.RequestRFC003AccessToken(context.Background(), bearerToken, 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)") }) t.Run("endpoint security validation (only HTTPS in strict mode)", func(t *testing.T) { - ctx := createRPContext(t) + ctx := createRPContext(t, nil) httpServer := httptest.NewServer(&http2.Handler{ StatusCode: http.StatusOK, }) @@ -87,25 +93,25 @@ func TestRelyingParty_RequestAccessToken(t *testing.T) { t.Cleanup(httpsServer.Close) t.Run("HTTPS in strict mode", func(t *testing.T) { - ctx.relyingParty.secureMode = true + ctx.relyingParty.strictMode = true - response, err := ctx.relyingParty.RequestAccessToken(context.Background(), bearerToken, mustParseURL(httpsServer.URL)) + response, err := ctx.relyingParty.RequestRFC003AccessToken(context.Background(), bearerToken, mustParseURL(httpsServer.URL)) assert.NoError(t, err) assert.NotNil(t, response) }) t.Run("HTTP allowed in non-strict mode", func(t *testing.T) { - ctx.relyingParty.secureMode = false + ctx.relyingParty.strictMode = false - response, err := ctx.relyingParty.RequestAccessToken(context.Background(), bearerToken, mustParseURL(httpServer.URL)) + response, err := ctx.relyingParty.RequestRFC003AccessToken(context.Background(), bearerToken, mustParseURL(httpServer.URL)) assert.NoError(t, err) assert.NotNil(t, response) }) t.Run("HTTP not allowed in strict mode", func(t *testing.T) { - ctx.relyingParty.secureMode = true + ctx.relyingParty.strictMode = true - response, err := ctx.relyingParty.RequestAccessToken(context.Background(), bearerToken, mustParseURL(httpServer.URL)) + response, err := ctx.relyingParty.RequestRFC003AccessToken(context.Background(), bearerToken, 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) @@ -113,6 +119,99 @@ func TestRelyingParty_RequestAccessToken(t *testing.T) { }) } +func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { + walletDID := did.MustParseDID("did:test:123") + scopes := "first second" + credentials := []vcr.VerifiableCredential{credential.ValidNutsOrganizationCredential(t)} + + t.Run("ok", func(t *testing.T) { + ctx := createOAuthRPContext(t) + ctx.wallet.EXPECT().List(gomock.Any(), walletDID).Return(credentials, nil) + ctx.wallet.EXPECT().BuildPresentation(gomock.Any(), credentials, gomock.Any(), &walletDID, false).Return(&vc.VerifiablePresentation{}, nil).Return(&vc.VerifiablePresentation{}, nil) + + response, err := ctx.relyingParty.RequestRFC021AccessToken(context.Background(), walletDID, ctx.verifierDID, scopes) + + assert.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, "token", response.AccessToken) + assert.Equal(t, "bearer", response.TokenType) + }) + t.Run("error - access denied", func(t *testing.T) { + oauthError := oauth.OAuth2Error{ + Code: "invalid_scope", + Description: "the scope you requested is unknown", + } + oauthErrorBytes, _ := json.Marshal(oauthError) + ctx := createOAuthRPContext(t) + ctx.token = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusBadRequest) + _, _ = writer.Write(oauthErrorBytes) + } + ctx.wallet.EXPECT().List(gomock.Any(), walletDID).Return(credentials, nil) + ctx.wallet.EXPECT().BuildPresentation(gomock.Any(), credentials, gomock.Any(), &walletDID, false).Return(&vc.VerifiablePresentation{}, nil).Return(&vc.VerifiablePresentation{}, nil) + + _, err := ctx.relyingParty.RequestRFC021AccessToken(context.Background(), walletDID, ctx.verifierDID, scopes) + + require.Error(t, err) + httpError, ok := err.(core.HttpError) + require.True(t, ok) + assert.Equal(t, http.StatusBadRequest, httpError.StatusCode) + assert.Equal(t, oauthErrorBytes, httpError.ResponseBody) + }) + t.Run("error - no matching credentials", func(t *testing.T) { + ctx := createOAuthRPContext(t) + ctx.wallet.EXPECT().List(gomock.Any(), walletDID).Return([]vcr.VerifiableCredential{}, nil) + + _, err := ctx.relyingParty.RequestRFC021AccessToken(context.Background(), walletDID, ctx.verifierDID, scopes) + + assert.Error(t, err) + // the error should be a 412 precondition failed + assert.EqualError(t, err, "no matching credentials") + }) + t.Run("error - failed to get presentation definition", func(t *testing.T) { + ctx := createOAuthRPContext(t) + ctx.presentationDefinition = nil + + _, err := ctx.relyingParty.RequestRFC021AccessToken(context.Background(), walletDID, ctx.verifierDID, scopes) + + assert.Error(t, err) + assert.EqualError(t, err, "failed to retrieve presentation definition: server returned HTTP 404 (expected: 200)") + }) + t.Run("error - failed to get authorization server metadata", func(t *testing.T) { + ctx := createOAuthRPContext(t) + ctx.metadata = nil + + _, err := ctx.relyingParty.RequestRFC021AccessToken(context.Background(), walletDID, ctx.verifierDID, scopes) + + assert.Error(t, err) + assert.EqualError(t, err, "failed to retrieve remote OAuth Authorization Server metadata: server returned HTTP 404 (expected: 200)") + }) + t.Run("error - faulty presentation definition", func(t *testing.T) { + ctx := createOAuthRPContext(t) + ctx.presentationDefinition = func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write([]byte("{")) + } + + _, err := ctx.relyingParty.RequestRFC021AccessToken(context.Background(), walletDID, ctx.verifierDID, scopes) + + assert.Error(t, err) + assert.EqualError(t, err, "failed to retrieve presentation definition: unable to unmarshal response: unexpected end of JSON input") + }) + t.Run("error - failed to build vp", func(t *testing.T) { + ctx := createOAuthRPContext(t) + ctx.wallet.EXPECT().List(gomock.Any(), walletDID).Return(credentials, nil) + ctx.wallet.EXPECT().BuildPresentation(gomock.Any(), credentials, gomock.Any(), &walletDID, false).Return(&vc.VerifiablePresentation{}, nil).Return(nil, errors.New("error")) + + _, err := ctx.relyingParty.RequestRFC021AccessToken(context.Background(), walletDID, ctx.verifierDID, scopes) + + assert.Error(t, err) + assert.EqualError(t, err, "failed to create verifiable presentation: error") + }) +} + func TestService_CreateJwtBearerToken(t *testing.T) { usi := vc.VerifiablePresentation{} @@ -146,7 +245,7 @@ func TestService_CreateJwtBearerToken(t *testing.T) { } t.Run("create a JwtBearerToken", func(t *testing.T) { - ctx := createRPContext(t) + ctx := createRPContext(t, nil) ctx.didResolver.EXPECT().Resolve(authorizerDID, gomock.Any()).Return(authorizerDIDDocument, nil, nil).AnyTimes() ctx.serviceResolver.EXPECT().GetCompoundServiceEndpoint(authorizerDID, expectedService, services.OAuthEndpointType, true).Return(expectedAudience, nil) @@ -163,7 +262,7 @@ func TestService_CreateJwtBearerToken(t *testing.T) { }) t.Run("create a JwtBearerToken with valid credentials", func(t *testing.T) { - ctx := createRPContext(t) + ctx := createRPContext(t, nil) ctx.didResolver.EXPECT().Resolve(authorizerDID, gomock.Any()).Return(authorizerDIDDocument, nil, nil).AnyTimes() ctx.serviceResolver.EXPECT().GetCompoundServiceEndpoint(authorizerDID, expectedService, services.OAuthEndpointType, true).Return(expectedAudience, nil) @@ -180,7 +279,7 @@ func TestService_CreateJwtBearerToken(t *testing.T) { }) t.Run("create a JwtBearerToken with invalid credentials fails", func(t *testing.T) { - ctx := createRPContext(t) + ctx := createRPContext(t, nil) invalidCredential := validCredential invalidCredential.Type = []ssi.URI{} @@ -197,7 +296,7 @@ func TestService_CreateJwtBearerToken(t *testing.T) { }) t.Run("authorizer without endpoint", func(t *testing.T) { - ctx := createRPContext(t) + ctx := createRPContext(t, nil) document := getAuthorizerDIDDocument() document.Service = []did.Service{} @@ -210,7 +309,7 @@ func TestService_CreateJwtBearerToken(t *testing.T) { }) t.Run("request without authorizer", func(t *testing.T) { - ctx := createRPContext(t) + ctx := createRPContext(t, nil) request := services.CreateJwtGrantRequest{ Requester: requesterDID.String(), @@ -224,7 +323,7 @@ func TestService_CreateJwtBearerToken(t *testing.T) { }) t.Run("signing error", func(t *testing.T) { - ctx := createRPContext(t) + ctx := createRPContext(t, nil) ctx.didResolver.EXPECT().Resolve(authorizerDID, gomock.Any()).Return(authorizerDIDDocument, nil, nil).AnyTimes() ctx.serviceResolver.EXPECT().GetCompoundServiceEndpoint(authorizerDID, expectedService, services.OAuthEndpointType, true).Return(expectedAudience, nil) @@ -238,16 +337,6 @@ func TestService_CreateJwtBearerToken(t *testing.T) { }) } -func TestRelyingParty_Configure(t *testing.T) { - t.Run("ok - config valid", func(t *testing.T) { - ctx := createRPContext(t) - - ctx.relyingParty.Configure(true) - - assert.True(t, ctx.relyingParty.secureMode) - }) -} - type rpTestContext struct { ctrl *gomock.Controller keyStore *crypto.MockKeyStore @@ -256,32 +345,128 @@ type rpTestContext struct { serviceResolver *didman.MockCompoundServiceResolver relyingParty *relyingParty audit context.Context + wallet *holder.MockWallet } -var createRPContext = func(t *testing.T) *rpTestContext { +func createRPContext(t *testing.T, tlsConfig *tls.Config) *rpTestContext { ctrl := gomock.NewController(t) privateKeyStore := crypto.NewMockKeyStore(ctrl) keyResolver := resolver.NewMockKeyResolver(ctrl) serviceResolver := didman.NewMockCompoundServiceResolver(ctrl) didResolver := resolver.NewMockDIDResolver(ctrl) + wallet := holder.NewMockWallet(ctrl) + + if tlsConfig == nil { + tlsConfig = &tls.Config{} + } + tlsConfig.InsecureSkipVerify = true return &rpTestContext{ - ctrl: ctrl, - keyStore: privateKeyStore, - keyResolver: keyResolver, - serviceResolver: serviceResolver, - didResolver: didResolver, + audit: audit.TestContext(), + ctrl: ctrl, + didResolver: didResolver, + keyStore: privateKeyStore, + keyResolver: keyResolver, relyingParty: &relyingParty{ + httpClientTLS: tlsConfig, keyResolver: keyResolver, privateKeyStore: privateKeyStore, serviceResolver: serviceResolver, - httpClientTLS: &tls.Config{ - InsecureSkipVerify: true, - }, + wallet: wallet, + }, + serviceResolver: serviceResolver, + wallet: wallet, + } +} + +type rpOAuthTestContext struct { + *rpTestContext + authzServerMetadata oauth.AuthorizationServerMetadata + handler http.HandlerFunc + tlsServer *httptest.Server + verifierDID did.DID + metadata func(writer http.ResponseWriter) + presentationDefinition func(writer http.ResponseWriter) + token func(writer http.ResponseWriter) +} + +func createOAuthRPContext(t *testing.T) *rpOAuthTestContext { + presentationDefinition := ` +{ + "input_descriptors": [ + { + "name": "Pick 1", + "group": ["A"], + "constraints": { + "fields": [ + { + "path": [ + "$.type" + ], + "filter": { + "type": "string", + "const": "NutsOrganizationCredential" + } + } + ] + } + } + ] +} +` + formats := make(map[string]map[string][]string) + formats["jwt_vp"] = make(map[string][]string) + authzServerMetadata := oauth.AuthorizationServerMetadata{VPFormats: formats} + ctx := &rpOAuthTestContext{ + rpTestContext: createRPContext(t, nil), + metadata: func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + bytes, _ := json.Marshal(authzServerMetadata) + _, _ = writer.Write(bytes) + return + }, + presentationDefinition: func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write([]byte(presentationDefinition)) + return + }, + token: func(writer http.ResponseWriter) { + writer.Header().Add("Content-Type", "application/json") + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write([]byte(`{"access_token": "token", "token_type": "bearer"}`)) + return }, - audit: audit.TestContext(), } + ctx.handler = func(writer http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/.well-known/oauth-authorization-server": + if ctx.metadata != nil { + ctx.metadata(writer) + return + } + case "/presentation_definition": + if ctx.presentationDefinition != nil { + ctx.presentationDefinition(writer) + return + } + case "/token": + if ctx.token != nil { + ctx.token(writer) + return + } + } + writer.WriteHeader(http.StatusNotFound) + } + ctx.tlsServer = http2.TestTLSServer(t, ctx.handler) + ctx.verifierDID = didweb.ServerURLToDIDWeb(t, ctx.tlsServer.URL) + authzServerMetadata.TokenEndpoint = ctx.tlsServer.URL + "/token" + authzServerMetadata.PresentationDefinitionEndpoint = ctx.tlsServer.URL + "/presentation_definition" + ctx.authzServerMetadata = authzServerMetadata + + return ctx } func mustParseURL(str string) url.URL { diff --git a/codegen/configs/auth_client_v1.yaml b/codegen/configs/auth_client_v1.yaml index 7280dc1594..7629aef716 100644 --- a/codegen/configs/auth_client_v1.yaml +++ b/codegen/configs/auth_client_v1.yaml @@ -8,3 +8,4 @@ output-options: - VerifiableCredential - VerifiablePresentation - AccessTokenResponse + - AccessTokenRequestFailedResponse diff --git a/codegen/configs/auth_iam.yaml b/codegen/configs/auth_iam.yaml index 0766286caa..86fc29a911 100644 --- a/codegen/configs/auth_iam.yaml +++ b/codegen/configs/auth_iam.yaml @@ -9,5 +9,5 @@ output-options: - DIDDocument - OAuthAuthorizationServerMetadata - OAuthClientMetadata - - ErrorResponse - PresentationDefinition + - TokenResponse diff --git a/codegen/configs/auth_v1.yaml b/codegen/configs/auth_v1.yaml index 23b92666fc..25ee1db8ae 100644 --- a/codegen/configs/auth_v1.yaml +++ b/codegen/configs/auth_v1.yaml @@ -10,3 +10,4 @@ output-options: - VerifiableCredential - VerifiablePresentation - AccessTokenResponse + - AccessTokenRequestFailedResponse diff --git a/core/http_client.go b/core/http_client.go index 3787771653..a89a093552 100644 --- a/core/http_client.go +++ b/core/http_client.go @@ -21,10 +21,12 @@ package core import ( "context" + "crypto/tls" "errors" "fmt" "io" "net/http" + "time" ) // HttpError describes an error returned when invoking a remote server. @@ -140,20 +142,38 @@ func newEmptyTokenGenerator() AuthorizationTokenGenerator { } // NewStrictHTTPClient creates a HTTPRequestDoer that only allows HTTPS calls when strictmode is enabled. -func NewStrictHTTPClient(strictmode bool, client *http.Client) HTTPRequestDoer { - return &strictHTTPClient{ - client: client, - strictmode: strictmode, +func NewStrictHTTPClient(strictmode bool, timeout time.Duration, tlsConfig *tls.Config) *StrictHTTPClient { + if tlsConfig == nil { + tlsConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + } + } + + transport := http.DefaultTransport + // Might not be http.Transport in testing + if httpTransport, ok := transport.(*http.Transport); ok { + // cloning the transport might reduce performance. + httpTransport = httpTransport.Clone() + httpTransport.TLSClientConfig = tlsConfig + transport = httpTransport + } + + return &StrictHTTPClient{ + client: &http.Client{ + Transport: transport, + Timeout: timeout, + }, + strictMode: strictmode, } } -type strictHTTPClient struct { +type StrictHTTPClient struct { client *http.Client - strictmode bool + strictMode bool } -func (s *strictHTTPClient) Do(req *http.Request) (*http.Response, error) { - if s.strictmode && req.URL.Scheme != "https" { +func (s *StrictHTTPClient) Do(req *http.Request) (*http.Response, error) { + if s.strictMode && req.URL.Scheme != "https" { return nil, errors.New("strictmode is enabled, but request is not over HTTPS") } return s.client.Do(req) diff --git a/core/http_client_test.go b/core/http_client_test.go index 4dc5dae438..8bf0c77929 100644 --- a/core/http_client_test.go +++ b/core/http_client_test.go @@ -27,6 +27,7 @@ import ( stdHttp "net/http" "net/http/httptest" "testing" + "time" ) func TestHTTPClient(t *testing.T) { @@ -89,7 +90,7 @@ func TestHTTPClient(t *testing.T) { func TestStrictHTTPClient_Do(t *testing.T) { t.Run("error on HTTP call when strictmode is enabled", func(t *testing.T) { - client := NewStrictHTTPClient(true, &stdHttp.Client{}) + client := NewStrictHTTPClient(true, time.Second, nil) httpRequest, _ := stdHttp.NewRequest("GET", "http://example.com", nil) _, err := client.Do(httpRequest) diff --git a/crypto/random.go b/crypto/random.go new file mode 100644 index 0000000000..0c79728be4 --- /dev/null +++ b/crypto/random.go @@ -0,0 +1,34 @@ +/* + * 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 crypto + +import ( + "crypto/rand" + "encoding/base64" +) + +// GenerateNonce creates a 128 bit secure random +func GenerateNonce() string { + buf := make([]byte, 128/8) + _, err := rand.Read(buf) + if err != nil { + panic(err) + } + return base64.RawURLEncoding.EncodeToString(buf) +} diff --git a/auth/types.go b/crypto/random_test.go similarity index 69% rename from auth/types.go rename to crypto/random_test.go index ae212a0d56..91e7afcb27 100644 --- a/auth/types.go +++ b/crypto/random_test.go @@ -1,5 +1,6 @@ /* - * Copyright (C) 2021 Nuts community + * 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 @@ -13,10 +14,20 @@ * * You should have received a copy of the GNU General Public License * along with this program. If not, see . - * */ -package auth +package crypto + +import ( + "encoding/base64" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRandom(t *testing.T) { + nonce := GenerateNonce() + decoded, _ := base64.RawURLEncoding.DecodeString(nonce) -// ModuleName contains the name of this module -const ModuleName = "Auth" + assert.Len(t, decoded, 16) +} diff --git a/docs/_static/auth/iam.yaml b/docs/_static/auth/iam.yaml index 59a5ba762f..977f7303e6 100644 --- a/docs/_static/auth/iam.yaml +++ b/docs/_static/auth/iam.yaml @@ -69,20 +69,8 @@ paths: application/json: schema: "$ref": "#/components/schemas/TokenResponse" - "404": - description: Unknown issuer - content: - application/json: - schema: - "$ref": "#/components/schemas/ErrorResponse" - "400": - description: > - Invalid request. Code can be "invalid_request", "invalid_client", "invalid_grant", "unauthorized_client", "unsupported_grant_type" or "invalid_scope". - Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-token-error-response - content: - application/json: - schema: - "$ref": "#/components/schemas/ErrorResponse" + "default": + $ref: '../common/error_response.yaml' "/iam/{id}/authorize": get: summary: Used by resource owners to initiate the authorization code flow. @@ -156,14 +144,8 @@ paths: application/json: schema: "$ref": "#/components/schemas/PresentationDefinition" - "400": - description: invalid scope - content: - application/json: - schema: - "$ref": "#/components/schemas/ErrorResponse" - "404": - description: Unknown DID + "default": + $ref: '../common/error_response.yaml' # TODO: What format to use? (codegenerator breaks on aliases) # See issue https://github.com/nuts-foundation/nuts-node/issues/2365 # create aliases for the specced path @@ -292,6 +274,7 @@ paths: error returns: * 400 - one of the parameters has the wrong format + * 412 - the organization wallet does not contain the correct credentials * 503 - the authorizer could not be reached or returned an error tags: - auth @@ -377,13 +360,3 @@ components: description: | A presentation definition is a JSON object that describes the desired verifiable credentials and presentation formats. Specified at https://identity.foundation/presentation-exchange/spec/v2.0.0/ - type: object - ErrorResponse: - type: object - required: - - error - properties: - error: - type: string - description: Code identifying the error that occurred. - example: "invalid_request" diff --git a/openid4vc/types.go b/openid4vc/types.go new file mode 100644 index 0000000000..b75973668e --- /dev/null +++ b/openid4vc/types.go @@ -0,0 +1,29 @@ +/* + * Nuts node + * Copyright (C) 2021 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 . + */ + +// openid4vc contains common constants and logic for OpenID4VCI, SiopV2 and OpenID4VP +package openid4vc + +// VerifiableCredentialJSONLDFormat defines the JSON-LD format identifier for Verifiable Credentials. +const VerifiableCredentialJSONLDFormat = "ldp_vc" + +// VerifiablePresentationJSONLDFormat defines the JSON-LD format identifier for Verifiable Presentations. +const VerifiablePresentationJSONLDFormat = "ldp_vp" + +// VerifiablePresentationJWTFormat defines the JWT format identifier for Verifiable Presentations. +const VerifiablePresentationJWTFormat = "jwt_vp" diff --git a/vcr/ambassador_test.go b/vcr/ambassador_test.go index 11f25f5fb6..aedb6b979b 100644 --- a/vcr/ambassador_test.go +++ b/vcr/ambassador_test.go @@ -107,9 +107,7 @@ func TestAmbassador_handleReprocessEvent(t *testing.T) { ctx.vcr.ambassador.(*ambassador).writer = mockWriter // load VC - vc := vc.VerifiableCredential{} - vcJSON, _ := os.ReadFile("test/vc.json") - json.Unmarshal(vcJSON, &vc) + vc := credential.ValidNutsOrganizationCredential(t) // load key pem, _ := os.ReadFile("test/private.pem") diff --git a/vcr/api/openid4vci/v0/api.go b/vcr/api/openid4vci/v0/api.go index 432a5e4c7b..209ebb467d 100644 --- a/vcr/api/openid4vci/v0/api.go +++ b/vcr/api/openid4vci/v0/api.go @@ -24,6 +24,7 @@ import ( "github.com/labstack/echo/v4" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/audit" + "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/log" @@ -39,7 +40,7 @@ type ProviderMetadata = openid4vci.ProviderMetadata type CredentialIssuerMetadata = openid4vci.CredentialIssuerMetadata // TokenResponse is the response of the OpenID Connect token endpoint -type TokenResponse = openid4vci.TokenResponse +type TokenResponse = oauth.TokenResponse // CredentialOfferResponse is the response to the OpenID4VCI credential offer type CredentialOfferResponse = openid4vci.CredentialOfferResponse diff --git a/vcr/api/openid4vci/v0/holder_test.go b/vcr/api/openid4vci/v0/holder_test.go index 622d9e044c..80b74fd29f 100644 --- a/vcr/api/openid4vci/v0/holder_test.go +++ b/vcr/api/openid4vci/v0/holder_test.go @@ -23,6 +23,7 @@ import ( "encoding/json" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/openid4vc" "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/holder" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" @@ -84,7 +85,7 @@ func TestWrapper_HandleCredentialOffer(t *testing.T) { CredentialIssuer: issuerDID.String(), Credentials: []openid4vci.OfferedCredential{ { - Format: openid4vci.VerifiableCredentialJSONLDFormat, + Format: openid4vc.VerifiableCredentialJSONLDFormat, CredentialDefinition: &openid4vci.CredentialDefinition{ Context: []ssi.URI{ssi.MustParseURI("a"), ssi.MustParseURI("b")}, Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("HumanCredential")}, diff --git a/vcr/api/openid4vci/v0/issuer.go b/vcr/api/openid4vci/v0/issuer.go index 292b9eda05..77e3c24731 100644 --- a/vcr/api/openid4vci/v0/issuer.go +++ b/vcr/api/openid4vci/v0/issuer.go @@ -23,6 +23,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/nuts-foundation/nuts-node/openid4vc" "github.com/nuts-foundation/nuts-node/vcr/issuer" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "net/http" @@ -107,7 +108,7 @@ func (w Wrapper) RequestCredential(ctx context.Context, request RequestCredentia } return RequestCredential200JSONResponse(CredentialResponse{ Credential: &credentialMap, - Format: openid4vci.VerifiableCredentialJSONLDFormat, + Format: openid4vc.VerifiableCredentialJSONLDFormat, }), nil } @@ -129,10 +130,11 @@ func (w Wrapper) RequestAccessToken(ctx context.Context, request RequestAccessTo if err != nil { return nil, err } + expiresIn := int(issuer.TokenTTL.Seconds()) return RequestAccessToken200JSONResponse(TokenResponse{ AccessToken: accessToken, - CNonce: cNonce, - ExpiresIn: int(issuer.TokenTTL.Seconds()), + CNonce: &cNonce, + ExpiresIn: &expiresIn, TokenType: "bearer", }), nil } diff --git a/vcr/api/openid4vci/v0/issuer_test.go b/vcr/api/openid4vci/v0/issuer_test.go index 1758789dbd..13fa3b2f04 100644 --- a/vcr/api/openid4vci/v0/issuer_test.go +++ b/vcr/api/openid4vci/v0/issuer_test.go @@ -116,7 +116,7 @@ func TestWrapper_RequestAccessToken(t *testing.T) { require.NoError(t, err) assert.Equal(t, "access-token", response.(RequestAccessToken200JSONResponse).AccessToken) - assert.Equal(t, "c_nonce", response.(RequestAccessToken200JSONResponse).CNonce) + assert.Equal(t, "c_nonce", *response.(RequestAccessToken200JSONResponse).CNonce) }) t.Run("unknown tenant", func(t *testing.T) { ctrl := gomock.NewController(t) diff --git a/vcr/test/vc.json b/vcr/assets/test_assets/vc.json similarity index 100% rename from vcr/test/vc.json rename to vcr/assets/test_assets/vc.json diff --git a/vcr/context_test.go b/vcr/context_test.go index b29d06d096..332bc01271 100644 --- a/vcr/context_test.go +++ b/vcr/context_test.go @@ -22,11 +22,11 @@ package vcr import ( "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/jsonld" + "github.com/nuts-foundation/nuts-node/vcr/assets" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/piprate/json-gold/ld" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "os" "testing" ) @@ -37,7 +37,7 @@ func TestNutsV1Context(t *testing.T) { reader := jsonld.Reader{DocumentLoader: jsonldManager.DocumentLoader()} t.Run("NutsOrganizationCredential", func(t *testing.T) { - vcJSON, _ := os.ReadFile("test/vc.json") + vcJSON, _ := assets.TestAssets.ReadFile("test_assets/vc.json") documents, err := reader.ReadBytes(vcJSON) if err != nil { panic(err) diff --git a/vcr/credential/resolver_test.go b/vcr/credential/resolver_test.go index 48dc7074d5..bec1b0ccb2 100644 --- a/vcr/credential/resolver_test.go +++ b/vcr/credential/resolver_test.go @@ -35,8 +35,8 @@ func TestFindValidator(t *testing.T) { }) t.Run("validator and builder found for NutsOrganizationCredential", func(t *testing.T) { - vc := validNutsOrganizationCredential() - v := FindValidator(*vc) + vc := ValidNutsOrganizationCredential(t) + v := FindValidator(vc) assert.NotNil(t, v) }) diff --git a/vcr/credential/revocation_test.go b/vcr/credential/revocation_test.go index b38ca71515..7a595e2ff0 100644 --- a/vcr/credential/revocation_test.go +++ b/vcr/credential/revocation_test.go @@ -26,14 +26,11 @@ import ( "time" ssi "github.com/nuts-foundation/go-did" - "github.com/nuts-foundation/go-did/vc" "github.com/stretchr/testify/assert" ) func TestBuildRevocation(t *testing.T) { - target := vc.VerifiableCredential{} - vcData, _ := os.ReadFile("../test/vc.json") - json.Unmarshal(vcData, &target) + target := ValidNutsOrganizationCredential(t) at := time.Now() nowFunc = func() time.Time { diff --git a/vcr/credential/test.go b/vcr/credential/test.go index c6e0e6e2a0..363567b84e 100644 --- a/vcr/credential/test.go +++ b/vcr/credential/test.go @@ -21,7 +21,8 @@ package credential import ( "encoding/json" - "os" + "github.com/nuts-foundation/nuts-node/vcr/assets" + "testing" "time" ssi "github.com/nuts-foundation/go-did" @@ -54,11 +55,17 @@ func ValidNutsAuthorizationCredential() *vc.VerifiableCredential { } } -func validNutsOrganizationCredential() *vc.VerifiableCredential { +func ValidNutsOrganizationCredential(t *testing.T) vc.VerifiableCredential { inputVC := vc.VerifiableCredential{} - vcJSON, _ := os.ReadFile("../test/vc.json") - _ = json.Unmarshal(vcJSON, &inputVC) - return &inputVC + vcJSON, err := assets.TestAssets.ReadFile("test_assets/vc.json") + if err != nil { + t.Fatal(err) + } + err = json.Unmarshal(vcJSON, &inputVC) + if err != nil { + t.Fatal(err) + } + return inputVC } func stringToURI(input string) ssi.URI { diff --git a/vcr/credential/validator_test.go b/vcr/credential/validator_test.go index 76093f1c04..5b4002b523 100644 --- a/vcr/credential/validator_test.go +++ b/vcr/credential/validator_test.go @@ -38,44 +38,44 @@ func TestNutsOrganizationCredentialValidator_Validate(t *testing.T) { validator := nutsOrganizationCredentialValidator{} t.Run("ok", func(t *testing.T) { - v := validNutsOrganizationCredential() + v := ValidNutsOrganizationCredential(t) - err := validator.Validate(*v) + err := validator.Validate(v) assert.NoError(t, err) }) t.Run("failed - missing custom type", func(t *testing.T) { - v := validNutsOrganizationCredential() + v := ValidNutsOrganizationCredential(t) v.Type = []ssi.URI{vc.VerifiableCredentialTypeV1URI()} - err := validator.Validate(*v) + err := validator.Validate(v) assert.EqualError(t, err, "validation failed: type 'NutsOrganizationCredential' is required") }) t.Run("failed - missing credential subject", func(t *testing.T) { - v := validNutsOrganizationCredential() + v := ValidNutsOrganizationCredential(t) v.CredentialSubject = []interface{}{} - err := validator.Validate(*v) + err := validator.Validate(v) assert.EqualError(t, err, "validation failed: single CredentialSubject expected") }) t.Run("failed - missing organization", func(t *testing.T) { - v := validNutsOrganizationCredential() + v := ValidNutsOrganizationCredential(t) var credentialSubject = make(map[string]interface{}) credentialSubject["id"] = vdr.TestDIDB.String() v.CredentialSubject = []interface{}{credentialSubject} - err := validator.Validate(*v) + err := validator.Validate(v) assert.EqualError(t, err, "validation failed: 'credentialSubject.organization' is empty") }) t.Run("failed - missing organization name", func(t *testing.T) { - v := validNutsOrganizationCredential() + v := ValidNutsOrganizationCredential(t) var credentialSubject = make(map[string]interface{}) credentialSubject["id"] = vdr.TestDIDB.String() credentialSubject["organization"] = map[string]interface{}{ @@ -83,13 +83,13 @@ func TestNutsOrganizationCredentialValidator_Validate(t *testing.T) { } v.CredentialSubject = []interface{}{credentialSubject} - err := validator.Validate(*v) + err := validator.Validate(v) assert.EqualError(t, err, "validation failed: 'credentialSubject.name' is empty") }) t.Run("failed - missing organization city", func(t *testing.T) { - v := validNutsOrganizationCredential() + v := ValidNutsOrganizationCredential(t) var credentialSubject = make(map[string]interface{}) credentialSubject["id"] = vdr.TestDIDB.String() credentialSubject["organization"] = map[string]interface{}{ @@ -97,13 +97,13 @@ func TestNutsOrganizationCredentialValidator_Validate(t *testing.T) { } v.CredentialSubject = []interface{}{credentialSubject} - err := validator.Validate(*v) + err := validator.Validate(v) assert.EqualError(t, err, "validation failed: 'credentialSubject.city' is empty") }) t.Run("failed - empty organization city", func(t *testing.T) { - v := validNutsOrganizationCredential() + v := ValidNutsOrganizationCredential(t) var credentialSubject = make(map[string]interface{}) credentialSubject["id"] = vdr.TestDIDB.String() credentialSubject["organization"] = map[string]interface{}{ @@ -112,13 +112,13 @@ func TestNutsOrganizationCredentialValidator_Validate(t *testing.T) { } v.CredentialSubject = []interface{}{credentialSubject} - err := validator.Validate(*v) + err := validator.Validate(v) assert.EqualError(t, err, "validation failed: 'credentialSubject.city' is empty") }) t.Run("failed - empty organization name", func(t *testing.T) { - v := validNutsOrganizationCredential() + v := ValidNutsOrganizationCredential(t) var credentialSubject = make(map[string]interface{}) credentialSubject["id"] = vdr.TestDIDB.String() credentialSubject["organization"] = map[string]interface{}{ @@ -127,13 +127,13 @@ func TestNutsOrganizationCredentialValidator_Validate(t *testing.T) { } v.CredentialSubject = []interface{}{credentialSubject} - err := validator.Validate(*v) + err := validator.Validate(v) assert.EqualError(t, err, "validation failed: 'credentialSubject.name' is empty") }) t.Run("failed - missing credentialSubject.ID", func(t *testing.T) { - v := validNutsOrganizationCredential() + v := ValidNutsOrganizationCredential(t) var credentialSubject = make(map[string]interface{}) credentialSubject["organization"] = map[string]interface{}{ "name": "Because we care B.V.", @@ -141,13 +141,13 @@ func TestNutsOrganizationCredentialValidator_Validate(t *testing.T) { } v.CredentialSubject = []interface{}{credentialSubject} - err := validator.Validate(*v) + err := validator.Validate(v) assert.EqualError(t, err, "validation failed: 'credentialSubject.ID' is nil") }) t.Run("failed - invalid credentialSubject.ID", func(t *testing.T) { - v := validNutsOrganizationCredential() + v := ValidNutsOrganizationCredential(t) var credentialSubject = make(map[string]interface{}) credentialSubject["id"] = "invalid" credentialSubject["organization"] = map[string]interface{}{ @@ -156,27 +156,27 @@ func TestNutsOrganizationCredentialValidator_Validate(t *testing.T) { } v.CredentialSubject = []interface{}{credentialSubject} - err := validator.Validate(*v) + err := validator.Validate(v) assert.EqualError(t, err, "validation failed: invalid 'credentialSubject.id': invalid DID") }) t.Run("failed - invalid ID", func(t *testing.T) { - v := validNutsOrganizationCredential() + v := ValidNutsOrganizationCredential(t) otherID := vdr.TestDIDB.URI() v.ID = &otherID - err := validator.Validate(*v) + err := validator.Validate(v) assert.Error(t, err) assert.EqualError(t, err, "validation failed: credential ID must start with issuer") }) t.Run("failed - missing nuts context", func(t *testing.T) { - v := validNutsOrganizationCredential() + v := ValidNutsOrganizationCredential(t) v.Context = []ssi.URI{stringToURI("https://www.w3.org/2018/credentials/v1")} - err := validator.Validate(*v) + err := validator.Validate(v) assert.EqualError(t, err, "validation failed: context 'https://nuts.nl/credentials/v1' is required") }) @@ -373,7 +373,7 @@ func TestNutsAuthorizationCredentialValidator_Validate(t *testing.T) { func TestAllFieldsDefinedValidator(t *testing.T) { validator := AllFieldsDefinedValidator{jsonld.NewTestJSONLDManager(t).DocumentLoader()} t.Run("ok", func(t *testing.T) { - inputVC := *validNutsOrganizationCredential() + inputVC := ValidNutsOrganizationCredential(t) err := validator.Validate(inputVC) @@ -387,7 +387,7 @@ func TestAllFieldsDefinedValidator(t *testing.T) { "city": "EIbergen", } - inputVC := *validNutsOrganizationCredential() + inputVC := ValidNutsOrganizationCredential(t) inputVC.CredentialSubject[0] = invalidCredentialSubject err := validator.Validate(inputVC) @@ -400,7 +400,7 @@ func TestDefaultCredentialValidator(t *testing.T) { validator := defaultCredentialValidator{} t.Run("ok - NutsOrganizationCredential", func(t *testing.T) { - err := validator.Validate(*validNutsOrganizationCredential()) + err := validator.Validate(ValidNutsOrganizationCredential(t)) assert.NoError(t, err) }) @@ -420,37 +420,37 @@ func TestDefaultCredentialValidator(t *testing.T) { }) t.Run("failed - missing ID", func(t *testing.T) { - v := validNutsOrganizationCredential() + v := ValidNutsOrganizationCredential(t) v.ID = nil - err := validator.Validate(*v) + err := validator.Validate(v) assert.EqualError(t, err, "validation failed: 'ID' is required") }) t.Run("failed - missing proof", func(t *testing.T) { - v := validNutsOrganizationCredential() + v := ValidNutsOrganizationCredential(t) v.Proof = nil - err := validator.Validate(*v) + err := validator.Validate(v) assert.EqualError(t, err, "validation failed: 'proof' is required for JSON-LD credentials") }) t.Run("failed - missing default context", func(t *testing.T) { - v := validNutsOrganizationCredential() + v := ValidNutsOrganizationCredential(t) v.Context = []ssi.URI{stringToURI(NutsV1Context)} - err := validator.Validate(*v) + err := validator.Validate(v) assert.EqualError(t, err, "validation failed: default context is required") }) t.Run("failed - missing default type", func(t *testing.T) { - v := validNutsOrganizationCredential() + v := ValidNutsOrganizationCredential(t) v.Type = []ssi.URI{stringToURI(NutsOrganizationCredentialType)} - err := validator.Validate(*v) + err := validator.Validate(v) assert.EqualError(t, err, "validation failed: type 'VerifiableCredential' is required") }) diff --git a/vcr/holder/openid.go b/vcr/holder/openid.go index aa4c3ce927..ffabe98811 100644 --- a/vcr/holder/openid.go +++ b/vcr/holder/openid.go @@ -22,7 +22,8 @@ import ( "context" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/vdr/resolver" + "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/openid4vc" "net/http" "time" @@ -35,6 +36,7 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/log" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" vcrTypes "github.com/nuts-foundation/nuts-node/vcr/types" + "github.com/nuts-foundation/nuts-node/vdr/resolver" ) // OpenIDHandler is the interface for handling issuer operations using OpenID4VCI. @@ -94,7 +96,7 @@ func (h *openidHandler) HandleCredentialOffer(ctx context.Context, offer openid4 } } offeredCredential := offer.Credentials[0] - if offeredCredential.Format != openid4vci.VerifiableCredentialJSONLDFormat { + if offeredCredential.Format != openid4vc.VerifiableCredentialJSONLDFormat { return openid4vci.Error{ Err: fmt.Errorf("credential offer: unsupported format '%s'", offeredCredential.Format), Code: openid4vci.UnsupportedCredentialType, @@ -146,7 +148,7 @@ func (h *openidHandler) HandleCredentialOffer(ctx context.Context, offer openid4 } } - if accessTokenResponse.CNonce == "" { + if accessTokenResponse.CNonce == nil { return openid4vci.Error{ Err: errors.New("c_nonce is missing"), Code: openid4vci.InvalidToken, @@ -192,7 +194,7 @@ func getPreAuthorizedCodeFromOffer(offer openid4vci.CredentialOffer) string { return preAuthorizedCode } -func (h *openidHandler) retrieveCredential(ctx context.Context, issuerClient openid4vci.IssuerAPIClient, offer *openid4vci.CredentialDefinition, tokenResponse *openid4vci.TokenResponse) (*vc.VerifiableCredential, error) { +func (h *openidHandler) retrieveCredential(ctx context.Context, issuerClient openid4vci.IssuerAPIClient, offer *openid4vci.CredentialDefinition, tokenResponse *oauth.TokenResponse) (*vc.VerifiableCredential, error) { keyID, _, err := h.resolver.ResolveKey(h.did, nil, resolver.NutsSigningKeyType) headers := map[string]interface{}{ "typ": openid4vci.JWTTypeOpenID4VCIProof, // MUST be openid4vci-proof+jwt, which explicitly types the proof JWT as recommended in Section 3.11 of [RFC8725]. @@ -211,7 +213,7 @@ func (h *openidHandler) retrieveCredential(ctx context.Context, issuerClient ope credentialRequest := openid4vci.CredentialRequest{ CredentialDefinition: offer, - Format: openid4vci.VerifiableCredentialJSONLDFormat, + Format: openid4vc.VerifiableCredentialJSONLDFormat, Proof: &openid4vci.CredentialRequestProof{ Jwt: proof, ProofType: "jwt", diff --git a/vcr/holder/openid_test.go b/vcr/holder/openid_test.go index a7bca1967a..2856ed2982 100644 --- a/vcr/holder/openid_test.go +++ b/vcr/holder/openid_test.go @@ -25,8 +25,10 @@ import ( "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/core" "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/openid4vc" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vcr/types" "github.com/nuts-foundation/nuts-node/vdr/resolver" @@ -71,17 +73,16 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { CredentialIssuer: issuerDID.String(), CredentialEndpoint: "credential-endpoint", } + nonce := "nonsens" t.Run("ok", func(t *testing.T) { ctrl := gomock.NewController(t) - nonce := "nonsens" issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) issuerAPIClient.EXPECT().Metadata().Return(metadata) issuerAPIClient.EXPECT().RequestAccessToken("urn:ietf:params:oauth:grant-type:pre-authorized_code", map[string]string{ "pre-authorized_code": "code", - }).Return(&openid4vci.TokenResponse{ + }).Return(&oauth.TokenResponse{ AccessToken: "access-token", - CNonce: nonce, - ExpiresIn: 0, + CNonce: &nonce, TokenType: "bearer", }, nil) issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), gomock.Any(), "access-token"). @@ -95,7 +96,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { jwtSigner.EXPECT().SignJWT(gomock.Any(), map[string]interface{}{ "aud": issuerDID.String(), "iat": int64(1735689600), - "nonce": nonce, + "nonce": &nonce, }, gomock.Any(), "key-id").Return("signed-jwt", nil) keyResolver := resolver.NewMockKeyResolver(ctrl) keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI("key-id"), nil, nil) @@ -176,7 +177,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { t.Run("error - empty access token", func(t *testing.T) { ctrl := gomock.NewController(t) issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) - issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(&openid4vci.TokenResponse{}, nil) + issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(&oauth.TokenResponse{}, nil) w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil).(*openidHandler) w.issuerClientCreator = func(_ context.Context, httpClient core.HTTPRequestDoer, credentialIssuerIdentifier string) (openid4vci.IssuerAPIClient, error) { @@ -190,7 +191,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { t.Run("error - empty c_nonce", func(t *testing.T) { ctrl := gomock.NewController(t) issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) - issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(&openid4vci.TokenResponse{AccessToken: "foo"}, nil) + issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(&oauth.TokenResponse{AccessToken: "foo"}, nil) w := NewOpenIDHandler(holderDID, "https://holder.example.com", &http.Client{}, nil, nil, nil).(*openidHandler) w.issuerClientCreator = func(_ context.Context, httpClient core.HTTPRequestDoer, credentialIssuerIdentifier string) (openid4vci.IssuerAPIClient, error) { @@ -230,7 +231,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { ctrl := gomock.NewController(t) issuerAPIClient := openid4vci.NewMockIssuerAPIClient(ctrl) issuerAPIClient.EXPECT().Metadata().Return(metadata) - issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(&openid4vci.TokenResponse{AccessToken: "access-token", CNonce: "c_nonce"}, nil) + issuerAPIClient.EXPECT().RequestAccessToken(gomock.Any(), gomock.Any()).Return(&oauth.TokenResponse{AccessToken: "access-token", CNonce: &nonce}, nil) issuerAPIClient.EXPECT().RequestCredential(gomock.Any(), gomock.Any(), gomock.Any()).Return(&vc.VerifiableCredential{ Context: offer.CredentialDefinition.Context, Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential")}, @@ -274,7 +275,7 @@ func Test_wallet_HandleCredentialOffer(t *testing.T) { // offeredCredential returns a structure that can be used as CredentialOffer.Credentials, func offeredCredential() []openid4vci.OfferedCredential { return []openid4vci.OfferedCredential{{ - Format: openid4vci.VerifiableCredentialJSONLDFormat, + Format: openid4vc.VerifiableCredentialJSONLDFormat, CredentialDefinition: &openid4vci.CredentialDefinition{ Context: []ssi.URI{ ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), diff --git a/vcr/issuer/openid.go b/vcr/issuer/openid.go index cdaa200475..889460d628 100644 --- a/vcr/issuer/openid.go +++ b/vcr/issuer/openid.go @@ -21,8 +21,6 @@ package issuer import ( "context" crypt "crypto" - "crypto/rand" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -34,6 +32,7 @@ import ( "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/openid4vc" "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr/issuer/assets" "github.com/nuts-foundation/nuts-node/vcr/log" @@ -77,9 +76,6 @@ const preAuthCodeRefType = "preauthcode" const accessTokenRefType = "accesstoken" const cNonceRefType = "c_nonce" -// openidSecretSizeBits is the size of the generated random secrets (access tokens, pre-authorized codes) in bits. -const openidSecretSizeBits = 128 - // OpenIDHandler defines the interface for handling OpenID4VCI issuer operations. type OpenIDHandler interface { // ProviderMetadata returns the OpenID Connect provider metadata. @@ -164,12 +160,12 @@ func (i *openidHandler) HandleAccessTokenRequest(ctx context.Context, preAuthori StatusCode: http.StatusBadRequest, } } - accessToken := generateCode() + accessToken := crypto.GenerateNonce() err = i.store.StoreReference(ctx, flow.ID, accessTokenRefType, accessToken) if err != nil { return "", "", err } - cNonce := generateCode() + cNonce := crypto.GenerateNonce() err = i.store.StoreReference(ctx, flow.ID, cNonceRefType, cNonce) if err != nil { return "", "", err @@ -188,7 +184,7 @@ func (i *openidHandler) HandleAccessTokenRequest(ctx context.Context, preAuthori } func (i *openidHandler) OfferCredential(ctx context.Context, credential vc.VerifiableCredential, walletIdentifier string) error { - preAuthorizedCode := generateCode() + preAuthorizedCode := crypto.GenerateNonce() walletMetadataURL := core.JoinURLPaths(walletIdentifier, openid4vci.WalletMetadataWellKnownPath) log.Logger(). WithField(core.LogFieldCredentialID, credential.ID). @@ -212,7 +208,7 @@ func (i *openidHandler) OfferCredential(ctx context.Context, credential vc.Verif } func (i *openidHandler) HandleCredentialRequest(ctx context.Context, request openid4vci.CredentialRequest, accessToken string) (*vc.VerifiableCredential, error) { - if request.Format != openid4vci.VerifiableCredentialJSONLDFormat { + if request.Format != openid4vc.VerifiableCredentialJSONLDFormat { return nil, openid4vci.Error{ Err: fmt.Errorf("credential request: unsupported format '%s'", request.Format), Code: openid4vci.UnsupportedCredentialType, @@ -284,7 +280,7 @@ func (i *openidHandler) validateProof(ctx context.Context, flow *Flow, request o // augment invalid_proof errors according to ยง7.3.2 of openid4vci spec generateProofError := func(err openid4vci.Error) error { - cnonce := generateCode() + cnonce := crypto.GenerateNonce() if err := i.store.StoreReference(ctx, flow.ID, cNonceRefType, cnonce); err != nil { return err } @@ -413,7 +409,7 @@ func (i *openidHandler) createOffer(ctx context.Context, credential vc.Verifiabl offer := openid4vci.CredentialOffer{ CredentialIssuer: i.issuerIdentifierURL, Credentials: []openid4vci.OfferedCredential{{ - Format: openid4vci.VerifiableCredentialJSONLDFormat, + Format: openid4vc.VerifiableCredentialJSONLDFormat, CredentialDefinition: &openid4vci.CredentialDefinition{ Context: credential.Context, Type: credential.Type, @@ -492,15 +488,6 @@ func (i *openidHandler) loadCredentialDefinitions() error { return err } -func generateCode() string { - buf := make([]byte, openidSecretSizeBits/8) - _, err := rand.Read(buf) - if err != nil { - panic(err) - } - return base64.URLEncoding.EncodeToString(buf) -} - func deepcopy(src []map[string]interface{}) []map[string]interface{} { dst := make([]map[string]interface{}, len(src)) for i := range src { diff --git a/vcr/issuer/openid_test.go b/vcr/issuer/openid_test.go index 281eeaaa0f..449de9eda6 100644 --- a/vcr/issuer/openid_test.go +++ b/vcr/issuer/openid_test.go @@ -28,6 +28,7 @@ import ( "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/openid4vc" "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vdr/resolver" @@ -143,7 +144,7 @@ func Test_memoryIssuer_HandleCredentialRequest(t *testing.T) { proof, err := keyStore.SignJWT(ctx, claims, headers, headers["kid"]) require.NoError(t, err) return openid4vci.CredentialRequest{ - Format: openid4vci.VerifiableCredentialJSONLDFormat, + Format: openid4vc.VerifiableCredentialJSONLDFormat, CredentialDefinition: &openid4vci.CredentialDefinition{ Context: []ssi.URI{ ssi.MustParseURI("https://www.w3.org/2018/credentials/v1"), diff --git a/vcr/openid4vci/issuer_client.go b/vcr/openid4vci/issuer_client.go index 7a20812bae..abac02d343 100644 --- a/vcr/openid4vci/issuer_client.go +++ b/vcr/openid4vci/issuer_client.go @@ -25,6 +25,7 @@ import ( "errors" "fmt" "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/vcr/log" "io" @@ -192,7 +193,7 @@ func httpDo(httpClient core.HTTPRequestDoer, httpRequest *http.Request, result i // OAuth2Client defines a generic OAuth2 client. type OAuth2Client interface { // RequestAccessToken requests an access token from the Authorization Server. - RequestAccessToken(grantType string, params map[string]string) (*TokenResponse, error) + RequestAccessToken(grantType string, params map[string]string) (*oauth.TokenResponse, error) } var _ OAuth2Client = &httpOAuth2Client{} @@ -202,7 +203,7 @@ type httpOAuth2Client struct { httpClient core.HTTPRequestDoer } -func (c httpOAuth2Client) RequestAccessToken(grantType string, params map[string]string) (*TokenResponse, error) { +func (c httpOAuth2Client) RequestAccessToken(grantType string, params map[string]string) (*oauth.TokenResponse, error) { values := url.Values{} values.Add("grant_type", grantType) for key, value := range params { @@ -210,7 +211,7 @@ func (c httpOAuth2Client) RequestAccessToken(grantType string, params map[string } httpRequest, _ := http.NewRequestWithContext(context.Background(), "POST", c.metadata.TokenEndpoint, strings.NewReader(values.Encode())) httpRequest.Header.Add("Content-Type", "application/x-www-form-urlencoded") - var accessTokenResponse TokenResponse + var accessTokenResponse oauth.TokenResponse err := httpDo(c.httpClient, httpRequest, &accessTokenResponse) if err != nil { return nil, fmt.Errorf("request access token error: %w", err) diff --git a/vcr/openid4vci/issuer_client_mock.go b/vcr/openid4vci/issuer_client_mock.go index 13b000e602..9da6b18275 100644 --- a/vcr/openid4vci/issuer_client_mock.go +++ b/vcr/openid4vci/issuer_client_mock.go @@ -13,6 +13,7 @@ import ( reflect "reflect" vc "github.com/nuts-foundation/go-did/vc" + oauth "github.com/nuts-foundation/nuts-node/auth/oauth" gomock "go.uber.org/mock/gomock" ) @@ -54,10 +55,10 @@ func (mr *MockIssuerAPIClientMockRecorder) Metadata() *gomock.Call { } // RequestAccessToken mocks base method. -func (m *MockIssuerAPIClient) RequestAccessToken(grantType string, params map[string]string) (*TokenResponse, error) { +func (m *MockIssuerAPIClient) RequestAccessToken(grantType string, params map[string]string) (*oauth.TokenResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RequestAccessToken", grantType, params) - ret0, _ := ret[0].(*TokenResponse) + ret0, _ := ret[0].(*oauth.TokenResponse) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -107,10 +108,10 @@ func (m *MockOAuth2Client) EXPECT() *MockOAuth2ClientMockRecorder { } // RequestAccessToken mocks base method. -func (m *MockOAuth2Client) RequestAccessToken(grantType string, params map[string]string) (*TokenResponse, error) { +func (m *MockOAuth2Client) RequestAccessToken(grantType string, params map[string]string) (*oauth.TokenResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RequestAccessToken", grantType, params) - ret0, _ := ret[0].(*TokenResponse) + ret0, _ := ret[0].(*oauth.TokenResponse) ret1, _ := ret[1].(error) return ret0, ret1 } diff --git a/vcr/openid4vci/issuer_client_test.go b/vcr/openid4vci/issuer_client_test.go index 2e7f48491e..2c0fbcad20 100644 --- a/vcr/openid4vci/issuer_client_test.go +++ b/vcr/openid4vci/issuer_client_test.go @@ -20,6 +20,7 @@ package openid4vci import ( "context" + "github.com/nuts-foundation/nuts-node/openid4vc" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "net/http" @@ -89,7 +90,7 @@ func Test_httpIssuerClient_RequestCredential(t *testing.T) { httpClient := &http.Client{} credentialRequest := CredentialRequest{ CredentialDefinition: &CredentialDefinition{}, - Format: VerifiableCredentialJSONLDFormat, + Format: openid4vc.VerifiableCredentialJSONLDFormat, } t.Run("ok", func(t *testing.T) { setup := setupClientTest(t) diff --git a/vcr/openid4vci/test.go b/vcr/openid4vci/test.go index eb5c88dc70..8435c1ce7e 100644 --- a/vcr/openid4vci/test.go +++ b/vcr/openid4vci/test.go @@ -22,6 +22,8 @@ import ( "context" "encoding/json" "fmt" + "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/openid4vc" "github.com/nuts-foundation/nuts-node/test" "net/http" "testing" @@ -34,7 +36,7 @@ func setupClientTest(t *testing.T) *oidcClientTestContext { providerMetadata := new(ProviderMetadata) walletMetadata := new(OAuth2ClientMetadata) credentialResponse := CredentialResponse{ - Format: VerifiableCredentialJSONLDFormat, + Format: openid4vc.VerifiableCredentialJSONLDFormat, Credential: &map[string]interface{}{ "@context": []string{"https://www.w3.org/2018/credentials/v1"}, "type": []string{"VerifiableCredential"}, @@ -51,7 +53,7 @@ func setupClientTest(t *testing.T) *oidcClientTestContext { clientTest.issuerMetadataHandler = clientTest.httpGetHandler(issuerMetadata) clientTest.providerMetadataHandler = clientTest.httpGetHandler(providerMetadata) clientTest.credentialHandler = clientTest.httpPostHandler(credentialResponse) - clientTest.tokenHandler = clientTest.httpPostHandler(TokenResponse{AccessToken: "secret"}) + clientTest.tokenHandler = clientTest.httpPostHandler(oauth.TokenResponse{AccessToken: "secret"}) clientTest.walletMetadataHandler = clientTest.httpGetHandler(walletMetadata) clientTest.credentialOfferHandler = clientTest.httpGetHandler(CredentialOfferResponse{CredentialOfferStatusReceived}) diff --git a/vcr/openid4vci/types.go b/vcr/openid4vci/types.go index 37ca8544bc..e5d030b005 100644 --- a/vcr/openid4vci/types.go +++ b/vcr/openid4vci/types.go @@ -41,9 +41,6 @@ const ProviderMetadataWellKnownPath = "/.well-known/oauth-authorization-server" // Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata- const CredentialIssuerMetadataWellKnownPath = "/.well-known/openid-credential-issuer" -// VerifiableCredentialJSONLDFormat defines the JSON-LD format identifier for Verifiable Credentials. -const VerifiableCredentialJSONLDFormat = "ldp_vc" - // JWTTypeOpenID4VCIProof defines the OpenID4VCI JWT-subtype (used as typ claim in the JWT). const JWTTypeOpenID4VCIProof = "openid4vci-proof+jwt" @@ -150,25 +147,6 @@ type CredentialResponse struct { CNonce *string `json:"c_nonce,omitempty"` } -// TokenResponse defines the response for OAuth2 access token requests, extended with OpenID4VCI parameters. -// Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-successful-token-response -type TokenResponse struct { - // AccessToken defines the access token issued by the authorization server. - AccessToken string `json:"access_token"` - - // CNonce defines the JSON string containing a nonce to be used to create a proof of possession of key material when requesting a Credential. - // When received, the WalletAPIClient MUST use this nonce value for its subsequent credential requests until the Credential Issuer provides a fresh nonce. - // Although optional in the spec, we use a concrete value since we always fill it. - CNonce string `json:"c_nonce,omitempty"` - - // ExpiresIn defines the lifetime in seconds of the access token. - // Although optional in the spec, we use a concrete value since we always fill it. - ExpiresIn int `json:"expires_in,omitempty"` - - // TokenType defines the type of the token issued as described in [RFC6749]. - TokenType string `json:"token_type"` -} - // Config holds the config for the OpenID4VCI credential issuer and wallet type Config struct { // DefinitionsDIR defines the directory where the additional credential definitions are stored diff --git a/vcr/pe/presentation_definition_test.go b/vcr/pe/presentation_definition_test.go index b935bf2c15..32ecd602d5 100644 --- a/vcr/pe/presentation_definition_test.go +++ b/vcr/pe/presentation_definition_test.go @@ -20,13 +20,14 @@ package pe import ( "encoding/json" + "testing" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/pe/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "os" - "testing" ) const testPresentationDefinition = ` @@ -100,13 +101,11 @@ func TestMatch(t *testing.T) { vc2 := vc.VerifiableCredential{ID: &id2} vc3 := vc.VerifiableCredential{ID: &id3} vc4 := vc.VerifiableCredential{ID: &id4} + verifiableCredential := credential.ValidNutsOrganizationCredential(t) t.Run("Basic", func(t *testing.T) { presentationDefinition := PresentationDefinition{} _ = json.Unmarshal([]byte(testPresentationDefinition), &presentationDefinition) - verifiableCredential := vc.VerifiableCredential{} - vcJSON, _ := os.ReadFile("../test/vc.json") - _ = json.Unmarshal(vcJSON, &verifiableCredential) t.Run("Happy flow", func(t *testing.T) { vcs, mappingObjects, err := presentationDefinition.Match([]vc.VerifiableCredential{verifiableCredential}) @@ -271,9 +270,7 @@ func TestMatch(t *testing.T) { } func Test_matchFormat(t *testing.T) { - verifiableCredential := vc.VerifiableCredential{} - vcJSON, _ := os.ReadFile("../test/vc.json") - _ = json.Unmarshal(vcJSON, &verifiableCredential) + verifiableCredential := credential.ValidNutsOrganizationCredential(t) t.Run("no format", func(t *testing.T) { match := matchFormat(nil, vc.VerifiableCredential{}) diff --git a/vcr/pe/presentation_submission.go b/vcr/pe/presentation_submission.go index 5771e4443f..fa858d199a 100644 --- a/vcr/pe/presentation_submission.go +++ b/vcr/pe/presentation_submission.go @@ -75,9 +75,27 @@ type SignInstruction struct { inputDescriptorMappingObjects []InputDescriptorMappingObject } +// Empty returns true if there are no VCs in the SignInstruction. +func (signInstruction SignInstruction) Empty() bool { + return len(signInstruction.VerifiableCredentials) == 0 +} + +// SignInstructions is a list of SignInstruction. +type SignInstructions []SignInstruction + +// Empty returns true if all SignInstructions are empty. +func (signInstructions SignInstructions) Empty() bool { + for _, signInstruction := range []SignInstruction(signInstructions) { + if !signInstruction.Empty() { + return false + } + } + return true +} + // Build creates a PresentationSubmission from the added wallets. // The VP format is determined by the given format. -func (b *PresentationSubmissionBuilder) Build(format string) (PresentationSubmission, []SignInstruction, error) { +func (b *PresentationSubmissionBuilder) Build(format string) (PresentationSubmission, SignInstructions, error) { presentationSubmission := PresentationSubmission{ Id: uuid.New().String(), DefinitionId: b.presentationDefinition.Id, diff --git a/vcr/store_test.go b/vcr/store_test.go index 85585b80fc..f9fd84e429 100644 --- a/vcr/store_test.go +++ b/vcr/store_test.go @@ -26,6 +26,7 @@ import ( "encoding/json" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/crypto/storage/spi" + "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/stretchr/testify/require" "os" @@ -39,9 +40,7 @@ import ( func TestVcr_StoreCredential(t *testing.T) { // load VC - target := vc.VerifiableCredential{} - vcJSON, _ := os.ReadFile("test/vc.json") - json.Unmarshal(vcJSON, &target) + target := credential.ValidNutsOrganizationCredential(t) holderDID := did.MustParseDID(target.CredentialSubject[0].(map[string]interface{})["id"].(string)) // load pub key @@ -134,9 +133,7 @@ func TestVcr_StoreCredential(t *testing.T) { func TestStore_writeCredential(t *testing.T) { // load VC - target := vc.VerifiableCredential{} - vcJSON, _ := os.ReadFile("test/vc.json") - json.Unmarshal(vcJSON, &target) + target := credential.ValidNutsOrganizationCredential(t) t.Run("ok - stored in JSON-LD collection", func(t *testing.T) { ctx := newMockContext(t) diff --git a/vcr/test/openid4vci_integration_test.go b/vcr/test/openid4vci_integration_test.go index fd9a0f943c..0aed84a117 100644 --- a/vcr/test/openid4vci_integration_test.go +++ b/vcr/test/openid4vci_integration_test.go @@ -24,6 +24,7 @@ import ( "github.com/nuts-foundation/nuts-node/core" httpModule "github.com/nuts-foundation/nuts-node/http" "github.com/nuts-foundation/nuts-node/network/log" + "github.com/nuts-foundation/nuts-node/openid4vc" "github.com/nuts-foundation/nuts-node/vcr/issuer" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vdr" @@ -229,7 +230,7 @@ func TestOpenID4VCIErrorResponses(t *testing.T) { require.NoError(t, err) requestBody, _ := json.Marshal(openid4vci.CredentialRequest{ - Format: openid4vci.VerifiableCredentialJSONLDFormat, + Format: openid4vc.VerifiableCredentialJSONLDFormat, }) t.Run("error from API layer (missing access token)", func(t *testing.T) { diff --git a/vcr/vcr.go b/vcr/vcr.go index a5a1a8f7b7..acd60de598 100644 --- a/vcr/vcr.go +++ b/vcr/vcr.go @@ -256,19 +256,9 @@ func (c *vcr) Configure(config core.ServerConfig) error { // This is because the credential is requested by the wallet synchronously during the offer handling, // meaning while the issuer allocated an HTTP connection the wallet will try to allocate one as well. // This moved back to 1 http.Client when the credential is requested asynchronously. - // Should be fixed as part of https://github.com/nuts-foundation/nuts-node/issues/2039 - issuerTransport := http.DefaultTransport.(*http.Transport).Clone() - issuerTransport.TLSClientConfig = tlsConfig - c.issuerHttpClient = core.NewStrictHTTPClient(config.Strictmode, &http.Client{ - Timeout: c.config.OpenID4VCI.Timeout, - Transport: issuerTransport, - }) - walletTransport := http.DefaultTransport.(*http.Transport).Clone() - walletTransport.TLSClientConfig = tlsConfig - c.walletHttpClient = core.NewStrictHTTPClient(config.Strictmode, &http.Client{ - Timeout: c.config.OpenID4VCI.Timeout, - Transport: walletTransport, - }) + // Should be fixed as part of https://github.com/nuts-foundation/nuts-node/issues/2039 (also fix core.NewStrictHTTPClient) + c.issuerHttpClient = core.NewStrictHTTPClient(config.Strictmode, c.config.OpenID4VCI.Timeout, tlsConfig) + c.walletHttpClient = core.NewStrictHTTPClient(config.Strictmode, c.config.OpenID4VCI.Timeout, tlsConfig) c.openidSessionStore = c.storageClient.GetSessionDatabase() } c.issuer = issuer.NewIssuer(c.issuerStore, c, networkPublisher, openidHandlerFn, didResolver, c.keyStore, c.jsonldManager, c.trustConfig) diff --git a/vcr/vcr_test.go b/vcr/vcr_test.go index ccb85482d4..f9389d9055 100644 --- a/vcr/vcr_test.go +++ b/vcr/vcr_test.go @@ -30,6 +30,7 @@ import ( "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/pki" "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/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vdr" @@ -80,9 +81,7 @@ func TestVCR_Configure(t *testing.T) { }) t.Run("strictmode passed to client APIs", func(t *testing.T) { // load test VC - testVC := vc.VerifiableCredential{} - vcJSON, _ := os.ReadFile("test/vc.json") - _ = json.Unmarshal(vcJSON, &testVC) + testVC := credential.ValidNutsOrganizationCredential(t) issuerDID := did.MustParseDID(testVC.Issuer.String()) testDirectory := io.TestDirectory(t) ctrl := gomock.NewController(t) diff --git a/vcr/verifier/verifier_test.go b/vcr/verifier/verifier_test.go index 9bbffea192..57746924dc 100644 --- a/vcr/verifier/verifier_test.go +++ b/vcr/verifier/verifier_test.go @@ -54,9 +54,7 @@ import ( ) func testCredential(t *testing.T) vc.VerifiableCredential { - subject := vc.VerifiableCredential{} - vcJSON, _ := os.ReadFile("../test/vc.json") - require.NoError(t, json.Unmarshal(vcJSON, &subject)) + subject := credential.ValidNutsOrganizationCredential(t) return subject } diff --git a/vdr/didweb/test.go b/vdr/didweb/test.go new file mode 100644 index 0000000000..51e4aa14be --- /dev/null +++ b/vdr/didweb/test.go @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package didweb + +import ( + "github.com/nuts-foundation/go-did/did" + "github.com/stretchr/testify/require" + "net/url" + "strings" + "testing" +) + +func ServerURLToDIDWeb(t *testing.T, stringUrl string) did.DID { + stringUrl = strings.ReplaceAll(stringUrl, "127.0.0.1", "localhost") + asURL, err := url.Parse(stringUrl) + require.NoError(t, err) + testDID, err := URLToDID(*asURL) + require.NoError(t, err) + return *testDID +}