nuts-foundation/nuts-node

View on GitHub
auth/api/iam/openid4vci.go

Summary

Maintainability
A
1 hr
Test Coverage
A
91%
/*
 * 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 <https://www.gnu.org/licenses/>.
 *
 */

package iam

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "net/http"
    "net/url"
    "time"

    "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/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/vdr/resolver"
)

var timeFunc = time.Now

// jwtTypeOpenID4VCIProof defines the OpenID4VCI JWT-subtype (used as typ claim in the JWT).
const jwtTypeOpenID4VCIProof = "openid4vci-proof+jwt"

func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, request RequestOpenid4VCICredentialIssuanceRequestObject) (RequestOpenid4VCICredentialIssuanceResponseObject, 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.
    redirectUri, err := createOAuth2BaseURL(*requestHolder)
    if err != nil {
        return nil, fmt.Errorf("failed to create callback URL for verification: %w", err)
    }
    redirectUri = redirectUri.JoinPath(oauth.CallbackPath)
    // Store the session
    err = r.oauthClientStateStore().Put(state, &OAuthSession{
        ClientFlow:  credentialRequestClientFlow,
        OwnDID:      requestHolder,
        OtherDID:    issuerDid,
        RedirectURI: request.Body.RedirectUri,
        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
        TokenEndpoint:            authzServerMetadata.TokenEndpoint,
        IssuerURL:                authzServerMetadata.Issuer,
        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.ClientIDSchemeParam:       didClientIDScheme,
        oauth.AuthorizationDetailsParam: string(authorizationDetails),
        oauth.RedirectURIParam:          redirectUri.String(),
        oauth.CodeChallengeParam:        pkceParams.Challenge,
        oauth.CodeChallengeMethodParam:  pkceParams.ChallengeMethod,
    })

    return RequestOpenid4VCICredentialIssuance200JSONResponse{
        RedirectURI: redirectUrl.String(),
    }, nil
}

func (r Wrapper) handleOpenID4VCICallback(ctx context.Context, authorizationCode string, oauthSession *OAuthSession) (CallbackResponseObject, error) {
    // extract callback URI at calling app from OAuthSession
    // this is the URI where the user-agent will be redirected to
    appCallbackURI := oauthSession.redirectURI()

    checkURL, err := createOAuth2BaseURL(*oauthSession.OwnDID)
    if err != nil {
        return nil, fmt.Errorf("failed to create callback URL for verification: %w", err)
    }
    checkURL = checkURL.JoinPath(oauth.CallbackPath)

    // use code to request access token from remote token endpoint
    response, err := r.auth.IAMClient().AccessToken(ctx, authorizationCode, oauthSession.TokenEndpoint, checkURL.String(), *oauthSession.OwnDID, oauthSession.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", oauthSession.TokenEndpoint, err.Error())), appCallbackURI)
    }

    // make proof and collect credential
    proofJWT, err := r.openid4vciProof(ctx, *oauthSession.OwnDID, oauthSession.IssuerURL, response.Get(oauth.CNonceParam))
    if err != nil {
        return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error building proof to fetch the credential from endpoint %s, error: %s", oauthSession.IssuerCredentialEndpoint, err.Error())), appCallbackURI)
    }
    credentials, err := r.auth.IAMClient().VerifiableCredentials(ctx, oauthSession.IssuerCredentialEndpoint, response.AccessToken, proofJWT)
    if err != nil {
        return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("error while fetching the credential from endpoint %s, error: %s", oauthSession.IssuerCredentialEndpoint, err.Error())), appCallbackURI)
    }
    // validate credential
    // TODO: check that issued credential is bound to DID that requested it (OwnDID)???
    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())), appCallbackURI)
    }
    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())), appCallbackURI)
    }
    // store credential in wallet
    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())), appCallbackURI)
    }
    return Callback302Response{
        Headers: Callback302ResponseHeaders{Location: appCallbackURI.String()},
    }, nil
}

func (r *Wrapper) openid4vciProof(ctx context.Context, holderDid did.DID, audience string, nonce string) (string, error) {
    kid, _, err := r.keyResolver.ResolveKey(holderDid, nil, resolver.AssertionMethod)
    if err != nil {
        return "", fmt.Errorf("failed to resolve key for did (%s): %w", holderDid.String(), err)
    }
    headers := map[string]interface{}{
        "typ": jwtTypeOpenID4VCIProof, // MUST be openid4vci-proof+jwt, which explicitly types the proof JWT as recommended in Section 3.11 of [RFC8725].
        "kid": kid.String(),           // JOSE Header containing the key ID. If the Credential shall be bound to a DID, the kid refers to a DID URL which identifies a particular key in the DID Document that the Credential shall be bound to.
    }
    if err != nil {
        // can't fail or would have failed before
        return "", err
    }
    claims := map[string]interface{}{
        jwt.IssuerKey:   holderDid.String(),
        jwt.AudienceKey: audience, // Credential Issuer Identifier
        jwt.IssuedAtKey: timeFunc().Unix(),
    }
    if nonce != "" {
        claims[oauth.NonceParam] = nonce
    }
    proofJwt, err := r.jwtSigner.SignJWT(ctx, claims, headers, kid.String())
    if err != nil {
        return "", fmt.Errorf("failed to sign the JWT with kid (%s): %w", kid.String(), err)
    }
    return proofJwt, nil
}