diff --git a/Dockerfile b/Dockerfile index a2cc2894ef..78c0645711 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # golang alpine -FROM golang:1.21.3-alpine as builder +FROM golang:1.21.4-alpine as builder ARG TARGETARCH ARG TARGETOS @@ -11,6 +11,9 @@ ARG GIT_VERSION=undefined LABEL maintainer="wout.slakhorst@nuts.nl" RUN apk update \ + && apk add --no-cache \ + gcc \ + musl-dev \ && update-ca-certificates ENV GO111MODULE on @@ -22,7 +25,7 @@ COPY go.sum . RUN go mod download && go mod verify COPY . . -RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-w -s -X 'github.com/nuts-foundation/nuts-node/core.GitCommit=${GIT_COMMIT}' -X 'github.com/nuts-foundation/nuts-node/core.GitBranch=${GIT_BRANCH}' -X 'github.com/nuts-foundation/nuts-node/core.GitVersion=${GIT_VERSION}'" -o /opt/nuts/nuts +RUN CGO_ENABLED=1 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags="-w -s -X 'github.com/nuts-foundation/nuts-node/core.GitCommit=${GIT_COMMIT}' -X 'github.com/nuts-foundation/nuts-node/core.GitBranch=${GIT_BRANCH}' -X 'github.com/nuts-foundation/nuts-node/core.GitVersion=${GIT_VERSION}'" -o /opt/nuts/nuts # alpine FROM alpine:3.18.4 diff --git a/README.rst b/README.rst index ddee309491..f6316b814b 100644 --- a/README.rst +++ b/README.rst @@ -252,6 +252,7 @@ The following options can be configured on the server: storage.redis.sentinel.password Password for authenticating to Redis Sentinels. storage.redis.sentinel.username Username for authenticating to Redis Sentinels. storage.redis.tls.truststorefile PEM file containing the trusted CA certificate(s) for authenticating remote Redis servers. Can only be used when connecting over TLS (use 'rediss://' as scheme in address). + storage.sql.connection Connection string for the SQL database. If not set, it defaults to a SQLite database stored inside the configured data directory **VCR** vcr.openid4vci.definitionsdir Directory with the additional credential definitions the node could issue (experimental, may change without notice). vcr.openid4vci.enabled true Enable issuing and receiving credentials over OpenID4VCI. diff --git a/api/ssi_types_test.go b/api/ssi_types_test.go index 30d3ea4841..91155745c4 100644 --- a/api/ssi_types_test.go +++ b/api/ssi_types_test.go @@ -172,7 +172,7 @@ func createDidDocument() did.Document { KeyAgreement: did.VerificationRelationships{verificationRelationship}, VerificationMethod: did.VerificationMethods{verificationMethod}, Controller: []did.DID{did.MustParseDID("did:example:controller")}, - ID: verificationMethod.ID, + ID: verificationMethod.ID.DID, Service: []did.Service{ { ID: ssi.MustParseURI("example"), 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/irma/signer_test.go b/auth/services/irma/signer_test.go index 6cba581302..2f84ccb6a6 100644 --- a/auth/services/irma/signer_test.go +++ b/auth/services/irma/signer_test.go @@ -21,6 +21,7 @@ package irma import ( "context" "errors" + "github.com/nuts-foundation/go-did/did" "github.com/stretchr/testify/require" "testing" @@ -109,7 +110,7 @@ func TestService_StartSigningSession(t *testing.T) { func TestService_SigningSessionStatus(t *testing.T) { correctContractText := "EN:PractitionerLogin:v3 I hereby declare to act on behalf of verpleeghuis De nootjes located in Caretown. This declaration is valid from maandag 1 oktober 12:00:00 until maandag 1 oktober 13:00:00." holder := vdr.TestDIDA - keyID := holder + keyID := did.DIDURL{DID: holder} keyID.Fragment = keyID.ID ctx := context.Background() 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 c190f63bda..4a78c2af73 100644 --- a/auth/services/oauth/authz_server_test.go +++ b/auth/services/oauth/authz_server_test.go @@ -80,7 +80,7 @@ func getAuthorizerDIDDocument() *did.Document { doc := did.Document{ ID: id, } - signingKeyID := id + signingKeyID := did.DIDURL{DID: id} signingKeyID.Fragment = "signing-key" key, err := did.NewVerificationMethod(signingKeyID, ssi.JsonWebKey2020, id, authorizerSigningKey.Public()) if err != nil { @@ -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/api/v1/api.go b/crypto/api/v1/api.go index ca676e0887..1fe53d425d 100644 --- a/crypto/api/v1/api.go +++ b/crypto/api/v1/api.go @@ -191,8 +191,8 @@ func (w *Wrapper) EncryptJwe(ctx context.Context, request EncryptJweRequestObjec return EncryptJwe200TextResponse(jwe), err } -func (w *Wrapper) resolvePublicKey(id *did.DID) (key crypt.PublicKey, keyID ssi.URI, err error) { - if id.IsURL() { +func (w *Wrapper) resolvePublicKey(id *did.DIDURL) (key crypt.PublicKey, keyID ssi.URI, err error) { + if id.Fragment != "" { // Assume it is a keyId now := time.Now() key, err = w.K.ResolveKeyByID(id.String(), &now, resolver.KeyAgreement) @@ -202,7 +202,7 @@ func (w *Wrapper) resolvePublicKey(id *did.DID) (key crypt.PublicKey, keyID ssi. keyID = id.URI() } else { // Assume it is a DID - keyID, key, err = w.K.ResolveKey(*id, nil, resolver.KeyAgreement) + keyID, key, err = w.K.ResolveKey(id.DID, nil, resolver.KeyAgreement) if err != nil { return nil, ssi.URI{}, err } 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/didman/didman_test.go b/didman/didman_test.go index d84285e850..193b2c157c 100644 --- a/didman/didman_test.go +++ b/didman/didman_test.go @@ -539,7 +539,7 @@ func TestDidman_GetContactInformation(t *testing.T) { func TestDidman_DeleteEndpointsByType(t *testing.T) { id, _ := did.ParseDID("did:nuts:123") - serviceID := *id + serviceID := did.DIDURL{DID: *id} serviceID.Fragment = "abc" endpointType := "eOverdracht" endpoints := []did.Service{{ 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/docs/pages/deployment/cli-reference.rst b/docs/pages/deployment/cli-reference.rst index 83c30879d8..0bf3c272ea 100755 --- a/docs/pages/deployment/cli-reference.rst +++ b/docs/pages/deployment/cli-reference.rst @@ -44,7 +44,7 @@ The following options apply to the server commands below: --http.default.log string What to log about HTTP requests. Options are 'nothing', 'metadata' (log request method, URI, IP and response code), and 'metadata-and-body' (log the request and response body, in addition to the metadata). (default "metadata") --http.default.tls string Whether to enable TLS for the default interface, options are 'disabled', 'server', 'server-client'. Leaving it empty is synonymous to 'disabled', --internalratelimiter When set, expensive internal calls are rate-limited to protect the network. Always enabled in strict mode. (default true) - --jsonld.contexts.localmapping stringToString This setting allows mapping external URLs to local files for e.g. preventing external dependencies. These mappings have precedence over those in remoteallowlist. (default [https://nuts.nl/credentials/v1=assets/contexts/nuts.ldjson,https://www.w3.org/2018/credentials/v1=assets/contexts/w3c-credentials-v1.ldjson,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json=assets/contexts/lds-jws2020-v1.ldjson,https://schema.org=assets/contexts/schema-org-v13.ldjson]) + --jsonld.contexts.localmapping stringToString This setting allows mapping external URLs to local files for e.g. preventing external dependencies. These mappings have precedence over those in remoteallowlist. (default [https://schema.org=assets/contexts/schema-org-v13.ldjson,https://nuts.nl/credentials/v1=assets/contexts/nuts.ldjson,https://www.w3.org/2018/credentials/v1=assets/contexts/w3c-credentials-v1.ldjson,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json=assets/contexts/lds-jws2020-v1.ldjson]) --jsonld.contexts.remoteallowlist strings In strict mode, fetching external JSON-LD contexts is not allowed except for context-URLs listed here. (default [https://schema.org,https://www.w3.org/2018/credentials/v1,https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json]) --loggerformat string Log format (text, json) (default "text") --network.bootstrapnodes strings List of bootstrap nodes (':') which the node initially connect to. @@ -70,6 +70,7 @@ The following options apply to the server commands below: --storage.redis.sentinel.username string Username for authenticating to Redis Sentinels. --storage.redis.tls.truststorefile string PEM file containing the trusted CA certificate(s) for authenticating remote Redis servers. Can only be used when connecting over TLS (use 'rediss://' as scheme in address). --storage.redis.username string Redis database username. If set, it overrides the username in the connection URL. + --storage.sql.connection string Connection string for the SQL database. If not set, it defaults to a SQLite database stored inside the configured data directory --strictmode When set, insecure settings are forbidden. (default true) --tls.certfile string PEM file containing the certificate for the server (also used as client certificate). --tls.certheader string Name of the HTTP header that will contain the client certificate when TLS is offloaded. diff --git a/docs/pages/deployment/server_options.rst b/docs/pages/deployment/server_options.rst index f389b0d124..03b274bb46 100755 --- a/docs/pages/deployment/server_options.rst +++ b/docs/pages/deployment/server_options.rst @@ -78,6 +78,7 @@ storage.redis.sentinel.password Password for authenticating to Redis Sentinels. storage.redis.sentinel.username Username for authenticating to Redis Sentinels. storage.redis.tls.truststorefile PEM file containing the trusted CA certificate(s) for authenticating remote Redis servers. Can only be used when connecting over TLS (use 'rediss://' as scheme in address). + storage.sql.connection Connection string for the SQL database. If not set, it defaults to a SQLite database stored inside the configured data directory **VCR** vcr.openid4vci.definitionsdir Directory with the additional credential definitions the node could issue (experimental, may change without notice). vcr.openid4vci.enabled true Enable issuing and receiving credentials over OpenID4VCI. diff --git a/docs/pages/deployment/storage-configuration.rst b/docs/pages/deployment/storage-configuration.rst index 22dfece03b..8bec008f61 100644 --- a/docs/pages/deployment/storage-configuration.rst +++ b/docs/pages/deployment/storage-configuration.rst @@ -51,6 +51,22 @@ The server's certificate will be verified against the OS' CA bundle. Make sure to `configure persistence for your Redis server `_. +SQL +=== + +.. note:: + + SQL storage is still in development, for now you'll still need the other storage options described by this document. + +As we're transitioning to protocols with less shared state, we foresee Nuts' data models to become more relational. +To simplify things, we intent to move towards SQL based storage in the future. +The first database to be supported in SQLite, to aid development and demo/workshop setups. Other, supported SQL databases might be: +- MySQL family (MariaDB, Percona) +- PostgreSQL + +By default, storage SQLite will be used in a file called ``sqlite.db`` in the configured data directory. +This can be overridden by configuring a connection string in ``storage.sql.connection`` (only SQLite for now). + Redis Sentinel ^^^^^^^^^^^^^^ diff --git a/go.mod b/go.mod index cfc064d990..1db68e11ee 100644 --- a/go.mod +++ b/go.mod @@ -14,17 +14,18 @@ require ( github.com/goodsign/monday v1.0.1 github.com/google/uuid v1.4.0 github.com/hashicorp/vault/api v1.10.0 + github.com/jinzhu/now v1.1.5 // indirect github.com/knadh/koanf v1.5.0 - github.com/labstack/echo/v4 v4.11.2 + github.com/labstack/echo/v4 v4.11.3 github.com/lestrrat-go/jwx/v2 v2.0.16 github.com/magiconair/properties v1.8.7 - github.com/mdp/qrterminal/v3 v3.1.1 + github.com/mdp/qrterminal/v3 v3.2.0 github.com/mr-tron/base58 v1.2.0 github.com/multiformats/go-multicodec v0.9.0 github.com/nats-io/nats-server/v2 v2.10.4 github.com/nats-io/nats.go v1.31.0 github.com/nuts-foundation/crypto-ecies v0.0.0-20211207143025-5b84f9efce2b - github.com/nuts-foundation/go-did v0.8.0 + github.com/nuts-foundation/go-did v0.9.0 github.com/nuts-foundation/go-leia/v4 v4.0.0 github.com/nuts-foundation/go-stoabs v1.9.0 // check the oapi-codegen tool version in the makefile when upgrading the runtime @@ -44,7 +45,7 @@ require ( go.uber.org/atomic v1.11.0 go.uber.org/goleak v1.3.0 go.uber.org/mock v0.3.0 - golang.org/x/crypto v0.14.0 + golang.org/x/crypto v0.15.0 golang.org/x/time v0.4.0 google.golang.org/grpc v1.59.0 google.golang.org/protobuf v1.31.0 @@ -117,7 +118,7 @@ require ( github.com/lib/pq v1.10.6 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v2.0.1+incompatible // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect @@ -162,9 +163,9 @@ require ( github.com/yuin/gopher-lua v1.1.0 // indirect golang.org/x/net v0.17.0 // indirect golang.org/x/sync v0.3.0 // indirect - golang.org/x/sys v0.13.0 // indirect - golang.org/x/term v0.13.0 // indirect - golang.org/x/text v0.13.0 // indirect + golang.org/x/sys v0.14.0 // indirect + golang.org/x/term v0.14.0 // indirect + golang.org/x/text v0.14.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect gopkg.in/Regis24GmbH/go-diacritics.v2 v2.0.3 // indirect gorm.io/driver/sqlite v1.5.4 diff --git a/go.sum b/go.sum index a85c384213..e6d0c82292 100644 --- a/go.sum +++ b/go.sum @@ -335,8 +335,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/labstack/echo/v4 v4.11.2 h1:T+cTLQxWCDfqDEoydYm5kCobjmHwOwcv4OJAPHilmdE= -github.com/labstack/echo/v4 v4.11.2/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws= +github.com/labstack/echo/v4 v4.11.3 h1:Upyu3olaqSHkCjs1EJJwQ3WId8b8b1hxbogyommKktM= +github.com/labstack/echo/v4 v4.11.3/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws= github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8= github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= @@ -375,16 +375,16 @@ github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOA github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus= github.com/mattn/go-sqlite3 v2.0.1+incompatible h1:xQ15muvnzGBHpIpdrNi1DA5x0+TcBZzsIDwmw9uTHzw= github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= -github.com/mdp/qrterminal/v3 v3.1.1 h1:cIPwg3QU0OIm9+ce/lRfWXhPwEjOSKwk3HBwL3HBTyc= -github.com/mdp/qrterminal/v3 v3.1.1/go.mod h1:5lJlXe7Jdr8wlPDdcsJttv1/knsRgzXASyr4dcGZqNU= +github.com/mdp/qrterminal/v3 v3.2.0 h1:qteQMXO3oyTK4IHwj2mWsKYYRBOp1Pj2WRYFYYNTCdk= +github.com/mdp/qrterminal/v3 v3.2.0/go.mod h1:XGGuua4Lefrl7TLEsSONiD+UEjQXJZ4mPzF+gWYIJkk= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= @@ -448,8 +448,8 @@ github.com/nightlyone/lockfile v1.0.0/go.mod h1:rywoIealpdNse2r832aiD9jRk8ErCatR github.com/npillmayer/nestext v0.1.3/go.mod h1:h2lrijH8jpicr25dFY+oAJLyzlya6jhnuG+zWp9L0Uk= github.com/nuts-foundation/crypto-ecies v0.0.0-20211207143025-5b84f9efce2b h1:80icUxWHwE1MrIOOEK5rxrtyKOgZeq5Iu1IjAEkggTY= github.com/nuts-foundation/crypto-ecies v0.0.0-20211207143025-5b84f9efce2b/go.mod h1:6YUioYirD6/8IahZkoS4Ypc8xbeJW76Xdk1QKcziNTM= -github.com/nuts-foundation/go-did v0.8.0 h1:L+XEaX87/P2SY762rhxIUDxj3LNrvk1LDJTtNgQ810o= -github.com/nuts-foundation/go-did v0.8.0/go.mod h1:L39mh6SBsuenqeZw2JxARx4a/bwdARwchG2x3zPMTjc= +github.com/nuts-foundation/go-did v0.9.0 h1:JBz1cYaMxplKZ31QyWierrR3Yt2RIpaxZTt8KFm4Ph4= +github.com/nuts-foundation/go-did v0.9.0/go.mod h1:L39mh6SBsuenqeZw2JxARx4a/bwdARwchG2x3zPMTjc= github.com/nuts-foundation/go-leia/v4 v4.0.0 h1:/unYCk18qGG2HWcJK4ld4CaM6k7Tdr0bR1vQd1Jwfcg= github.com/nuts-foundation/go-leia/v4 v4.0.0/go.mod h1:A246dA4nhY99OPCQpG/XbQ/iPyyfSaJchanivuPWpao= github.com/nuts-foundation/go-stoabs v1.9.0 h1:zK+ugfolaJYyBvGwsRuavLVdycXk4Yw/1gI+tz17lWQ= @@ -639,8 +639,9 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= +golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= @@ -752,14 +753,16 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.14.0 h1:LGK9IlZ8T9jvdy6cTdfKUCltatMFOehAQo9SRC46UQ8= +golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= @@ -769,8 +772,9 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.4.0 h1:Z81tqI5ddIoXDPvVQ7/7CC9TnLM7ubaFG2qXYd5BbYY= golang.org/x/time v0.4.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= diff --git a/network/dag/keys_test.go b/network/dag/keys_test.go index 89d8bf56e3..ca60aea340 100644 --- a/network/dag/keys_test.go +++ b/network/dag/keys_test.go @@ -39,7 +39,7 @@ func TestNutsKeyResolver_ResolvePublicKey(t *testing.T) { doc := &did.Document{ ID: did.MustParseDID("did:nuts:123"), } - mockKID := doc.ID + mockKID := did.DIDURL{DID: doc.ID} mockKID.Fragment = "key-1" vm, err := did.NewVerificationMethod(mockKID, ssi.JsonWebKey2020, doc.ID, pk.Public()) require.NoError(t, err) diff --git a/network/network_integration_test.go b/network/network_integration_test.go index 3e463b7e73..d689dd7231 100644 --- a/network/network_integration_test.go +++ b/network/network_integration_test.go @@ -1006,7 +1006,7 @@ func resetIntegrationTest(t *testing.T) { writeDIDDocument := func(subject string) { nodeDID := did.MustParseDID(subject) document := did.Document{ID: nodeDID} - kid := nodeDID + kid := did.DIDURL{DID: nodeDID} kid.Fragment = "key-1" key, _ := keyStore.New(audit.TestContext(), func(_ crypto.PublicKey) (string, error) { return kid.String(), nil diff --git a/network/network_test.go b/network/network_test.go index 751822a7ee..fadfb31cd6 100644 --- a/network/network_test.go +++ b/network/network_test.go @@ -615,7 +615,7 @@ func TestNetwork_selfTestNutsCommAddress(t *testing.T) { func TestNetwork_validateNodeDID(t *testing.T) { ctx := context.Background() - keyID := *nodeDID + keyID := did.DIDURL{DID: *nodeDID} keyID.Fragment = "some-key" key := crypto.NewTestKey(keyID.String()).(*crypto.TestKey).PrivateKey documentWithoutNutsCommService := &did.Document{ @@ -1252,7 +1252,7 @@ func TestNetwork_checkHealth(t *testing.T) { }) t.Run("authentication", func(t *testing.T) { - keyID := *nodeDID + keyID := did.DIDURL{DID: *nodeDID} keyID.Fragment = "some-key" completeDocument := &did.Document{ KeyAgreement: []did.VerificationRelationship{ 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/storage/cmd/cmd.go b/storage/cmd/cmd.go index 911aa3396e..9cf8d59626 100644 --- a/storage/cmd/cmd.go +++ b/storage/cmd/cmd.go @@ -38,5 +38,6 @@ func FlagSet() *pflag.FlagSet { flagSet.StringSlice("storage.redis.sentinel.nodes", defs.Redis.Sentinel.Nodes, "Addresses of the Redis Sentinels to connect to initially. Setting this property enables Redis Sentinel.") flagSet.String("storage.redis.sentinel.username", defs.Redis.Sentinel.Username, "Username for authenticating to Redis Sentinels.") flagSet.String("storage.redis.sentinel.password", defs.Redis.Sentinel.Password, "Password for authenticating to Redis Sentinels.") + flagSet.String("storage.sql.connection", defs.SQL.ConnectionString, "Connection string for the SQL database. If not set, it defaults to a SQLite database stored inside the configured data directory") return flagSet } diff --git a/storage/config.go b/storage/config.go index 9cf8a0b653..44f57aa22c 100644 --- a/storage/config.go +++ b/storage/config.go @@ -37,3 +37,9 @@ func DefaultConfig() Config { type SQLConfig struct { ConnectionString string `koanf:"connection"` } + +// SQLConfig specifies config for the SQL storage engine. +type SQLConfig struct { + // ConnectionString is the connection string for the SQL database. + ConnectionString string `koanf:"connection"` +} diff --git a/storage/engine.go b/storage/engine.go index 8ef00d7e43..fecd2db7d6 100644 --- a/storage/engine.go +++ b/storage/engine.go @@ -33,6 +33,7 @@ import ( "github.com/sirupsen/logrus" "gorm.io/driver/sqlite" "gorm.io/gorm" + "path" "strings" "sync" "time" @@ -67,19 +68,18 @@ func (e *engine) Config() interface{} { } // Name returns the name of the engine. -func (e engine) Name() string { +func (e *engine) Name() string { return "Storage" } -func (e engine) Start() error { - err := e.migrateSQL() - if err != nil { - return fmt.Errorf("failed to migrate SQL database: %w", err) +func (e *engine) Start() error { + if err := e.initSQLDatabase(); err != nil { + return fmt.Errorf("failed to initialize SQL database: %w", err) } return nil } -func (e engine) Shutdown() error { +func (e *engine) Shutdown() error { e.storesMux.Lock() defer e.storesMux.Unlock() @@ -110,11 +110,14 @@ func (e engine) Shutdown() error { // Close session database e.sessionDatabase.close() // Close SQL db - underlyingDB, err := e.sqlDB.DB() - if err != nil { - return err + if e.sqlDB != nil { + underlyingDB, err := e.sqlDB.DB() + if err != nil { + return err + } + return underlyingDB.Close() } - return underlyingDB.Close() + return nil } func (e *engine) Configure(config core.ServerConfig) error { @@ -135,7 +138,7 @@ func (e *engine) Configure(config core.ServerConfig) error { return fmt.Errorf("unable to configure BBolt database: %w", err) } e.databases = append(e.databases, bboltDB) - return e.initSQLDatabase() + return nil } func (e *engine) GetProvider(moduleName string) Provider { @@ -149,18 +152,23 @@ func (e *engine) GetSessionDatabase() SessionDatabase { return e.sessionDatabase } -func (e *engine) SQLDatabase() *gorm.DB { +func (e *engine) GetSQLDatabase() *gorm.DB { return e.sqlDB } +// initSQLDatabase initializes the SQL database connection. +// If the connection string is not configured, it defaults to a SQLite database, stored in the node's data directory. +// Note: only SQLite is supported for now func (e *engine) initSQLDatabase() error { - // Note: only SQLite is supported for now + connectionString := e.config.SQL.ConnectionString + if len(connectionString) == 0 { + connectionString = "file:" + path.Join(e.datadir, "sqlite.db") + } var err error - e.sqlDB, err = gorm.Open(sqlite.Open(e.config.SQL.ConnectionString), &gorm.Config{}) - return err -} - -func (e *engine) migrateSQL() error { + e.sqlDB, err = gorm.Open(sqlite.Open(connectionString), &gorm.Config{}) + if err != nil { + return err + } log.Logger().Debug("Running database migrations...") underlyingDB, err := e.sqlDB.DB() if err != nil { @@ -175,22 +183,16 @@ func (e *engine) migrateSQL() error { return err } migrations, err := migrate.NewWithInstance("iofs", sourceDriver, e.sqlDB.Name(), databaseDriver) - migrations.Log = migrationLogger{} if err != nil { return err } - return migrations.Up() -} - -type migrationLogger struct { -} - -func (m migrationLogger) Printf(format string, v ...interface{}) { - log.Logger().Infof(format, v...) -} - -func (m migrationLogger) Verbose() bool { - return log.Logger().Level >= logrus.DebugLevel + migrations.Log = sqlMigrationLogger{} + err = migrations.Up() + if errors.Is(err, migrate.ErrNoChange) { + // There was nothing to migrate + return nil + } + return err } type provider struct { @@ -247,3 +249,14 @@ func (p *provider) getStore(moduleName string, name string, adapter database) (s } return store, err } + +type sqlMigrationLogger struct { +} + +func (m sqlMigrationLogger) Printf(format string, v ...interface{}) { + log.Logger().Infof(format, v...) +} + +func (m sqlMigrationLogger) Verbose() bool { + return log.Logger().Level >= logrus.DebugLevel +} diff --git a/storage/engine_test.go b/storage/engine_test.go index 6ade1a14ba..82f1af7524 100644 --- a/storage/engine_test.go +++ b/storage/engine_test.go @@ -22,10 +22,12 @@ import ( "errors" "github.com/nuts-foundation/go-stoabs" "github.com/nuts-foundation/nuts-node/core" - "github.com/nuts-foundation/nuts-node/storage/test" + "github.com/nuts-foundation/nuts-node/test/io" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "os" + "path" "testing" ) @@ -34,7 +36,7 @@ func Test_New(t *testing.T) { } func Test_engine_Name(t *testing.T) { - assert.Equal(t, "Storage", engine{}.Name()) + assert.Equal(t, "Storage", (&engine{}).Name()) } func Test_engine_lifecycle(t *testing.T) { @@ -99,33 +101,51 @@ func Test_engine_Shutdown(t *testing.T) { } func Test_engine_sqlDatabase(t *testing.T) { - t.Run("test migrations", func(t *testing.T) { - // override default migrations with test migrations - old := sqlMigrationsFS + t.Run("defaults to SQLite in data directory", func(t *testing.T) { + e := New() + dataDir := io.TestDirectory(t) + require.NoError(t, e.Configure(core.ServerConfig{Datadir: dataDir})) + require.NoError(t, e.Start()) t.Cleanup(func() { - sqlMigrationsFS = old + _ = e.Shutdown() }) - sqlMigrationsFS = test.SQLMigrations + assert.FileExists(t, path.Join(dataDir, "sqlite.db")) + }) + t.Run("unable to open SQLite database", func(t *testing.T) { + dataDir := io.TestDirectory(t) + require.NoError(t, os.Remove(dataDir)) e := New() + require.NoError(t, e.Configure(core.ServerConfig{Datadir: dataDir})) + err := e.Start() + assert.EqualError(t, err, "failed to initialize SQL database: unable to open database file") + }) + t.Run("nothing to migrate (already migrated)", func(t *testing.T) { + dataDir := io.TestDirectory(t) + e := New() + require.NoError(t, e.Configure(core.ServerConfig{Datadir: dataDir})) + require.NoError(t, e.Start()) + require.NoError(t, e.Shutdown()) + e = New() + require.NoError(t, e.Configure(core.ServerConfig{Datadir: dataDir})) + require.NoError(t, e.Start()) + require.NoError(t, e.Shutdown()) + }) + t.Run("runs migrations", func(t *testing.T) { + e := New().(*engine) + e.config.SQL.ConnectionString = SQLiteInMemoryConnectionString require.NoError(t, e.Configure(*core.NewServerConfig())) - - // Start() runs migrations require.NoError(t, e.Start()) - // Verify the test table can be queried - underlyingDB, err := e.SQLDatabase().DB() + t.Cleanup(func() { + _ = e.Shutdown() + }) + + underlyingDB, err := e.GetSQLDatabase().DB() require.NoError(t, err) - row := underlyingDB.QueryRow("SELECT count(*) FROM testtable") + row := underlyingDB.QueryRow("SELECT count(*) FROM schema_migrations") require.NoError(t, row.Err()) var count int assert.NoError(t, row.Scan(&count)) assert.Equal(t, 1, count) - - require.NoError(t, e.Shutdown()) - }) - t.Run("actual migrations", func(t *testing.T) { - e := New() - require.NoError(t, e.Configure(*core.NewServerConfig())) - require.NoError(t, e.Start()) - require.NoError(t, e.Shutdown()) }) + } diff --git a/storage/interface.go b/storage/interface.go index 308b88b4d1..83ab900ad0 100644 --- a/storage/interface.go +++ b/storage/interface.go @@ -39,7 +39,8 @@ type Engine interface { // GetSessionDatabase returns the SessionDatabase GetSessionDatabase() SessionDatabase - SQLDatabase() *gorm.DB + // GetSQLDatabase returns the SQL database. + GetSQLDatabase() *gorm.DB } // Provider lets callers get access to stores. diff --git a/storage/mock.go b/storage/mock.go index b015a6d4df..a4aa1c0bfa 100644 --- a/storage/mock.go +++ b/storage/mock.go @@ -84,9 +84,9 @@ func (mr *MockEngineMockRecorder) GetSessionDatabase() *gomock.Call { } // SQLDatabase mocks base method. -func (m *MockEngine) SQLDatabase() *gorm.DB { +func (m *MockEngine) GetSQLDatabase() *gorm.DB { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SQLDatabase") + ret := m.ctrl.Call(m, "GetSQLDatabase") ret0, _ := ret[0].(*gorm.DB) return ret0 } @@ -94,7 +94,7 @@ func (m *MockEngine) SQLDatabase() *gorm.DB { // SQLDatabase indicates an expected call of SQLDatabase. func (mr *MockEngineMockRecorder) SQLDatabase() *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SQLDatabase", reflect.TypeOf((*MockEngine)(nil).SQLDatabase)) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSQLDatabase", reflect.TypeOf((*MockEngine)(nil).GetSQLDatabase)) } // Shutdown mocks base method. diff --git a/storage/sql_migrations/1_initial.down.sql b/storage/sql_migrations/1_initial.down.sql new file mode 100644 index 0000000000..6bb32b4a81 --- /dev/null +++ b/storage/sql_migrations/1_initial.down.sql @@ -0,0 +1 @@ +select 1 \ No newline at end of file diff --git a/storage/sql_migrations/1_initial.up.sql b/storage/sql_migrations/1_initial.up.sql new file mode 100644 index 0000000000..6bb32b4a81 --- /dev/null +++ b/storage/sql_migrations/1_initial.up.sql @@ -0,0 +1 @@ +select 1 \ No newline at end of file diff --git a/storage/sql_migrations/README.md b/storage/sql_migrations/README.md new file mode 100644 index 0000000000..535333d49a --- /dev/null +++ b/storage/sql_migrations/README.md @@ -0,0 +1,9 @@ +This directory contains SQL schema migrations, run at startup of the node. + +Refer to https://github.com/golang-migrate/migrate/blob/master/MIGRATIONS.md on how to write migrations. + +Files should be named according to the following: `__..sql`. +For instance: `2_usecase_list.up.sql`. + +AVOID changing migrations in master (unless the migration breaks the node horribly) for those running a `master` version. +DO NOT alter migrations in a released version: it might break vendor deployments or cause data corruption. \ No newline at end of file diff --git a/storage/test.go b/storage/test.go index 95fb052a18..34cc9a0ef7 100644 --- a/storage/test.go +++ b/storage/test.go @@ -28,8 +28,12 @@ import ( "testing" ) +// SQLiteInMemoryConnectionString is a connection string for an in-memory SQLite database +const SQLiteInMemoryConnectionString = "file::memory:?cache=shared" + func NewTestStorageEngineInDir(dir string) Engine { - result := New() + result := New().(*engine) + result.config.SQL = SQLConfig{ConnectionString: SQLiteInMemoryConnectionString} _ = result.Configure(core.TestServerConfig(core.ServerConfig{Datadir: dir + "/data"})) return result } diff --git a/vcr/ambassador_test.go b/vcr/ambassador_test.go index d32cf908da..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") @@ -375,7 +373,7 @@ func (s stubRoundTripper) RoundTrip(request *http.Request) (*http.Response, erro func documentWithPublicKey(t *testing.T, publicKey crypt.PublicKey) *did.Document { id := did.MustParseDID("did:nuts:CuE3qeFGGLhEAS3gKzhMCeqd1dGa9at5JCbmCfyMU2Ey") - keyID := id + keyID := did.DIDURL{DID: id} keyID.Fragment = "sNGDQ3NlOe6Icv0E7_ufviOLG6Y25bSEyS5EbXBgp8Y" vm, err := did.NewVerificationMethod(keyID, ssi.JsonWebKey2020, id, publicKey) require.NoError(t, err) 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/holder/wallet.go b/vcr/holder/wallet.go index 00b6a13e20..5d96dd3300 100644 --- a/vcr/holder/wallet.go +++ b/vcr/holder/wallet.go @@ -112,7 +112,7 @@ func (h wallet) buildJWTPresentation(ctx context.Context, subjectDID did.DID, cr headers := map[string]interface{}{ jws.TypeKey: "JWT", } - id := subjectDID + id := did.DIDURL{DID: subjectDID} id.Fragment = strings.ToLower(uuid.NewString()) claims := map[string]interface{}{ jwt.IssuerKey: subjectDID.String(), @@ -145,7 +145,7 @@ func (h wallet) buildJSONLDPresentation(ctx context.Context, subjectDID did.DID, types := []ssi.URI{VerifiablePresentationLDType} types = append(types, options.AdditionalTypes...) - id := subjectDID + id := did.DIDURL{DID: subjectDID} id.Fragment = strings.ToLower(uuid.NewString()) idURI := id.URI() unsignedVP := &vc.VerifiablePresentation{ diff --git a/vcr/holder/wallet_test.go b/vcr/holder/wallet_test.go index 00f9eac6e3..fa5a79a1e8 100644 --- a/vcr/holder/wallet_test.go +++ b/vcr/holder/wallet_test.go @@ -86,7 +86,7 @@ func TestWallet_BuildPresentation(t *testing.T) { require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.ID, "id must be set") - assert.Equal(t, testDID, did.MustParseDIDURL(result.ID.String()).WithoutURL(), "id must be the DID of the holder") + assert.Equal(t, testDID, did.MustParseDIDURL(result.ID.String()).DID, "id must be the DID of the holder") assert.NotEmpty(t, result.ID.Fragment, "id must have a fragment") assert.Equal(t, JSONLDPresentationFormat, result.Format()) }) @@ -148,7 +148,7 @@ func TestWallet_BuildPresentation(t *testing.T) { require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, result.ID, "id must be set") - assert.Equal(t, testDID, did.MustParseDIDURL(result.ID.String()).WithoutURL(), "id must be the DID of the holder") + assert.Equal(t, testDID, did.MustParseDIDURL(result.ID.String()).DID, "id must be the DID of the holder") assert.NotEmpty(t, result.ID.Fragment, "id must have a fragment") assert.Equal(t, JWTPresentationFormat, result.Format()) assert.NotNil(t, result.JWT()) @@ -466,7 +466,7 @@ func createCredential(keyID string) vc.VerifiableCredential { "city": "Hengelo", "name": "De beste zorg" }, - "id": "` + did.MustParseDIDURL(keyID).WithoutURL().String() + `" + "id": "` + did.MustParseDIDURL(keyID).DID.String() + `" }, "issuanceDate": "2021-12-24T13:21:29.087205+01:00", "issuer": "did:nuts:4tzMaWfpizVKeA8fscC3JTdWBc3asUWWMj5hUFHdWX3H", diff --git a/vcr/issuer/issuer_test.go b/vcr/issuer/issuer_test.go index d978de26c7..9f3cc9e30e 100644 --- a/vcr/issuer/issuer_test.go +++ b/vcr/issuer/issuer_test.go @@ -690,7 +690,7 @@ func Test_issuer_Revoke(t *testing.T) { store: store, } revocation, err := sut.Revoke(ctx, ssi.MustParseURI("a#38E90E8C-F7E5-4333-B63A-F9DD155A0272")) - assert.EqualError(t, err, "failed to extract issuer: invalid DID") + assert.EqualError(t, err, "failed to extract issuer: invalid DID: DID must start with 'did:'") assert.Nil(t, revocation) }) diff --git a/vcr/issuer/keyresolver_test.go b/vcr/issuer/keyresolver_test.go index 2353aff38c..eae952d030 100644 --- a/vcr/issuer/keyresolver_test.go +++ b/vcr/issuer/keyresolver_test.go @@ -34,7 +34,7 @@ import ( func Test_vdrKeyResolver_ResolveAssertionKey(t *testing.T) { ctx := context.Background() issuerDID, _ := did.ParseDID("did:nuts:123") - methodID := *issuerDID + methodID := did.DIDURL{DID: *issuerDID} methodID.Fragment = "abc" publicKey := crypto.NewTestKey(issuerDID.String() + "abc").Public() newMethod, err := did.NewVerificationMethod(methodID, ssi.JsonWebKey2020, *issuerDID, publicKey) diff --git a/vcr/issuer/network_publisher.go b/vcr/issuer/network_publisher.go index 416f77f275..7eaac674cc 100644 --- a/vcr/issuer/network_publisher.go +++ b/vcr/issuer/network_publisher.go @@ -73,13 +73,13 @@ func (p networkPublisher) PublishCredential(ctx context.Context, verifiableCrede } } - key, err := p.keyResolver.ResolveAssertionKey(ctx, *issuerDID) + key, err := p.keyResolver.ResolveAssertionKey(ctx, issuerDID.DID) if err != nil { return fmt.Errorf("could not resolve an assertion key for issuer: %w", err) } // find did document/metadata for originating TXs - _, meta, err := p.didResolver.Resolve(*issuerDID, nil) + _, meta, err := p.didResolver.Resolve(issuerDID.DID, nil) if err != nil { return err } @@ -103,7 +103,7 @@ func (p networkPublisher) PublishCredential(ctx context.Context, verifiableCrede } func (p networkPublisher) generateParticipants(verifiableCredential vc.VerifiableCredential) ([]did.DID, error) { - issuer, _ := did.ParseDID(verifiableCredential.Issuer.String()) + issuer, _ := did.ParseDIDURL(verifiableCredential.Issuer.String()) participants := make([]did.DID, 0) var ( base []credential.BaseCredentialSubject @@ -118,7 +118,7 @@ func (p networkPublisher) generateParticipants(verifiableCredential vc.Verifiabl } // participants are not the issuer and the credentialSubject.id but the DID that holds the concrete endpoint for the NutsComm service - for _, vcp := range []did.DID{*issuer, *credentialSubjectID} { + for _, vcp := range []did.DID{issuer.DID, *credentialSubjectID} { serviceOwner, err := p.resolveNutsCommServiceOwner(vcp) if err != nil { return nil, fmt.Errorf("failed to resolve participating node (did=%s): %w", vcp.String(), err) @@ -149,7 +149,7 @@ func (p networkPublisher) resolveNutsCommServiceOwner(DID did.DID) (*did.DID, er } func (p networkPublisher) PublishRevocation(ctx context.Context, revocation credential.Revocation) error { - issuerDID, err := did.ParseDIDURL(revocation.Issuer.String()) + issuerDID, err := did.ParseDID(revocation.Issuer.String()) if err != nil { return fmt.Errorf("invalid revocation issuer: %w", err) } diff --git a/vcr/issuer/network_publisher_test.go b/vcr/issuer/network_publisher_test.go index e0199471c6..29b2b0612a 100644 --- a/vcr/issuer/network_publisher_test.go +++ b/vcr/issuer/network_publisher_test.go @@ -335,7 +335,7 @@ func Test_networkPublisher_PublishRevocation(t *testing.T) { publisher := NewNetworkPublisher(nil, nil, nil) revocationToPublish := credential.Revocation{} err := publisher.PublishRevocation(ctx, revocationToPublish) - assert.EqualError(t, err, "invalid revocation issuer: invalid DID") + assert.EqualError(t, err, "invalid revocation issuer: invalid DID: DID must start with 'did:'") }) }) 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 ea44364c24..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) @@ -544,7 +534,7 @@ func (c *vcr) Untrusted(credentialType ssi.URI) ([]ssi.URI, error) { if err != nil { return err } - _, _, err = didResolver.Resolve(*issuerDid, nil) + _, _, err = didResolver.Resolve(issuerDid.DID, nil) if err != nil { if !(errors.Is(err, did.DeactivatedErr) || errors.Is(err, resolver.ErrNoActiveController)) { return err diff --git a/vcr/vcr_test.go b/vcr/vcr_test.go index 8290c4c4c4..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) @@ -382,19 +381,19 @@ func TestVcr_Untrusted(t *testing.T) { t.Run("Untrusted", func(t *testing.T) { confirmTrustedStatus(t, instance, testCredential.Issuer, instance.Untrusted, 0) confirmUntrustedStatus(t, func(issuer ssi.URI) ([]ssi.URI, error) { - mockDidResolver.EXPECT().Resolve(did.MustParseDIDURL(testCredential.Issuer.String()), nil).Return(nil, nil, nil) + mockDidResolver.EXPECT().Resolve(did.MustParseDID(testCredential.Issuer.String()), nil).Return(nil, nil, nil) return instance.Untrusted(issuer) }, 1) }) t.Run("Untrusted - did deactivated", func(t *testing.T) { confirmUntrustedStatus(t, func(issuer ssi.URI) ([]ssi.URI, error) { - mockDidResolver.EXPECT().Resolve(did.MustParseDIDURL(testCredential.Issuer.String()), nil).Return(nil, nil, did.DeactivatedErr) + mockDidResolver.EXPECT().Resolve(did.MustParseDID(testCredential.Issuer.String()), nil).Return(nil, nil, did.DeactivatedErr) return instance.Untrusted(issuer) }, 0) }) t.Run("Untrusted - no active controller", func(t *testing.T) { confirmUntrustedStatus(t, func(issuer ssi.URI) ([]ssi.URI, error) { - mockDidResolver.EXPECT().Resolve(did.MustParseDIDURL(testCredential.Issuer.String()), nil).Return(nil, nil, resolver.ErrNoActiveController) + mockDidResolver.EXPECT().Resolve(did.MustParseDID(testCredential.Issuer.String()), nil).Return(nil, nil, resolver.ErrNoActiveController) return instance.Untrusted(issuer) }, 0) }) diff --git a/vcr/verifier/verifier_test.go b/vcr/verifier/verifier_test.go index 8f1d647b9b..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 } @@ -99,7 +97,7 @@ func Test_verifier_Validate(t *testing.T) { require.NoError(t, err) template := testCredential(t) - template.Issuer = did.MustParseDIDURL(key.KID()).WithoutURL().URI() + template.Issuer = did.MustParseDIDURL(key.KID()).DID.URI() cred, err := vc.CreateJWTVerifiableCredential(audit.TestContext(), template, func(ctx context.Context, claims map[string]interface{}, headers map[string]interface{}) (string, error) { return keyStore.SignJWT(ctx, claims, headers, key) diff --git a/vdr/didjwk/resolver.go b/vdr/didjwk/resolver.go index bcb78e0493..dae339209d 100644 --- a/vdr/didjwk/resolver.go +++ b/vdr/didjwk/resolver.go @@ -82,9 +82,9 @@ func (w Resolver) Resolve(id did.DID, _ *resolver.ResolveMetadata) (*did.Documen // Create a new DID verification method. // See https://www.w3.org/TR/did-core/#verification-methods - keyID := id.WithoutURL() + keyID := did.DIDURL{DID: id} keyID.Fragment = "0" - verificationMethod, err := did.NewVerificationMethod(keyID, godid.JsonWebKey2020, id.WithoutURL(), publicRawKey) + verificationMethod, err := did.NewVerificationMethod(keyID, godid.JsonWebKey2020, id, publicRawKey) if err != nil { return nil, nil, fmt.Errorf("failed to create verification method: %w", err) } @@ -93,7 +93,7 @@ func (w Resolver) Resolve(id did.DID, _ *resolver.ResolveMetadata) (*did.Documen var document did.Document // Set the document ID - document.ID = id.WithoutURL() + document.ID = id // Add the verification method document.AddAssertionMethod(verificationMethod) diff --git a/vdr/didjwk/resolver_test.go b/vdr/didjwk/resolver_test.go index 887621d5fa..0be0e9aee1 100644 --- a/vdr/didjwk/resolver_test.go +++ b/vdr/didjwk/resolver_test.go @@ -69,7 +69,7 @@ func TestResolver_Resolve(t *testing.T) { // Generate a test function using the specified JWK JSON string return func(t *testing.T) { // Parse the DID - id := did.MustParseDIDURL("did:jwk:" + id) + id := did.MustParseDID("did:jwk:" + id) // Resolve the DID, which returns a document/error doc, md, err := resolver.Resolve(id, nil) diff --git a/vdr/didkey/resolver.go b/vdr/didkey/resolver.go index 3e01fb266a..1d84a5809c 100644 --- a/vdr/didkey/resolver.go +++ b/vdr/didkey/resolver.go @@ -121,7 +121,7 @@ func (r Resolver) Resolve(id did.DID, _ *resolver.ResolveMetadata) (*did.Documen }, ID: id, } - keyID := id + keyID := did.DIDURL{DID: id} keyID.Fragment = id.ID vm, err := did.NewVerificationMethod(keyID, ssi.JsonWebKey2020, id, key) if err != nil { diff --git a/vdr/didnuts/ambassador_test.go b/vdr/didnuts/ambassador_test.go index 7c7c630e25..c6d251aeda 100644 --- a/vdr/didnuts/ambassador_test.go +++ b/vdr/didnuts/ambassador_test.go @@ -801,13 +801,11 @@ func newDidDocWithOptions(opts management.DIDCreationOptions) (did.Document, jwk didDocument, key, err := docCreator.Create(audit.TestContext(), opts) signingKey, _ := jwk.FromRaw(key.Public()) thumbStr, _ := crypto.Thumbprint(signingKey) - didStr := fmt.Sprintf("did:nuts:%s", thumbStr) - id, _ := did.ParseDID(didStr) - didDocument.ID = *id + didDocument.ID = did.MustParseDID(fmt.Sprintf("did:nuts:%s", thumbStr)) if err != nil { return did.Document{}, nil, err } - serviceID := didDocument.ID + serviceID := did.DIDURL{DID: didDocument.ID} serviceID.Fragment = "1234" didDocument.Service = []did.Service{ { diff --git a/vdr/didnuts/creator.go b/vdr/didnuts/creator.go index fdd4c4e98a..a271b29a9a 100644 --- a/vdr/didnuts/creator.go +++ b/vdr/didnuts/creator.go @@ -113,7 +113,7 @@ func getKIDName(pKey crypto.PublicKey, idFunc func(key jwk.Key) (string, error)) } // assemble - kid := &did.DID{} + kid := &did.DIDURL{} kid.Method = MethodName kid.ID = idString kid.Fragment = jwKey.KeyID() diff --git a/vdr/didnuts/creator_test.go b/vdr/didnuts/creator_test.go index d3b30145f0..9eb4cec779 100644 --- a/vdr/didnuts/creator_test.go +++ b/vdr/didnuts/creator_test.go @@ -62,7 +62,7 @@ func TestCreator_Create(t *testing.T) { assert.NoError(t, err, "create should not return an error") assert.NotNil(t, doc, "create should return a document") assert.NotNil(t, key, "create should return a Key") - assert.Equal(t, did.MustParseDIDURL(kc.key.KID()).WithoutURL(), doc.ID, "the DID Doc should have the expected id") + assert.Equal(t, did.MustParseDIDURL(kc.key.KID()).DID, doc.ID, "the DID Doc should have the expected id") assert.Len(t, doc.VerificationMethod, 1, "it should have one verificationMethod") assert.Equal(t, kc.key.KID(), doc.VerificationMethod[0].ID.String(), "verificationMethod should have the correct id") diff --git a/vdr/didnuts/didstore/merge_test.go b/vdr/didnuts/didstore/merge_test.go index 649ded67eb..501da614ce 100644 --- a/vdr/didnuts/didstore/merge_test.go +++ b/vdr/didnuts/didstore/merge_test.go @@ -27,15 +27,14 @@ import ( func TestMerge(t *testing.T) { didA, _ := did.ParseDID("did:nuts:A") - didB, _ := did.ParseDID("did:nuts:B") - uriA := ssi.MustParseURI("did:nuts:A#A") - uriB := ssi.MustParseURI("did:nuts:A#B") - vmA := &did.VerificationMethod{ID: *didA, Type: ssi.JsonWebKey2020} - vmB := &did.VerificationMethod{ID: *didB, Type: ssi.JsonWebKey2020} + uriA := did.MustParseDIDURL("did:nuts:A#A") + uriB := did.MustParseDIDURL("did:nuts:A#B") + vmA := &did.VerificationMethod{ID: uriA, Type: ssi.JsonWebKey2020} + vmB := &did.VerificationMethod{ID: uriB, Type: ssi.JsonWebKey2020} vrA := &did.VerificationRelationship{VerificationMethod: vmA} vrB := &did.VerificationRelationship{VerificationMethod: vmB} - serviceA := did.Service{ID: uriA, Type: "type A"} - serviceB := did.Service{ID: uriB, Type: "type B"} + serviceA := did.Service{ID: uriA.URI(), Type: "type A"} + serviceB := did.Service{ID: uriB.URI(), Type: "type B"} type test struct { title string diff --git a/vdr/didnuts/manipulator.go b/vdr/didnuts/manipulator.go index 9e2f2ad0a4..4702cee121 100644 --- a/vdr/didnuts/manipulator.go +++ b/vdr/didnuts/manipulator.go @@ -73,7 +73,7 @@ func (u Manipulator) AddVerificationMethod(ctx context.Context, id did.DID, keyU } // RemoveVerificationMethod is a helper function to remove a verificationMethod from a DID Document -func (u Manipulator) RemoveVerificationMethod(ctx context.Context, id, keyID did.DID) error { +func (u Manipulator) RemoveVerificationMethod(ctx context.Context, id did.DID, keyID did.DIDURL) error { doc, meta, err := u.Resolver.Resolve(id, &resolver.ResolveMetadata{AllowDeactivated: true}) if err != nil { return err diff --git a/vdr/didnuts/resolver_test.go b/vdr/didnuts/resolver_test.go index 92bc9206c3..d4379a0f98 100644 --- a/vdr/didnuts/resolver_test.go +++ b/vdr/didnuts/resolver_test.go @@ -244,7 +244,7 @@ func TestResolveControllers(t *testing.T) { // Doc C is active docCID, _ := did.ParseDID("did:nuts:C") - docCIDCapInv := *docCID + docCIDCapInv := did.DIDURL{DID: *docCID} docCIDCapInv.Fragment = "cap-inv" docC := did.Document{ID: *docCID} docC.AddCapabilityInvocation(&did.VerificationMethod{ID: docCIDCapInv}) @@ -252,7 +252,7 @@ func TestResolveControllers(t *testing.T) { // Doc A is active docAID, _ := did.ParseDID("did:nuts:A") - docAIDCapInv := *docAID + docAIDCapInv := did.DIDURL{DID: *docAID} docAIDCapInv.Fragment = "cap-inv" docA := did.Document{ID: *docAID} docA.Controller = []did.DID{docA.ID, docB.ID, docC.ID} 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 +} diff --git a/vdr/didweb/web.go b/vdr/didweb/web.go index 288aaebc94..368222f057 100644 --- a/vdr/didweb/web.go +++ b/vdr/didweb/web.go @@ -117,8 +117,8 @@ func (w Resolver) Resolve(id did.DID, _ *resolver.ResolveMetadata) (*did.Documen return nil, nil, fmt.Errorf("did:web JSON unmarshal error: %w", err) } - if !document.ID.Equals(id.WithoutURL()) { - return nil, nil, fmt.Errorf("did:web document ID mismatch: %s != %s", document.ID, id.WithoutURL()) + if !document.ID.Equals(id) { + return nil, nil, fmt.Errorf("did:web document ID mismatch: %s != %s", document.ID, id) } return &document, &resolver.DocumentMetadata{}, nil diff --git a/vdr/didweb/web_test.go b/vdr/didweb/web_test.go index 577fdca1ee..d8c7093a45 100644 --- a/vdr/didweb/web_test.go +++ b/vdr/didweb/web_test.go @@ -131,15 +131,6 @@ func TestResolver_Resolve(t *testing.T) { assert.NotNil(t, md) assert.NotNil(t, doc) }) - t.Run("resolve DID with path", func(t *testing.T) { - id := did.MustParseDIDURL(baseDID.String() + "/some/path") - doc, md, err := resolver.Resolve(id, nil) - - require.NoError(t, err) - assert.NotNil(t, md) - require.NotNil(t, doc) - assert.Equal(t, baseDID, doc.ID) - }) t.Run("resolve without port number", func(t *testing.T) { // The other tests all use a port number, since the test HTTPS server is running on a random port. @@ -163,7 +154,7 @@ func TestResolver_Resolve(t *testing.T) { assert.Equal(t, "https://example.com/.well-known/did.json", requestURL) }) t.Run("not found", func(t *testing.T) { - id := did.MustParseDIDURL(baseDID.String() + ":not-found") + id := did.MustParseDID(baseDID.String() + ":not-found") doc, md, err := resolver.Resolve(id, nil) assert.EqualError(t, err, "did:web non-ok HTTP status: 404 Not Found") @@ -171,7 +162,7 @@ func TestResolver_Resolve(t *testing.T) { assert.Nil(t, doc) }) t.Run("unsupported content-type", func(t *testing.T) { - id := did.MustParseDIDURL(baseDID.String() + ":unsupported-content-type") + id := did.MustParseDID(baseDID.String() + ":unsupported-content-type") doc, md, err := resolver.Resolve(id, nil) assert.EqualError(t, err, "did:web unsupported content-type: text/plain") @@ -179,7 +170,7 @@ func TestResolver_Resolve(t *testing.T) { assert.Nil(t, doc) }) t.Run("server returns invalid JSON", func(t *testing.T) { - id := did.MustParseDIDURL(baseDID.String() + ":invalid-json") + id := did.MustParseDID(baseDID.String() + ":invalid-json") doc, md, err := resolver.Resolve(id, nil) assert.EqualError(t, err, "did:web JSON unmarshal error: invalid character '\\x01' looking for beginning of value") @@ -187,7 +178,7 @@ func TestResolver_Resolve(t *testing.T) { assert.Nil(t, doc) }) t.Run("server returns no content-type", func(t *testing.T) { - id := did.MustParseDIDURL(baseDID.String() + ":no-content-type") + id := did.MustParseDID(baseDID.String() + ":no-content-type") doc, md, err := resolver.Resolve(id, nil) assert.EqualError(t, err, "did:web invalid content-type: mime: no media type") @@ -195,7 +186,7 @@ func TestResolver_Resolve(t *testing.T) { assert.Nil(t, doc) }) t.Run("ID in document does not match DID being resolved", func(t *testing.T) { - id := did.MustParseDIDURL(baseDID.String() + ":invalid-id-in-document") + id := did.MustParseDID(baseDID.String() + ":invalid-id-in-document") doc, md, err := resolver.Resolve(id, nil) assert.ErrorContains(t, err, "did:web document ID mismatch") diff --git a/vdr/management/management.go b/vdr/management/management.go index 2bd7846ea6..76d53eb145 100644 --- a/vdr/management/management.go +++ b/vdr/management/management.go @@ -58,7 +58,7 @@ type DocManipulator interface { // It returns an ErrNotFound when there is no VerificationMethod with the provided kid in the document. // It returns an ErrDeactivated when the DID document has the deactivated state. // It returns an ErrDIDNotManagedByThisNode if the DID document is not managed by this node. - RemoveVerificationMethod(ctx context.Context, id, keyID did.DID) error + RemoveVerificationMethod(ctx context.Context, id did.DID, keyID did.DIDURL) error // AddVerificationMethod generates a new key and adds it, wrapped as a VerificationMethod, to a DID document. // It accepts a DID as identifier for the DID document. diff --git a/vdr/management/management_mock.go b/vdr/management/management_mock.go index 29159293b4..f3d5f10cc2 100644 --- a/vdr/management/management_mock.go +++ b/vdr/management/management_mock.go @@ -146,7 +146,7 @@ func (mr *MockDocManipulatorMockRecorder) Deactivate(ctx, id any) *gomock.Call { } // RemoveVerificationMethod mocks base method. -func (m *MockDocManipulator) RemoveVerificationMethod(ctx context.Context, id, keyID did.DID) error { +func (m *MockDocManipulator) RemoveVerificationMethod(ctx context.Context, id did.DID, keyID did.DIDURL) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "RemoveVerificationMethod", ctx, id, keyID) ret0, _ := ret[0].(error) diff --git a/vdr/resolver/did.go b/vdr/resolver/did.go index 9e1a93626e..0db789b871 100644 --- a/vdr/resolver/did.go +++ b/vdr/resolver/did.go @@ -158,7 +158,7 @@ func GetDIDFromURL(didURL string) (did.DID, error) { if err != nil { return did.DID{}, err } - return parsed.WithoutURL(), nil + return parsed.DID, nil } // IsDeactivated returns true if the DID.Document has already been deactivated diff --git a/vdr/resolver/did_test.go b/vdr/resolver/did_test.go index 98cabb7a4e..f0b0cbf743 100644 --- a/vdr/resolver/did_test.go +++ b/vdr/resolver/did_test.go @@ -97,7 +97,7 @@ func Test_deactivatedError_Is(t *testing.T) { func newDidDoc() did.Document { privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) id := did.MustParseDID("did:example:sakjsakldjsakld") - keyID := id + keyID := did.DIDURL{DID: id} keyID.Fragment = "key-1" vm, _ := did.NewVerificationMethod(keyID, ssi.JsonWebKey2020, id, privateKey.Public()) doc := did.Document{ diff --git a/vdr/resolver/key_test.go b/vdr/resolver/key_test.go index a103ae0fdc..d9db59ea7e 100644 --- a/vdr/resolver/key_test.go +++ b/vdr/resolver/key_test.go @@ -51,7 +51,7 @@ func TestKeyResolver_ResolveKey(t *testing.T) { }) t.Run("error - key not found", func(t *testing.T) { - keyId, key, err := keyResolver.ResolveKey(did.MustParseDIDURL(doc.ID.String()), nil, CapabilityDelegation) + keyId, key, err := keyResolver.ResolveKey(did.MustParseDID(doc.ID.String()), nil, CapabilityDelegation) assert.EqualError(t, err, "key not found in DID document") assert.Empty(t, keyId) assert.Nil(t, key) @@ -86,7 +86,7 @@ func TestKeyResolver_ResolveKeyByID(t *testing.T) { }) t.Run("error - document not found", func(t *testing.T) { - unknownDID := did.MustParseDIDURL("did:example:123") + unknownDID := did.MustParseDID("did:example:123") resolver.EXPECT().Resolve(unknownDID, gomock.Any()).Return(nil, nil, ErrNotFound) key, err := keyResolver.ResolveKeyByID(unknownDID.String()+"#456", nil, AssertionMethod) assert.EqualError(t, err, "unable to find the DID document") diff --git a/vdr/vdr_test.go b/vdr/vdr_test.go index 89f17ea820..208776bd65 100644 --- a/vdr/vdr_test.go +++ b/vdr/vdr_test.go @@ -339,7 +339,7 @@ func TestVDR_ConflictingDocuments(t *testing.T) { t.Run("ok - 1 owned conflict", func(t *testing.T) { client := crypto.NewMemoryCryptoInstance() - keyID := TestDIDA + keyID := did.DIDURL{DID: TestDIDA} keyID.Fragment = "1" _, _ = client.New(audit.TestContext(), crypto.StringNamingFunc(keyID.String())) vdr := NewVDR(client, nil, didstore.NewTestStore(t), nil)