diff --git a/auth/api/auth/v1/api_test.go b/auth/api/auth/v1/api_test.go
index 7d4491fc13..95439944c4 100644
--- a/auth/api/auth/v1/api_test.go
+++ b/auth/api/auth/v1/api_test.go
@@ -67,6 +67,7 @@ type mockAuthClient struct {
authzServer *oauth.MockAuthorizationServer
relyingParty *oauth.MockRelyingParty
verifier *oauth.MockVerifier
+ holder *oauth.MockHolder
}
func (m *mockAuthClient) V2APIEnabled() bool {
@@ -85,6 +86,10 @@ func (m *mockAuthClient) Verifier() oauth.Verifier {
return m.verifier
}
+func (m *mockAuthClient) Holder() oauth.Holder {
+ return m.holder
+}
+
func (m *mockAuthClient) ContractNotary() services.ContractNotary {
return m.contractNotary
}
@@ -105,6 +110,7 @@ func createContext(t *testing.T) *TestContext {
relyingParty := oauth.NewMockRelyingParty(ctrl)
verifier := oauth.NewMockVerifier(ctrl)
mockCredentialResolver := vcr.NewMockResolver(ctrl)
+ holder := oauth.NewMockHolder(ctrl)
authMock := &mockAuthClient{
ctrl: ctrl,
@@ -112,6 +118,7 @@ func createContext(t *testing.T) *TestContext {
authzServer: authzServer,
relyingParty: relyingParty,
verifier: verifier,
+ holder: holder,
}
requestCtx := audit.TestContext()
diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go
index 9cfe7455c0..6b8a5a0932 100644
--- a/auth/api/iam/api.go
+++ b/auth/api/iam/api.go
@@ -295,9 +295,7 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho
case responseTypeVPToken:
// Options:
// - OpenID4VP flow, vp_token is sent in Authorization Response
- // TODO: Check parameters for right flow
- // TODO: Do we actually need this? (probably not)
- panic("not implemented")
+ return r.handleAuthorizeRequestFromVerifier(ctx, *ownDID, params)
case responseTypeVPIDToken:
// Options:
// - OpenID4VP+SIOP flow, vp_token is sent in Authorization Response
diff --git a/auth/api/iam/openid4vp.go b/auth/api/iam/openid4vp.go
index 2c92bb7bc6..ab9787baf7 100644
--- a/auth/api/iam/openid4vp.go
+++ b/auth/api/iam/openid4vp.go
@@ -24,22 +24,24 @@ import (
"encoding/json"
"errors"
"fmt"
- "net/http"
- "net/url"
- "strings"
-
"github.com/google/uuid"
"github.com/labstack/echo/v4"
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/oauth"
+ oauth2 "github.com/nuts-foundation/nuts-node/auth/services/oauth"
"github.com/nuts-foundation/nuts-node/crypto"
httpNuts "github.com/nuts-foundation/nuts-node/http"
+ "github.com/nuts-foundation/nuts-node/network/log"
"github.com/nuts-foundation/nuts-node/storage"
"github.com/nuts-foundation/nuts-node/vcr/credential"
"github.com/nuts-foundation/nuts-node/vcr/holder"
+ "github.com/nuts-foundation/nuts-node/vcr/pe"
"github.com/nuts-foundation/nuts-node/vdr/didweb"
+ "net/http"
+ "net/url"
+ "strings"
)
var oauthNonceKey = []string{"oauth", "nonce"}
@@ -104,6 +106,8 @@ func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, verifier
// like this:
// GET /authorize?
// response_type=vp_token
+ // &client_id_scheme=did
+ // &client_metadata_uri=https%3A%2F%2Fexample.com%2Fiam%2F123%2F%2Fclient_metadata
// &client_id=did:web:example.com:iam:123
// &client_id_scheme=did
// &client_metadata_uri=https%3A%2F%2Fexample.com%2F.well-known%2Fauthorization-server%2Fiam%2F123%2F%2F
@@ -131,8 +135,8 @@ func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, verifier
authServerURL := httpNuts.AddQueryParams(*walletURL, map[string]string{
responseTypeParam: responseTypeVPToken,
- clientIDParam: verifier.String(),
clientIDSchemeParam: didScheme,
+ clientIDParam: verifier.String(),
responseURIParam: callbackURL.String(),
presentationDefUriParam: presentationDefinitionURI.String(),
clientMetadataURIParam: metadataURL.String(),
@@ -158,6 +162,157 @@ func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, verifier
}, nil
}
+func (r Wrapper) handleAuthorizeRequestFromVerifier(ctx context.Context, walletDID did.DID, params map[string]string) (HandleAuthorizeRequestResponseObject, error) {
+ // we expect an OpenID4VP request like this
+ // GET /iam/456/authorize?response_type=vp_token&client_id=did:web:example.com:iam:123&nonce=xyz
+ // &response_mode=direct_post&response_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb&presentation_definition_uri=example.com%2Fiam%2F123%2Fpresentation_definition?scope=a+b HTTP/1.1
+ // Host: server.com
+ // The following parameters are expected
+ // response_type, REQUIRED. Value MUST be set to "vp_token".
+ // client_id, REQUIRED. This must be a did:web
+ // response_uri, REQUIRED. This must be the verifier node url
+ // response_mode, REQUIRED. Value MUST be "direct_post"
+ // state, REQUIRED. Original client state from the holder (in the client role)
+ // presentation_definition_uri, REQUIRED. For getting the presentation definition
+
+ // check the response URL because later errors will redirect to this URL
+ responseURI, responseOK := params[responseURIParam]
+
+ // get the original authorization request of the client, if something fails we need the redirectURI from this request
+ // get the state parameter
+ state, ok := params[stateParam]
+ if !ok && !responseOK {
+ // log and render error page
+ log.Logger().Error("handleAuthorizeRequestFromVerifier is missing state and response_uri parameter")
+ // this goes to the user-agent :(
+ return nil, oauthError(oauth.ServerError, "something went wrong", nil)
+ }
+ if !ok {
+ // post error to responseURI, if it fails, it'll render error page
+ return r.sendAndHandleDirectPostError(ctx, oauthError(oauth.InvalidRequest, "missing state parameter", nil), responseURI)
+ }
+
+ // check client state
+ // if no state, post error
+ var session OAuthSession
+ err := r.oauthClientStateStore().Get(state, &session)
+ if err != nil {
+ return r.sendAndHandleDirectPostError(ctx, oauthError(oauth.InvalidRequest, "state has expired", nil), responseURI)
+ }
+ clientRedirectURL := session.redirectURI()
+
+ verifierID, ok := params[clientIDParam]
+ if !ok {
+ return r.sendAndHandleDirectPostError(ctx, oauthError(oauth.InvalidRequest, "missing client_id parameter", clientRedirectURL), responseURI)
+ }
+ // the verifier must be a did:web
+ verifierDID, err := did.ParseDID(verifierID)
+ if err != nil || verifierDID.Method != "web" {
+ return r.sendAndHandleDirectPostError(ctx, oauthError(oauth.InvalidRequest, "invalid client_id parameter", clientRedirectURL), responseURI)
+ }
+ // get verifier metadata
+ clientMetadataURI, ok := params[clientMetadataURIParam]
+ if !ok {
+ return r.sendAndHandleDirectPostError(ctx, oauthError(oauth.InvalidRequest, "missing client_metadata_uri parameter", clientRedirectURL), responseURI)
+ }
+ // 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, oauthError(oauth.ServerError, "failed to get client metadata (verifier)", clientRedirectURL), responseURI)
+ }
+ // get presentation_definition from presentation_definition_uri
+ presentationDefinitionURI, ok := params[presentationDefUriParam]
+ if !ok {
+ return r.sendAndHandleDirectPostError(ctx, oauthError(oauth.InvalidRequest, "missing presentation_definition_uri parameter", clientRedirectURL), responseURI)
+ }
+ presentationDefinition, err := r.auth.RelyingParty().PresentationDefinition(ctx, presentationDefinitionURI)
+ if err != nil {
+ return r.sendAndHandleDirectPostError(ctx, oauthError(oauth.InvalidPresentationDefinitionURI, fmt.Sprintf("failed to retrieve presentation definition on %s", presentationDefinitionURI), clientRedirectURL), responseURI)
+ }
+
+ // check nonce
+ nonce, ok := params[nonceParam]
+ if !ok {
+ return r.sendAndHandleDirectPostError(ctx, oauthError(oauth.InvalidRequest, "missing nonce parameter", clientRedirectURL), responseURI)
+ }
+
+ // 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, nonce)
+ if err != nil {
+ if errors.Is(err, oauth2.ErrNoCredentials) {
+ return r.sendAndHandleDirectPostError(ctx, oauthError(oauth.InvalidRequest, "no credentials available", clientRedirectURL), responseURI)
+ }
+ return r.sendAndHandleDirectPostError(ctx, oauthError(oauth.ServerError, err.Error(), clientRedirectURL), responseURI)
+ }
+
+ return r.sendAndHandleDirectPost(ctx, *vp, *submission, responseURI, *clientRedirectURL)
+}
+
+// sendAndHandleDirectPost sends OpenID4VP direct_post to the verifier. The verifier responds with a redirect to the client (including error fields if needed).
+// 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) sendAndHandleDirectPost(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI string, clientRedirectURL url.URL) (HandleAuthorizeRequestResponseObject, error) {
+ redirectURI, err := r.auth.Holder().PostAuthorizationResponse(ctx, vp, presentationSubmission, verifierResponseURI)
+ if err == nil {
+ return HandleAuthorizeRequest302Response{
+ HandleAuthorizeRequest302ResponseHeaders{
+ Location: redirectURI,
+ },
+ }, nil
+ }
+
+ msg := fmt.Sprintf("failed to post authorization response to verifier @ %s", verifierResponseURI)
+ log.Logger().WithError(err).Error(msg)
+
+ // clientRedirectURI has been checked earlier in te process.
+ clientRedirectURL = httpNuts.AddQueryParams(clientRedirectURL, map[string]string{
+ oauth.ErrorParam: string(oauth.ServerError),
+ oauth.ErrorDescriptionParam: msg,
+ })
+ return HandleAuthorizeRequest302Response{
+ HandleAuthorizeRequest302ResponseHeaders{
+ Location: clientRedirectURL.String(),
+ },
+ }, nil
+}
+
+// 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)
+ if err == nil {
+ return HandleAuthorizeRequest302Response{
+ HandleAuthorizeRequest302ResponseHeaders{
+ Location: redirectURI,
+ },
+ }, nil
+ }
+
+ msg := fmt.Sprintf("failed to post error to verifier @ %s", verifierResponseURI)
+ log.Logger().WithError(err).Error(msg)
+
+ if auth2Error.RedirectURI == nil {
+ // render error page because all else failed, in a correct flow this should never happen
+ // it could be the case that the client state has just expired, so no redirectURI is present and the verifier is not responding
+ log.Logger().WithError(err).Error("failed to post error to verifier and no clientRedirectURI present")
+ return nil, oauthError(oauth.ServerError, "something went wrong", nil)
+ }
+
+ // clientRedirectURL has been checked earlier in te process.
+ clientRedirectURL := httpNuts.AddQueryParams(*auth2Error.RedirectURI, map[string]string{
+ oauth.ErrorParam: string(oauth.ServerError),
+ oauth.ErrorDescriptionParam: msg,
+ })
+ return HandleAuthorizeRequest302Response{
+ HandleAuthorizeRequest302ResponseHeaders{
+ Location: clientRedirectURL.String(),
+ },
+ }, 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,
@@ -181,7 +336,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(params map[string]string, session *OAuthSession) (HandleAuthorizeRequestResponseObject, error) {
+func (r Wrapper) handlePresentationRequest(params map[string]string, session *OAuthSession) (HandleAuthorizeRequestResponseObject, error) {
ctx := context.TODO()
// Presentation definition is always derived from the scope.
// Later on, we might support presentation_definition and/or presentation_definition_uri parameters instead of scope as well.
diff --git a/auth/api/iam/user.go b/auth/api/iam/user.go
index 1cfda26719..d89d859818 100644
--- a/auth/api/iam/user.go
+++ b/auth/api/iam/user.go
@@ -48,7 +48,7 @@ var oauthClientStateKey = []string{"oauth", "client_state"}
var userRedirectSessionKey = []string{"user", "redirect"}
var userSessionKey = []string{"user", "session"}
-func (r *Wrapper) requestUserAccessToken(_ context.Context, requester did.DID, request RequestAccessTokenRequestObject) (RequestAccessTokenResponseObject, error) {
+func (r Wrapper) requestUserAccessToken(_ context.Context, requester did.DID, request RequestAccessTokenRequestObject) (RequestAccessTokenResponseObject, error) {
// generate a redirect token valid for 5 seconds
token := crypto.GenerateNonce()
store := r.userRedirectStore()
@@ -78,7 +78,7 @@ func (r *Wrapper) requestUserAccessToken(_ context.Context, requester did.DID, r
// handleUserLanding is the handler for the landing page of the user.
// It renders the page with the correct context based on the token.
-func (r *Wrapper) handleUserLanding(echoCtx echo.Context) error {
+func (r Wrapper) handleUserLanding(echoCtx echo.Context) error {
// todo: user authentication is currently not implemented, user consent is not implemented
// This means that this handler succeeds if the token is valid
// It only checks for an existing RequestAccessTokenRequestObject in the store
@@ -136,14 +136,14 @@ func (r *Wrapper) handleUserLanding(echoCtx echo.Context) error {
return echoCtx.Redirect(http.StatusFound, redirectURL.String())
}
-func (r *Wrapper) userRedirectStore() storage.SessionStore {
+func (r Wrapper) userRedirectStore() storage.SessionStore {
return r.storageEngine.GetSessionDatabase().GetStore(userRedirectTimeout, userRedirectSessionKey...)
}
-func (r *Wrapper) userSessionStore() storage.SessionStore {
+func (r Wrapper) userSessionStore() storage.SessionStore {
return r.storageEngine.GetSessionDatabase().GetStore(userSessionTimeout, userSessionKey...)
}
-func (r *Wrapper) oauthClientStateStore() storage.SessionStore {
+func (r Wrapper) oauthClientStateStore() storage.SessionStore {
return r.storageEngine.GetSessionDatabase().GetStore(oAuthFlowTimeout, oauthClientStateKey...)
}
diff --git a/auth/auth.go b/auth/auth.go
index 4b7ed93c22..04dccb5081 100644
--- a/auth/auth.go
+++ b/auth/auth.go
@@ -50,6 +50,7 @@ type Auth struct {
authzServer oauth.AuthorizationServer
relyingParty oauth.RelyingParty
verifier oauth.Verifier
+ holder oauth.Holder
contractNotary services.ContractNotary
serviceResolver didman.CompoundServiceResolver
keyStore crypto.KeyStore
@@ -119,6 +120,10 @@ func (auth *Auth) Verifier() oauth.Verifier {
return auth.verifier
}
+func (auth *Auth) Holder() oauth.Holder {
+ return auth.holder
+}
+
// Configure the Auth struct by creating a validator and create an Irma server
func (auth *Auth) Configure(config core.ServerConfig) error {
if auth.config.Irma.SchemeManager == "" {
@@ -165,13 +170,14 @@ func (auth *Auth) Configure(config core.ServerConfig) error {
return fmt.Errorf("failed to load presentation exchange mapping file: %w", err)
}
}
-
+ clientTimeout := time.Duration(auth.config.HTTPTimeout) * time.Second
accessTokenLifeSpan := time.Duration(auth.config.AccessTokenLifeSpan) * time.Second
auth.authzServer = oauth.NewAuthorizationServer(auth.vdrInstance.Resolver(), auth.vcr, auth.vcr.Verifier(), auth.serviceResolver,
auth.keyStore, auth.contractNotary, auth.jsonldManager, accessTokenLifeSpan)
auth.relyingParty = oauth.NewRelyingParty(auth.vdrInstance.Resolver(), auth.serviceResolver,
- auth.keyStore, auth.vcr.Wallet(), time.Duration(auth.config.HTTPTimeout)*time.Second, tlsConfig, config.Strictmode)
- auth.verifier = oauth.NewVerifier(config.Strictmode, time.Duration(auth.config.HTTPTimeout)*time.Second, tlsConfig)
+ auth.keyStore, auth.vcr.Wallet(), clientTimeout, tlsConfig, config.Strictmode)
+ auth.verifier = oauth.NewVerifier(config.Strictmode, clientTimeout, tlsConfig)
+ auth.holder = oauth.NewHolder(auth.vcr.Wallet(), config.Strictmode, clientTimeout, tlsConfig)
if err := auth.authzServer.Configure(auth.config.ClockSkew, config.Strictmode); err != nil {
return err
diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go
index 508306205d..f16d20ca16 100644
--- a/auth/client/iam/client.go
+++ b/auth/client/iam/client.go
@@ -90,6 +90,40 @@ func (hb HTTPClient) OAuthAuthorizationServerMetadata(ctx context.Context, webDI
return &metadata, nil
}
+// ClientMetadata retrieves the client metadata from the client metadata endpoint given in the authorization request.
+// We use the AuthorizationServerMetadata struct since it overlaps greatly with the client metadata.
+func (hb HTTPClient) ClientMetadata(ctx context.Context, endpoint string) (*oauth.AuthorizationServerMetadata, error) {
+ _, err := core.ParsePublicURL(endpoint, hb.strictMode)
+ if err != nil {
+ return nil, err
+ }
+
+ // create a GET request
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ return nil, err
+ }
+ response, err := hb.httpClient.Do(request.WithContext(ctx))
+ if err != nil {
+ return nil, fmt.Errorf("failed to call endpoint: %w", err)
+ }
+ if httpErr := core.TestResponseCode(http.StatusOK, response); httpErr != nil {
+ return nil, httpErr
+ }
+
+ var metadata oauth.AuthorizationServerMetadata
+ var data []byte
+
+ if data, err = io.ReadAll(response.Body); err != nil {
+ return nil, fmt.Errorf("unable to read response: %w", err)
+ }
+ if err = json.Unmarshal(data, &metadata); err != nil {
+ return nil, fmt.Errorf("unable to unmarshal response: %w", err)
+ }
+
+ return &metadata, nil
+}
+
// PresentationDefinition retrieves the presentation definition from the presentation definition endpoint (as specified by RFC021) for the given scope.
func (hb HTTPClient) PresentationDefinition(ctx context.Context, definitionEndpoint string, scopes string) (*pe.PresentationDefinition, error) {
presentationDefinitionURL, err := core.ParsePublicURL(definitionEndpoint, hb.strictMode)
@@ -185,3 +219,66 @@ func (hb HTTPClient) AccessToken(ctx context.Context, tokenEndpoint string, vp v
}
return token, nil
}
+
+// PostError posts an OAuth error to the redirect URL and returns the redirect URL with the error as query parameter.
+func (hb HTTPClient) PostError(ctx context.Context, auth2Error oauth.OAuth2Error, verifierCallbackURL string) (string, error) {
+ // initiate http client, create a POST request with x-www-form-urlencoded body and send it to the redirect URL
+ data := url.Values{}
+ data.Set(oauth.ErrorParam, string(auth2Error.Code))
+ data.Set(oauth.ErrorDescriptionParam, auth2Error.Description)
+ request, err := http.NewRequestWithContext(ctx, http.MethodPost, verifierCallbackURL, strings.NewReader(data.Encode()))
+ request.Header.Add("Accept", "application/json")
+ request.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+ if err != nil {
+ return "", err
+ }
+ response, err := hb.httpClient.Do(request.WithContext(ctx))
+ if err != nil {
+ return "", err
+ }
+ if err = core.TestResponseCode(http.StatusOK, response); err != nil {
+ return "", err
+ }
+ // take the redirectURL from the response body and return it
+ var responseData []byte
+ if responseData, err = io.ReadAll(response.Body); err != nil {
+ return "", fmt.Errorf("unable to read response: %w", err)
+ }
+ var redirect oauth.Redirect
+ if err = json.Unmarshal(responseData, &redirect); err != nil {
+ return "", fmt.Errorf("unable to unmarshal response: %w, %s", err, string(responseData))
+ }
+ return redirect.RedirectURI, nil
+}
+
+func (hb HTTPClient) PostAuthorizationResponse(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI string) (string, error) {
+ // initiate http client, create a POST request with x-www-form-urlencoded body and send it to the redirect URL
+ psBytes, _ := json.Marshal(presentationSubmission)
+ data := url.Values{}
+ data.Set(oauth.VpTokenParam, vp.Raw())
+ data.Set(oauth.PresentationSubmissionParam, string(psBytes))
+ request, err := http.NewRequestWithContext(ctx, http.MethodPost, verifierResponseURI, strings.NewReader(data.Encode()))
+ if err != nil {
+ return "", err
+ }
+ request.Header.Add("Accept", "application/json")
+ request.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+
+ response, err := hb.httpClient.Do(request.WithContext(ctx))
+ if err != nil {
+ return "", err
+ }
+ if err = core.TestResponseCode(http.StatusOK, response); err != nil {
+ return "", err
+ }
+ // take the redirectURL from the response body and return it
+ var responseData []byte
+ if responseData, err = io.ReadAll(response.Body); err != nil {
+ return "", fmt.Errorf("unable to read response: %w", err)
+ }
+ var redirect oauth.Redirect
+ if err = json.Unmarshal(responseData, &redirect); err != nil {
+ return "", fmt.Errorf("unable to unmarshal response: %w, %s", err, string(responseData))
+ }
+ return redirect.RedirectURI, nil
+}
diff --git a/auth/interface.go b/auth/interface.go
index 1749b017a1..d70510ad76 100644
--- a/auth/interface.go
+++ b/auth/interface.go
@@ -32,6 +32,8 @@ const ModuleName = "Auth"
type AuthenticationServices interface {
// AuthzServer returns the oauth.AuthorizationServer
AuthzServer() oauth.AuthorizationServer
+ // Holder returens the oauth.Holder
+ Holder() oauth.Holder
// RelyingParty returns the oauth.RelyingParty
RelyingParty() oauth.RelyingParty
// Verifier returns the oauth.Verifier service provider
diff --git a/auth/mock.go b/auth/mock.go
index a1af1bdd49..c7e48ae3bb 100644
--- a/auth/mock.go
+++ b/auth/mock.go
@@ -69,6 +69,20 @@ func (mr *MockAuthenticationServicesMockRecorder) ContractNotary() *gomock.Call
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContractNotary", reflect.TypeOf((*MockAuthenticationServices)(nil).ContractNotary))
}
+// Holder mocks base method.
+func (m *MockAuthenticationServices) Holder() oauth.Holder {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Holder")
+ ret0, _ := ret[0].(oauth.Holder)
+ return ret0
+}
+
+// Holder indicates an expected call of Holder.
+func (mr *MockAuthenticationServicesMockRecorder) Holder() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Holder", reflect.TypeOf((*MockAuthenticationServices)(nil).Holder))
+}
+
// PresentationDefinitions mocks base method.
func (m *MockAuthenticationServices) PresentationDefinitions() *pe.DefinitionResolver {
m.ctrl.T.Helper()
diff --git a/auth/oauth/error.go b/auth/oauth/error.go
index 1ea92ae377..cbf89dbaed 100644
--- a/auth/oauth/error.go
+++ b/auth/oauth/error.go
@@ -44,6 +44,8 @@ const (
ServerError ErrorCode = "server_error"
// InvalidScope is returned when the requested scope is invalid, unknown or malformed.
InvalidScope = ErrorCode("invalid_scope")
+ // InvalidPresentationDefinitionURI is returned when the requested presentation definition URI is invalid or can't be reached.
+ InvalidPresentationDefinitionURI = ErrorCode("invalid_presentation_definition_uri")
)
// Make sure the error implements core.HTTPStatusCodeError, so the HTTP request logger can log the correct status code.
diff --git a/auth/oauth/types.go b/auth/oauth/types.go
index a95422bb85..a8ab2d32d0 100644
--- a/auth/oauth/types.go
+++ b/auth/oauth/types.go
@@ -50,10 +50,19 @@ const (
ScopeParam = "scope"
// PresentationSubmissionParam is the parameter name for the presentation_submission parameter
PresentationSubmissionParam = "presentation_submission"
+ // VpTokenParam is the parameter name for the vp_token parameter
+ VpTokenParam = "vp_token"
// VpTokenGrantType is the grant_type for the vp_token-bearer grant type
VpTokenGrantType = "vp_token-bearer"
)
+const (
+ // ErrorParam is the parameter name for the error parameter
+ ErrorParam = "error"
+ // ErrorDescriptionParam is the parameter name for the error_description parameter
+ ErrorDescriptionParam = "error_description"
+)
+
// IssuerIdToWellKnown converts the OAuth2 Issuer identity to the specified well-known endpoint by inserting the well-known at the root of the path.
// It returns no url and an error when issuer is not a valid URL.
func IssuerIdToWellKnown(issuer string, wellKnown string, strictmode bool) (*url.URL, error) {
@@ -130,3 +139,9 @@ type AuthorizationServerMetadata struct {
// If omitted, the default value is `pre-registered` (referring to the client), which is currently not supported.
ClientIdSchemesSupported []string `json:"client_id_schemes_supported,omitempty"`
}
+
+// Redirect is the response from the verifier on the direct_post authorization response.
+type Redirect struct {
+ // RedirectURI is the URI to redirect the user-agent to.
+ RedirectURI string `json:"redirect_uri"`
+}
diff --git a/auth/services/oauth/holder.go b/auth/services/oauth/holder.go
new file mode 100644
index 0000000000..77dd3191cb
--- /dev/null
+++ b/auth/services/oauth/holder.go
@@ -0,0 +1,124 @@
+/*
+ * 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 .
+ *
+ */
+
+package oauth
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "fmt"
+ "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/vcr/holder"
+ "github.com/nuts-foundation/nuts-node/vcr/pe"
+ "github.com/nuts-foundation/nuts-node/vcr/signature/proof"
+ "time"
+)
+
+var _ Holder = (*HolderService)(nil)
+
+var ErrNoCredentials = errors.New("no matching credentials")
+
+type HolderService struct {
+ strictMode bool
+ httpClientTimeout time.Duration
+ httpClientTLS *tls.Config
+ wallet holder.Wallet
+}
+
+// NewHolder returns an implementation of Holder
+func NewHolder(wallet holder.Wallet, strictMode bool, httpClientTimeout time.Duration, httpClientTLS *tls.Config) *HolderService {
+ return &HolderService{
+ wallet: wallet,
+ strictMode: strictMode,
+ httpClientTimeout: httpClientTimeout,
+ httpClientTLS: httpClientTLS,
+ }
+}
+
+func (v *HolderService) BuildPresentation(ctx context.Context, walletDID did.DID, presentationDefinition pe.PresentationDefinition, verifierMetadata oauth.AuthorizationServerMetadata, nonce string) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) {
+ // get VCs from own wallet
+ credentials, err := v.wallet.List(ctx, walletDID)
+ if err != nil {
+ return nil, nil, errors.New("failed to retrieve wallet credentials")
+ }
+
+ expires := time.Now().Add(time.Minute * 15) //todo
+ // build VP
+ submissionBuilder := presentationDefinition.PresentationSubmissionBuilder()
+ submissionBuilder.AddWallet(walletDID, credentials)
+ format := pe.ChooseVPFormat(verifierMetadata.VPFormats)
+ presentationSubmission, signInstructions, err := submissionBuilder.Build(format)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to build presentation submission: %w", err)
+ }
+ if signInstructions.Empty() {
+ return nil, nil, ErrNoCredentials
+ }
+
+ // todo: support multiple wallets
+ vp, err := v.wallet.BuildPresentation(ctx, signInstructions[0].VerifiableCredentials, holder.PresentationOptions{
+ Format: format,
+ ProofOptions: proof.ProofOptions{
+ Created: time.Now(),
+ Challenge: &nonce,
+ Expires: &expires,
+ },
+ }, &walletDID, false)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to create verifiable presentation: %w", err)
+ }
+ return vp, &presentationSubmission, nil
+}
+
+func (v *HolderService) ClientMetadata(ctx context.Context, endpoint string) (*oauth.AuthorizationServerMetadata, error) {
+ iamClient := iam.NewHTTPClient(v.strictMode, v.httpClientTimeout, v.httpClientTLS)
+ // The verifier has become the client
+
+ metadata, err := iamClient.ClientMetadata(ctx, endpoint)
+ if err != nil {
+ return nil, fmt.Errorf("failed to retrieve remote OAuth Authorization Server metadata: %w", err)
+ }
+ return metadata, nil
+}
+
+func (v *HolderService) PostError(ctx context.Context, auth2Error oauth.OAuth2Error, verifierResponseURI string) (string, error) {
+ iamClient := iam.NewHTTPClient(v.strictMode, v.httpClientTimeout, v.httpClientTLS)
+
+ redirectURL, err := iamClient.PostError(ctx, auth2Error, verifierResponseURI)
+ if err == nil {
+ return redirectURL, nil
+ }
+
+ return "", fmt.Errorf("failed to post error to verifier: %w", err)
+}
+
+func (v *HolderService) PostAuthorizationResponse(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI string) (string, error) {
+ iamClient := iam.NewHTTPClient(v.strictMode, v.httpClientTimeout, v.httpClientTLS)
+
+ redirectURL, err := iamClient.PostAuthorizationResponse(ctx, vp, presentationSubmission, verifierResponseURI)
+ if err == nil {
+ return redirectURL, nil
+ }
+
+ return "", fmt.Errorf("failed to post authorization response to verifier: %w", err)
+}
diff --git a/auth/services/oauth/interface.go b/auth/services/oauth/interface.go
index 28c237c7a6..072bd32eaa 100644
--- a/auth/services/oauth/interface.go
+++ b/auth/services/oauth/interface.go
@@ -20,10 +20,13 @@ package oauth
import (
"context"
+ "github.com/nuts-foundation/go-did/vc"
+ "net/url"
+
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/nuts-node/auth/oauth"
"github.com/nuts-foundation/nuts-node/auth/services"
- "net/url"
+ "github.com/nuts-foundation/nuts-node/vcr/pe"
)
// RelyingParty implements the OAuth2 relying party role.
@@ -31,6 +34,11 @@ 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)
+
+ // PresentationDefinition returns the presentation definition from the given endpoint.
+ // the presentationDefinitionURL contains the full path including the query parameters.
+ PresentationDefinition(ctx context.Context, presentationDefinitionURL string) (*pe.PresentationDefinition, 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.
@@ -55,3 +63,15 @@ type Verifier interface {
// ClientMetadataURL constructs the URL to the client metadata of the local verifier.
ClientMetadataURL(webdid did.DID) (*url.URL, error)
}
+
+// 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, verifierMetadata oauth.AuthorizationServerMetadata, nonce string) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error)
+ // ClientMetadata returns the metadata of the remote verifier.
+ ClientMetadata(ctx context.Context, endpoint string) (*oauth.AuthorizationServerMetadata, 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)
+ // 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) (string, error)
+}
diff --git a/auth/services/oauth/mock.go b/auth/services/oauth/mock.go
index babd76913b..7954f8b52d 100644
--- a/auth/services/oauth/mock.go
+++ b/auth/services/oauth/mock.go
@@ -14,8 +14,10 @@ import (
reflect "reflect"
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"
services "github.com/nuts-foundation/nuts-node/auth/services"
+ pe "github.com/nuts-foundation/nuts-node/vcr/pe"
gomock "go.uber.org/mock/gomock"
)
@@ -72,6 +74,21 @@ func (mr *MockRelyingPartyMockRecorder) CreateJwtGrant(ctx, request any) *gomock
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateJwtGrant", reflect.TypeOf((*MockRelyingParty)(nil).CreateJwtGrant), ctx, request)
}
+// PresentationDefinition mocks base method.
+func (m *MockRelyingParty) PresentationDefinition(ctx context.Context, presentationDefinitionURL string) (*pe.PresentationDefinition, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "PresentationDefinition", ctx, presentationDefinitionURL)
+ ret0, _ := ret[0].(*pe.PresentationDefinition)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// PresentationDefinition indicates an expected call of PresentationDefinition.
+func (mr *MockRelyingPartyMockRecorder) PresentationDefinition(ctx, presentationDefinitionURL any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PresentationDefinition", reflect.TypeOf((*MockRelyingParty)(nil).PresentationDefinition), ctx, presentationDefinitionURL)
+}
+
// RequestRFC003AccessToken mocks base method.
func (m *MockRelyingParty) RequestRFC003AccessToken(ctx context.Context, jwtGrantToken string, authServerEndpoint url.URL) (*oauth.TokenResponse, error) {
m.ctrl.T.Helper()
@@ -221,3 +238,87 @@ func (mr *MockVerifierMockRecorder) ClientMetadataURL(webdid any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClientMetadataURL", reflect.TypeOf((*MockVerifier)(nil).ClientMetadataURL), webdid)
}
+
+// MockHolder is a mock of Holder interface.
+type MockHolder struct {
+ ctrl *gomock.Controller
+ recorder *MockHolderMockRecorder
+}
+
+// MockHolderMockRecorder is the mock recorder for MockHolder.
+type MockHolderMockRecorder struct {
+ mock *MockHolder
+}
+
+// NewMockHolder creates a new mock instance.
+func NewMockHolder(ctrl *gomock.Controller) *MockHolder {
+ mock := &MockHolder{ctrl: ctrl}
+ mock.recorder = &MockHolderMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockHolder) EXPECT() *MockHolderMockRecorder {
+ return m.recorder
+}
+
+// BuildPresentation mocks base method.
+func (m *MockHolder) BuildPresentation(ctx context.Context, walletDID did.DID, presentationDefinition pe.PresentationDefinition, verifierMetadata oauth.AuthorizationServerMetadata, nonce string) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "BuildPresentation", ctx, walletDID, presentationDefinition, verifierMetadata, nonce)
+ ret0, _ := ret[0].(*vc.VerifiablePresentation)
+ ret1, _ := ret[1].(*pe.PresentationSubmission)
+ ret2, _ := ret[2].(error)
+ return ret0, ret1, ret2
+}
+
+// BuildPresentation indicates an expected call of BuildPresentation.
+func (mr *MockHolderMockRecorder) BuildPresentation(ctx, walletDID, presentationDefinition, verifierMetadata, nonce any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BuildPresentation", reflect.TypeOf((*MockHolder)(nil).BuildPresentation), ctx, walletDID, presentationDefinition, verifierMetadata, nonce)
+}
+
+// ClientMetadata mocks base method.
+func (m *MockHolder) ClientMetadata(ctx context.Context, endpoint string) (*oauth.AuthorizationServerMetadata, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "ClientMetadata", ctx, endpoint)
+ ret0, _ := ret[0].(*oauth.AuthorizationServerMetadata)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// ClientMetadata indicates an expected call of ClientMetadata.
+func (mr *MockHolderMockRecorder) ClientMetadata(ctx, endpoint any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClientMetadata", reflect.TypeOf((*MockHolder)(nil).ClientMetadata), ctx, endpoint)
+}
+
+// PostAuthorizationResponse mocks base method.
+func (m *MockHolder) PostAuthorizationResponse(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI string) (string, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "PostAuthorizationResponse", ctx, vp, presentationSubmission, verifierResponseURI)
+ ret0, _ := ret[0].(string)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// PostAuthorizationResponse indicates an expected call of PostAuthorizationResponse.
+func (mr *MockHolderMockRecorder) PostAuthorizationResponse(ctx, vp, presentationSubmission, verifierResponseURI any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostAuthorizationResponse", reflect.TypeOf((*MockHolder)(nil).PostAuthorizationResponse), ctx, vp, presentationSubmission, verifierResponseURI)
+}
+
+// PostError mocks base method.
+func (m *MockHolder) PostError(ctx context.Context, auth2Error oauth.OAuth2Error, verifierResponseURI string) (string, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "PostError", ctx, auth2Error, verifierResponseURI)
+ 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 {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PostError", reflect.TypeOf((*MockHolder)(nil).PostError), ctx, auth2Error, verifierResponseURI)
+}
diff --git a/auth/services/oauth/relying_party.go b/auth/services/oauth/relying_party.go
index 9da8482646..3cb78f10ff 100644
--- a/auth/services/oauth/relying_party.go
+++ b/auth/services/oauth/relying_party.go
@@ -23,12 +23,12 @@ import (
"crypto/tls"
"errors"
"fmt"
+ "github.com/lestrrat-go/jwx/v2/jwt"
"net/http"
"net/url"
"strings"
"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/api/auth/v1/client"
@@ -41,6 +41,7 @@ import (
nutsHttp "github.com/nuts-foundation/nuts-node/http"
"github.com/nuts-foundation/nuts-node/vcr/credential"
"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"
"github.com/nuts-foundation/nuts-node/vdr/resolver"
)
@@ -257,6 +258,24 @@ func chooseVPFormat(formats map[string]map[string][]string) string {
return ""
}
+func (s *relyingParty) PresentationDefinition(ctx context.Context, presentationDefinitionURL string) (*pe.PresentationDefinition, error) {
+ parsedURL, err := url.Parse(presentationDefinitionURL)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse presentation definition URL: %w", err)
+ }
+ presentationDefinitionEndpoint := *parsedURL
+ presentationDefinitionEndpoint.RawQuery = ""
+ presentationDefinitionEndpoint.Fragment = ""
+ scope := parsedURL.Query().Get("scope")
+
+ iamClient := iam.NewHTTPClient(s.strictMode, s.httpClientTimeout, s.httpClientTLS)
+ presentationDefinition, err := iamClient.PresentationDefinition(ctx, presentationDefinitionEndpoint.String(), scope)
+ if err != nil {
+ return nil, fmt.Errorf("failed to retrieve presentation definition: %w", err)
+ }
+ return presentationDefinition, nil
+}
+
var timeFunc = time.Now
// standalone func for easier testing
diff --git a/vcr/pe/format.go b/vcr/pe/format.go
new file mode 100644
index 0000000000..e0986e43a1
--- /dev/null
+++ b/vcr/pe/format.go
@@ -0,0 +1,36 @@
+/*
+ * 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 pe
+
+import "github.com/nuts-foundation/go-did/vc"
+
+// ChooseVPFormat determines the format of the Verifiable Presentation based on the authorization server metadata.
+func ChooseVPFormat(formats map[string]map[string][]string) string {
+ // They are in preferred order
+ if _, ok := formats[vc.JWTPresentationProofFormat]; ok {
+ return vc.JWTPresentationProofFormat
+ }
+ if _, ok := formats["jwt_vp_json"]; ok {
+ return vc.JWTPresentationProofFormat
+ }
+ if _, ok := formats[vc.JSONLDPresentationProofFormat]; ok {
+ return vc.JSONLDPresentationProofFormat
+ }
+ return ""
+}