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..be72649e6c 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.URI()).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 f7badfa4a8..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"` } @@ -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..49103e0d88 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"}, walletDID, 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)"}, walletDID, 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"}, walletDID, 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)"}, walletDID, 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)}, walletDID, 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.URI()) 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"}, walletDID, 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()}, walletDID, 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,14 +269,22 @@ 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, walletDID did.DID, verifierResponseURI string, verifierClientState string) (HandleAuthorizeRequestResponseObject, error) { + redirectURI, err := r.auth.Holder().PostError(ctx, auth2Error, verifierResponseURI, verifierClientState) if err == nil { - return HandleAuthorizeRequest302Response{ - HandleAuthorizeRequest302ResponseHeaders{ - Location: redirectURI, - }, - }, nil + // check redirectURI against registered callbackURI + registeredURL, err := didweb.DIDToURL(walletDID) + if err != nil { + return nil, err + } + if strings.HasPrefix(redirectURI, registeredURL.String()) { + return HandleAuthorizeRequest302Response{ + HandleAuthorizeRequest302ResponseHeaders{ + Location: redirectURI, + }, + }, nil + } + log.Logger().Errorf("verifier responded with incorrect callbackURI: \"%s\" for wallet: %s", redirectURI, walletDID.String()) } msg := fmt.Sprintf("failed to post error to verifier @ %s", verifierResponseURI) @@ -286,6 +309,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 +579,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 +780,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..20a33b520f 100644 --- a/auth/api/iam/openid4vp_test.go +++ b/auth/api/iam/openid4vp_test.go @@ -21,15 +21,18 @@ package iam import ( "bytes" "context" + "encoding/json" + "github.com/nuts-foundation/nuts-node/vdr/didweb" + "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 +127,7 @@ func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) { responseURIParam: responseURI, responseTypeParam: responseTypeVPToken, scopeParam: "test", + stateParam: "state", } } @@ -131,7 +135,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 +145,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 +156,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 +166,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 +177,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 +213,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 +225,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.URI()).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 +238,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.URI()).Return(nil, nil, oauth2.ErrNoCredentials) + expectPostError(t, ctx, oauth.InvalidRequest, "no credentials available", responseURI, "state") _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params) @@ -243,14 +250,330 @@ 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) - return "redirect", nil + assert.Equal(t, verifierClientState, state) + holderURL, _ := didweb.DIDToURL(holderDID) + require.NotNil(t, holderURL) + return holderURL.JoinPath("callback").String(), nil }) } @@ -258,16 +581,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 +599,25 @@ 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}, holderDID, "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{}, holderDID, "response", "state") + + require.Error(t, err) + require.Equal(t, "server_error - something went wrong", err.Error()) + }) + t.Run("invalid redirect_uri from verifier", func(t *testing.T) { + ctx := newTestClient(t) + ctx.holderRole.EXPECT().PostError(gomock.Any(), gomock.Any(), "response", "state").Return("https://example.com", nil) - _, err := ctx.client.sendAndHandleDirectPostError(context.Background(), oauth.OAuth2Error{}, "response") + _, err := ctx.client.sendAndHandleDirectPostError(context.Background(), oauth.OAuth2Error{}, holderDID, "response", "state") require.Error(t, err) require.Equal(t, "server_error - something went wrong", err.Error()) @@ -384,6 +717,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 +788,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..b1f09ab0a8 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,55 @@ 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 mapped, ok := s[credentialMapStateKey].(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..9bcdf48c62 100644 --- a/auth/services/oauth/holder.go +++ b/auth/services/oauth/holder.go @@ -24,11 +24,13 @@ import ( "crypto/tls" "errors" "fmt" + 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/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 +59,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 ssi.URI) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) { // get VCs from own wallet credentials, err := v.wallet.List(ctx, walletDID) if err != nil { @@ -78,12 +80,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 +107,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..5179f44866 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.URI()) 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.URI()) 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.URI()) 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.URI()) 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..20b25b55f3 100644 --- a/auth/services/oauth/interface.go +++ b/auth/services/oauth/interface.go @@ -20,6 +20,7 @@ package oauth import ( "context" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/vc" "net/url" @@ -34,7 +35,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 +63,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 ssi.URI) (*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..9f8215ddb3 100644 --- a/auth/services/oauth/mock.go +++ b/auth/services/oauth/mock.go @@ -14,6 +14,7 @@ import ( url "net/url" reflect "reflect" + ssi "github.com/nuts-foundation/go-did" did "github.com/nuts-foundation/go-did/did" vc "github.com/nuts-foundation/go-did/vc" oauth "github.com/nuts-foundation/nuts-node/auth/oauth" @@ -249,9 +250,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 ssi.URI) (*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 +260,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 +296,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..d0c354c7e6 --- /dev/null +++ b/e2e-tests/oauth-flow/openid4vp/docker-compose.yml @@ -0,0 +1,40 @@ +version: "3.7" +services: + nodeA: + image: "${IMAGE_NODE_A:-nutsfoundation/nuts-node:master}" + ports: + - "10443:443" + - "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-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 + nodeB: + image: "${IMAGE_NODE_B:-nutsfoundation/nuts-node:master}" + ports: + - "20443:443" + - "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 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..caec2b2597 --- /dev/null +++ b/e2e-tests/oauth-flow/openid4vp/node-A/nuts.yaml @@ -0,0 +1,30 @@ +url: https://nodeA +verbosity: debug +strictmode: false +internalratelimiter: false +datadir: /opt/nuts/data +http: + default: + address: :1323 + log: metadata-and-body + alt: + iam: + address: :443 + log: metadata-and-body + tls: server + .well-known: + address: :443 + log: metadata-and-body + tls: server +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/nuts.yaml b/e2e-tests/oauth-flow/openid4vp/node-B/nuts.yaml new file mode 100644 index 0000000000..9c04166c08 --- /dev/null +++ b/e2e-tests/oauth-flow/openid4vp/node-B/nuts.yaml @@ -0,0 +1,30 @@ +url: https://nodeB +verbosity: debug +strictmode: false +internalratelimiter: false +datadir: /opt/nuts/data +http: + default: + address: :1323 + log: metadata-and-body + alt: + iam: + address: :443 + log: metadata-and-body + tls: server + .well-known: + address: :443 + log: metadata-and-body + tls: server +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..bd656aead3 --- /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 --remove-orphans +docker compose up --wait nodeA nodeB + +echo "------------------------------------" +echo "Registering vendors..." +echo "------------------------------------" +# Register Party A +PARTY_A_DIDDOC=$(docker compose exec nodeA nuts vdr create-did --v2) +PARTY_A_DID=$(echo $PARTY_A_DIDDOC | jq -r .id) +echo Vendor A DID: $PARTY_A_DID + +# Register Vendor B +PARTY_B_DIDDOC=$(docker compose exec nodeB nuts vdr create-did --v2) +PARTY_B_DID=$(echo $PARTY_B_DIDDOC | jq -r .id) +echo Vendor B DID: $PARTY_B_DID + +# Issue NutsOrganizationCredential for Vendor B +REQUEST="{\"type\":\"NutsOrganizationCredential\",\"issuer\":\"${PARTY_B_DID}\", \"credentialSubject\": {\"id\":\"${PARTY_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/${PARTY_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\":\"${PARTY_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/${PARTY_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 0604267256..7c091e1208 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..f4139e9b2a 100644 --- a/vcr/pe/presentation_submission.go +++ b/vcr/pe/presentation_submission.go @@ -149,11 +149,11 @@ 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 + for _, signInstruction := range nonEmptySignInstructions { + if len(signInstruction.Mappings) == 1 { + signInstruction.Mappings[0].Path = "$.verifiableCredential" } } 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) + }) }) }