From 2f9e393e9197a5597a9aa5f886074686f433b9a8 Mon Sep 17 00:00:00 2001 From: Wout Slakhorst Date: Tue, 12 Dec 2023 15:39:24 +0100 Subject: [PATCH] added authorize endpoint as specified by rfc6549 authorization code (#2626) wip added bunch of tests added missing API tests fix failing test add tests for failing directpost responses refactor ami client and reuse response parsing/code checking happy flow tests for holder service error tests for holder service comment touchup on some comments fix didkey pattern remove url decode from middleware, now handled by codegen bind add callback, fix request logger fix logger test add e2e test added handling of error direct_post --- auth/api/iam/api.go | 9 +- auth/api/iam/api_test.go | 2 +- auth/api/iam/generated.go | 101 ++++- auth/api/iam/openid4vp.go | 311 +++++++++++-- auth/api/iam/openid4vp_test.go | 422 +++++++++++++++++- auth/api/iam/s2s_vptoken.go | 103 +---- auth/api/iam/s2s_vptoken_test.go | 4 +- auth/api/iam/session.go | 50 ++- auth/api/iam/types.go | 9 + auth/api/iam/user.go | 5 + auth/api/iam/validation.go | 100 +++++ auth/client/iam/client.go | 10 +- auth/services/oauth/holder.go | 16 +- auth/services/oauth/holder_test.go | 14 +- auth/services/oauth/interface.go | 5 +- auth/services/oauth/mock.go | 16 +- auth/services/oauth/relying_party.go | 9 +- auth/services/oauth/relying_party_test.go | 3 +- codegen/configs/auth_iam.yaml | 2 + docs/_static/auth/iam.yaml | 65 +++ .../oauth-flow/openid4vp/docker-compose.yml | 56 +++ .../oauth-flow/openid4vp/node-A/nginx.conf | 47 ++ .../oauth-flow/openid4vp/node-A/nuts.yaml | 21 + .../node-A/presentationexchangemapping.json | 43 ++ .../oauth-flow/openid4vp/node-B/nginx.conf | 47 ++ .../oauth-flow/openid4vp/node-B/nuts.yaml | 21 + .../node-B/presentationexchangemapping.json | 43 ++ e2e-tests/oauth-flow/openid4vp/run-test.sh | 125 ++++++ e2e-tests/oauth-flow/run-tests.sh | 7 + http/requestlogger.go | 5 +- http/requestlogger_test.go | 7 +- vcr/holder/wallet_test.go | 2 - vcr/pe/presentation_submission.go | 13 +- vcr/pe/presentation_submission_test.go | 171 +++---- 34 files changed, 1604 insertions(+), 260 deletions(-) create mode 100644 auth/api/iam/validation.go create mode 100644 e2e-tests/oauth-flow/openid4vp/docker-compose.yml create mode 100644 e2e-tests/oauth-flow/openid4vp/node-A/nginx.conf create mode 100644 e2e-tests/oauth-flow/openid4vp/node-A/nuts.yaml create mode 100644 e2e-tests/oauth-flow/openid4vp/node-A/presentationexchangemapping.json create mode 100644 e2e-tests/oauth-flow/openid4vp/node-B/nginx.conf create mode 100644 e2e-tests/oauth-flow/openid4vp/node-B/nuts.yaml create mode 100644 e2e-tests/oauth-flow/openid4vp/node-B/presentationexchangemapping.json create mode 100755 e2e-tests/oauth-flow/openid4vp/run-test.sh diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 2ae5483358..7b4623ba39 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -139,11 +139,8 @@ func (r Wrapper) HandleTokenRequest(ctx context.Context, request HandleTokenRequ case "authorization_code": // Options: // - OpenID4VCI - // - OpenID4VP, vp_token is sent in Token Response - return nil, oauth.OAuth2Error{ - Code: oauth.UnsupportedGrantType, - Description: "not implemented yet", - } + // - OpenID4VP + return r.handleAccessTokenRequest(ctx, *ownDID, request.Body.Code, request.Body.RedirectUri, request.Body.ClientId) case "urn:ietf:params:oauth:grant-type:pre-authorized_code": // Options: // - OpenID4VCI @@ -399,7 +396,7 @@ func (r Wrapper) RequestAccessToken(ctx context.Context, request RequestAccessTo return nil, err } if !isWallet { - return nil, core.InvalidInputError("did not owned by this node: %w", err) + return nil, core.InvalidInputError("did not owned by this node") } if request.Body.UserID != nil && len(*request.Body.UserID) > 0 { // forward to user flow diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index a661584075..166590a82b 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -273,7 +273,7 @@ func TestWrapper_HandleAuthorizeRequest(t *testing.T) { ctx.vdr.EXPECT().IsOwner(gomock.Any(), holderDID).Return(true, nil) ctx.holderRole.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/iam/verifier").Return(&clientMetadata, nil) ctx.holderRole.EXPECT().PresentationDefinition(gomock.Any(), "https://example.com/iam/verifier/presentation_definition?scope=test").Return(&pe.PresentationDefinition{}, nil) - ctx.holderRole.EXPECT().BuildPresentation(gomock.Any(), holderDID, pe.PresentationDefinition{}, clientMetadata.VPFormats, "nonce").Return(&vc.VerifiablePresentation{}, &pe.PresentationSubmission{}, nil) + ctx.holderRole.EXPECT().BuildPresentation(gomock.Any(), holderDID, pe.PresentationDefinition{}, clientMetadata.VPFormats, "nonce", verifierDID).Return(&vc.VerifiablePresentation{}, &pe.PresentationSubmission{}, nil) ctx.holderRole.EXPECT().PostAuthorizationResponse(gomock.Any(), vc.VerifiablePresentation{}, pe.PresentationSubmission{}, "https://example.com/iam/verifier/response", "state").Return("https://example.com/iam/holder/redirect", nil) res, err := ctx.client.HandleAuthorizeRequest(requestContext(map[string]string{ diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index eb46a661b8..d795fb3b20 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -105,12 +105,30 @@ type PresentationDefinitionParams struct { Scope string `form:"scope" json:"scope"` } +// HandleAuthorizeResponseFormdataBody defines parameters for HandleAuthorizeResponse. +type HandleAuthorizeResponseFormdataBody struct { + // Error error code as defined by the OAuth2 specification + Error *string `form:"error,omitempty" json:"error,omitempty"` + + // ErrorDescription error description as defined by the OAuth2 specification + ErrorDescription *string `form:"error_description,omitempty" json:"error_description,omitempty"` + PresentationSubmission *string `form:"presentation_submission,omitempty" json:"presentation_submission,omitempty"` + + // State the client state for the verifier + State *string `form:"state,omitempty" json:"state,omitempty"` + + // VpToken A Verifiable Presentation in either JSON-LD or JWT format. + VpToken *string `form:"vp_token,omitempty" json:"vp_token,omitempty"` +} + // HandleTokenRequestFormdataBody defines parameters for HandleTokenRequest. type HandleTokenRequestFormdataBody struct { Assertion *string `form:"assertion,omitempty" json:"assertion,omitempty"` + ClientId *string `form:"client_id,omitempty" json:"client_id,omitempty"` Code *string `form:"code,omitempty" json:"code,omitempty"` GrantType string `form:"grant_type" json:"grant_type"` PresentationSubmission *string `form:"presentation_submission,omitempty" json:"presentation_submission,omitempty"` + RedirectUri *string `form:"redirect_uri,omitempty" json:"redirect_uri,omitempty"` Scope *string `form:"scope,omitempty" json:"scope,omitempty"` } @@ -119,7 +137,7 @@ type RequestAccessTokenJSONBody struct { // RedirectURL The URL to which the user-agent will be redirected after the authorization request. RedirectURL *string `json:"redirectURL,omitempty"` - // Scope The scope that will be The service for which this access token can be used. + // Scope The scope that will be the service for which this access token can be used. Scope string `json:"scope"` // UserID The ID of the user for which this access token is requested. @@ -127,6 +145,9 @@ type RequestAccessTokenJSONBody struct { Verifier string `json:"verifier"` } +// HandleAuthorizeResponseFormdataRequestBody defines body for HandleAuthorizeResponse for application/x-www-form-urlencoded ContentType. +type HandleAuthorizeResponseFormdataRequestBody HandleAuthorizeResponseFormdataBody + // HandleTokenRequestFormdataRequestBody defines body for HandleTokenRequest for application/x-www-form-urlencoded ContentType. type HandleTokenRequestFormdataRequestBody HandleTokenRequestFormdataBody @@ -153,6 +174,9 @@ type ServerInterface interface { // Used by relying parties to obtain a presentation definition for desired scopes as specified by Nuts RFC021. // (GET /iam/{id}/presentation_definition) PresentationDefinition(ctx echo.Context, id string, params PresentationDefinitionParams) error + // Used by wallets to post the authorization response or error to. + // (POST /iam/{id}/response) + HandleAuthorizeResponse(ctx echo.Context, id string) error // Used by to request access- or refresh tokens. // (POST /iam/{id}/token) HandleTokenRequest(ctx echo.Context, id string) error @@ -277,6 +301,24 @@ func (w *ServerInterfaceWrapper) PresentationDefinition(ctx echo.Context) error return err } +// HandleAuthorizeResponse converts echo context to params. +func (w *ServerInterfaceWrapper) HandleAuthorizeResponse(ctx echo.Context) error { + var err error + // ------------- Path parameter "id" ------------- + var id string + + err = runtime.BindStyledParameterWithLocation("simple", false, "id", runtime.ParamLocationPath, ctx.Param("id"), &id) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter id: %s", err)) + } + + ctx.Set(JwtBearerAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.HandleAuthorizeResponse(ctx, id) + return err +} + // HandleTokenRequest converts echo context to params. func (w *ServerInterfaceWrapper) HandleTokenRequest(ctx echo.Context) error { var err error @@ -357,6 +399,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/iam/:id/did.json", wrapper.GetWebDID) router.GET(baseURL+"/iam/:id/oauth-client", wrapper.OAuthClientMetadata) router.GET(baseURL+"/iam/:id/presentation_definition", wrapper.PresentationDefinition) + router.POST(baseURL+"/iam/:id/response", wrapper.HandleAuthorizeResponse) router.POST(baseURL+"/iam/:id/token", wrapper.HandleTokenRequest) router.POST(baseURL+"/internal/auth/v2/accesstoken/introspect", wrapper.IntrospectAccessToken) router.POST(baseURL+"/internal/auth/v2/:did/request-access-token", wrapper.RequestAccessToken) @@ -545,6 +588,24 @@ func (response PresentationDefinitiondefaultApplicationProblemPlusJSONResponse) return json.NewEncoder(w).Encode(response.Body) } +type HandleAuthorizeResponseRequestObject struct { + Id string `json:"id"` + Body *HandleAuthorizeResponseFormdataRequestBody +} + +type HandleAuthorizeResponseResponseObject interface { + VisitHandleAuthorizeResponseResponse(w http.ResponseWriter) error +} + +type HandleAuthorizeResponse200JSONResponse RedirectResponse + +func (response HandleAuthorizeResponse200JSONResponse) VisitHandleAuthorizeResponseResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + type HandleTokenRequestRequestObject struct { Id string `json:"id"` Body *HandleTokenRequestFormdataRequestBody @@ -679,6 +740,9 @@ type StrictServerInterface interface { // Used by relying parties to obtain a presentation definition for desired scopes as specified by Nuts RFC021. // (GET /iam/{id}/presentation_definition) PresentationDefinition(ctx context.Context, request PresentationDefinitionRequestObject) (PresentationDefinitionResponseObject, error) + // Used by wallets to post the authorization response or error to. + // (POST /iam/{id}/response) + HandleAuthorizeResponse(ctx context.Context, request HandleAuthorizeResponseRequestObject) (HandleAuthorizeResponseResponseObject, error) // Used by to request access- or refresh tokens. // (POST /iam/{id}/token) HandleTokenRequest(ctx context.Context, request HandleTokenRequestRequestObject) (HandleTokenRequestResponseObject, error) @@ -829,6 +893,41 @@ func (sh *strictHandler) PresentationDefinition(ctx echo.Context, id string, par return nil } +// HandleAuthorizeResponse operation middleware +func (sh *strictHandler) HandleAuthorizeResponse(ctx echo.Context, id string) error { + var request HandleAuthorizeResponseRequestObject + + request.Id = id + + if form, err := ctx.FormParams(); err == nil { + var body HandleAuthorizeResponseFormdataRequestBody + if err := runtime.BindForm(&body, form, nil, nil); err != nil { + return err + } + request.Body = &body + } else { + return err + } + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.HandleAuthorizeResponse(ctx.Request().Context(), request.(HandleAuthorizeResponseRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "HandleAuthorizeResponse") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(HandleAuthorizeResponseResponseObject); ok { + return validResponse.VisitHandleAuthorizeResponseResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + // HandleTokenRequest operation middleware func (sh *strictHandler) HandleTokenRequest(ctx echo.Context, id string) error { var request HandleTokenRequestRequestObject diff --git a/auth/api/iam/openid4vp.go b/auth/api/iam/openid4vp.go index 1354e27438..b9ff1c3e8a 100644 --- a/auth/api/iam/openid4vp.go +++ b/auth/api/iam/openid4vp.go @@ -28,6 +28,7 @@ import ( "net/url" "slices" "strings" + "time" "github.com/google/uuid" "github.com/labstack/echo/v4" @@ -86,16 +87,16 @@ func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, verifier // the walletDID must be a did:web walletDID, err := did.ParseDID(walletID) if err != nil || walletDID.Method != "web" { - return nil, oauthError(oauth.InvalidRequest, "invalid client_id parameter (only did:web is supported)", redirectURL) + return nil, withCallbackURI(oauthError(oauth.InvalidRequest, "invalid client_id parameter (only did:web is supported)"), redirectURL) } metadata, err := r.auth.Verifier().AuthorizationServerMetadata(ctx, *walletDID) if err != nil { - return nil, oauthError(oauth.ServerError, "failed to get metadata from wallet", redirectURL) + return nil, withCallbackURI(oauthError(oauth.ServerError, "failed to get metadata from wallet"), redirectURL) } // own generic endpoint ownURL, err := didweb.DIDToURL(verifier) if err != nil { - return nil, oauthError(oauth.ServerError, "invalid verifier DID", redirectURL) + return nil, withCallbackURI(oauthError(oauth.ServerError, "invalid verifier DID"), redirectURL) } // generate presentation_definition_uri based on own presentation_definition endpoint + scope pdURL := ownURL.JoinPath("presentation_definition") @@ -118,25 +119,28 @@ func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, verifier // &nonce=n-0S6_WzA2Mj HTTP/1.1 walletURL, err := url.Parse(metadata.AuthorizationEndpoint) if err != nil || len(metadata.AuthorizationEndpoint) == 0 { - return nil, oauthError(oauth.InvalidRequest, "invalid wallet endpoint", redirectURL) + return nil, withCallbackURI(oauthError(oauth.InvalidRequest, "invalid wallet endpoint"), redirectURL) } nonce := crypto.GenerateNonce() callbackURL := *ownURL callbackURL.Path, err = url.JoinPath(callbackURL.Path, "response") if err != nil { - return nil, oauthError(oauth.ServerError, "failed to construct redirect path", redirectURL) + return nil, withCallbackURI(oauthError(oauth.ServerError, "failed to construct redirect path"), redirectURL) } metadataURL, err := r.auth.Verifier().ClientMetadataURL(verifier) if err != nil { - return nil, oauthError(oauth.ServerError, "failed to construct metadata URL", redirectURL) + return nil, withCallbackURI(oauthError(oauth.ServerError, "failed to construct metadata URL"), redirectURL) } // check metadata for supported client_id_schemes if !slices.Contains(metadata.ClientIdSchemesSupported, didScheme) { - return nil, oauthError(oauth.InvalidRequest, "wallet metadata does not contain did in client_id_schemes_supported", redirectURL) + return nil, withCallbackURI(oauthError(oauth.InvalidRequest, "wallet metadata does not contain did in client_id_schemes_supported"), redirectURL) } + // create a client state for the verifier + state := crypto.GenerateNonce() + // todo: because of the did scheme, the request needs to be signed using JAR according to ยง5.7 of the openid4vp spec authServerURL := httpNuts.AddQueryParams(*walletURL, map[string]string{ @@ -148,17 +152,21 @@ func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, verifier clientMetadataURIParam: metadataURL.String(), responseModeParam: responseModeDirectPost, nonceParam: nonce, + stateParam: state, }) openid4vpRequest := OAuthSession{ ClientID: verifier.String(), Scope: params[scopeParam], OwnDID: verifier, - ClientState: nonce, + ClientState: state, RedirectURI: redirectURL.String(), } - // use nonce to store authorization request in session store + // use nonce and state to store authorization request in session store if err = r.oauthNonceStore().Put(nonce, openid4vpRequest); err != nil { - return nil, oauthError(oauth.ServerError, "failed to store server state", redirectURL) + return nil, oauth.OAuth2Error{Code: oauth.ServerError, Description: "failed to store server state"} + } + if err = r.oauthClientStateStore().Put(state, openid4vpRequest); err != nil { + return nil, oauth.OAuth2Error{Code: oauth.ServerError, Description: "failed to store server state"} } return HandleAuthorizeRequest302Response{ @@ -176,6 +184,9 @@ func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, verifier // The following parameters are expected // response_type, REQUIRED. Value MUST be set to "vp_token". // client_id, REQUIRED. This must be a did:web +// client_id_scheme, REQUIRED. This must be did +// clientMetadataURIParam, REQUIRED. This must be the verifier metadata endpoint +// nonce, REQUIRED. // response_uri, REQUIRED. This must be the verifier node url // response_mode, REQUIRED. Value MUST be "direct_post" // presentation_definition_uri, REQUIRED. For getting the presentation definition @@ -188,48 +199,53 @@ func (r Wrapper) handleAuthorizeRequestFromVerifier(ctx context.Context, walletD if responseMode != responseModeDirectPost { return nil, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "invalid response_mode parameter"} } + // check the response URL because later errors will redirect to this URL responseURI, responseOK := params[responseURIParam] if !responseOK { return nil, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "missing response_uri parameter"} } + // we now have a valid responseURI, if we also have a clientState then the verifier can also redirect back to the original caller using its client state + state := params[stateParam] + clientIDScheme := params[clientIDSchemeParam] if clientIDScheme != didScheme { - return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "invalid client_id_scheme parameter"}, responseURI) + return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "invalid client_id_scheme parameter"}, responseURI, state) } + verifierID := params[clientIDParam] // the verifier must be a did:web verifierDID, err := did.ParseDID(verifierID) if err != nil || verifierDID.Method != "web" { - return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "invalid client_id parameter (only did:web is supported)"}, responseURI) + return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "invalid client_id parameter (only did:web is supported)"}, responseURI, state) } + nonce, ok := params[nonceParam] if !ok { - return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "missing nonce parameter"}, responseURI) + return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "missing nonce parameter"}, responseURI, state) } // get verifier metadata clientMetadataURI := params[clientMetadataURIParam] - // we ignore any client_metadata, but officially an error must be returned when that param is present. metadata, err := r.auth.Holder().ClientMetadata(ctx, clientMetadataURI) if err != nil { - return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.ServerError, Description: "failed to get client metadata (verifier)"}, responseURI) + return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.ServerError, Description: "failed to get client metadata (verifier)"}, responseURI, state) } // get presentation_definition from presentation_definition_uri presentationDefinitionURI := params[presentationDefUriParam] presentationDefinition, err := r.auth.Holder().PresentationDefinition(ctx, presentationDefinitionURI) if err != nil { - return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidPresentationDefinitionURI, Description: fmt.Sprintf("failed to retrieve presentation definition on %s", presentationDefinitionURI)}, responseURI) + return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidPresentationDefinitionURI, Description: fmt.Sprintf("failed to retrieve presentation definition on %s", presentationDefinitionURI)}, responseURI, state) } // at this point in the flow it would be possible to ask the user to confirm the credentials to use // all params checked, delegate responsibility to the holder - vp, submission, err := r.auth.Holder().BuildPresentation(ctx, walletDID, *presentationDefinition, metadata.VPFormats, nonce) + vp, submission, err := r.auth.Holder().BuildPresentation(ctx, walletDID, *presentationDefinition, metadata.VPFormats, nonce, *verifierDID) if err != nil { if errors.Is(err, oauthServices.ErrNoCredentials) { - return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "no credentials available"}, responseURI) + return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "no credentials available"}, responseURI, state) } - return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.ServerError, Description: err.Error()}, responseURI) + return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.ServerError, Description: err.Error()}, responseURI, state) } // any error here is a server error, might need a fixup to prevent exposing to a user @@ -243,7 +259,6 @@ func (r Wrapper) sendAndHandleDirectPost(ctx context.Context, vp vc.VerifiablePr if err != nil { return nil, err } - return HandleAuthorizeRequest302Response{ HandleAuthorizeRequest302ResponseHeaders{ Location: redirectURI, @@ -254,8 +269,8 @@ func (r Wrapper) sendAndHandleDirectPost(ctx context.Context, vp vc.VerifiablePr // sendAndHandleDirectPostError sends errors from handleAuthorizeRequestFromVerifier as direct_post to the verifier. The verifier responds with a redirect to the client (including error fields). // If the direct post fails, the user-agent will be redirected back to the client with an error. (Original redirect_uri). // If no redirect_uri is present, the user-agent will be redirected to the error page. -func (r Wrapper) sendAndHandleDirectPostError(ctx context.Context, auth2Error oauth.OAuth2Error, verifierResponseURI string) (HandleAuthorizeRequestResponseObject, error) { - redirectURI, err := r.auth.Holder().PostError(ctx, auth2Error, verifierResponseURI) +func (r Wrapper) sendAndHandleDirectPostError(ctx context.Context, auth2Error oauth.OAuth2Error, verifierResponseURI string, verifierClientState string) (HandleAuthorizeRequestResponseObject, error) { + redirectURI, err := r.auth.Holder().PostError(ctx, auth2Error, verifierResponseURI, verifierClientState) if err == nil { return HandleAuthorizeRequest302Response{ HandleAuthorizeRequest302ResponseHeaders{ @@ -286,6 +301,253 @@ func (r Wrapper) sendAndHandleDirectPostError(ctx context.Context, auth2Error oa }, nil } +func (r Wrapper) HandleAuthorizeResponse(ctx context.Context, request HandleAuthorizeResponseRequestObject) (HandleAuthorizeResponseResponseObject, error) { + // this can be an error post or a submission. We check for the presence of the error parameter. + if request.Body.Error != nil { + return r.handleAuthorizeResponseError(ctx, request) + } + + // successful response + return r.handleAuthorizeResponseSubmission(ctx, request) +} + +func (r Wrapper) handleAuthorizeResponseError(_ context.Context, request HandleAuthorizeResponseRequestObject) (HandleAuthorizeResponseResponseObject, error) { + // we know error is not empty + code := *request.Body.Error + var description string + if request.Body.ErrorDescription != nil { + description = *request.Body.ErrorDescription + } + + // check if the state param is present and if we have a client state for it + var oauthSession OAuthSession + if request.Body.State != nil { + if err := r.oauthClientStateStore().Get(*request.Body.State, &oauthSession); err == nil { + // we use the redirectURI from the oauthSession to redirect the user back to its own error page + if oauthSession.redirectURI() != nil { + location := httpNuts.AddQueryParams(*oauthSession.redirectURI(), map[string]string{ + oauth.ErrorParam: code, + oauth.ErrorDescriptionParam: description, + }) + return HandleAuthorizeResponse200JSONResponse{ + RedirectURI: location.String(), + }, nil + } + } + } + // we don't have a client state, so we can't redirect to the holder redirectURI + // return an error page instead + return nil, oauthError(oauth.ErrorCode(code), description) +} + +func (r Wrapper) handleAuthorizeResponseSubmission(ctx context.Context, request HandleAuthorizeResponseRequestObject) (HandleAuthorizeResponseResponseObject, error) { + verifier, err := r.idToOwnedDID(ctx, request.Id) + if err != nil { + return nil, oauthError(oauth.InvalidRequest, "unknown verifier id") + } + + if request.Body.VpToken == nil { + return nil, oauthError(oauth.InvalidRequest, "missing vp_token") + } + + pexEnvelope, err := pe.ParseEnvelope([]byte(*request.Body.VpToken)) + if err != nil || len(pexEnvelope.Presentations) == 0 { + return nil, oauthError(oauth.InvalidRequest, "invalid vp_token") + } + + // note: instead of using the challenge to lookup the oauth session, we could also add a client state from the verifier. + // this would allow us to lookup the redirectURI without checking the VP first. + + // extract the nonce from the vp(s) + nonce, err := extractChallenge(pexEnvelope.Presentations[0]) + if nonce == "" { + return nil, oauthError(oauth.InvalidRequest, "failed to extract nonce from vp_token") + } + var oauthSession OAuthSession + if err = r.oauthNonceStore().Get(nonce, &oauthSession); err != nil { + return nil, oauthError(oauth.InvalidRequest, "invalid or expired nonce") + } + // any future error can be sent to the client using the redirectURI from the oauthSession + callbackURI := oauthSession.redirectURI() + + if request.Body.PresentationSubmission == nil { + return nil, oauthError(oauth.InvalidRequest, "missing presentation_submission") + } + submission, err := pe.ParsePresentationSubmission([]byte(*request.Body.PresentationSubmission)) + if err != nil { + return nil, withCallbackURI(oauthError(oauth.InvalidRequest, fmt.Sprintf("invalid presentation_submission: %s", err.Error())), callbackURI) + } + + // validate all presentations: + // - same credentialSubject for VCs + // - same audience for VPs + // - same signer + var credentialSubjectID did.DID + for _, presentation := range pexEnvelope.Presentations { + if subjectDID, err := validatePresentationSigner(presentation, credentialSubjectID); err != nil { + return nil, withCallbackURI(oauthError(oauth.InvalidRequest, err.Error()), callbackURI) + } else { + credentialSubjectID = *subjectDID + } + if err := r.validatePresentationAudience(presentation, *verifier); err != nil { + return nil, withCallbackURI(err, callbackURI) + } + } + + // validate the presentation_submission against the presentation_definition (by scope) + // the resulting credential map is stored and later used to generate the access token + credentialMap, _, err := r.validatePresentationSubmission(ctx, *verifier, oauthSession.Scope, submission, pexEnvelope) + if err != nil { + return nil, withCallbackURI(err, callbackURI) + } + + // check presence of the nonce and make sure the nonce is burned in the process. + if err := r.validatePresentationNonce(pexEnvelope.Presentations); err != nil { + return nil, withCallbackURI(err, callbackURI) + } + + // Check signatures of VP and VCs. Trust should be established by the Presentation Definition. + for _, presentation := range pexEnvelope.Presentations { + _, err = r.vcr.Verifier().VerifyVP(presentation, true, true, nil) + if err != nil { + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "presentation(s) or contained credential(s) are invalid", + InternalError: err, + RedirectURI: callbackURI, + } + } + } + + // we take the existing OAuthSession and add the credential map to it + // the credential map contains InputDescriptor.Id -> VC mappings + // todo: use the InputDescriptor.Path to map the Id to Value@JSONPath since this will be later used to set the state for the access token + oauthSession.ServerState = ServerState{} + oauthSession.ServerState[credentialMapStateKey] = credentialMap + oauthSession.ServerState[presentationsStateKey] = pexEnvelope.Presentations + oauthSession.ServerState[submissionStateKey] = *submission + + authorizationCode := crypto.GenerateNonce() + err = r.oauthCodeStore().Put(authorizationCode, oauthSession) + if err != nil { + return nil, oauth.OAuth2Error{ + Code: oauth.ServerError, + Description: "failed to store authorization code", + InternalError: err, + RedirectURI: callbackURI, + } + } + + // construct redirect URI according to RFC6749 + redirectURI := httpNuts.AddQueryParams(*callbackURI, map[string]string{ + codeParam: authorizationCode, + stateParam: oauthSession.ClientState, + }) + return HandleAuthorizeResponse200JSONResponse{RedirectURI: redirectURI.String()}, nil +} + +func withCallbackURI(err error, callbackURI *url.URL) error { + oauthErr := err.(oauth.OAuth2Error) + oauthErr.RedirectURI = callbackURI + return oauthErr +} + +// extractChallenge extracts the nonce from the presentation. +// it uses the nonce from the JWT if available, otherwise it uses the challenge from the LD proof. +func extractChallenge(presentation vc.VerifiablePresentation) (string, error) { + var nonce string + switch presentation.Format() { + case vc.JWTPresentationProofFormat: + nonceRaw, _ := presentation.JWT().Get("nonce") + nonce, _ = nonceRaw.(string) + case vc.JSONLDPresentationProofFormat: + proof, err := credential.ParseLDProof(presentation) + if err != nil { + return "", err + } + if proof.Challenge != nil && *proof.Challenge != "" { + nonce = *proof.Challenge + } + } + return nonce, nil +} + +// validatePresentationNonce checks if the nonce is the same for all presentations. +// it deletes all nonces from the session store in the process. +// errors are returned as OAuth2 errors. +func (r Wrapper) validatePresentationNonce(presentations []vc.VerifiablePresentation) error { + var nonce string + var returnErr error + for _, presentation := range presentations { + nextNonce, err := extractChallenge(presentation) + _ = r.oauthNonceStore().Delete(nextNonce) + if nextNonce == "" { + // fallback on nonce instead of challenge, todo: should be uniform, check vc data model specs for JWT/JSON-LD + nextNonce, err = extractNonce(presentation) + if nextNonce == "" { + // error when all presentations are missing nonce's + returnErr = oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + InternalError: err, + Description: "presentation has invalid/missing nonce", + } + } + } + if nonce != "" && nonce != nextNonce { + returnErr = oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "not all presentations have the same nonce", + } + } + nonce = nextNonce + } + + return returnErr +} + +func (r Wrapper) handleAccessTokenRequest(ctx context.Context, verifier did.DID, authorizationCode *string, redirectURI *string, clientId *string) (HandleTokenRequestResponseObject, error) { + // first check redirectURI + if redirectURI == nil { + return nil, oauthError(oauth.InvalidRequest, "missing redirect_uri parameter") + } + callbackURI, err := url.Parse(*redirectURI) + if err != nil { + return nil, oauthError(oauth.InvalidRequest, "invalid redirect_uri parameter") + } + + // check if the authorization code is valid + var oauthSession OAuthSession + err = r.oauthCodeStore().Get(*authorizationCode, &oauthSession) + if err != nil { + return nil, withCallbackURI(oauthError(oauth.InvalidRequest, "invalid authorization code"), callbackURI) + } + + // check if the redirectURI matches the one from the authorization request + if oauthSession.redirectURI() != nil && oauthSession.redirectURI().String() != *redirectURI { + return nil, withCallbackURI(oauthError(oauth.InvalidRequest, "redirect_uri does not match"), callbackURI) + } + + // check if the client_id matches the one from the authorization request + if oauthSession.ClientID != *clientId { + return nil, withCallbackURI(oauthError(oauth.InvalidRequest, "client_id does not match"), callbackURI) + } + + presentations := oauthSession.ServerState.VerifiablePresentations() + submission := oauthSession.ServerState.PresentationSubmission() + definition, err := r.policyBackend.PresentationDefinition(ctx, verifier, oauthSession.Scope) + if err != nil { + return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("failed to fetch presentation definition: %s", err.Error())), callbackURI) + } + credentialMap := oauthSession.ServerState.CredentialMap() + subject, _ := did.ParseDID(oauthSession.ClientID) + + response, err := r.createS2SAccessToken(verifier, time.Now(), presentations, submission, *definition, oauthSession.Scope, *subject, credentialMap) + if err != nil { + return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("failed to create access token: %s", err.Error())), callbackURI) + } + return HandleTokenRequest200JSONResponse(*response), nil +} + // createPresentationRequest creates a new Authorization Request as specified by OpenID4VP: https://openid.net/specs/openid-4-verifiable-presentations-1_0.html. // It is sent by a verifier to a wallet, to request one or more verifiable credentials as verifiable presentation from the wallet. func (r Wrapper) sendPresentationRequest(ctx context.Context, response http.ResponseWriter, scope string, @@ -309,7 +571,7 @@ func (r Wrapper) sendPresentationRequest(ctx context.Context, response http.Resp // handlePresentationRequest handles an Authorization Request as specified by OpenID4VP: https://openid.net/specs/openid-4-verifiable-presentations-1_0.html. // It is handled by a wallet, called by a verifier who wants the wallet to present one or more verifiable credentials. -func (r *Wrapper) handlePresentationRequest(ctx context.Context, params map[string]string, session *OAuthSession) (HandleAuthorizeRequestResponseObject, error) { +func (r Wrapper) handlePresentationRequest(ctx context.Context, params map[string]string, session *OAuthSession) (HandleAuthorizeRequestResponseObject, error) { // Todo: for compatibility, we probably need to support presentation_definition and/or presentation_definition_uri. if err := assertParamNotPresent(params, presentationDefUriParam); err != nil { return nil, err @@ -510,10 +772,9 @@ func assertParamNotPresent(params map[string]string, param ...string) error { return nil } -func oauthError(code oauth.ErrorCode, description string, redirectURL *url.URL) oauth.OAuth2Error { +func oauthError(code oauth.ErrorCode, description string) oauth.OAuth2Error { return oauth.OAuth2Error{ Code: code, Description: description, - RedirectURI: redirectURL, } } diff --git a/auth/api/iam/openid4vp_test.go b/auth/api/iam/openid4vp_test.go index 40ef2227b7..43ee45f349 100644 --- a/auth/api/iam/openid4vp_test.go +++ b/auth/api/iam/openid4vp_test.go @@ -21,15 +21,17 @@ package iam import ( "bytes" "context" + "encoding/json" + "net/http" + "net/url" + "strings" + "testing" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth" "github.com/nuts-foundation/nuts-node/auth/oauth" - "net/http" - "net/url" - "testing" - oauth2 "github.com/nuts-foundation/nuts-node/auth/services/oauth" "github.com/nuts-foundation/nuts-node/policy" "github.com/nuts-foundation/nuts-node/storage" @@ -124,6 +126,7 @@ func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { responseURIParam: responseURI, responseTypeParam: responseTypeVPToken, scopeParam: "test", + stateParam: "state", } } @@ -131,7 +134,7 @@ func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { ctx := newTestClient(t) params := defaultParams() params[clientIDParam] = "did:nuts:1" - expectPostError(t, ctx, oauth.InvalidRequest, "invalid client_id parameter (only did:web is supported)", responseURI) + expectPostError(t, ctx, oauth.InvalidRequest, "invalid client_id parameter (only did:web is supported)", responseURI, "state") _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params) @@ -141,7 +144,7 @@ func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { ctx := newTestClient(t) params := defaultParams() params[clientIDSchemeParam] = "other" - expectPostError(t, ctx, oauth.InvalidRequest, "invalid client_id_scheme parameter", responseURI) + expectPostError(t, ctx, oauth.InvalidRequest, "invalid client_id_scheme parameter", responseURI, "state") _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params) @@ -152,7 +155,7 @@ func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { params := defaultParams() delete(params, clientMetadataURIParam) ctx.holderRole.EXPECT().ClientMetadata(gomock.Any(), "").Return(nil, assert.AnError) - expectPostError(t, ctx, oauth.ServerError, "failed to get client metadata (verifier)", responseURI) + expectPostError(t, ctx, oauth.ServerError, "failed to get client metadata (verifier)", responseURI, "state") _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params) @@ -162,7 +165,7 @@ func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { ctx := newTestClient(t) params := defaultParams() delete(params, nonceParam) - expectPostError(t, ctx, oauth.InvalidRequest, "missing nonce parameter", responseURI) + expectPostError(t, ctx, oauth.InvalidRequest, "missing nonce parameter", responseURI, "state") _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params) @@ -173,7 +176,7 @@ func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { params := defaultParams() params[presentationDefUriParam] = "://example.com" ctx.holderRole.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/iam/verifier").Return(nil, assert.AnError) - expectPostError(t, ctx, oauth.ServerError, "failed to get client metadata (verifier)", responseURI) + expectPostError(t, ctx, oauth.ServerError, "failed to get client metadata (verifier)", responseURI, "state") _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params) @@ -209,9 +212,10 @@ func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { t.Run("invalid presentation_definition_uri", func(t *testing.T) { ctx := newTestClient(t) params := defaultParams() + putState(ctx, "state") ctx.holderRole.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/iam/verifier").Return(&clientMetadata, nil) ctx.holderRole.EXPECT().PresentationDefinition(gomock.Any(), "https://example.com/iam/verifier/presentation_definition?scope=test").Return(nil, assert.AnError) - expectPostError(t, ctx, oauth.InvalidPresentationDefinitionURI, "failed to retrieve presentation definition on https://example.com/iam/verifier/presentation_definition?scope=test", responseURI) + expectPostError(t, ctx, oauth.InvalidPresentationDefinitionURI, "failed to retrieve presentation definition on https://example.com/iam/verifier/presentation_definition?scope=test", responseURI, "state") _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params) @@ -220,10 +224,11 @@ func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { t.Run("failed to create verifiable presentation", func(t *testing.T) { ctx := newTestClient(t) params := defaultParams() + putState(ctx, "state") ctx.holderRole.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/iam/verifier").Return(&clientMetadata, nil) ctx.holderRole.EXPECT().PresentationDefinition(gomock.Any(), "https://example.com/iam/verifier/presentation_definition?scope=test").Return(&pe.PresentationDefinition{}, nil) - ctx.holderRole.EXPECT().BuildPresentation(gomock.Any(), holderDID, pe.PresentationDefinition{}, clientMetadata.VPFormats, "nonce").Return(nil, nil, assert.AnError) - expectPostError(t, ctx, oauth.ServerError, assert.AnError.Error(), responseURI) + ctx.holderRole.EXPECT().BuildPresentation(gomock.Any(), holderDID, pe.PresentationDefinition{}, clientMetadata.VPFormats, "nonce", verifierDID).Return(nil, nil, assert.AnError) + expectPostError(t, ctx, oauth.ServerError, assert.AnError.Error(), responseURI, "state") _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params) @@ -232,10 +237,11 @@ func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { t.Run("missing credentials in wallet", func(t *testing.T) { ctx := newTestClient(t) params := defaultParams() + putState(ctx, "state") ctx.holderRole.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/iam/verifier").Return(&clientMetadata, nil) ctx.holderRole.EXPECT().PresentationDefinition(gomock.Any(), "https://example.com/iam/verifier/presentation_definition?scope=test").Return(&pe.PresentationDefinition{}, nil) - ctx.holderRole.EXPECT().BuildPresentation(gomock.Any(), holderDID, pe.PresentationDefinition{}, clientMetadata.VPFormats, "nonce").Return(nil, nil, oauth2.ErrNoCredentials) - expectPostError(t, ctx, oauth.InvalidRequest, "no credentials available", responseURI) + ctx.holderRole.EXPECT().BuildPresentation(gomock.Any(), holderDID, pe.PresentationDefinition{}, clientMetadata.VPFormats, "nonce", verifierDID).Return(nil, nil, oauth2.ErrNoCredentials) + expectPostError(t, ctx, oauth.InvalidRequest, "no credentials available", responseURI, "state") _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params) @@ -243,13 +249,327 @@ func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { }) } +func TestWrapper_HandleAuthorizeResponse(t *testing.T) { + t.Run("submission", func(t *testing.T) { + challenge := "challenge" + // simple vp + vpToken := `{"type":"VerifiablePresentation", "verifiableCredential":{"type":"VerifiableCredential", "credentialSubject":{"id":"did:web:example.com:iam:holder"}},"proof":{"challenge":"challenge","domain":"did:web:example.com:iam:verifier","proofPurpose":"assertionMethod","type":"JsonWebSignature2020","verificationMethod":"did:web:example.com:iam:holder#0"}}` + // simple definition + definition := pe.PresentationDefinition{InputDescriptors: []*pe.InputDescriptor{ + {Id: "1", Constraints: &pe.Constraints{Fields: []pe.Field{{Path: []string{"$.type"}}}}}, + }} + // simple submission + submissionAsStr := `{"id":"1", "definition_id":"1", "descriptor_map":[{"id":"1","format":"ldp_vc","path":"$.verifiableCredential"}]}` + // simple request + baseRequest := func() HandleAuthorizeResponseRequestObject { + return HandleAuthorizeResponseRequestObject{ + Body: &HandleAuthorizeResponseFormdataRequestBody{ + VpToken: &vpToken, + PresentationSubmission: &submissionAsStr, + }, + Id: "verifier", + } + } + t.Run("ok", func(t *testing.T) { + ctx := newTestClient(t) + putNonce(ctx, challenge) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), verifierDID).Return(true, nil) + ctx.policy.EXPECT().PresentationDefinition(gomock.Any(), gomock.Any(), "test").Return(&definition, nil) + ctx.vcVerifier.EXPECT().VerifyVP(gomock.Any(), true, true, nil).Return(nil, nil) + + response, err := ctx.client.HandleAuthorizeResponse(context.Background(), baseRequest()) + + require.NoError(t, err) + redirectURI := response.(HandleAuthorizeResponse200JSONResponse).RedirectURI + assert.Contains(t, redirectURI, "https://example.com/iam/holder/cb?code=") + assert.Contains(t, redirectURI, "state=state") + }) + t.Run("failed to verify vp", func(t *testing.T) { + ctx := newTestClient(t) + putNonce(ctx, challenge) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), verifierDID).Return(true, nil) + ctx.policy.EXPECT().PresentationDefinition(gomock.Any(), gomock.Any(), "test").Return(&definition, nil) + ctx.vcVerifier.EXPECT().VerifyVP(gomock.Any(), true, true, nil).Return(nil, assert.AnError) + + _, err := ctx.client.HandleAuthorizeResponse(context.Background(), baseRequest()) + + oauthErr := assertOAuthError(t, err, "presentation(s) or contained credential(s) are invalid") + assert.Equal(t, "https://example.com/iam/holder/cb", oauthErr.RedirectURI.String()) + }) + t.Run("expired nonce", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), verifierDID).Return(true, nil) + + _, err := ctx.client.HandleAuthorizeResponse(context.Background(), baseRequest()) + + _ = assertOAuthError(t, err, "invalid or expired nonce") + }) + t.Run("missing challenge in proof", func(t *testing.T) { + ctx := newTestClient(t) + putNonce(ctx, challenge) + request := baseRequest() + proof := `{"proof":{}}` + request.Body.VpToken = &proof + ctx.vdr.EXPECT().IsOwner(gomock.Any(), verifierDID).Return(true, nil) + + _, err := ctx.client.HandleAuthorizeResponse(context.Background(), request) + + _ = assertOAuthError(t, err, "failed to extract nonce from vp_token") + }) + t.Run("unknown verifier id", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), verifierDID).Return(false, nil) + + _, err := ctx.client.HandleAuthorizeResponse(context.Background(), baseRequest()) + + _ = assertOAuthError(t, err, "unknown verifier id") + }) + t.Run("invalid vp_token", func(t *testing.T) { + ctx := newTestClient(t) + request := baseRequest() + invalidToken := "}" + request.Body.VpToken = &invalidToken + ctx.vdr.EXPECT().IsOwner(gomock.Any(), verifierDID).Return(true, nil) + + _, err := ctx.client.HandleAuthorizeResponse(context.Background(), request) + + _ = assertOAuthError(t, err, "invalid vp_token") + }) + t.Run("missing vp_token", func(t *testing.T) { + ctx := newTestClient(t) + request := baseRequest() + request.Body.VpToken = nil + ctx.vdr.EXPECT().IsOwner(gomock.Any(), verifierDID).Return(true, nil) + + _, err := ctx.client.HandleAuthorizeResponse(context.Background(), request) + + _ = assertOAuthError(t, err, "missing vp_token") + }) + t.Run("invalid presentation_submission", func(t *testing.T) { + ctx := newTestClient(t) + request := baseRequest() + submission := "}" + request.Body.PresentationSubmission = &submission + putNonce(ctx, challenge) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), verifierDID).Return(true, nil) + + _, err := ctx.client.HandleAuthorizeResponse(context.Background(), request) + + _ = assertOAuthError(t, err, "invalid presentation_submission: invalid character '}' looking for beginning of value") + }) + t.Run("missing presentation_submission", func(t *testing.T) { + ctx := newTestClient(t) + request := baseRequest() + request.Body.PresentationSubmission = nil + putNonce(ctx, challenge) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), verifierDID).Return(true, nil) + + _, err := ctx.client.HandleAuthorizeResponse(context.Background(), request) + + _ = assertOAuthError(t, err, "missing presentation_submission") + }) + t.Run("invalid signer", func(t *testing.T) { + ctx := newTestClient(t) + putNonce(ctx, challenge) + request := baseRequest() + vpToken := `{"type":"VerifiablePresentation", "verifiableCredential":{"type":"VerifiableCredential", "credentialSubject":{}},"proof":{"challenge":"challenge","domain":"did:web:example.com:iam:verifier","proofPurpose":"assertionMethod","type":"JsonWebSignature2020","verificationMethod":"did:web:example.com:iam:holder#0"}}` + request.Body.VpToken = &vpToken + ctx.vdr.EXPECT().IsOwner(gomock.Any(), verifierDID).Return(true, nil) + + _, err := ctx.client.HandleAuthorizeResponse(context.Background(), request) + + _ = assertOAuthError(t, err, "unable to get subject DID from VC: credential subjects have no ID") + }) + t.Run("invalid audience/domain", func(t *testing.T) { + ctx := newTestClient(t) + putNonce(ctx, challenge) + request := baseRequest() + vpToken := `{"type":"VerifiablePresentation", "verifiableCredential":{"type":"VerifiableCredential", "credentialSubject":{"id":"did:web:example.com:iam:holder"}},"proof":{"challenge":"challenge","proofPurpose":"assertionMethod","type":"JsonWebSignature2020","verificationMethod":"did:web:example.com:iam:holder#0"}}` + request.Body.VpToken = &vpToken + ctx.vdr.EXPECT().IsOwner(gomock.Any(), verifierDID).Return(true, nil) + + _, err := ctx.client.HandleAuthorizeResponse(context.Background(), request) + + _ = assertOAuthError(t, err, "presentation audience/domain is missing or does not match") + }) + t.Run("submission does not match definition", func(t *testing.T) { + ctx := newTestClient(t) + putNonce(ctx, challenge) + request := baseRequest() + submission := `{"id":"1", "definition_id":"2", "descriptor_map":[{"id":"2","format":"ldp_vc","path":"$.verifiableCredential"}]}` + request.Body.PresentationSubmission = &submission + ctx.vdr.EXPECT().IsOwner(gomock.Any(), verifierDID).Return(true, nil) + ctx.policy.EXPECT().PresentationDefinition(gomock.Any(), gomock.Any(), "test").Return(&definition, nil) + + _, err := ctx.client.HandleAuthorizeResponse(context.Background(), request) + + _ = assertOAuthError(t, err, "presentation submission does not conform to Presentation Definition") + }) + }) + t.Run("error", func(t *testing.T) { + code := string(oauth.InvalidRequest) + description := "error description" + state := "state" + baseRequest := func() HandleAuthorizeResponseRequestObject { + return HandleAuthorizeResponseRequestObject{ + Body: &HandleAuthorizeResponseFormdataRequestBody{ + Error: &code, + ErrorDescription: &description, + State: &state, + }, + Id: "verifier", + } + } + t.Run("with client state", func(t *testing.T) { + ctx := newTestClient(t) + putState(ctx, "state") + + response, err := ctx.client.HandleAuthorizeResponse(context.Background(), baseRequest()) + + require.NoError(t, err) + redirectURI := response.(HandleAuthorizeResponse200JSONResponse).RedirectURI + assert.Contains(t, redirectURI, "https://example.com/iam/holder/cb?error=invalid_request&error_description=error+description") + }) + t.Run("without client state", func(t *testing.T) { + ctx := newTestClient(t) + + _, err := ctx.client.HandleAuthorizeResponse(context.Background(), baseRequest()) + + require.Error(t, err) + _ = assertOAuthError(t, err, "error description") + }) + }) +} + +func Test_handleAccessTokenRequest(t *testing.T) { + redirectURI := "https://example.com/iam/holder/cb" + code := "code" + clientID := "did:web:example.com:iam:holder" + vpStr := `{"type":"VerifiablePresentation", "id":"vp", "verifiableCredential":{"type":"VerifiableCredential", "id":"vc", "credentialSubject":{"id":"did:web:example.com:iam:holder"}}}` + vp, err := vc.ParseVerifiablePresentation(vpStr) + require.NoError(t, err) + definition := pe.PresentationDefinition{InputDescriptors: []*pe.InputDescriptor{ + {Id: "1", Constraints: &pe.Constraints{Fields: []pe.Field{{Path: []string{"$.type"}}}}}, + }} + submissionAsStr := `{"id":"1", "definition_id":"1", "descriptor_map":[{"id":"1","format":"ldp_vc","path":"$.verifiableCredential"}]}` + var submission pe.PresentationSubmission + _ = json.Unmarshal([]byte(submissionAsStr), &submission) + validSession := OAuthSession{ + ClientID: clientID, + OwnDID: verifierDID, + RedirectURI: redirectURI, + Scope: "scope", + ServerState: map[string]interface{}{ + "presentations": []vc.VerifiablePresentation{*vp}, + "presentationSubmission": submission, + }, + } + t.Run("ok", func(t *testing.T) { + ctx := newTestClient(t) + putSession(ctx, code, validSession) + ctx.policy.EXPECT().PresentationDefinition(gomock.Any(), verifierDID, "scope").Return(&definition, nil) + + response, err := ctx.client.handleAccessTokenRequest(context.Background(), verifierDID, &code, &redirectURI, &clientID) + + require.NoError(t, err) + token, ok := response.(HandleTokenRequest200JSONResponse) + require.True(t, ok) + assert.NotEmpty(t, token.AccessToken) + assert.Equal(t, "bearer", token.TokenType) + assert.Equal(t, 900, *token.ExpiresIn) + assert.Equal(t, "scope", *token.Scope) + + }) + t.Run("invalid authorization code", func(t *testing.T) { + ctx := newTestClient(t) + + _, err := ctx.client.handleAccessTokenRequest(context.Background(), verifierDID, &code, &redirectURI, &clientID) + + require.Error(t, err) + _ = assertOAuthError(t, err, "invalid authorization code") + }) + t.Run("invalid client_id", func(t *testing.T) { + ctx := newTestClient(t) + putSession(ctx, code, validSession) + clientID := "other" + + _, err := ctx.client.handleAccessTokenRequest(context.Background(), verifierDID, &code, &redirectURI, &clientID) + + require.Error(t, err) + _ = assertOAuthError(t, err, "client_id does not match") + }) + t.Run("invalid redirectURI", func(t *testing.T) { + ctx := newTestClient(t) + putSession(ctx, code, validSession) + redirectURI := "other" + + _, err := ctx.client.handleAccessTokenRequest(context.Background(), verifierDID, &code, &redirectURI, &clientID) + + require.Error(t, err) + _ = assertOAuthError(t, err, "redirect_uri does not match") + }) + t.Run("presentation definition backend server error", func(t *testing.T) { + ctx := newTestClient(t) + putSession(ctx, code, validSession) + ctx.policy.EXPECT().PresentationDefinition(gomock.Any(), verifierDID, "scope").Return(nil, assert.AnError) + + _, err := ctx.client.handleAccessTokenRequest(context.Background(), verifierDID, &code, &redirectURI, &clientID) + + require.Error(t, err) + oauthErr, ok := err.(oauth.OAuth2Error) + require.True(t, ok) + assert.Equal(t, oauth.ServerError, oauthErr.Code) + assert.Equal(t, "failed to fetch presentation definition: assert.AnError general error for testing", oauthErr.Description) + }) +} + +func Test_validatePresentationNonce(t *testing.T) { + t.Run("ok", func(t *testing.T) { + vpStr := `{"@context":["https://www.w3.org/2018/credentials/v1","https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json"],"proof":{"challenge":"1"}}` + vp, err := vc.ParseVerifiablePresentation(vpStr) + require.NoError(t, err) + vps := []vc.VerifiablePresentation{*vp, *vp} + ctx := newTestClient(t) + putNonce(ctx, "1") + + // call also burns the nonce + err = ctx.client.validatePresentationNonce(vps) + + require.NoError(t, err) + err = ctx.client.oauthNonceStore().Get("1", nil) + assert.Equal(t, storage.ErrNotFound, err) + }) + t.Run("different nonce", func(t *testing.T) { + vpStr1 := `{"@context":["https://www.w3.org/2018/credentials/v1","https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json"],"proof":{"challenge":"1"}}` + vpStr2 := `{"@context":["https://www.w3.org/2018/credentials/v1","https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json"],"proof":{"challenge":"2"}}` + vp1, err := vc.ParseVerifiablePresentation(vpStr1) + require.NoError(t, err) + vp2, err := vc.ParseVerifiablePresentation(vpStr2) + require.NoError(t, err) + vps := []vc.VerifiablePresentation{*vp1, *vp2} + ctx := newTestClient(t) + putNonce(ctx, "1") + putNonce(ctx, "2") + + // call also burns the nonce + err = ctx.client.validatePresentationNonce(vps) + + assert.EqualError(t, err, "invalid_request - not all presentations have the same nonce") + err = ctx.client.oauthNonceStore().Get("1", nil) + assert.Equal(t, storage.ErrNotFound, err) + err = ctx.client.oauthNonceStore().Get("2", nil) + assert.Equal(t, storage.ErrNotFound, err) + }) +} + // expectPostError is a convenience method to add an expectation to the holderRole mock. // it checks if the right error is posted to the verifier. -func expectPostError(t *testing.T, ctx *testCtx, errorCode oauth.ErrorCode, description string, expectedResponseURI string) { - ctx.holderRole.EXPECT().PostError(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, err oauth.OAuth2Error, responseURI string) (string, error) { +func expectPostError(t *testing.T, ctx *testCtx, errorCode oauth.ErrorCode, description string, expectedResponseURI string, verifierClientState string) { + ctx.holderRole.EXPECT().PostError(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, err oauth.OAuth2Error, responseURI string, state string) (string, error) { assert.Equal(t, errorCode, err.Code) assert.Equal(t, description, err.Description) assert.Equal(t, expectedResponseURI, responseURI) + assert.Equal(t, verifierClientState, state) return "redirect", nil }) } @@ -258,16 +578,17 @@ func TestWrapper_sendAndHandleDirectPost(t *testing.T) { t.Run("failed to post response", func(t *testing.T) { ctx := newTestClient(t) ctx.holderRole.EXPECT().PostAuthorizationResponse(gomock.Any(), gomock.Any(), gomock.Any(), "response", "").Return("", assert.AnError) + _, err := ctx.client.sendAndHandleDirectPost(context.Background(), vc.VerifiablePresentation{}, pe.PresentationSubmission{}, "response", "") - require.Error(t, err) + assert.Equal(t, assert.AnError, err) }) } func TestWrapper_sendAndHandleDirectPostError(t *testing.T) { t.Run("failed to post error with redirect available", func(t *testing.T) { ctx := newTestClient(t) - ctx.holderRole.EXPECT().PostError(gomock.Any(), gomock.Any(), "response").Return("", assert.AnError) + ctx.holderRole.EXPECT().PostError(gomock.Any(), gomock.Any(), "response", "state").Return("", assert.AnError) redirectURI := test.MustParseURL("https://example.com/redirect") expected := HandleAuthorizeRequest302Response{ Headers: HandleAuthorizeRequest302ResponseHeaders{ @@ -275,16 +596,16 @@ func TestWrapper_sendAndHandleDirectPostError(t *testing.T) { }, } - redirect, err := ctx.client.sendAndHandleDirectPostError(context.Background(), oauth.OAuth2Error{RedirectURI: redirectURI}, "response") + redirect, err := ctx.client.sendAndHandleDirectPostError(context.Background(), oauth.OAuth2Error{RedirectURI: redirectURI}, "response", "state") require.NoError(t, err) assert.Equal(t, expected, redirect) }) t.Run("failed to post error without redirect available", func(t *testing.T) { ctx := newTestClient(t) - ctx.holderRole.EXPECT().PostError(gomock.Any(), gomock.Any(), "response").Return("", assert.AnError) + ctx.holderRole.EXPECT().PostError(gomock.Any(), gomock.Any(), "response", "state").Return("", assert.AnError) - _, err := ctx.client.sendAndHandleDirectPostError(context.Background(), oauth.OAuth2Error{}, "response") + _, err := ctx.client.sendAndHandleDirectPostError(context.Background(), oauth.OAuth2Error{}, "response", "state") require.Error(t, err) require.Equal(t, "server_error - something went wrong", err.Error()) @@ -384,6 +705,53 @@ func TestWrapper_handlePresentationRequest(t *testing.T) { }) } +func Test_extractChallenge(t *testing.T) { + t.Run("JSON-LD", func(t *testing.T) { + vpStr := + ` +{ + "@context":["https://www.w3.org/2018/credentials/v1","https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json"], + "proof":{ + "challenge":"86OZCbJWV4-V7XPAiXu-Rg" + } +} +` + // remove whitespace, tabs and newlines first otherwise the parsing doesn't know the format + vpStr = strings.ReplaceAll(vpStr, "\n", "") + vpStr = strings.ReplaceAll(vpStr, "\t", "") + vpStr = strings.ReplaceAll(vpStr, " ", "") + vp, err := vc.ParseVerifiablePresentation(vpStr) + require.NoError(t, err) + require.NotNil(t, vp) + + challenge, err := extractChallenge(*vp) + + require.NoError(t, err) + assert.Equal(t, "86OZCbJWV4-V7XPAiXu-Rg", challenge) + }) + + t.Run("JWT", func(t *testing.T) { + jwt := "eyJhbGciOiJFUzI1NiIsImtpZCI6ImRpZDpudXRzOkd2a3p4c2V6SHZFYzhuR2hnejZYbzNqYnFrSHdzd0xtV3czQ1l0Q203aEFXI2FiYy1tZXRob2QtMSIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJkaWQ6bnV0czpHdmt6eHNlekh2RWM4bkdoZ3o2WG8zamJxa0h3c3dMbVd3M0NZdENtN2hBVyM5NDA0NTM2Mi0zYjEyLTQyODUtYTJiNi0wZDAzZDQ0NzBkYTciLCJuYmYiOjE3MDUzMTEwNTQsIm5vbmNlIjoibm9uY2UiLCJzdWIiOiJkaWQ6bnV0czpHdmt6eHNlekh2RWM4bkdoZ3o2WG8zamJxa0h3c3dMbVd3M0NZdENtN2hBVyIsInZwIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOiJWZXJpZmlhYmxlUHJlc2VudGF0aW9uIiwidmVyaWZpYWJsZUNyZWRlbnRpYWwiOnsiQGNvbnRleHQiOlsiaHR0cHM6Ly93d3cudzMub3JnLzIwMTgvY3JlZGVudGlhbHMvdjEiLCJodHRwczovL251dHMubmwvY3JlZGVudGlhbHMvdjEiLCJodHRwczovL3czYy1jY2cuZ2l0aHViLmlvL2xkcy1qd3MyMDIwL2NvbnRleHRzL2xkcy1qd3MyMDIwLXYxLmpzb24iXSwiY3JlZGVudGlhbFN1YmplY3QiOnsiY29tcGFueSI6eyJjaXR5IjoiSGVuZ2VsbyIsIm5hbWUiOiJEZSBiZXN0ZSB6b3JnIn0sImlkIjoiZGlkOm51dHM6R3ZrenhzZXpIdkVjOG5HaGd6NlhvM2picWtId3N3TG1XdzNDWXRDbTdoQVcifSwiaWQiOiJkaWQ6bnV0czo0dHpNYVdmcGl6VktlQThmc2NDM0pUZFdCYzNhc1VXV01qNWhVRkhkV1gzSCNjOWJmZmE5OC1jOGViLTQ4YzItOTIwYy1mNjk5NjEyY2Q0NjUiLCJpc3N1YW5jZURhdGUiOiIyMDIxLTEyLTI0VDEzOjIxOjI5LjA4NzIwNSswMTowMCIsImlzc3VlciI6ImRpZDpudXRzOjR0ek1hV2ZwaXpWS2VBOGZzY0MzSlRkV0JjM2FzVVdXTWo1aFVGSGRXWDNIIiwicHJvb2YiOnsiY3JlYXRlZCI6IjIwMjEtMTItMjRUMTM6MjE6MjkuMDg3MjA1KzAxOjAwIiwiandzIjoiZXlKaGJHY2lPaUpGVXpJMU5pSXNJbUkyTkNJNlptRnNjMlVzSW1OeWFYUWlPbHNpWWpZMElsMTkuLmhQTTJHTGMxSzlkMkQ4U2J2ZTAwNHg5U3VtakxxYVhUaldoVWh2cVdSd3hmUldsd2ZwNWdIRFVZdVJvRWpoQ1hmTHQtX3Uta25DaFZtSzk4ME4zTEJ3IiwicHJvb2ZQdXJwb3NlIjoiTnV0c1NpZ25pbmdLZXlUeXBlIiwidHlwZSI6Ikpzb25XZWJTaWduYXR1cmUyMDIwIiwidmVyaWZpY2F0aW9uTWV0aG9kIjoiZGlkOm51dHM6R3ZrenhzZXpIdkVjOG5HaGd6NlhvM2picWtId3N3TG1XdzNDWXRDbTdoQVcjYWJjLW1ldGhvZC0xIn0sInR5cGUiOlsiQ29tcGFueUNyZWRlbnRpYWwiLCJWZXJpZmlhYmxlQ3JlZGVudGlhbCJdfX19.FpeltS-E5f6k65Am0unxCdptvjs1-A-cgOPbYItlhBSZ_Ipx2xBYV6fBBInAvpTITzDYQ6hWVjDfmpmF2B9dUw" + vp, err := vc.ParseVerifiablePresentation(jwt) + require.NoError(t, err) + require.NotNil(t, vp) + + challenge, err := extractChallenge(*vp) + + require.NoError(t, err) + assert.Equal(t, "nonce", challenge) + }) +} + +func assertOAuthError(t *testing.T, err error, expectedDescription string) oauth.OAuth2Error { + require.Error(t, err) + oauthErr, ok := err.(oauth.OAuth2Error) + require.True(t, ok, "expected oauth error") + assert.Equal(t, oauth.InvalidRequest, oauthErr.Code) + assert.Equal(t, expectedDescription, oauthErr.Description) + return oauthErr +} + type stubResponseWriter struct { headers http.Header body *bytes.Buffer @@ -408,3 +776,15 @@ func (s *stubResponseWriter) Write(i []byte) (int, error) { func (s *stubResponseWriter) WriteHeader(statusCode int) { s.statusCode = statusCode } + +func putState(ctx *testCtx, state string) { + _ = ctx.client.oauthClientStateStore().Put(state, OAuthSession{OwnDID: holderDID, RedirectURI: "https://example.com/iam/holder/cb"}) +} + +func putNonce(ctx *testCtx, nonce string) { + _ = ctx.client.oauthNonceStore().Put(nonce, OAuthSession{Scope: "test", ClientState: "state", OwnDID: verifierDID, RedirectURI: "https://example.com/iam/holder/cb"}) +} + +func putSession(ctx *testCtx, code string, oauthSession OAuthSession) { + _ = ctx.client.oauthCodeStore().Put(code, oauthSession) +} diff --git a/auth/api/iam/s2s_vptoken.go b/auth/api/iam/s2s_vptoken.go index abbdbb6951..210012d5c3 100644 --- a/auth/api/iam/s2s_vptoken.go +++ b/auth/api/iam/s2s_vptoken.go @@ -68,7 +68,7 @@ func (r Wrapper) handleS2SAccessTokenRequest(ctx context.Context, issuer did.DID return nil, err } if subjectDID, err := validatePresentationSigner(presentation, credentialSubjectID); err != nil { - return nil, err + return nil, oauthError(oauth.InvalidRequest, err.Error()) } else { credentialSubjectID = *subjectDID } @@ -158,30 +158,6 @@ func (r Wrapper) createS2SAccessToken(issuer did.DID, issueTime time.Time, prese }, nil } -// validatePresentationSubmission checks if the presentation submission is valid for the given scope: -// 1. Resolve presentation definition for the requested scope -// 2. Check submission against presentation and definition -func (r Wrapper) validatePresentationSubmission(ctx context.Context, authorizer did.DID, scope string, submission *pe.PresentationSubmission, pexEnvelope *pe.Envelope) (map[string]vc.VerifiableCredential, *PresentationDefinition, error) { - definition, err := r.policyBackend.PresentationDefinition(ctx, authorizer, scope) - if err != nil { - return nil, nil, oauth.OAuth2Error{ - Code: oauth.InvalidScope, - InternalError: err, - Description: fmt.Sprintf("unsupported scope (%s) for presentation exchange: %s", scope, err.Error()), - } - } - - credentialMap, err := submission.Validate(*pexEnvelope, *definition) - if err != nil { - return nil, nil, oauth.OAuth2Error{ - Code: oauth.InvalidRequest, - Description: "presentation submission does not conform to Presentation Definition", - InternalError: err, - } - } - return credentialMap, definition, err -} - // validateS2SPresentationMaxValidity checks that the presentation is valid for a reasonable amount of time. func validateS2SPresentationMaxValidity(presentation vc.VerifiablePresentation) error { created := credential.PresentationIssuanceDate(presentation) @@ -201,53 +177,15 @@ func validateS2SPresentationMaxValidity(presentation vc.VerifiablePresentation) return nil } -// validatePresentationSigner checks if the presenter of the VP is the same as the subject of the VCs being presented. -func validatePresentationSigner(presentation vc.VerifiablePresentation, expectedCredentialSubjectDID did.DID) (*did.DID, error) { - subjectDID, err := credential.PresenterIsCredentialSubject(presentation) - if err != nil { - return nil, oauth.OAuth2Error{ - Code: oauth.InvalidRequest, - Description: err.Error(), - } - } - if subjectDID == nil { - return nil, oauth.OAuth2Error{ - Code: oauth.InvalidRequest, - Description: "presentation signer is not credential subject", - } - } - if !expectedCredentialSubjectDID.Empty() && !subjectDID.Equals(expectedCredentialSubjectDID) { - return nil, oauth.OAuth2Error{ - Code: oauth.InvalidRequest, - Description: "not all presentations have the same credential subject ID", - } - } - return subjectDID, nil -} - // validateS2SPresentationNonce checks if the nonce has been used before; 'nonce' claim for JWTs or LDProof's 'nonce' for JSON-LD. func (r Wrapper) validateS2SPresentationNonce(presentation vc.VerifiablePresentation) error { - var nonce string - switch presentation.Format() { - case vc.JWTPresentationProofFormat: - nonceRaw, _ := presentation.JWT().Get("nonce") - nonce, _ = nonceRaw.(string) - if nonce == "" { - return oauth.OAuth2Error{ - Code: oauth.InvalidRequest, - Description: "presentation has invalid/missing nonce", - } - } - case vc.JSONLDPresentationProofFormat: - proof, err := credential.ParseLDProof(presentation) - if err != nil || proof.Nonce == nil || *proof.Nonce == "" { - return oauth.OAuth2Error{ - Code: oauth.InvalidRequest, - InternalError: err, - Description: "presentation has invalid proof or nonce", - } + nonce, err := extractNonce(presentation) + if nonce == "" { + return oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + InternalError: err, + Description: "presentation has invalid/missing nonce", } - nonce = *proof.Nonce } nonceStore := r.storageEngine.GetSessionDatabase().GetStore(s2sMaxPresentationValidity+s2sMaxClockSkew, "s2s", "nonce") @@ -272,31 +210,24 @@ func (r Wrapper) validateS2SPresentationNonce(presentation vc.VerifiablePresenta return nonceError } -// validatePresentationAudience checks if the presentation audience (aud claim for JWTs, domain property for JSON-LD proofs) contains the issuer DID. -func (r Wrapper) validatePresentationAudience(presentation vc.VerifiablePresentation, issuer did.DID) error { - var audience []string +// extractNonce extracts the nonce from the presentation. +// it uses the nonce from the JWT if available, otherwise it uses the nonce from the LD proof. +func extractNonce(presentation vc.VerifiablePresentation) (string, error) { + var nonce string switch presentation.Format() { case vc.JWTPresentationProofFormat: - audience = presentation.JWT().Audience() + nonceRaw, _ := presentation.JWT().Get("nonce") + nonce, _ = nonceRaw.(string) case vc.JSONLDPresentationProofFormat: proof, err := credential.ParseLDProof(presentation) if err != nil { - return err + return "", err } - if proof.Domain != nil { - audience = []string{*proof.Domain} + if proof.Nonce != nil && *proof.Nonce != "" { + nonce = *proof.Nonce } } - for _, aud := range audience { - if aud == issuer.String() { - return nil - } - } - return oauth.OAuth2Error{ - Code: oauth.InvalidRequest, - Description: "presentation audience/domain is missing or does not match", - InternalError: fmt.Errorf("expected: %s, got: %v", issuer, audience), - } + return nonce, nil } type AccessToken struct { diff --git a/auth/api/iam/s2s_vptoken_test.go b/auth/api/iam/s2s_vptoken_test.go index 3d5f3d4ef1..1a5bb60a54 100644 --- a/auth/api/iam/s2s_vptoken_test.go +++ b/auth/api/iam/s2s_vptoken_test.go @@ -250,7 +250,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, verifiableCredential) resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, presentation.Raw()) - assert.EqualError(t, err, "invalid_request - presentation has invalid proof or nonce") + assert.EqualError(t, err, "invalid_request - presentation has invalid/missing nonce") assert.Nil(t, resp) }) t.Run("JSON-LD VP has empty nonce", func(t *testing.T) { @@ -263,7 +263,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, verifiableCredential) resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, presentation.Raw()) - assert.EqualError(t, err, "invalid_request - presentation has invalid proof or nonce") + assert.EqualError(t, err, "invalid_request - presentation has invalid/missing nonce") assert.Nil(t, resp) }) t.Run("JWT VP is missing nonce", func(t *testing.T) { diff --git a/auth/api/iam/session.go b/auth/api/iam/session.go index 12531c011c..be02c01ad5 100644 --- a/auth/api/iam/session.go +++ b/auth/api/iam/session.go @@ -20,7 +20,9 @@ package iam import ( "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/http" + "github.com/nuts-foundation/nuts-node/vcr/pe" "net/url" ) @@ -30,11 +32,57 @@ type OAuthSession struct { OwnDID did.DID ClientState string RedirectURI string - ServerState map[string]interface{} + ServerState ServerState ResponseType string PresentationDefinition PresentationDefinition } +// ServerState is a convenience type for extracting different types of data from the session. +type ServerState map[string]interface{} + +const ( + credentialMapStateKey = "credentialMap" + presentationsStateKey = "presentations" + submissionStateKey = "presentationSubmission" +) + +// VerifiablePresentations returns the verifiable presentations from the server state. +// If the server state does not contain a verifiable presentation, an empty slice is returned. +func (s ServerState) VerifiablePresentations() []vc.VerifiablePresentation { + presentations := make([]vc.VerifiablePresentation, 0) + if val, ok := s[presentationsStateKey]; ok { + // each entry should be castable to a VerifiablePresentation + if arr, ok := val.([]interface{}); ok { + for _, v := range arr { + if vp, ok := v.(vc.VerifiablePresentation); ok { + presentations = append(presentations, vp) + } + } + } + } + return presentations +} + +// PresentationSubmission returns the Presentation Submission from the server state. +func (s ServerState) PresentationSubmission() pe.PresentationSubmission { + if val, ok := s[submissionStateKey]; ok { + if pd, ok := val.(pe.PresentationSubmission); ok { + return pd + } + } + return pe.PresentationSubmission{} +} + +// CredentialMap returns the credential map from the server state. +func (s ServerState) CredentialMap() map[string]vc.VerifiableCredential { + if val, ok := s[credentialMapStateKey]; ok { + if mapped, ok := val.(map[string]vc.VerifiableCredential); ok { + return mapped + } + } + return map[string]vc.VerifiableCredential{} +} + // UserSession is the session object for handling the user browser session. // A RedirectSession is replaced with a UserSession. type UserSession struct { diff --git a/auth/api/iam/types.go b/auth/api/iam/types.go index 95093b5262..93715e9b1a 100644 --- a/auth/api/iam/types.go +++ b/auth/api/iam/types.go @@ -42,6 +42,11 @@ type ErrorResponse = oauth.OAuth2Error // PresentationDefinition is an alias type PresentationDefinition = pe.PresentationDefinition +// PresentationSubmission is an alias +type PresentationSubmission = pe.PresentationSubmission + +type RedirectResponse = oauth.Redirect + // TokenResponse is an alias type TokenResponse = oauth.TokenResponse @@ -131,6 +136,10 @@ const clientMetadataURIParam = "client_metadata_uri" // Specified by https://openid.bitbucket.io/connect/openid-4-verifiable-presentations-1_0.html#name-authorization-request const clientIDSchemeParam = "client_id_scheme" +// codeParam is the name of the code parameter. +// Specified by https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2 +const codeParam = "code" + // scopeParam is the name of the scope parameter. // Specified by https://datatracker.ietf.org/doc/html/rfc6749#section-3.3 const scopeParam = "scope" diff --git a/auth/api/iam/user.go b/auth/api/iam/user.go index d89d859818..acb1495fa1 100644 --- a/auth/api/iam/user.go +++ b/auth/api/iam/user.go @@ -45,6 +45,7 @@ const ( ) var oauthClientStateKey = []string{"oauth", "client_state"} +var oauthCodeKey = []string{"oauth", "code"} var userRedirectSessionKey = []string{"user", "redirect"} var userSessionKey = []string{"user", "session"} @@ -147,3 +148,7 @@ func (r Wrapper) userSessionStore() storage.SessionStore { func (r Wrapper) oauthClientStateStore() storage.SessionStore { return r.storageEngine.GetSessionDatabase().GetStore(oAuthFlowTimeout, oauthClientStateKey...) } + +func (r Wrapper) oauthCodeStore() storage.SessionStore { + return r.storageEngine.GetSessionDatabase().GetStore(oAuthFlowTimeout, oauthCodeKey...) +} diff --git a/auth/api/iam/validation.go b/auth/api/iam/validation.go new file mode 100644 index 0000000000..de882aa7f7 --- /dev/null +++ b/auth/api/iam/validation.go @@ -0,0 +1,100 @@ +/* + * 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" + "errors" + "fmt" + "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/pe" +) + +// validatePresentationSigner checks if the presenter of the VP is the same as the subject of the VCs being presented. +// All returned errors can be used as description in an OAuth2 error. +func validatePresentationSigner(presentation vc.VerifiablePresentation, expectedCredentialSubjectDID did.DID) (*did.DID, error) { + subjectDID, err := credential.PresenterIsCredentialSubject(presentation) + if err != nil { + return nil, err + } + if subjectDID == nil { + return nil, errors.New("presentation signer is not credential subject") + } + if !expectedCredentialSubjectDID.Empty() && !subjectDID.Equals(expectedCredentialSubjectDID) { + return nil, errors.New("not all presentations have the same credential subject ID") + } + return subjectDID, nil +} + +// validatePresentationAudience checks if the presentation audience (aud claim for JWTs, domain property for JSON-LD proofs) contains the issuer DID. +// it returns an OAuth2 error if the audience is missing or does not match the issuer. +func (r Wrapper) validatePresentationAudience(presentation vc.VerifiablePresentation, issuer did.DID) error { + var audience []string + switch presentation.Format() { + case vc.JWTPresentationProofFormat: + audience = presentation.JWT().Audience() + case vc.JSONLDPresentationProofFormat: + proof, err := credential.ParseLDProof(presentation) + if err != nil { + return err + } + if proof.Domain != nil { + audience = []string{*proof.Domain} + } + } + for _, aud := range audience { + if aud == issuer.String() { + return nil + } + } + return oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "presentation audience/domain is missing or does not match", + InternalError: fmt.Errorf("expected: %s, got: %v", issuer, audience), + } +} + +// validatePresentationSubmission checks if the presentation submission is valid for the given scope: +// 1. Resolve presentation definition for the requested scope +// 2. Check submission against presentation and definition +// +// Errors are returned as OAuth2 errors. +func (r Wrapper) validatePresentationSubmission(ctx context.Context, authorizer did.DID, scope string, submission *pe.PresentationSubmission, pexEnvelope *pe.Envelope) (map[string]vc.VerifiableCredential, *PresentationDefinition, error) { + definition, err := r.policyBackend.PresentationDefinition(ctx, authorizer, scope) + if err != nil { + return nil, nil, oauth.OAuth2Error{ + Code: oauth.InvalidScope, + InternalError: err, + Description: fmt.Sprintf("unsupported scope (%s) for presentation exchange: %s", scope, err.Error()), + } + } + + credentialMap, err := submission.Validate(*pexEnvelope, *definition) + if err != nil { + return nil, nil, oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "presentation submission does not conform to Presentation Definition", + InternalError: err, + } + } + return credentialMap, definition, err +} diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index b47375a145..f0ca5c1e83 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -104,7 +104,7 @@ func (hb HTTPClient) ClientMetadata(ctx context.Context, endpoint string) (*oaut return nil, err } var metadata oauth.OAuthClientMetadata - return &metadata, hb.doRequest(request, &metadata) + return &metadata, hb.doRequest(ctx, request, &metadata) } // PresentationDefinition retrieves the presentation definition from the presentation definition endpoint (as specified by RFC021) for the given scope. @@ -115,7 +115,7 @@ func (hb HTTPClient) PresentationDefinition(ctx context.Context, presentationDef return nil, err } var presentationDefinition pe.PresentationDefinition - return &presentationDefinition, hb.doRequest(request, &presentationDefinition) + return &presentationDefinition, hb.doRequest(ctx, request, &presentationDefinition) } func (hb HTTPClient) AccessToken(ctx context.Context, tokenEndpoint string, vp vc.VerifiablePresentation, submission pe.PresentationSubmission, scopes string) (oauth.TokenResponse, error) { @@ -205,14 +205,14 @@ func (hb HTTPClient) postFormExpectRedirect(ctx context.Context, form url.Values request.Header.Add("Accept", "application/json") request.Header.Add("Content-Type", "application/x-www-form-urlencoded") var redirect oauth.Redirect - if err := hb.doRequest(request, &redirect); err != nil { + if err := hb.doRequest(ctx, request, &redirect); err != nil { return "", err } return redirect.RedirectURI, nil } -func (hb HTTPClient) doRequest(request *http.Request, target interface{}) error { - response, err := hb.httpClient.Do(request) +func (hb HTTPClient) doRequest(ctx context.Context, request *http.Request, target interface{}) error { + response, err := hb.httpClient.Do(request.WithContext(ctx)) if err != nil { return fmt.Errorf("failed to call endpoint: %w", err) } diff --git a/auth/services/oauth/holder.go b/auth/services/oauth/holder.go index 5f77e208ec..2f804b6cdd 100644 --- a/auth/services/oauth/holder.go +++ b/auth/services/oauth/holder.go @@ -29,6 +29,7 @@ import ( "github.com/nuts-foundation/nuts-node/auth/client/iam" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/http" "github.com/nuts-foundation/nuts-node/vcr/holder" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vcr/signature/proof" @@ -57,7 +58,7 @@ func NewHolder(wallet holder.Wallet, strictMode bool, httpClientTimeout time.Dur } } -func (v *HolderService) BuildPresentation(ctx context.Context, walletDID did.DID, presentationDefinition pe.PresentationDefinition, acceptedFormats map[string]map[string][]string, nonce string) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { +func (v *HolderService) BuildPresentation(ctx context.Context, walletDID did.DID, presentationDefinition pe.PresentationDefinition, acceptedFormats map[string]map[string][]string, nonce string, audience did.DID) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { // get VCs from own wallet credentials, err := v.wallet.List(ctx, walletDID) if err != nil { @@ -78,12 +79,15 @@ func (v *HolderService) BuildPresentation(ctx context.Context, walletDID did.DID } // todo: support multiple wallets + audienceStr := audience.String() vp, err := v.wallet.BuildPresentation(ctx, signInstructions[0].VerifiableCredentials, holder.PresentationOptions{ Format: format, ProofOptions: proof.ProofOptions{ Created: time.Now(), Challenge: &nonce, + Domain: &audienceStr, Expires: &expires, + Nonce: &nonce, }, }, &walletDID, false) if err != nil { @@ -102,14 +106,20 @@ func (v *HolderService) ClientMetadata(ctx context.Context, endpoint string) (*o return metadata, nil } -func (v *HolderService) PostError(ctx context.Context, auth2Error oauth.OAuth2Error, verifierResponseURI string) (string, error) { +func (v *HolderService) PostError(ctx context.Context, auth2Error oauth.OAuth2Error, verifierResponseURI string, verifierClientState string) (string, error) { iamClient := iam.NewHTTPClient(v.strictMode, v.httpClientTimeout, v.httpClientTLS) responseURL, err := core.ParsePublicURL(verifierResponseURI, v.strictMode) if err != nil { return "", fmt.Errorf("failed to post error to verifier: %w", err) } - redirectURL, err := iamClient.PostError(ctx, auth2Error, *responseURL) + validURL := *responseURL + if verifierClientState != "" { + validURL = http.AddQueryParams(*responseURL, map[string]string{ + oauth.StateParam: verifierClientState, + }) + } + redirectURL, err := iamClient.PostError(ctx, auth2Error, validURL) if err != nil { return "", fmt.Errorf("failed to post error to verifier: %w", err) } diff --git a/auth/services/oauth/holder_test.go b/auth/services/oauth/holder_test.go index 0d7696046a..8bbc9ec151 100644 --- a/auth/services/oauth/holder_test.go +++ b/auth/services/oauth/holder_test.go @@ -74,7 +74,7 @@ func TestHolderService_PostError(t *testing.T) { Description: "missing required parameter", } - redirect, err := ctx.holder.PostError(ctx.audit, oauthError, endpoint) + redirect, err := ctx.holder.PostError(ctx.audit, oauthError, endpoint, "state") require.NoError(t, err) assert.Equal(t, "redirect", redirect) @@ -84,7 +84,7 @@ func TestHolderService_PostError(t *testing.T) { endpoint := fmt.Sprintf("%s/error", ctx.tlsServer.URL) ctx.errorResponse = nil - redirect, err := ctx.holder.PostError(ctx.audit, oauth.OAuth2Error{}, endpoint) + redirect, err := ctx.holder.PostError(ctx.audit, oauth.OAuth2Error{}, endpoint, "state") assert.Error(t, err) assert.Empty(t, redirect) @@ -126,6 +126,7 @@ func TestHolderService_PostResponse(t *testing.T) { func TestHolderService_BuildPresentation(t *testing.T) { credentials := []vcr.VerifiableCredential{credential.ValidNutsOrganizationCredential(t)} walletDID := did.MustParseDID("did:web:example.com:iam:wallet") + verifierDID := did.MustParseDID("did:web:example.com:iam:verifier") presentationDefinition := pe.PresentationDefinition{InputDescriptors: []*pe.InputDescriptor{{Constraints: &pe.Constraints{Fields: []pe.Field{{Path: []string{"$.type"}}}}}}} vpFormats := oauth.DefaultOpenIDSupportedFormats() @@ -134,18 +135,19 @@ func TestHolderService_BuildPresentation(t *testing.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) - vp, submission, err := ctx.holder.BuildPresentation(context.Background(), walletDID, presentationDefinition, vpFormats, "") + vp, submission, err := ctx.holder.BuildPresentation(context.Background(), walletDID, presentationDefinition, vpFormats, "", verifierDID) assert.NoError(t, err) require.NotNil(t, vp) require.NotNil(t, submission) + }) // wallet failure, build failure, no credentials t.Run("error - wallet failure", func(t *testing.T) { ctx := createHolderContext(t, nil) ctx.wallet.EXPECT().List(gomock.Any(), walletDID).Return(nil, assert.AnError) - vp, submission, err := ctx.holder.BuildPresentation(context.Background(), walletDID, presentationDefinition, vpFormats, "") + vp, submission, err := ctx.holder.BuildPresentation(context.Background(), walletDID, presentationDefinition, vpFormats, "", verifierDID) assert.Error(t, err) assert.Nil(t, vp) @@ -156,7 +158,7 @@ func TestHolderService_BuildPresentation(t *testing.T) { ctx.wallet.EXPECT().List(gomock.Any(), walletDID).Return(credentials, nil) ctx.wallet.EXPECT().BuildPresentation(gomock.Any(), credentials, gomock.Any(), &walletDID, false).Return(nil, assert.AnError) - vp, submission, err := ctx.holder.BuildPresentation(context.Background(), walletDID, presentationDefinition, vpFormats, "") + vp, submission, err := ctx.holder.BuildPresentation(context.Background(), walletDID, presentationDefinition, vpFormats, "", verifierDID) assert.Error(t, err) assert.Nil(t, vp) @@ -166,7 +168,7 @@ func TestHolderService_BuildPresentation(t *testing.T) { ctx := createHolderContext(t, nil) ctx.wallet.EXPECT().List(gomock.Any(), walletDID).Return(credentials, nil) - vp, submission, err := ctx.holder.BuildPresentation(context.Background(), walletDID, pe.PresentationDefinition{}, vpFormats, "") + vp, submission, err := ctx.holder.BuildPresentation(context.Background(), walletDID, pe.PresentationDefinition{}, vpFormats, "", verifierDID) assert.Equal(t, ErrNoCredentials, err) assert.Nil(t, vp) diff --git a/auth/services/oauth/interface.go b/auth/services/oauth/interface.go index e7de3fa5e0..906adbe43a 100644 --- a/auth/services/oauth/interface.go +++ b/auth/services/oauth/interface.go @@ -34,7 +34,6 @@ type RelyingParty interface { CreateJwtGrant(ctx context.Context, request services.CreateJwtGrantRequest) (*services.JwtBearerTokenResult, error) // CreateAuthorizationRequest creates an OAuth2.0 authorizationRequest redirect URL that redirects to the authorization server. CreateAuthorizationRequest(ctx context.Context, requestHolder did.DID, verifier did.DID, scopes string, clientState string) (*url.URL, error) - // RequestRFC003AccessToken is called by the local EHR node to request an access token from a remote Nuts node using Nuts RFC003. RequestRFC003AccessToken(ctx context.Context, jwtGrantToken string, authServerEndpoint url.URL) (*oauth.TokenResponse, error) // RequestRFC021AccessToken is called by the local EHR node to request an access token from a remote Nuts node using Nuts RFC021. @@ -63,11 +62,11 @@ type Verifier interface { // Holder implements the OpenID4VP Holder role which acts as Authorization server in the OpenID4VP flow. type Holder interface { // BuildPresentation builds a Verifiable Presentation based on the given presentation definition. - BuildPresentation(ctx context.Context, walletDID did.DID, presentationDefinition pe.PresentationDefinition, acceptedFormats map[string]map[string][]string, nonce string) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) + BuildPresentation(ctx context.Context, walletDID did.DID, presentationDefinition pe.PresentationDefinition, acceptedFormats map[string]map[string][]string, nonce string, audience did.DID) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) // ClientMetadata returns the metadata of the remote verifier. ClientMetadata(ctx context.Context, endpoint string) (*oauth.OAuthClientMetadata, error) // PostError posts an error to the verifier. If it fails, an error is returned. - PostError(ctx context.Context, auth2Error oauth.OAuth2Error, verifierResponseURI string) (string, error) + PostError(ctx context.Context, auth2Error oauth.OAuth2Error, verifierResponseURI string, verifierClientState string) (string, error) // PostAuthorizationResponse posts the authorization response to the verifier. If it fails, an error is returned. PostAuthorizationResponse(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI string, state string) (string, error) // PresentationDefinition returns the presentation definition from the given endpoint. diff --git a/auth/services/oauth/mock.go b/auth/services/oauth/mock.go index 3f8ecce34a..dba75966d8 100644 --- a/auth/services/oauth/mock.go +++ b/auth/services/oauth/mock.go @@ -249,9 +249,9 @@ func (m *MockHolder) EXPECT() *MockHolderMockRecorder { } // BuildPresentation mocks base method. -func (m *MockHolder) BuildPresentation(ctx context.Context, walletDID did.DID, presentationDefinition pe.PresentationDefinition, acceptedFormats map[string]map[string][]string, nonce string) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { +func (m *MockHolder) BuildPresentation(ctx context.Context, walletDID did.DID, presentationDefinition pe.PresentationDefinition, acceptedFormats map[string]map[string][]string, nonce string, audience did.DID) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "BuildPresentation", ctx, walletDID, presentationDefinition, acceptedFormats, nonce) + ret := m.ctrl.Call(m, "BuildPresentation", ctx, walletDID, presentationDefinition, acceptedFormats, nonce, audience) ret0, _ := ret[0].(*vc.VerifiablePresentation) ret1, _ := ret[1].(*pe.PresentationSubmission) ret2, _ := ret[2].(error) @@ -259,9 +259,9 @@ func (m *MockHolder) BuildPresentation(ctx context.Context, walletDID did.DID, p } // BuildPresentation indicates an expected call of BuildPresentation. -func (mr *MockHolderMockRecorder) BuildPresentation(ctx, walletDID, presentationDefinition, acceptedFormats, nonce any) *gomock.Call { +func (mr *MockHolderMockRecorder) BuildPresentation(ctx, walletDID, presentationDefinition, acceptedFormats, nonce, audience any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BuildPresentation", reflect.TypeOf((*MockHolder)(nil).BuildPresentation), ctx, walletDID, presentationDefinition, acceptedFormats, nonce) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BuildPresentation", reflect.TypeOf((*MockHolder)(nil).BuildPresentation), ctx, walletDID, presentationDefinition, acceptedFormats, nonce, audience) } // ClientMetadata mocks base method. @@ -295,18 +295,18 @@ func (mr *MockHolderMockRecorder) PostAuthorizationResponse(ctx, vp, presentatio } // PostError mocks base method. -func (m *MockHolder) PostError(ctx context.Context, auth2Error oauth.OAuth2Error, verifierResponseURI string) (string, error) { +func (m *MockHolder) PostError(ctx context.Context, auth2Error oauth.OAuth2Error, verifierResponseURI, verifierClientState string) (string, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "PostError", ctx, auth2Error, verifierResponseURI) + ret := m.ctrl.Call(m, "PostError", ctx, auth2Error, verifierResponseURI, verifierClientState) ret0, _ := ret[0].(string) ret1, _ := ret[1].(error) return ret0, ret1 } // PostError indicates an expected call of PostError. -func (mr *MockHolderMockRecorder) PostError(ctx, auth2Error, verifierResponseURI any) *gomock.Call { +func (mr *MockHolderMockRecorder) PostError(ctx, auth2Error, verifierResponseURI, verifierClientState any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostError", reflect.TypeOf((*MockHolder)(nil).PostError), ctx, auth2Error, verifierResponseURI) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostError", reflect.TypeOf((*MockHolder)(nil).PostError), ctx, auth2Error, verifierResponseURI, verifierClientState) } // PresentationDefinition mocks base method. diff --git a/auth/services/oauth/relying_party.go b/auth/services/oauth/relying_party.go index 3505b8d24f..0b5f35ca04 100644 --- a/auth/services/oauth/relying_party.go +++ b/auth/services/oauth/relying_party.go @@ -24,6 +24,7 @@ import ( "errors" "fmt" "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/nuts-foundation/nuts-node/vdr/didweb" "net/http" "net/url" "strings" @@ -126,12 +127,18 @@ func (s *relyingParty) CreateAuthorizationRequest(ctx context.Context, requestHo if err != nil { return nil, fmt.Errorf("failed to parse authorization endpoint URL: %w", err) } - // todo: redirect_uri + // construct callback URL for wallet + callbackURL, err := didweb.DIDToURL(requestHolder) + if err != nil { + return nil, fmt.Errorf("failed to create callback URL: %w", err) + } + callbackURL = callbackURL.JoinPath("callback") redirectURL := nutsHttp.AddQueryParams(*endpoint, map[string]string{ "client_id": requestHolder.String(), "response_type": "code", "scope": scopes, "state": clientState, + "redirect_uri": callbackURL.String(), }) return &redirectURL, nil } diff --git a/auth/services/oauth/relying_party_test.go b/auth/services/oauth/relying_party_test.go index 5ed0d484c6..7375b1d677 100644 --- a/auth/services/oauth/relying_party_test.go +++ b/auth/services/oauth/relying_party_test.go @@ -223,7 +223,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { } func TestRelyingParty_AuthorizationRequest(t *testing.T) { - walletDID := did.MustParseDID("did:test:123") + walletDID := did.MustParseDID("did:web:test.test:iam:123") scopes := "first second" clientState := crypto.GenerateNonce() @@ -238,6 +238,7 @@ func TestRelyingParty_AuthorizationRequest(t *testing.T) { assert.Equal(t, "code", redirectURL.Query().Get("response_type")) assert.Equal(t, "first second", redirectURL.Query().Get("scope")) assert.NotEmpty(t, redirectURL.Query().Get("state")) + assert.Equal(t, "https://test.test/iam/123/callback", redirectURL.Query().Get("redirect_uri")) }) t.Run("error - failed to get authorization server metadata", func(t *testing.T) { ctx := createOAuthRPContext(t) diff --git a/codegen/configs/auth_iam.yaml b/codegen/configs/auth_iam.yaml index 9c0cc4278b..5cc9320f51 100644 --- a/codegen/configs/auth_iam.yaml +++ b/codegen/configs/auth_iam.yaml @@ -10,5 +10,7 @@ output-options: - OAuthAuthorizationServerMetadata - OAuthClientMetadata - PresentationDefinition + - PresentationSubmission + - RedirectResponse - TokenResponse - VerifiablePresentation diff --git a/docs/_static/auth/iam.yaml b/docs/_static/auth/iam.yaml index bed2533975..64533e6338 100644 --- a/docs/_static/auth/iam.yaml +++ b/docs/_static/auth/iam.yaml @@ -58,10 +58,14 @@ paths: example: urn:ietf:params:oauth:grant-type:authorized_code code: type: string + client_id: + type: string assertion: type: string presentation_submission: type: string + redirect_uri: + type: string scope: type: string responses: @@ -149,6 +153,52 @@ paths: "$ref": "#/components/schemas/PresentationDefinition" "default": $ref: '../common/error_response.yaml' + "/iam/{id}/response": + post: + summary: Used by wallets to post the authorization response or error to. + description: | + Specified by https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#name-response-mode-direct_postjw + The response is either an error response with error, error_description and state filled or a submission with vp_token and presentation_submission filled. + When an error is posted, the state is used to fetch the holder's callbackURI from the verifiers client state. + operationId: handleAuthorizeResponse + tags: + - oauth2 + parameters: + - name: id + in: path + required: true + description: the id part of the web DID + schema: + type: string + example: EwVMYK2ugaMvRHUbGFBhuyF423JuNQbtpes35eHhkQic + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + error: + description: error code as defined by the OAuth2 specification + type: string + error_description: + description: error description as defined by the OAuth2 specification + type: string + presentation_submission: + type: string + state: + description: the client state for the verifier + type: string + vp_token: + description: A Verifiable Presentation in either JSON-LD or JWT format. + type: string + responses: + "200": + description: Authorization response with a redirect URL, also used for error returns if possible. + content: + application/json: + schema: + $ref: '#/components/schemas/RedirectResponse' # 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 @@ -362,6 +412,16 @@ components: $ref: '../common/ssi_types.yaml#/components/schemas/DIDDocument' VerifiablePresentation: $ref: '../common/ssi_types.yaml#/components/schemas/VerifiablePresentation' + RedirectResponse: + type: object + required: + - redirect_uri + properties: + redirect_uri: + type: string + description: | + The URL to which the user-agent will be redirected after the authorization request. + example: "https://example.com/callback" TokenResponse: type: object description: | @@ -408,6 +468,11 @@ components: 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 + PresentationSubmission: + description: | + A presentation submission is a JSON object that describes the mapping between the required verifiable credentials listed in the presentation definition and the supplied verifiable presentation. + Specified at https://identity.foundation/presentation-exchange/spec/v2.0.0/ + type: object ErrorResponse: type: object required: diff --git a/e2e-tests/oauth-flow/openid4vp/docker-compose.yml b/e2e-tests/oauth-flow/openid4vp/docker-compose.yml new file mode 100644 index 0000000000..4fd7d57230 --- /dev/null +++ b/e2e-tests/oauth-flow/openid4vp/docker-compose.yml @@ -0,0 +1,56 @@ +version: "3.7" +services: + nodeA-backend: + image: "${IMAGE_NODE_A:-nutsfoundation/nuts-node:master}" + ports: + - "11323:1323" + environment: + NUTS_CONFIGFILE: /opt/nuts/nuts.yaml + volumes: + - "./node-A/nuts.yaml:/opt/nuts/nuts.yaml:ro" + - "./node-A/data:/opt/nuts/data:rw" + - "../../tls-certs/nodeA-backend-certificate.pem:/opt/nuts/certificate-and-key.pem:ro" + - "../../tls-certs/truststore.pem:/opt/nuts/truststore.pem:ro" + # did:web resolver uses the OS CA bundle, but e2e tests use a self-signed CA which can be found in truststore.pem + # So we need to mount that file to the OS CA bundle location, otherwise did:web resolving will fail due to untrusted certs. + - "../../tls-certs/truststore.pem:/etc/ssl/certs/Nuts_RootCA.pem:ro" + - "../../tls-certs/truststore.pem:/etc/ssl/certs/truststore.pem:ro" + - "./node-A/presentationexchangemapping.json:/opt/nuts/policies/presentationexchangemapping.json:ro" + healthcheck: + interval: 1s # Make test run quicker by checking health status more often + nodeA: + image: nginx:1.25.1 + ports: + - "10443:443" + volumes: + - "./node-A/nginx.conf:/etc/nginx/nginx.conf:ro" + - "../../tls-certs/nodeA-certificate.pem:/etc/nginx/ssl/server.pem:ro" + - "../../tls-certs/nodeA-certificate.pem:/etc/nginx/ssl/key.pem:ro" + - "../../tls-certs/truststore.pem:/etc/nginx/ssl/truststore.pem:ro" + nodeB-backend: + image: "${IMAGE_NODE_B:-nutsfoundation/nuts-node:master}" + ports: + - "21323:1323" + environment: + NUTS_CONFIGFILE: /opt/nuts/nuts.yaml + volumes: + - "./node-B/data:/opt/nuts/data:rw" + - "./node-B/nuts.yaml:/opt/nuts/nuts.yaml:ro" + - "../../tls-certs/nodeB-certificate.pem:/opt/nuts/certificate-and-key.pem:ro" + - "../../tls-certs/truststore.pem:/opt/nuts/truststore.pem:ro" + # did:web resolver uses the OS CA bundle, but e2e tests use a self-signed CA which can be found in truststore.pem + # So we need to mount that file to the OS CA bundle location, otherwise did:web resolving will fail due to untrusted certs. + - "../../tls-certs/truststore.pem:/etc/ssl/certs/Nuts_RootCA.pem:ro" + - "../../tls-certs/truststore.pem:/etc/ssl/certs/truststore.pem:ro" + - "./node-B/presentationexchangemapping.json:/opt/nuts/policies/presentationexchangemapping.json:ro" + healthcheck: + interval: 1s # Make test run quicker by checking health status more often + nodeB: + image: nginx:1.25.1 + ports: + - "20443:443" + volumes: + - "./node-B/nginx.conf:/etc/nginx/nginx.conf:ro" + - "../../tls-certs/nodeB-certificate.pem:/etc/nginx/ssl/server.pem:ro" + - "../../tls-certs/nodeB-certificate.pem:/etc/nginx/ssl/key.pem:ro" + - "../../tls-certs/truststore.pem:/etc/nginx/ssl/truststore.pem:ro" diff --git a/e2e-tests/oauth-flow/openid4vp/node-A/nginx.conf b/e2e-tests/oauth-flow/openid4vp/node-A/nginx.conf new file mode 100644 index 0000000000..cf2113e8fa --- /dev/null +++ b/e2e-tests/oauth-flow/openid4vp/node-A/nginx.conf @@ -0,0 +1,47 @@ +user nginx; +worker_processes 1; + +error_log /var/log/nginx/error.log debug; +pid /var/run/nginx.pid; + + +events { + worker_connections 1024; +} + + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + keepalive_timeout 65; + + include /etc/nginx/conf.d/*.conf; + + upstream nodeA-backend { + server nodeA-backend:1323; + } + + server { + server_name nodeA; + listen 443 ssl; + http2 on; + ssl_certificate /etc/nginx/ssl/server.pem; + ssl_certificate_key /etc/nginx/ssl/key.pem; + ssl_client_certificate /etc/nginx/ssl/truststore.pem; + ssl_verify_client optional; + ssl_verify_depth 1; + ssl_protocols TLSv1.3; + + location / { + proxy_set_header X-Ssl-Client-Cert $ssl_client_escaped_cert; + proxy_pass http://nodeA-backend; + } + } +} diff --git a/e2e-tests/oauth-flow/openid4vp/node-A/nuts.yaml b/e2e-tests/oauth-flow/openid4vp/node-A/nuts.yaml new file mode 100644 index 0000000000..4459d8dae0 --- /dev/null +++ b/e2e-tests/oauth-flow/openid4vp/node-A/nuts.yaml @@ -0,0 +1,21 @@ +url: https://nodeA +verbosity: debug +strictmode: false +internalratelimiter: false +datadir: /opt/nuts/data +http: + default: + address: :1323 + log: metadata-and-body +auth: + v2apienabled: true + contractvalidators: + - dummy + irma: + autoupdateschemas: false +policy: + directory: /opt/nuts/policies +tls: + truststorefile: /opt/nuts/truststore.pem + certfile: /opt/nuts/certificate-and-key.pem + certkeyfile: /opt/nuts/certificate-and-key.pem diff --git a/e2e-tests/oauth-flow/openid4vp/node-A/presentationexchangemapping.json b/e2e-tests/oauth-flow/openid4vp/node-A/presentationexchangemapping.json new file mode 100644 index 0000000000..57a0759051 --- /dev/null +++ b/e2e-tests/oauth-flow/openid4vp/node-A/presentationexchangemapping.json @@ -0,0 +1,43 @@ +{ + "test": { + "format": { + "ldp_vp": { + "proof_type": ["JsonWebSignature2020"] + }, + "ldp_vc": { + "proof_type": ["JsonWebSignature2020"] + } + }, + "id": "pd_any_care_organization", + "name": "Care organization", + "purpose": "Finding a care organization for authorizing access to medical metadata", + "input_descriptors": [ + { + "id": "id_nuts_care_organization_cred", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "NutsOrganizationCredential" + } + }, + { + "path": ["$.credentialSubject.organization.name"], + "filter": { + "type": "string" + } + }, + { + "path": ["$.credentialSubject.organization.city"], + "filter": { + "type": "string" + } + } + ] + } + } + ] + } +} diff --git a/e2e-tests/oauth-flow/openid4vp/node-B/nginx.conf b/e2e-tests/oauth-flow/openid4vp/node-B/nginx.conf new file mode 100644 index 0000000000..55b6234507 --- /dev/null +++ b/e2e-tests/oauth-flow/openid4vp/node-B/nginx.conf @@ -0,0 +1,47 @@ +user nginx; +worker_processes 1; + +error_log /var/log/nginx/error.log debug; +pid /var/run/nginx.pid; + + +events { + worker_connections 1024; +} + + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + keepalive_timeout 65; + + include /etc/nginx/conf.d/*.conf; + + upstream nodeB-backend { + server nodeB-backend:1323; + } + + server { + server_name nodeB; + listen 443 ssl; + http2 on; + ssl_certificate /etc/nginx/ssl/server.pem; + ssl_certificate_key /etc/nginx/ssl/key.pem; + ssl_client_certificate /etc/nginx/ssl/truststore.pem; + ssl_verify_client optional; + ssl_verify_depth 1; + ssl_protocols TLSv1.3; + + location / { + proxy_set_header X-Ssl-Client-Cert $ssl_client_escaped_cert; + proxy_pass http://nodeB-backend; + } + } +} diff --git a/e2e-tests/oauth-flow/openid4vp/node-B/nuts.yaml b/e2e-tests/oauth-flow/openid4vp/node-B/nuts.yaml new file mode 100644 index 0000000000..a24a1cef65 --- /dev/null +++ b/e2e-tests/oauth-flow/openid4vp/node-B/nuts.yaml @@ -0,0 +1,21 @@ +url: https://nodeB +verbosity: debug +strictmode: false +internalratelimiter: false +datadir: /opt/nuts/data +http: + default: + address: :1323 + log: metadata-and-body +auth: + v2apienabled: true + contractvalidators: + - dummy + irma: + autoupdateschemas: false +policy: + directory: /opt/nuts/policies +tls: + truststorefile: /opt/nuts/truststore.pem + certfile: /opt/nuts/certificate-and-key.pem + certkeyfile: /opt/nuts/certificate-and-key.pem diff --git a/e2e-tests/oauth-flow/openid4vp/node-B/presentationexchangemapping.json b/e2e-tests/oauth-flow/openid4vp/node-B/presentationexchangemapping.json new file mode 100644 index 0000000000..57a0759051 --- /dev/null +++ b/e2e-tests/oauth-flow/openid4vp/node-B/presentationexchangemapping.json @@ -0,0 +1,43 @@ +{ + "test": { + "format": { + "ldp_vp": { + "proof_type": ["JsonWebSignature2020"] + }, + "ldp_vc": { + "proof_type": ["JsonWebSignature2020"] + } + }, + "id": "pd_any_care_organization", + "name": "Care organization", + "purpose": "Finding a care organization for authorizing access to medical metadata", + "input_descriptors": [ + { + "id": "id_nuts_care_organization_cred", + "constraints": { + "fields": [ + { + "path": ["$.type"], + "filter": { + "type": "string", + "const": "NutsOrganizationCredential" + } + }, + { + "path": ["$.credentialSubject.organization.name"], + "filter": { + "type": "string" + } + }, + { + "path": ["$.credentialSubject.organization.city"], + "filter": { + "type": "string" + } + } + ] + } + } + ] + } +} diff --git a/e2e-tests/oauth-flow/openid4vp/run-test.sh b/e2e-tests/oauth-flow/openid4vp/run-test.sh new file mode 100755 index 0000000000..3f38f16123 --- /dev/null +++ b/e2e-tests/oauth-flow/openid4vp/run-test.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +source ../../util.sh + +echo "------------------------------------" +echo "Cleaning up running Docker containers and volumes, and key material..." +echo "------------------------------------" +docker compose down +docker compose rm -f -v +rm -rf ./node-*/data +mkdir ./node-A/data ./node-B/data # 'data' dirs will be created with root owner by docker if they do not exit. This creates permission issues on CI. + +echo "------------------------------------" +echo "Starting Docker containers..." +echo "------------------------------------" +docker compose up -d +docker compose up --wait nodeA nodeA-backend nodeB nodeB-backend + +echo "------------------------------------" +echo "Registering vendors..." +echo "------------------------------------" +# Register Vendor A +VENDOR_A_DIDDOC=$(docker compose exec nodeA-backend nuts vdr create-did --v2) +VENDOR_A_DID=$(echo $VENDOR_A_DIDDOC | jq -r .id) +echo Vendor A DID: $VENDOR_A_DID + +# Register Vendor B +VENDOR_B_DIDDOC=$(docker compose exec nodeB-backend nuts vdr create-did --v2) +VENDOR_B_DID=$(echo $VENDOR_B_DIDDOC | jq -r .id) +echo Vendor B DID: $VENDOR_B_DID + +# Issue NutsOrganizationCredential for Vendor B +REQUEST="{\"type\":\"NutsOrganizationCredential\",\"issuer\":\"${VENDOR_B_DID}\", \"credentialSubject\": {\"id\":\"${VENDOR_B_DID}\", \"organization\":{\"name\":\"Caresoft B.V.\", \"city\":\"Caretown\"}},\"publishToNetwork\": false}" +RESPONSE=$(echo $REQUEST | curl -X POST --data-binary @- http://localhost:21323/internal/vcr/v2/issuer/vc -H "Content-Type:application/json") +if echo $RESPONSE | grep -q "VerifiableCredential"; then + echo "VC issued" +else + echo "FAILED: Could not issue NutsOrganizationCredential to node-B" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi + +RESPONSE=$(echo $RESPONSE | curl -X POST --data-binary @- http://localhost:21323/internal/vcr/v2/holder/${VENDOR_B_DID}/vc -H "Content-Type:application/json") +if echo $RESPONSE == ""; then + echo "VC stored in wallet" +else + echo "FAILED: Could not load NutsOrganizationCredential in node-B wallet" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi + +echo "---------------------------------------" +echo "Request access token call" +echo "---------------------------------------" +# Request access token +REQUEST="{\"verifier\":\"${VENDOR_A_DID}\",\"scope\":\"test\", \"userID\":\"1\", \"redirectURL\":\"http://callback\"}" +RESPONSE=$(echo $REQUEST | curl -D ./node-B/data/headers.txt -X POST -s --data-binary @- http://localhost:21323/internal/auth/v2/${VENDOR_B_DID}/request-access-token -H "Content-Type:application/json" -v) +if grep -q 'Location' ./node-B/data/headers.txt; then + LOCATION=$(grep 'Location' ./node-B/data/headers.txt | sed -E 's/Location: (.*)/\1/' | tr -d '\r') + echo "REDIRECTURL: $LOCATION" +else + echo $RESPONSE + echo "FAILED: Could not get redirectURL from node-B" 1>&2 + exitWithDockerLogs 1 +fi + +echo "--------------------------------------" +echo "Redirect user to local OAuth server..." +echo "--------------------------------------" + +LOCATION=$(echo $LOCATION | sed -E 's/nodeB/localhost:20443/') +RESPONSE=$(curl -D ./node-B/data/headers.txt $LOCATION -v -k) +if grep -q 'location' ./node-B/data/headers.txt; then + LOCATION=$(grep 'location' ./node-B/data/headers.txt | sed -E 's/location: (.*)/\1/' | tr -d '\r') + echo "REDIRECTURL: $LOCATION" +else + echo $RESPONSE + echo "FAILED: Could not get redirectURL from node-B" 1>&2 + exitWithDockerLogs 1 +fi + +echo "---------------------------------------" +echo "Redirect user to remote OAuth server..." +echo "---------------------------------------" + +LOCATION=$(echo $LOCATION | sed -E 's/nodeA/localhost:10443/') +RESPONSE=$(curl -D ./node-B/data/headers.txt $LOCATION -v -k) +if grep -q 'location' ./node-B/data/headers.txt; then + LOCATION=$(grep 'location' ./node-B/data/headers.txt | sed -E 's/location: (.*)/\1/' | tr -d '\r') + echo "REDIRECTURL: $LOCATION" +else + echo $RESPONSE + echo "FAILED: Could not get redirectURL from node-A" 1>&2 + exitWithDockerLogs 1 +fi + +echo "---------------------------------------" +echo "Build VP..." +echo "---------------------------------------" + +LOCATION=$(echo $LOCATION | sed -E 's/nodeB/localhost:20443/') +RESPONSE=$(curl -D ./node-B/data/headers.txt $LOCATION -v -k) +if grep -q 'location' ./node-B/data/headers.txt; then + LOCATION=$(grep 'location' ./node-B/data/headers.txt | sed -E 's/location: (.*)/\1/' | tr -d '\r') + echo "REDIRECTURL: $LOCATION" +else + echo $RESPONSE + echo "FAILED: Could not get redirectURL from node-B" 1>&2 + exitWithDockerLogs 1 +fi + +echo "---------------------------------------" +echo "Redirect user to local OAuth server ..." +echo "---------------------------------------" + +# todo, callback url is not registered yet + +#LOCATION=$(echo $LOCATION | sed -E 's/nodeB/localhost:20443/') +#RESPONSE=$(curl -D ./node-B/data/headers.txt $LOCATION -v -k) +#echo $RESPONSE + + +echo "------------------------------------" +echo "Stopping Docker containers..." +echo "------------------------------------" +docker compose stop diff --git a/e2e-tests/oauth-flow/run-tests.sh b/e2e-tests/oauth-flow/run-tests.sh index 31fb92f853..f2764adbdb 100755 --- a/e2e-tests/oauth-flow/run-tests.sh +++ b/e2e-tests/oauth-flow/run-tests.sh @@ -15,3 +15,10 @@ echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" pushd rfc021 ./run-test.sh popd + +echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" +echo "!! Running test: OpenID4VP flow !!" +echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" +pushd openid4vp +./run-test.sh +popd diff --git a/http/requestlogger.go b/http/requestlogger.go index 14156c038a..2d333087a3 100644 --- a/http/requestlogger.go +++ b/http/requestlogger.go @@ -71,16 +71,15 @@ func bodyLoggerMiddleware(skipper middleware.Skipper, logger *logrus.Entry) echo requestBody := "(not loggable: " + requestContentType + ")" if isLoggableContentType(requestContentType) { requestBody = string(request) + logger.Infof("HTTP request body: %s", requestBody) } responseContentType := e.Response().Header().Get("Content-Type") responseBody := "(not loggable: " + responseContentType + ")" if isLoggableContentType(responseContentType) { responseBody = string(response) + logger.Infof("HTTP response body: %s", responseBody) } - - logger.Infof("HTTP request body: %s", requestBody) - logger.Infof("HTTP response body: %s", responseBody) }, Skipper: skipper, }) diff --git a/http/requestlogger_test.go b/http/requestlogger_test.go index 0b0afbd265..2273db6df5 100644 --- a/http/requestlogger_test.go +++ b/http/requestlogger_test.go @@ -27,6 +27,7 @@ import ( "github.com/sirupsen/logrus" "github.com/sirupsen/logrus/hooks/test" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "net/http" "net/http/httptest" @@ -173,9 +174,7 @@ func Test_bodyLoggerMiddleware(t *testing.T) { return context.NoContent(http.StatusNoContent) })(echoMock) - assert.NoError(t, err) - assert.Len(t, hook.Entries, 2) - assert.Equal(t, `HTTP request body: (not loggable: application/binary)`, hook.AllEntries()[0].Message) - assert.Equal(t, `HTTP response body: (not loggable: application/binary)`, hook.AllEntries()[1].Message) + require.NoError(t, err) + assert.Len(t, hook.Entries, 0) }) } diff --git a/vcr/holder/wallet_test.go b/vcr/holder/wallet_test.go index 0401ec82b9..e3bafc8e08 100644 --- a/vcr/holder/wallet_test.go +++ b/vcr/holder/wallet_test.go @@ -44,8 +44,6 @@ import ( "go.uber.org/mock/gomock" ) -var testDID = vdr.TestDIDA - func TestWallet_BuildPresentation(t *testing.T) { var kid = vdr.TestMethodDIDA.String() testCredential := createCredential(kid) diff --git a/vcr/pe/presentation_submission.go b/vcr/pe/presentation_submission.go index 3ced0e08fa..2e4be556a2 100644 --- a/vcr/pe/presentation_submission.go +++ b/vcr/pe/presentation_submission.go @@ -149,13 +149,16 @@ func (b *PresentationSubmissionBuilder) Build(format string) (PresentationSubmis // the verifiableCredential property in Verifiable Presentations can be a single VC or an array of VCs when represented in JSON. // go-did always marshals a single VC as a single VC for JSON-LD VPs. So we might need to fix the mapping paths. - if format == vc.JSONLDPresentationProofFormat { - for _, signInstruction := range nonEmptySignInstructions { - if len(signInstruction.Mappings) == 1 { - signInstruction.Mappings[0].Path = "$.verifiableCredential" - } + + // todo the check below actually depends on the format of the credential and not the format of the VP + // commented for now because it's needed for VPs in JWT format as well. + //if format == vc.JSONLDPresentationProofFormat { + for _, signInstruction := range nonEmptySignInstructions { + if len(signInstruction.Mappings) == 1 { + signInstruction.Mappings[0].Path = "$.verifiableCredential" } } + //} index := 0 // last we create the descriptor map for the presentation submission diff --git a/vcr/pe/presentation_submission_test.go b/vcr/pe/presentation_submission_test.go index 80bd4b600c..7fc047b448 100644 --- a/vcr/pe/presentation_submission_test.go +++ b/vcr/pe/presentation_submission_test.go @@ -53,8 +53,10 @@ func TestPresentationSubmissionBuilder_Build(t *testing.T) { vc2 := credentialToJSONLD(vc.VerifiableCredential{ID: &id2}) vc3 := credentialToJSONLD(vc.VerifiableCredential{ID: &id3}) - t.Run("1 presentation with 1 credential", func(t *testing.T) { - expectedJSON := ` + t.Run("ldp_vp", func(t *testing.T) { + + t.Run("1 presentation with 1 credential", func(t *testing.T) { + expectedJSON := ` { "id": "for-test", "definition_id": "", @@ -66,26 +68,26 @@ func TestPresentationSubmissionBuilder_Build(t *testing.T) { } ] }` - presentationDefinition := PresentationDefinition{} - _ = json.Unmarshal([]byte(test.PickOne), &presentationDefinition) - builder := presentationDefinition.PresentationSubmissionBuilder() - builder.AddWallet(holder1, []vc.VerifiableCredential{vc1, vc2}) - - submission, signInstructions, err := builder.Build("ldp_vp") - - require.NoError(t, err) - require.NotNil(t, signInstructions) - assert.Len(t, signInstructions, 1) - require.Len(t, submission.DescriptorMap, 1) - assert.Equal(t, "$.verifiableCredential", submission.DescriptorMap[0].Path) - - submission.Id = "for-test" // easier assertion - actualJSON, _ := json.MarshalIndent(submission, "", " ") - println(string(actualJSON)) - assert.JSONEq(t, expectedJSON, string(actualJSON)) - }) - t.Run("1 presentation with 2 credentials", func(t *testing.T) { - expectedJSON := ` + presentationDefinition := PresentationDefinition{} + _ = json.Unmarshal([]byte(test.PickOne), &presentationDefinition) + builder := presentationDefinition.PresentationSubmissionBuilder() + builder.AddWallet(holder1, []vc.VerifiableCredential{vc1, vc2}) + + submission, signInstructions, err := builder.Build("ldp_vp") + + require.NoError(t, err) + require.NotNil(t, signInstructions) + assert.Len(t, signInstructions, 1) + require.Len(t, submission.DescriptorMap, 1) + assert.Equal(t, "$.verifiableCredential", submission.DescriptorMap[0].Path) + + submission.Id = "for-test" // easier assertion + actualJSON, _ := json.MarshalIndent(submission, "", " ") + println(string(actualJSON)) + assert.JSONEq(t, expectedJSON, string(actualJSON)) + }) + t.Run("1 presentation with 2 credentials", func(t *testing.T) { + expectedJSON := ` { "id": "for-test", "definition_id": "", @@ -102,25 +104,25 @@ func TestPresentationSubmissionBuilder_Build(t *testing.T) { } ] }` - presentationDefinition := PresentationDefinition{} - _ = json.Unmarshal([]byte(test.All), &presentationDefinition) - builder := presentationDefinition.PresentationSubmissionBuilder() - builder.AddWallet(holder1, []vc.VerifiableCredential{vc1, vc2}) - - submission, signInstructions, err := builder.Build("ldp_vp") - - require.NoError(t, err) - require.NotNil(t, signInstructions) - assert.Len(t, signInstructions, 1) - require.Len(t, submission.DescriptorMap, 2) - - submission.Id = "for-test" // easier assertion - actualJSON, _ := json.MarshalIndent(submission, "", " ") - println(string(actualJSON)) - assert.JSONEq(t, expectedJSON, string(actualJSON)) - }) - t.Run("2 presentations", func(t *testing.T) { - expectedJSON := ` + presentationDefinition := PresentationDefinition{} + _ = json.Unmarshal([]byte(test.All), &presentationDefinition) + builder := presentationDefinition.PresentationSubmissionBuilder() + builder.AddWallet(holder1, []vc.VerifiableCredential{vc1, vc2}) + + submission, signInstructions, err := builder.Build("ldp_vp") + + require.NoError(t, err) + require.NotNil(t, signInstructions) + assert.Len(t, signInstructions, 1) + require.Len(t, submission.DescriptorMap, 2) + + submission.Id = "for-test" // easier assertion + actualJSON, _ := json.MarshalIndent(submission, "", " ") + println(string(actualJSON)) + assert.JSONEq(t, expectedJSON, string(actualJSON)) + }) + t.Run("2 presentations", func(t *testing.T) { + expectedJSON := ` { "id": "for-test", "definition_id": "", @@ -148,25 +150,25 @@ func TestPresentationSubmissionBuilder_Build(t *testing.T) { ] } ` - presentationDefinition := PresentationDefinition{} - _ = json.Unmarshal([]byte(test.All), &presentationDefinition) - builder := presentationDefinition.PresentationSubmissionBuilder() - builder.AddWallet(holder1, []vc.VerifiableCredential{vc1}) - builder.AddWallet(holder2, []vc.VerifiableCredential{vc2}) - - submission, signInstructions, err := builder.Build("ldp_vp") - - require.NoError(t, err) - require.NotNil(t, signInstructions) - assert.Len(t, signInstructions, 2) - assert.Len(t, submission.DescriptorMap, 2) - - submission.Id = "for-test" // easier assertion - actualJSON, _ := json.MarshalIndent(submission, "", " ") - assert.JSONEq(t, expectedJSON, string(actualJSON)) - }) - t.Run("2 wallets, but 1 VP", func(t *testing.T) { - expectedJSON := ` + presentationDefinition := PresentationDefinition{} + _ = json.Unmarshal([]byte(test.All), &presentationDefinition) + builder := presentationDefinition.PresentationSubmissionBuilder() + builder.AddWallet(holder1, []vc.VerifiableCredential{vc1}) + builder.AddWallet(holder2, []vc.VerifiableCredential{vc2}) + + submission, signInstructions, err := builder.Build("ldp_vp") + + require.NoError(t, err) + require.NotNil(t, signInstructions) + assert.Len(t, signInstructions, 2) + assert.Len(t, submission.DescriptorMap, 2) + + submission.Id = "for-test" // easier assertion + actualJSON, _ := json.MarshalIndent(submission, "", " ") + assert.JSONEq(t, expectedJSON, string(actualJSON)) + }) + t.Run("2 wallets, but 1 VP", func(t *testing.T) { + expectedJSON := ` { "id": "for-test", "definition_id": "", @@ -183,22 +185,39 @@ func TestPresentationSubmissionBuilder_Build(t *testing.T) { } ] }` - presentationDefinition := PresentationDefinition{} - _ = json.Unmarshal([]byte(test.All), &presentationDefinition) - builder := presentationDefinition.PresentationSubmissionBuilder() - builder.AddWallet(holder1, []vc.VerifiableCredential{vc1, vc2}) - builder.AddWallet(holder2, []vc.VerifiableCredential{vc3}) - - submission, signInstructions, err := builder.Build("ldp_vp") - - require.NoError(t, err) - require.NotNil(t, signInstructions) - assert.Len(t, signInstructions, 1) - assert.Len(t, submission.DescriptorMap, 2) - - submission.Id = "for-test" // easier assertion - actualJSON, _ := json.MarshalIndent(submission, "", " ") - assert.JSONEq(t, expectedJSON, string(actualJSON)) + presentationDefinition := PresentationDefinition{} + _ = json.Unmarshal([]byte(test.All), &presentationDefinition) + builder := presentationDefinition.PresentationSubmissionBuilder() + builder.AddWallet(holder1, []vc.VerifiableCredential{vc1, vc2}) + builder.AddWallet(holder2, []vc.VerifiableCredential{vc3}) + + submission, signInstructions, err := builder.Build("ldp_vp") + + require.NoError(t, err) + require.NotNil(t, signInstructions) + assert.Len(t, signInstructions, 1) + assert.Len(t, submission.DescriptorMap, 2) + + submission.Id = "for-test" // easier assertion + actualJSON, _ := json.MarshalIndent(submission, "", " ") + assert.JSONEq(t, expectedJSON, string(actualJSON)) + }) + }) + t.Run("jwt_vp", func(t *testing.T) { + t.Run("1 presentation with 1 credential", func(t *testing.T) { + presentationDefinition := PresentationDefinition{} + _ = json.Unmarshal([]byte(test.PickOne), &presentationDefinition) + builder := presentationDefinition.PresentationSubmissionBuilder() + builder.AddWallet(holder1, []vc.VerifiableCredential{vc1, vc2}) + + submission, signInstructions, err := builder.Build("jwt_vp") + + require.NoError(t, err) + require.NotNil(t, signInstructions) + assert.Len(t, signInstructions, 1) + require.Len(t, submission.DescriptorMap, 1) + assert.Equal(t, "$.verifiableCredential", submission.DescriptorMap[0].Path) + }) }) }