auth/client/iam/openid4vp.go
/*
* Nuts node
* Copyright (C) 2023 Nuts community
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/
package iam
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
ssi "github.com/nuts-foundation/go-did"
"github.com/nuts-foundation/nuts-node/http/client"
"github.com/piprate/json-gold/ld"
"net/http"
"net/url"
"time"
"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"
nutsCrypto "github.com/nuts-foundation/nuts-node/crypto"
"github.com/nuts-foundation/nuts-node/crypto/dpop"
nutsHttp "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/vdr/didweb"
"github.com/nuts-foundation/nuts-node/vdr/resolver"
)
var _ Client = (*OpenID4VPClient)(nil)
type OpenID4VPClient struct {
httpClient HTTPClient
jwtSigner nutsCrypto.JWTSigner
keyResolver resolver.KeyResolver
strictMode bool
wallet holder.Wallet
ldDocumentLoader ld.DocumentLoader
}
// NewClient returns an implementation of Holder
func NewClient(wallet holder.Wallet, keyResolver resolver.KeyResolver, jwtSigner nutsCrypto.JWTSigner, ldDocumentLoader ld.DocumentLoader, strictMode bool, httpClientTimeout time.Duration) *OpenID4VPClient {
return &OpenID4VPClient{
httpClient: HTTPClient{
strictMode: strictMode,
httpClient: client.NewWithCache(httpClientTimeout),
},
keyResolver: keyResolver,
jwtSigner: jwtSigner,
ldDocumentLoader: ldDocumentLoader,
strictMode: strictMode,
wallet: wallet,
}
}
func (c *OpenID4VPClient) ClientMetadata(ctx context.Context, endpoint string) (*oauth.OAuthClientMetadata, error) {
iamClient := c.httpClient
metadata, err := iamClient.ClientMetadata(ctx, endpoint)
if err != nil {
return nil, fmt.Errorf("failed to retrieve OAuth client metadata: %w", err)
}
return metadata, nil
}
func (c *OpenID4VPClient) PostError(ctx context.Context, auth2Error oauth.OAuth2Error, verifierResponseURI string, verifierClientState string) (string, error) {
iamClient := c.httpClient
responseURL, err := core.ParsePublicURL(verifierResponseURI, c.strictMode)
if err != nil {
return "", fmt.Errorf("failed to post error to verifier: %w", err)
}
validURL := *responseURL
if verifierClientState != "" {
validURL = nutsHttp.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)
}
return redirectURL, nil
}
func (c *OpenID4VPClient) PostAuthorizationResponse(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI string, state string) (string, error) {
iamClient := c.httpClient
responseURL, err := core.ParsePublicURL(verifierResponseURI, c.strictMode)
if err != nil {
return "", fmt.Errorf("failed to post error to verifier: %w", err)
}
redirectURL, err := iamClient.PostAuthorizationResponse(ctx, vp, presentationSubmission, *responseURL, state)
if err == nil {
return redirectURL, nil
}
return "", fmt.Errorf("failed to post authorization response to verifier: %w", err)
}
func (c *OpenID4VPClient) PresentationDefinition(ctx context.Context, endpoint string) (*pe.PresentationDefinition, error) {
iamClient := c.httpClient
parsedURL, err := core.ParsePublicURL(endpoint, c.strictMode)
if err != nil {
return nil, fmt.Errorf("failed to retrieve presentation definition: %w", err)
}
presentationDefinition, err := iamClient.PresentationDefinition(ctx, *parsedURL)
if err != nil {
return nil, fmt.Errorf("failed to retrieve presentation definition: %w", err)
}
return presentationDefinition, nil
}
func (c *OpenID4VPClient) AuthorizationServerMetadata(ctx context.Context, oauthIssuer string) (*oauth.AuthorizationServerMetadata, error) {
iamClient := c.httpClient
parsedURL, err := core.ParsePublicURL(oauthIssuer, c.strictMode)
if err != nil {
return nil, fmt.Errorf("invalid oauth issuer url: %w", err)
}
// the wallet/client acts as authorization server
metadata, err := iamClient.OAuthAuthorizationServerMetadata(ctx, parsedURL.String())
if err != nil {
return nil, fmt.Errorf("failed to retrieve remote OAuth Authorization Server metadata: %w", err)
}
return metadata, nil
}
func (c *OpenID4VPClient) RequestObjectByGet(ctx context.Context, requestURI string) (string, error) {
iamClient := c.httpClient
parsedURL, err := core.ParsePublicURL(requestURI, c.strictMode)
if err != nil {
return "", fmt.Errorf("invalid request_uri: %w", err)
}
requestObject, err := iamClient.RequestObjectByGet(ctx, parsedURL.String())
if err != nil {
return "", fmt.Errorf("failed to retrieve JAR Request Object: %w", err)
}
return requestObject, nil
}
func (c *OpenID4VPClient) RequestObjectByPost(ctx context.Context, requestURI string, walletMetadata oauth.AuthorizationServerMetadata) (string, error) {
iamClient := c.httpClient
parsedURL, err := core.ParsePublicURL(requestURI, c.strictMode)
if err != nil {
return "", fmt.Errorf("invalid request_uri: %w", err)
}
// TODO: consider adding a 'wallet_nonce'
metadataBytes, _ := json.Marshal(walletMetadata)
form := url.Values{oauth.WalletMetadataParam: {string(metadataBytes)}}
requestObject, err := iamClient.RequestObjectByPost(ctx, parsedURL.String(), form)
if err != nil {
return "", fmt.Errorf("failed to retrieve JAR Request Object: %w", err)
}
return requestObject, nil
}
func (c *OpenID4VPClient) AccessToken(ctx context.Context, code string, tokenEndpoint string, callbackURI string, clientID did.DID, codeVerifier string, useDPoP bool) (*oauth.TokenResponse, error) {
iamClient := c.httpClient
// validate tokenEndpoint
parsedURL, err := core.ParsePublicURL(tokenEndpoint, c.strictMode)
if err != nil {
return nil, err
}
// call token endpoint
data := url.Values{}
data.Set(oauth.ClientIDParam, clientID.String())
data.Set(oauth.GrantTypeParam, oauth.AuthorizationCodeGrantType)
data.Set(oauth.CodeParam, code)
data.Set(oauth.RedirectURIParam, callbackURI)
data.Set(oauth.CodeVerifierParam, codeVerifier)
var dpopHeader string
if useDPoP {
// create DPoP header
request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsedURL.String(), nil)
if err != nil {
return nil, err
}
dpopHeader, err = c.dpop(ctx, clientID, *request)
if err != nil {
return nil, fmt.Errorf("failed to create DPoP header: %w", err)
}
}
token, err := iamClient.AccessToken(ctx, parsedURL.String(), data, dpopHeader)
if err != nil {
return nil, fmt.Errorf("remote server: error creating access token: %w", err)
}
return &token, nil
}
func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, requester did.DID, verifier did.DID, scopes string,
useDPoP bool, credentials []vc.VerifiableCredential) (*oauth.TokenResponse, error) {
iamClient := c.httpClient
oauthIssuer, err := didweb.DIDToURL(verifier)
if err != nil {
return nil, err
}
metadata, err := iamClient.OAuthAuthorizationServerMetadata(ctx, oauthIssuer.String())
if err != nil {
return nil, fmt.Errorf("failed to retrieve remote OAuth Authorization Server metadata: %w", err)
}
// get the presentation definition from the verifier
parsedURL, err := core.ParsePublicURL(metadata.PresentationDefinitionEndpoint, c.strictMode)
if err != nil {
return nil, err
}
presentationDefinitionURL := nutsHttp.AddQueryParams(*parsedURL, map[string]string{
"scope": scopes,
})
presentationDefinition, err := c.PresentationDefinition(ctx, presentationDefinitionURL.String())
if err != nil {
return nil, err
}
params := holder.BuildParams{
Audience: verifier.String(),
Expires: time.Now().Add(time.Second * 5),
Nonce: nutsCrypto.GenerateNonce(),
}
targetWallet := c.wallet
if len(credentials) > 0 {
// This feature is used for presenting self-attested credentials which aren't signed (they're only protected by the VP's signature).
// To make the API easier to use, we can set a few required fields if it's a self-attested credential.
for i, credential := range credentials {
credentials[i] = autoCorrectSelfAttestedCredential(credential, requester)
}
// We have additional credentials to present, aside from those in the persistent wallet.
// Create a temporary in-memory wallet with the requester's persisted VCs and the
targetWallet, err = c.walletWithExtraCredentials(ctx, requester, credentials)
if err != nil {
return nil, err
}
}
vp, submission, err := targetWallet.BuildSubmission(ctx, requester, *presentationDefinition, metadata.VPFormatsSupported, params)
if err != nil {
return nil, err
}
assertion := vp.Raw()
presentationSubmission, _ := json.Marshal(submission)
data := url.Values{}
data.Set(oauth.GrantTypeParam, oauth.VpTokenGrantType)
data.Set(oauth.AssertionParam, assertion)
data.Set(oauth.PresentationSubmissionParam, string(presentationSubmission))
data.Set(oauth.ScopeParam, scopes)
// create DPoP header
var dpopHeader string
if useDPoP {
request, err := http.NewRequestWithContext(ctx, http.MethodPost, metadata.TokenEndpoint, nil)
if err != nil {
return nil, err
}
dpopHeader, err = c.dpop(ctx, requester, *request)
if err != nil {
return nil, fmt.Errorf("failed tocreate DPoP header: %w", err)
}
}
log.Logger().Tracef("Requesting access token from '%s' for scope '%s'\n VP: %s\n Submission: %s", metadata.TokenEndpoint, scopes, assertion, string(presentationSubmission))
token, err := iamClient.AccessToken(ctx, metadata.TokenEndpoint, data, dpopHeader)
if err != nil {
// the error could be a http error, we just relay it here to make use of any 400 status codes.
return nil, err
}
return &oauth.TokenResponse{
AccessToken: token.AccessToken,
ExpiresIn: token.ExpiresIn,
TokenType: token.TokenType,
Scope: &scopes,
}, nil
}
func (c *OpenID4VPClient) OpenIdCredentialIssuerMetadata(ctx context.Context, oauthIssuerURI string) (*oauth.OpenIDCredentialIssuerMetadata, error) {
iamClient := c.httpClient
parsedURL, err := core.ParsePublicURL(oauthIssuerURI, c.strictMode)
if err != nil {
return nil, fmt.Errorf("invalid oauth issuer url: %w", err)
}
rsp, err := iamClient.OpenIdCredentialIssuerMetadata(ctx, parsedURL.String())
if err != nil {
return nil, fmt.Errorf("failed to retrieve Openid credential issuer metadata: %w", err)
}
return rsp, nil
}
func (c *OpenID4VPClient) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, proofJWT string) (*CredentialResponse, error) {
iamClient := c.httpClient
rsp, err := iamClient.VerifiableCredentials(ctx, credentialEndpoint, accessToken, proofJWT)
if err != nil {
return nil, fmt.Errorf("remote server: failed to retrieve credentials: %w", err)
}
return rsp, nil
}
func (c *OpenID4VPClient) walletWithExtraCredentials(ctx context.Context, subject did.DID, credentials []vc.VerifiableCredential) (holder.Wallet, error) {
walletCredentials, err := c.wallet.List(ctx, subject)
if err != nil {
return nil, err
}
return holder.NewMemoryWallet(c.ldDocumentLoader, c.keyResolver, c.jwtSigner, map[did.DID][]vc.VerifiableCredential{
subject: append(walletCredentials, credentials...),
}), nil
}
func (c *OpenID4VPClient) dpop(ctx context.Context, requester did.DID, request http.Request) (string, error) {
// find the key to sign the DPoP token with
keyID, _, err := c.keyResolver.ResolveKey(requester, nil, resolver.AssertionMethod)
if err != nil {
return "", err
}
token := dpop.New(request)
return c.jwtSigner.SignDPoP(ctx, *token, keyID.String())
}
// autoCorrectSelfAttestedCredential sets the required fields for a self-attested credential.
// These are provided through the API, and for convenience we set the required fields, if not already set.
// It only does this for unsigned credentials.
func autoCorrectSelfAttestedCredential(credential vc.VerifiableCredential, requester did.DID) vc.VerifiableCredential {
if len(credential.Proof) > 0 {
return credential
}
if credential.ID == nil {
credential.ID, _ = ssi.ParseURI(uuid.NewString())
}
if credential.Issuer.String() == "" {
credential.Issuer = requester.URI()
}
if credential.IssuanceDate.IsZero() {
credential.IssuanceDate = time.Now()
}
var credentialSubject []map[string]interface{}
_ = credential.UnmarshalCredentialSubject(&credentialSubject)
if len(credentialSubject) == 1 {
if _, ok := credentialSubject[0]["id"]; !ok {
credentialSubject[0]["id"] = requester.String()
credential.CredentialSubject[0] = credentialSubject[0]
}
}
return credential
}