From c3d0a26321842679f6c772ce68922b8d63c8137e Mon Sep 17 00:00:00 2001 From: Gerard Snaauw Date: Fri, 24 May 2024 17:37:33 +0200 Subject: [PATCH 1/2] move OpenID4VCI code to own file --- auth/api/iam/api.go | 176 +--------------- auth/api/iam/api_test.go | 342 ------------------------------ auth/api/iam/openid4vci.go | 191 +++++++++++++++++ auth/api/iam/openid4vci_test.go | 358 ++++++++++++++++++++++++++++++++ auth/api/iam/openid4vp.go | 2 +- 5 files changed, 552 insertions(+), 517 deletions(-) create mode 100644 auth/api/iam/openid4vci.go create mode 100644 auth/api/iam/openid4vci_test.go diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 6e4944d54d..0c7f8813a9 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -24,7 +24,6 @@ import ( "crypto" "embed" "encoding/base64" - "encoding/json" "errors" "fmt" "html/template" @@ -33,11 +32,9 @@ import ( "strings" "time" - "github.com/google/uuid" "github.com/labstack/echo/v4" "github.com/lestrrat-go/jwx/v2/jwt" "github.com/nuts-foundation/go-did/did" - "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/auth" "github.com/nuts-foundation/nuts-node/auth/api/iam/assets" @@ -92,7 +89,7 @@ type Wrapper struct { auth auth.AuthenticationServices policyBackend policy.PDPBackend storageEngine storage.Engine - JSONLDManager jsonld.JSONLD + jsonldManager jsonld.JSONLD vcr vcr.VCR vdr vdr.VDR jwtSigner nutsCrypto.JWTSigner @@ -114,7 +111,7 @@ func New( storageEngine: storageEngine, vcr: vcrInstance, vdr: vdrInstance, - JSONLDManager: jsonldManager, + jsonldManager: jsonldManager, jwtSigner: jwtSigner, keyResolver: resolver.DIDKeyResolver{Resolver: vdrInstance.Resolver()}, jar: &jar{ @@ -707,148 +704,6 @@ func (r Wrapper) StatusList(ctx context.Context, request StatusListRequestObject return StatusList200JSONResponse(*cred), nil } -func (r Wrapper) RequestOid4vciCredentialIssuance(ctx context.Context, request RequestOid4vciCredentialIssuanceRequestObject) (RequestOid4vciCredentialIssuanceResponseObject, error) { - if request.Body == nil { - // why did oapi-codegen generate a pointer for the body?? - return nil, core.InvalidInputError("missing request body") - } - // Parse and check the requester - requestHolder, err := r.toOwnedDID(ctx, request.Did) - if err != nil { - return nil, core.NotFoundError("requester DID: %w", err) - } - - // Parse the issuer - issuerDid, err := did.ParseDID(request.Body.Issuer) - if err != nil { - return nil, core.InvalidInputError("could not parse Issuer DID: %s: %w", request.Body.Issuer, err) - } - // Fetch metadata containing the endpoints - credentialIssuerMetadata, authzServerMetadata, err := r.openid4vciMetadata(ctx, *issuerDid) - if err != nil { - return nil, core.Error(http.StatusFailedDependency, "cannot locate endpoints for %s: %w", issuerDid.String(), err) - } - if len(credentialIssuerMetadata.CredentialEndpoint) == 0 { - return nil, errors.New("no credential_endpoint found") - } - if len(authzServerMetadata.AuthorizationEndpoint) == 0 { - return nil, errors.New("no authorization_endpoint found") - } - if len(authzServerMetadata.TokenEndpoint) == 0 { - return nil, errors.New("no token_endpoint found") - } - // Read and parse the authorization details - authorizationDetails := []byte("[]") - if len(request.Body.AuthorizationDetails) > 0 { - authorizationDetails, _ = json.Marshal(request.Body.AuthorizationDetails) - } - // Generate the state and PKCE - state := nutsCrypto.GenerateNonce() - pkceParams := generatePKCEParams() - - // Figure out our own redirect URL by parsing the did:web and extracting the host. - requesterDidUrl, err := didweb.DIDToURL(*requestHolder) - if err != nil { - return nil, fmt.Errorf("failed convert did (%s) to url: %w", requestHolder.String(), err) - } - redirectUri, err := url.Parse(fmt.Sprintf("https://%s/iam/oid4vci/callback", requesterDidUrl.Host)) - if err != nil { - return nil, fmt.Errorf("failed to create the url for host: %w", err) - } - // Store the session - err = r.openid4vciSessionStore().Put(state, &Oid4vciSession{ - HolderDid: requestHolder, - IssuerDid: issuerDid, - RemoteRedirectUri: request.Body.RedirectUri, - RedirectUri: redirectUri.String(), - PKCEParams: pkceParams, - // OpenID4VCI issuers may use multiple Authorization Servers - // We must use the token_endpoint that corresponds to the same Authorization Server used for the authorization_endpoint - IssuerTokenEndpoint: authzServerMetadata.TokenEndpoint, - IssuerCredentialEndpoint: credentialIssuerMetadata.CredentialEndpoint, - }) - if err != nil { - return nil, fmt.Errorf("failed to store session: %w", err) - } - // Build the redirect URL, the client browser should be redirected to. - authorizationEndpoint, err := url.Parse(authzServerMetadata.AuthorizationEndpoint) - if err != nil { - return nil, fmt.Errorf("failed to parse the authorization_endpoint: %w", err) - } - redirectUrl := nutsHttp.AddQueryParams(*authorizationEndpoint, map[string]string{ - oauth.ResponseTypeParam: oauth.CodeResponseType, - oauth.StateParam: state, - oauth.ClientIDParam: requestHolder.String(), - oauth.AuthorizationDetailsParam: string(authorizationDetails), - oauth.RedirectURIParam: redirectUri.String(), - oauth.CodeChallengeParam: pkceParams.Challenge, - oauth.CodeChallengeMethodParam: pkceParams.ChallengeMethod, - }) - - return RequestOid4vciCredentialIssuance200JSONResponse{ - RedirectURI: redirectUrl.String(), - }, nil -} - -func (r Wrapper) CallbackOid4vciCredentialIssuance(ctx context.Context, request CallbackOid4vciCredentialIssuanceRequestObject) (CallbackOid4vciCredentialIssuanceResponseObject, error) { - state := request.Params.State - oid4vciSession := Oid4vciSession{} - err := r.openid4vciSessionStore().Get(state, &oid4vciSession) - if err != nil { - return nil, core.NotFoundError("Cannot locate active session for state: %s", state) - } - if request.Params.Error != nil { - errorCode := oauth.ErrorCode(*request.Params.Error) - errorDescription := "" - if request.Params.ErrorDescription != nil { - errorDescription = *request.Params.ErrorDescription - } else { - errorDescription = fmt.Sprintf("Issuer returned error code: %s", *request.Params.Error) - } - return nil, withCallbackURI(oauthError(errorCode, errorDescription), oid4vciSession.remoteRedirectUri()) - } - code := request.Params.Code - pkceParams := oid4vciSession.PKCEParams - issuerDid := oid4vciSession.IssuerDid - holderDid := oid4vciSession.HolderDid - tokenEndpoint := oid4vciSession.IssuerTokenEndpoint - credentialEndpoint := oid4vciSession.IssuerCredentialEndpoint - if err != nil { - return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("cannot fetch the right endpoints: %s", err.Error())), oid4vciSession.remoteRedirectUri()) - } - response, err := r.auth.IAMClient().AccessToken(ctx, code, tokenEndpoint, oid4vciSession.RedirectUri, *holderDid, pkceParams.Verifier, false) - if err != nil { - return nil, withCallbackURI(oauthError(oauth.AccessDenied, fmt.Sprintf("error while fetching the access_token from endpoint: %s, error: %s", tokenEndpoint, err.Error())), oid4vciSession.remoteRedirectUri()) - } - cNonce := response.Get(oauth.CNonceParam) - proofJWT, err := r.proofJwt(ctx, *holderDid, *issuerDid, &cNonce) - if err != nil { - return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error building proof to fetch the credential from endpoint %s, error: %s", credentialEndpoint, err.Error())), oid4vciSession.remoteRedirectUri()) - } - credentials, err := r.auth.IAMClient().VerifiableCredentials(ctx, credentialEndpoint, response.AccessToken, proofJWT) - if err != nil { - return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while fetching the credential from endpoint %s, error: %s", credentialEndpoint, err.Error())), oid4vciSession.remoteRedirectUri()) - } - credential, err := vc.ParseVerifiableCredential(credentials.Credential) - if err != nil { - return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while parsing the credential: %s, error: %s", credentials.Credential, err.Error())), oid4vciSession.remoteRedirectUri()) - } - err = r.vcr.Verifier().Verify(*credential, true, true, nil) - if err != nil { - return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while verifying the credential from issuer: %s, error: %s", credential.Issuer.String(), err.Error())), oid4vciSession.remoteRedirectUri()) - } - err = r.vcr.Wallet().Put(ctx, *credential) - if err != nil { - return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while storing credential with id: %s, error: %s", credential.ID, err.Error())), oid4vciSession.remoteRedirectUri()) - } - - log.Logger().Debugf("stored the credential with id: %s, now redirecting to %s", credential.ID, oid4vciSession.RemoteRedirectUri) - - return CallbackOid4vciCredentialIssuance302Response{ - Headers: CallbackOid4vciCredentialIssuance302ResponseHeaders{Location: oid4vciSession.RemoteRedirectUri}, - }, nil -} - func (r Wrapper) openid4vciMetadata(ctx context.Context, issuerDid did.DID) (*oauth.OpenIDCredentialIssuerMetadata, *oauth.AuthorizationServerMetadata, error) { oauthIssuer, err := didweb.DIDToURL(issuerDid) if err != nil { @@ -945,28 +800,6 @@ func (r Wrapper) createAuthorizationRequest(ctx context.Context, client did.DID, return &redirectURL, nil } -func (r *Wrapper) proofJwt(ctx context.Context, holderDid did.DID, audienceDid did.DID, nonce *string) (string, error) { - // TODO: is this the right key type? - kid, _, err := r.keyResolver.ResolveKey(holderDid, nil, resolver.NutsSigningKeyType) - if err != nil { - return "", fmt.Errorf("failed to resolve key for did (%s): %w", holderDid.String(), err) - } - jti, _ := uuid.NewUUID() - claims := map[string]interface{}{ - "iss": holderDid.String(), - "aud": audienceDid.String(), - "jti": jti.String(), - } - if nonce != nil { - claims["nonce"] = nonce - } - proofJwt, err := r.jwtSigner.SignJWT(ctx, claims, nil, kid.String()) - if err != nil { - return "", fmt.Errorf("failed to sign the JWT with kid (%s): %w", kid.String(), err) - } - return proofJwt, nil -} - // requestedDID constructs a did:web DID as it was requested by the API caller. It can be a DID with or without user path, e.g.: // - did:web:example.com // - did:web:example:iam:1234 @@ -1002,11 +835,6 @@ func (r Wrapper) authzRequestObjectStore() storage.SessionStore { return r.storageEngine.GetSessionDatabase().GetStore(accessTokenValidity, oauthRequestObjectKey...) } -// openid4vciSessionStore is used by the Client to keep track of OpenID4VCI requests -func (r Wrapper) openid4vciSessionStore() storage.SessionStore { - return r.storageEngine.GetSessionDatabase().GetStore(oid4vciSessionValidity, "openid4vci") -} - // createOAuth2BaseURL creates an OAuth2 base URL for an owned did:web DID // It creates a URL in the following format: https:///oauth2/ func createOAuth2BaseURL(webDID did.DID) (*url.URL, error) { diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 005aee0c72..995a461b6e 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -1238,348 +1238,6 @@ func Test_createOAuth2BaseURL(t *testing.T) { }) } -func TestWrapper_RequestOid4vciCredentialIssuance(t *testing.T) { - redirectURI := "https://test.test/iam/123/cb" - authServer := "https://auth.server/" - metadata := oauth.OpenIDCredentialIssuerMetadata{ - CredentialIssuer: "issuer", - CredentialEndpoint: "endpoint", - AuthorizationServers: []string{authServer}, - Display: nil, - } - authzMetadata := oauth.AuthorizationServerMetadata{ - AuthorizationEndpoint: "https://auth.server/authorize", - TokenEndpoint: "https://auth.server/token", - } - t.Run("ok", func(t *testing.T) { - ctx := newTestClient(t) - ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerURL).Return(&metadata, nil) - ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) - response, err := ctx.client.RequestOid4vciCredentialIssuance(nil, RequestOid4vciCredentialIssuanceRequestObject{ - Did: holderDID.String(), - Body: &RequestOid4vciCredentialIssuanceJSONRequestBody{ - AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "format": "vc+sd-jwt"}}, - Issuer: issuerDID.String(), - RedirectUri: redirectURI, - }, - }) - require.NoError(t, err) - require.NotNil(t, response) - redirectUri, err := url.Parse(response.(RequestOid4vciCredentialIssuance200JSONResponse).RedirectURI) - require.NoError(t, err) - assert.Equal(t, "auth.server", redirectUri.Host) - assert.Equal(t, "/authorize", redirectUri.Path) - assert.True(t, redirectUri.Query().Has("state")) - assert.True(t, redirectUri.Query().Has("code_challenge")) - assert.Equal(t, "https://example.com/iam/oid4vci/callback", redirectUri.Query().Get("redirect_uri")) - assert.Equal(t, holderDID.String(), redirectUri.Query().Get("client_id")) - assert.Equal(t, "S256", redirectUri.Query().Get("code_challenge_method")) - assert.Equal(t, "code", redirectUri.Query().Get("response_type")) - assert.Equal(t, `[{"format":"vc+sd-jwt","type":"openid_credential"}]`, redirectUri.Query().Get("authorization_details")) - println(redirectUri.String()) - }) - t.Run("openid4vciMetadata", func(t *testing.T) { - t.Run("ok - fallback to issuerDID on empty AuthorizationServers", func(t *testing.T) { - ctx := newTestClient(t) - metadata := oauth.OpenIDCredentialIssuerMetadata{ - CredentialIssuer: "issuer", - CredentialEndpoint: "endpoint", - AuthorizationServers: []string{}, // empty - Display: nil, - } - ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerURL).Return(&metadata, nil) - ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, issuerURL).Return(nil, assert.AnError) - _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) - assert.ErrorIs(t, err, assert.AnError) - }) - - t.Run("error - none of the authorization servers can be reached", func(t *testing.T) { - ctx := newTestClient(t) - ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerURL).Return(&metadata, nil) - ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(nil, assert.AnError) - ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, issuerURL).Return(nil, assert.AnError) - - _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) - assert.ErrorIs(t, err, assert.AnError) - }) - t.Run("error - did not owned by this node", func(t *testing.T) { - ctx := newTestClient(t) - ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(false, nil) - _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) - require.Error(t, err) - assert.EqualError(t, err, "requester DID: DID document not managed by this node") - }) - t.Run("error - fetching credential issuer metadata fails", func(t *testing.T) { - ctx := newTestClient(t) - ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerURL).Return(nil, assert.AnError) - _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) - assert.ErrorIs(t, err, assert.AnError) - }) - }) - t.Run("error - issuer not a did", func(t *testing.T) { - req := requestCredentials(holderDID, issuerDID, redirectURI) - req.Body.Issuer = "not-a-did" - ctx := newTestClient(t) - ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) - - _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, req) - - assert.EqualError(t, err, "could not parse Issuer DID: not-a-did: invalid DID") - }) - t.Run("error - requester not a did:web", func(t *testing.T) { - req := requestCredentials(holderDID, issuerDID, redirectURI) - didNuts := did.MustParseDID("did:nuts:123") - req.Did = didNuts.String() - ctx := newTestClient(t) - ctx.vdr.EXPECT().IsOwner(nil, didNuts).Return(true, nil) - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerURL).Return(&metadata, nil) - ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) - - _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, req) - - assert.ErrorContains(t, err, "URL does not represent a Web DID\nunsupported DID method: nuts") - }) - t.Run("error - issuer not a did:web", func(t *testing.T) { - req := requestCredentials(holderDID, issuerDID, redirectURI) - req.Body.Issuer = "did:nuts:123" - ctx := newTestClient(t) - ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) - - _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, req) - - assert.ErrorContains(t, err, "invalid issuer: URL does not represent a Web DID\nunsupported DID method: nuts") - }) - t.Run("error - invalid authorization endpoint in metadata", func(t *testing.T) { - ctx := newTestClient(t) - ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerURL).Return(&metadata, nil) - invalidAuthzMetadata := oauth.AuthorizationServerMetadata{ - AuthorizationEndpoint: ":", - TokenEndpoint: "https://auth.server/token"} - ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&invalidAuthzMetadata, nil) - - _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) - - assert.EqualError(t, err, "failed to parse the authorization_endpoint: parse \":\": missing protocol scheme") - }) - t.Run("error - missing credential_endpoint", func(t *testing.T) { - ctx := newTestClient(t) - ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) - metadata := metadata - metadata.CredentialEndpoint = "" - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerURL).Return(&metadata, nil) - ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) - _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) - assert.EqualError(t, err, "no credential_endpoint found") - }) - t.Run("error - missing authorization_endpoint", func(t *testing.T) { - ctx := newTestClient(t) - ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) - authzMetadata := authzMetadata - authzMetadata.AuthorizationEndpoint = "" - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerURL).Return(&metadata, nil) - ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) - _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) - assert.EqualError(t, err, "no authorization_endpoint found") - }) - t.Run("error - missing token_endpoint", func(t *testing.T) { - ctx := newTestClient(t) - ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) - authzMetadata := authzMetadata - authzMetadata.TokenEndpoint = "" - ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerURL).Return(&metadata, nil) - ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) - _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) - assert.EqualError(t, err, "no token_endpoint found") - }) -} - -func requestCredentials(holderDID did.DID, issuerDID did.DID, redirectURI string) RequestOid4vciCredentialIssuanceRequestObject { - return RequestOid4vciCredentialIssuanceRequestObject{ - Did: holderDID.String(), - Body: &RequestOid4vciCredentialIssuanceJSONRequestBody{ - Issuer: issuerDID.String(), - RedirectUri: redirectURI, - }, - } -} - -func TestWrapper_CallbackOid4vciCredentialIssuance(t *testing.T) { - holderDID := did.MustParseDID("did:web:holder.test:iam:123") - issuerDID := did.MustParseDID("did:web:issuer.test:iam:456") - redirectURI := "https://test.test/iam/123/cb" - authServer := "https://auth.server" - tokenEndpoint := authServer + "/token" - cNonce := cryptoNuts.GenerateNonce() - credEndpoint := authServer + "/credz" - pkceParams := generatePKCEParams() - code := "code" - state := "state" - accessToken := "access_token" - verifiableCredential := createIssuerCredential(issuerDID, holderDID) - redirectUrl := "https://client.service/issuance_is_done" - - session := Oid4vciSession{ - HolderDid: &holderDID, - IssuerDid: &issuerDID, - RemoteRedirectUri: redirectUrl, - RedirectUri: redirectURI, - PKCEParams: pkceParams, - IssuerTokenEndpoint: tokenEndpoint, - IssuerCredentialEndpoint: credEndpoint, - } - tokenResponse := oauth.NewTokenResponse(accessToken, "Bearer", 0, "").With("c_nonce", cNonce) - credentialResponse := iam.CredentialResponse{ - Format: "jwt_vc", - Credential: verifiableCredential.Raw(), - } - t.Run("ok", func(t *testing.T) { - ctx := newTestClient(t) - require.NoError(t, ctx.client.openid4vciSessionStore().Put(state, &session)) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderDID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI("kid"), nil, nil) - ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(&credentialResponse, nil) - ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) - ctx.wallet.EXPECT().Put(nil, *verifiableCredential) - - callback, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ - Params: CallbackOid4vciCredentialIssuanceParams{ - Code: code, - State: state, - }, - }) - - require.NoError(t, err) - assert.NotNil(t, callback) - actual := callback.(CallbackOid4vciCredentialIssuance302Response) - assert.Equal(t, redirectUrl, actual.Headers.Location) - }) - t.Run("error_on_redirect", func(t *testing.T) { - ctx := newTestClient(t) - require.NoError(t, ctx.client.openid4vciSessionStore().Put(state, &session)) - errorCode := "failed" - errorDesc := "errorDesc" - - callback, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ - Params: CallbackOid4vciCredentialIssuanceParams{ - Code: "", - State: state, - Error: &errorCode, - ErrorDescription: &errorDesc, - }, - }) - - require.Error(t, err) - assert.Nil(t, callback) - assert.Equal(t, fmt.Sprintf("%s - %s", errorCode, errorDesc), err.Error()) - }) - t.Run("no_session", func(t *testing.T) { - ctx := newTestClient(t) - - _, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ - Params: CallbackOid4vciCredentialIssuanceParams{ - Code: code, - State: state, - }, - }) - require.Error(t, err) - }) - t.Run("fail_access_token", func(t *testing.T) { - ctx := newTestClient(t) - require.NoError(t, ctx.client.openid4vciSessionStore().Put(state, &session)) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderDID, pkceParams.Verifier, false).Return(nil, errors.New("FAIL")) - - callback, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ - Params: CallbackOid4vciCredentialIssuanceParams{ - Code: code, - State: state, - }, - }) - - assert.Error(t, err) - assert.Nil(t, callback) - assert.Equal(t, "access_denied - error while fetching the access_token from endpoint: https://auth.server/token, error: FAIL", err.Error()) - }) - t.Run("fail_credential_response", func(t *testing.T) { - ctx := newTestClient(t) - require.NoError(t, ctx.client.openid4vciSessionStore().Put(state, &session)) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderDID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI("kid"), nil, nil) - ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(nil, errors.New("FAIL")) - - callback, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ - Params: CallbackOid4vciCredentialIssuanceParams{ - Code: code, - State: state, - }, - }) - - assert.Error(t, err) - assert.Nil(t, callback) - assert.Equal(t, "server_error - error while fetching the credential from endpoint https://auth.server/credz, error: FAIL", err.Error()) - }) - t.Run("fail_verify", func(t *testing.T) { - ctx := newTestClient(t) - require.NoError(t, ctx.client.openid4vciSessionStore().Put(state, &session)) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderDID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI("kid"), nil, nil) - ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) - ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(&credentialResponse, nil) - ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil).Return(errors.New("FAIL")) - - callback, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ - Params: CallbackOid4vciCredentialIssuanceParams{ - Code: code, - State: state, - }, - }) - assert.Error(t, err) - assert.Nil(t, callback) - assert.Equal(t, "server_error - error while verifying the credential from issuer: did:web:issuer.test:iam:456, error: FAIL", err.Error()) - }) - t.Run("error - key not found", func(t *testing.T) { - ctx := newTestClient(t) - require.NoError(t, ctx.client.openid4vciSessionStore().Put(state, &session)) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderDID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return(ssi.URI{}, nil, resolver.ErrKeyNotFound) - - callback, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ - Params: CallbackOid4vciCredentialIssuanceParams{ - Code: code, - State: state, - }, - }) - - assert.Error(t, err) - assert.Nil(t, callback) - assert.ErrorContains(t, err, "failed to resolve key for did (did:web:holder.test:iam:123): "+resolver.ErrKeyNotFound.Error()) - }) - t.Run("error - signature failure", func(t *testing.T) { - ctx := newTestClient(t) - require.NoError(t, ctx.client.openid4vciSessionStore().Put(state, &session)) - ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderDID, pkceParams.Verifier, false).Return(tokenResponse, nil) - ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI("kid"), nil, nil) - ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("", errors.New("signature failed")) - - callback, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ - Params: CallbackOid4vciCredentialIssuanceParams{ - Code: code, - State: state, - }, - }) - - assert.Error(t, err) - assert.Nil(t, callback) - assert.ErrorContains(t, err, "failed to sign the JWT with kid (kid): signature failed") - }) -} - // testAuthzReqRedirectURI compares to expectedRedirectURI and actualRedirectURI // 'request_uri' is checked for presence, // and if expectedRedirectURI contains a 'request_uri' it will do a partial match on URL decoded actual value diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go new file mode 100644 index 0000000000..3c5e6aaccd --- /dev/null +++ b/auth/api/iam/openid4vci.go @@ -0,0 +1,191 @@ +package iam + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/google/uuid" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/auth/log" + "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/crypto" + nutsHttp "github.com/nuts-foundation/nuts-node/http" + "github.com/nuts-foundation/nuts-node/storage" + "github.com/nuts-foundation/nuts-node/vdr/didweb" + "github.com/nuts-foundation/nuts-node/vdr/resolver" +) + +func (r Wrapper) RequestOid4vciCredentialIssuance(ctx context.Context, request RequestOid4vciCredentialIssuanceRequestObject) (RequestOid4vciCredentialIssuanceResponseObject, error) { + if request.Body == nil { + // why did oapi-codegen generate a pointer for the body?? + return nil, core.InvalidInputError("missing request body") + } + // Parse and check the requester + requestHolder, err := r.toOwnedDID(ctx, request.Did) + if err != nil { + return nil, core.NotFoundError("requester DID: %w", err) + } + + // Parse the issuer + issuerDid, err := did.ParseDID(request.Body.Issuer) + if err != nil { + return nil, core.InvalidInputError("could not parse Issuer DID: %s: %w", request.Body.Issuer, err) + } + // Fetch metadata containing the endpoints + credentialIssuerMetadata, authzServerMetadata, err := r.openid4vciMetadata(ctx, *issuerDid) + if err != nil { + return nil, core.Error(http.StatusFailedDependency, "cannot locate endpoints for %s: %w", issuerDid.String(), err) + } + if len(credentialIssuerMetadata.CredentialEndpoint) == 0 { + return nil, errors.New("no credential_endpoint found") + } + if len(authzServerMetadata.AuthorizationEndpoint) == 0 { + return nil, errors.New("no authorization_endpoint found") + } + if len(authzServerMetadata.TokenEndpoint) == 0 { + return nil, errors.New("no token_endpoint found") + } + // Read and parse the authorization details + authorizationDetails := []byte("[]") + if len(request.Body.AuthorizationDetails) > 0 { + authorizationDetails, _ = json.Marshal(request.Body.AuthorizationDetails) + } + // Generate the state and PKCE + state := crypto.GenerateNonce() + pkceParams := generatePKCEParams() + + // Figure out our own redirect URL by parsing the did:web and extracting the host. + requesterDidUrl, err := didweb.DIDToURL(*requestHolder) + if err != nil { + return nil, fmt.Errorf("failed convert did (%s) to url: %w", requestHolder.String(), err) + } + redirectUri, err := url.Parse(fmt.Sprintf("https://%s/iam/oid4vci/callback", requesterDidUrl.Host)) + if err != nil { + return nil, fmt.Errorf("failed to create the url for host: %w", err) + } + // Store the session + err = r.openid4vciSessionStore().Put(state, &Oid4vciSession{ + HolderDid: requestHolder, + IssuerDid: issuerDid, + RemoteRedirectUri: request.Body.RedirectUri, + RedirectUri: redirectUri.String(), + PKCEParams: pkceParams, + // OpenID4VCI issuers may use multiple Authorization Servers + // We must use the token_endpoint that corresponds to the same Authorization Server used for the authorization_endpoint + IssuerTokenEndpoint: authzServerMetadata.TokenEndpoint, + IssuerCredentialEndpoint: credentialIssuerMetadata.CredentialEndpoint, + }) + if err != nil { + return nil, fmt.Errorf("failed to store session: %w", err) + } + // Build the redirect URL, the client browser should be redirected to. + authorizationEndpoint, err := url.Parse(authzServerMetadata.AuthorizationEndpoint) + if err != nil { + return nil, fmt.Errorf("failed to parse the authorization_endpoint: %w", err) + } + redirectUrl := nutsHttp.AddQueryParams(*authorizationEndpoint, map[string]string{ + oauth.ResponseTypeParam: oauth.CodeResponseType, + oauth.StateParam: state, + oauth.ClientIDParam: requestHolder.String(), + oauth.AuthorizationDetailsParam: string(authorizationDetails), + oauth.RedirectURIParam: redirectUri.String(), + oauth.CodeChallengeParam: pkceParams.Challenge, + oauth.CodeChallengeMethodParam: pkceParams.ChallengeMethod, + }) + + return RequestOid4vciCredentialIssuance200JSONResponse{ + RedirectURI: redirectUrl.String(), + }, nil +} + +func (r Wrapper) CallbackOid4vciCredentialIssuance(ctx context.Context, request CallbackOid4vciCredentialIssuanceRequestObject) (CallbackOid4vciCredentialIssuanceResponseObject, error) { + state := request.Params.State + oid4vciSession := Oid4vciSession{} + err := r.openid4vciSessionStore().Get(state, &oid4vciSession) + if err != nil { + return nil, core.NotFoundError("Cannot locate active session for state: %s", state) + } + if request.Params.Error != nil { + errorCode := oauth.ErrorCode(*request.Params.Error) + errorDescription := "" + if request.Params.ErrorDescription != nil { + errorDescription = *request.Params.ErrorDescription + } else { + errorDescription = fmt.Sprintf("Issuer returned error code: %s", *request.Params.Error) + } + return nil, withCallbackURI(oauthError(errorCode, errorDescription), oid4vciSession.remoteRedirectUri()) + } + code := request.Params.Code + pkceParams := oid4vciSession.PKCEParams + issuerDid := oid4vciSession.IssuerDid + holderDid := oid4vciSession.HolderDid + tokenEndpoint := oid4vciSession.IssuerTokenEndpoint + credentialEndpoint := oid4vciSession.IssuerCredentialEndpoint + if err != nil { + return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("cannot fetch the right endpoints: %s", err.Error())), oid4vciSession.remoteRedirectUri()) + } + response, err := r.auth.IAMClient().AccessToken(ctx, code, tokenEndpoint, oid4vciSession.RedirectUri, *holderDid, pkceParams.Verifier, false) + if err != nil { + return nil, withCallbackURI(oauthError(oauth.AccessDenied, fmt.Sprintf("error while fetching the access_token from endpoint: %s, error: %s", tokenEndpoint, err.Error())), oid4vciSession.remoteRedirectUri()) + } + cNonce := response.Get(oauth.CNonceParam) + proofJWT, err := r.proofJwt(ctx, *holderDid, *issuerDid, &cNonce) + if err != nil { + return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error building proof to fetch the credential from endpoint %s, error: %s", credentialEndpoint, err.Error())), oid4vciSession.remoteRedirectUri()) + } + credentials, err := r.auth.IAMClient().VerifiableCredentials(ctx, credentialEndpoint, response.AccessToken, proofJWT) + if err != nil { + return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while fetching the credential from endpoint %s, error: %s", credentialEndpoint, err.Error())), oid4vciSession.remoteRedirectUri()) + } + credential, err := vc.ParseVerifiableCredential(credentials.Credential) + if err != nil { + return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while parsing the credential: %s, error: %s", credentials.Credential, err.Error())), oid4vciSession.remoteRedirectUri()) + } + err = r.vcr.Verifier().Verify(*credential, true, true, nil) + if err != nil { + return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while verifying the credential from issuer: %s, error: %s", credential.Issuer.String(), err.Error())), oid4vciSession.remoteRedirectUri()) + } + err = r.vcr.Wallet().Put(ctx, *credential) + if err != nil { + return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while storing credential with id: %s, error: %s", credential.ID, err.Error())), oid4vciSession.remoteRedirectUri()) + } + + log.Logger().Debugf("stored the credential with id: %s, now redirecting to %s", credential.ID, oid4vciSession.RemoteRedirectUri) + + return CallbackOid4vciCredentialIssuance302Response{ + Headers: CallbackOid4vciCredentialIssuance302ResponseHeaders{Location: oid4vciSession.RemoteRedirectUri}, + }, nil +} + +func (r *Wrapper) proofJwt(ctx context.Context, holderDid did.DID, audienceDid did.DID, nonce *string) (string, error) { + // TODO: is this the right key type? + kid, _, err := r.keyResolver.ResolveKey(holderDid, nil, resolver.NutsSigningKeyType) + if err != nil { + return "", fmt.Errorf("failed to resolve key for did (%s): %w", holderDid.String(), err) + } + jti, _ := uuid.NewUUID() + claims := map[string]interface{}{ + "iss": holderDid.String(), + "aud": audienceDid.String(), + "jti": jti.String(), + } + if nonce != nil { + claims["nonce"] = nonce + } + proofJwt, err := r.jwtSigner.SignJWT(ctx, claims, nil, kid.String()) + if err != nil { + return "", fmt.Errorf("failed to sign the JWT with kid (%s): %w", kid.String(), err) + } + return proofJwt, nil +} + +// openid4vciSessionStore is used by the Client to keep track of OpenID4VCI requests +func (r Wrapper) openid4vciSessionStore() storage.SessionStore { + return r.storageEngine.GetSessionDatabase().GetStore(oid4vciSessionValidity, "openid4vci") +} diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go new file mode 100644 index 0000000000..4b5c826ff2 --- /dev/null +++ b/auth/api/iam/openid4vci_test.go @@ -0,0 +1,358 @@ +package iam + +import ( + "errors" + "fmt" + "net/url" + "testing" + + "github.com/nuts-foundation/go-did" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/auth/client/iam" + "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/vdr/resolver" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestWrapper_RequestOid4vciCredentialIssuance(t *testing.T) { + redirectURI := "https://test.test/iam/123/cb" + authServer := "https://auth.server/" + metadata := oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "issuer", + CredentialEndpoint: "endpoint", + AuthorizationServers: []string{authServer}, + Display: nil, + } + authzMetadata := oauth.AuthorizationServerMetadata{ + AuthorizationEndpoint: "https://auth.server/authorize", + TokenEndpoint: "https://auth.server/token", + } + t.Run("ok", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerURL).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) + response, err := ctx.client.RequestOid4vciCredentialIssuance(nil, RequestOid4vciCredentialIssuanceRequestObject{ + Did: holderDID.String(), + Body: &RequestOid4vciCredentialIssuanceJSONRequestBody{ + AuthorizationDetails: []map[string]interface{}{{"type": "openid_credential", "format": "vc+sd-jwt"}}, + Issuer: issuerDID.String(), + RedirectUri: redirectURI, + }, + }) + require.NoError(t, err) + require.NotNil(t, response) + redirectUri, err := url.Parse(response.(RequestOid4vciCredentialIssuance200JSONResponse).RedirectURI) + require.NoError(t, err) + assert.Equal(t, "auth.server", redirectUri.Host) + assert.Equal(t, "/authorize", redirectUri.Path) + assert.True(t, redirectUri.Query().Has("state")) + assert.True(t, redirectUri.Query().Has("code_challenge")) + assert.Equal(t, "https://example.com/iam/oid4vci/callback", redirectUri.Query().Get("redirect_uri")) + assert.Equal(t, holderDID.String(), redirectUri.Query().Get("client_id")) + assert.Equal(t, "S256", redirectUri.Query().Get("code_challenge_method")) + assert.Equal(t, "code", redirectUri.Query().Get("response_type")) + assert.Equal(t, `[{"format":"vc+sd-jwt","type":"openid_credential"}]`, redirectUri.Query().Get("authorization_details")) + println(redirectUri.String()) + }) + t.Run("openid4vciMetadata", func(t *testing.T) { + t.Run("ok - fallback to issuerDID on empty AuthorizationServers", func(t *testing.T) { + ctx := newTestClient(t) + metadata := oauth.OpenIDCredentialIssuerMetadata{ + CredentialIssuer: "issuer", + CredentialEndpoint: "endpoint", + AuthorizationServers: []string{}, // empty + Display: nil, + } + ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerURL).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, issuerURL).Return(nil, assert.AnError) + _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) + assert.ErrorIs(t, err, assert.AnError) + }) + + t.Run("error - none of the authorization servers can be reached", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerURL).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(nil, assert.AnError) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, issuerURL).Return(nil, assert.AnError) + + _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) + assert.ErrorIs(t, err, assert.AnError) + }) + t.Run("error - did not owned by this node", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(false, nil) + _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) + require.Error(t, err) + assert.EqualError(t, err, "requester DID: DID document not managed by this node") + }) + t.Run("error - fetching credential issuer metadata fails", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerURL).Return(nil, assert.AnError) + _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) + assert.ErrorIs(t, err, assert.AnError) + }) + }) + t.Run("error - issuer not a did", func(t *testing.T) { + req := requestCredentials(holderDID, issuerDID, redirectURI) + req.Body.Issuer = "not-a-did" + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) + + _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, req) + + assert.EqualError(t, err, "could not parse Issuer DID: not-a-did: invalid DID") + }) + t.Run("error - requester not a did:web", func(t *testing.T) { + req := requestCredentials(holderDID, issuerDID, redirectURI) + didNuts := did.MustParseDID("did:nuts:123") + req.Did = didNuts.String() + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(nil, didNuts).Return(true, nil) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerURL).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) + + _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, req) + + assert.ErrorContains(t, err, "URL does not represent a Web DID\nunsupported DID method: nuts") + }) + t.Run("error - issuer not a did:web", func(t *testing.T) { + req := requestCredentials(holderDID, issuerDID, redirectURI) + req.Body.Issuer = "did:nuts:123" + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) + + _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, req) + + assert.ErrorContains(t, err, "invalid issuer: URL does not represent a Web DID\nunsupported DID method: nuts") + }) + t.Run("error - invalid authorization endpoint in metadata", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerURL).Return(&metadata, nil) + invalidAuthzMetadata := oauth.AuthorizationServerMetadata{ + AuthorizationEndpoint: ":", + TokenEndpoint: "https://auth.server/token"} + ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&invalidAuthzMetadata, nil) + + _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) + + assert.EqualError(t, err, "failed to parse the authorization_endpoint: parse \":\": missing protocol scheme") + }) + t.Run("error - missing credential_endpoint", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) + metadata := metadata + metadata.CredentialEndpoint = "" + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerURL).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) + assert.EqualError(t, err, "no credential_endpoint found") + }) + t.Run("error - missing authorization_endpoint", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) + authzMetadata := authzMetadata + authzMetadata.AuthorizationEndpoint = "" + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerURL).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) + assert.EqualError(t, err, "no authorization_endpoint found") + }) + t.Run("error - missing token_endpoint", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(nil, holderDID).Return(true, nil) + authzMetadata := authzMetadata + authzMetadata.TokenEndpoint = "" + ctx.iamClient.EXPECT().OpenIdCredentialIssuerMetadata(nil, issuerURL).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) + _, err := ctx.client.RequestOid4vciCredentialIssuance(nil, requestCredentials(holderDID, issuerDID, redirectURI)) + assert.EqualError(t, err, "no token_endpoint found") + }) +} + +func requestCredentials(holderDID did.DID, issuerDID did.DID, redirectURI string) RequestOid4vciCredentialIssuanceRequestObject { + return RequestOid4vciCredentialIssuanceRequestObject{ + Did: holderDID.String(), + Body: &RequestOid4vciCredentialIssuanceJSONRequestBody{ + Issuer: issuerDID.String(), + RedirectUri: redirectURI, + }, + } +} + +func TestWrapper_CallbackOid4vciCredentialIssuance(t *testing.T) { + redirectURI := "https://test.test/iam/123/cb" + authServer := "https://auth.server" + tokenEndpoint := authServer + "/token" + cNonce := crypto.GenerateNonce() + credEndpoint := authServer + "/credz" + pkceParams := generatePKCEParams() + code := "code" + state := "state" + accessToken := "access_token" + verifiableCredential := createIssuerCredential(issuerDID, holderDID) + redirectUrl := "https://client.service/issuance_is_done" + + session := Oid4vciSession{ + HolderDid: &holderDID, + IssuerDid: &issuerDID, + RemoteRedirectUri: redirectUrl, + RedirectUri: redirectURI, + PKCEParams: pkceParams, + IssuerTokenEndpoint: tokenEndpoint, + IssuerCredentialEndpoint: credEndpoint, + } + tokenResponse := oauth.NewTokenResponse(accessToken, "Bearer", 0, "").With("c_nonce", cNonce) + credentialResponse := iam.CredentialResponse{ + Format: "jwt_vc", + Credential: verifiableCredential.Raw(), + } + t.Run("ok", func(t *testing.T) { + ctx := newTestClient(t) + require.NoError(t, ctx.client.openid4vciSessionStore().Put(state, &session)) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderDID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI("kid"), nil, nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) + ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(&credentialResponse, nil) + ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) + ctx.wallet.EXPECT().Put(nil, *verifiableCredential) + + callback, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ + Params: CallbackOid4vciCredentialIssuanceParams{ + Code: code, + State: state, + }, + }) + + require.NoError(t, err) + assert.NotNil(t, callback) + actual := callback.(CallbackOid4vciCredentialIssuance302Response) + assert.Equal(t, redirectUrl, actual.Headers.Location) + }) + t.Run("error_on_redirect", func(t *testing.T) { + ctx := newTestClient(t) + require.NoError(t, ctx.client.openid4vciSessionStore().Put(state, &session)) + errorCode := "failed" + errorDesc := "errorDesc" + + callback, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ + Params: CallbackOid4vciCredentialIssuanceParams{ + Code: "", + State: state, + Error: &errorCode, + ErrorDescription: &errorDesc, + }, + }) + + require.Error(t, err) + assert.Nil(t, callback) + assert.Equal(t, fmt.Sprintf("%s - %s", errorCode, errorDesc), err.Error()) + }) + t.Run("no_session", func(t *testing.T) { + ctx := newTestClient(t) + + _, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ + Params: CallbackOid4vciCredentialIssuanceParams{ + Code: code, + State: state, + }, + }) + require.Error(t, err) + }) + t.Run("fail_access_token", func(t *testing.T) { + ctx := newTestClient(t) + require.NoError(t, ctx.client.openid4vciSessionStore().Put(state, &session)) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderDID, pkceParams.Verifier, false).Return(nil, errors.New("FAIL")) + + callback, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ + Params: CallbackOid4vciCredentialIssuanceParams{ + Code: code, + State: state, + }, + }) + + assert.Error(t, err) + assert.Nil(t, callback) + assert.Equal(t, "access_denied - error while fetching the access_token from endpoint: https://auth.server/token, error: FAIL", err.Error()) + }) + t.Run("fail_credential_response", func(t *testing.T) { + ctx := newTestClient(t) + require.NoError(t, ctx.client.openid4vciSessionStore().Put(state, &session)) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderDID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI("kid"), nil, nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) + ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(nil, errors.New("FAIL")) + + callback, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ + Params: CallbackOid4vciCredentialIssuanceParams{ + Code: code, + State: state, + }, + }) + + assert.Error(t, err) + assert.Nil(t, callback) + assert.Equal(t, "server_error - error while fetching the credential from endpoint https://auth.server/credz, error: FAIL", err.Error()) + }) + t.Run("fail_verify", func(t *testing.T) { + ctx := newTestClient(t) + require.NoError(t, ctx.client.openid4vciSessionStore().Put(state, &session)) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderDID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI("kid"), nil, nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) + ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(&credentialResponse, nil) + ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil).Return(errors.New("FAIL")) + + callback, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ + Params: CallbackOid4vciCredentialIssuanceParams{ + Code: code, + State: state, + }, + }) + assert.Error(t, err) + assert.Nil(t, callback) + assert.Equal(t, "server_error - error while verifying the credential from issuer: did:web:example.com:iam:issuer, error: FAIL", err.Error()) + }) + t.Run("error - key not found", func(t *testing.T) { + ctx := newTestClient(t) + require.NoError(t, ctx.client.openid4vciSessionStore().Put(state, &session)) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderDID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return(ssi.URI{}, nil, resolver.ErrKeyNotFound) + + callback, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ + Params: CallbackOid4vciCredentialIssuanceParams{ + Code: code, + State: state, + }, + }) + + assert.Error(t, err) + assert.Nil(t, callback) + assert.ErrorContains(t, err, "failed to resolve key for did (did:web:example.com:iam:holder): "+resolver.ErrKeyNotFound.Error()) + }) + t.Run("error - signature failure", func(t *testing.T) { + ctx := newTestClient(t) + require.NoError(t, ctx.client.openid4vciSessionStore().Put(state, &session)) + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderDID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI("kid"), nil, nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("", errors.New("signature failed")) + + callback, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ + Params: CallbackOid4vciCredentialIssuanceParams{ + Code: code, + State: state, + }, + }) + + assert.Error(t, err) + assert.Nil(t, callback) + assert.ErrorContains(t, err, "failed to sign the JWT with kid (kid): signature failed") + }) +} diff --git a/auth/api/iam/openid4vp.go b/auth/api/iam/openid4vp.go index 5417be1e2f..14f2470a9f 100644 --- a/auth/api/iam/openid4vp.go +++ b/auth/api/iam/openid4vp.go @@ -322,7 +322,7 @@ func (r Wrapper) handleAuthorizeRequestFromVerifier(ctx context.Context, tenantD privateKey, err = userSession.Wallet.Key() walletDID = userSession.Wallet.DID targetWallet = holder.NewMemoryWallet( - r.JSONLDManager.DocumentLoader(), + r.jsonldManager.DocumentLoader(), resolver.DIDKeyResolver{Resolver: didjwk.NewResolver()}, crypto.MemoryJWTSigner{Key: privateKey}, map[did.DID][]vc.VerifiableCredential{userSession.Wallet.DID: userSession.Wallet.Credentials}, From ee1232ce16549b2794e59d8c7d1e438c75c43d6d Mon Sep 17 00:00:00 2001 From: Gerard Snaauw Date: Fri, 24 May 2024 17:40:39 +0200 Subject: [PATCH 2/2] copyright --- auth/api/iam/openid4vci.go | 18 ++++++++++++++++++ auth/api/iam/openid4vci_test.go | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index 3c5e6aaccd..338cf4ca52 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -1,3 +1,21 @@ +/* + * Copyright (C) 2024 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 ( diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 4b5c826ff2..bd0846689a 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -1,3 +1,21 @@ +/* + * Copyright (C) 2024 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 (