diff --git a/auth/api/auth/v1/api_test.go b/auth/api/auth/v1/api_test.go
index a56cf91a45..63e2f033bc 100644
--- a/auth/api/auth/v1/api_test.go
+++ b/auth/api/auth/v1/api_test.go
@@ -66,6 +66,7 @@ type mockAuthClient struct {
authzServer *oauth.MockAuthorizationServer
relyingParty *oauth.MockRelyingParty
verifier *oauth.MockVerifier
+ holder *oauth.MockHolder
}
func (m *mockAuthClient) V2APIEnabled() bool {
@@ -84,6 +85,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
}
@@ -100,6 +105,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,
@@ -107,6 +113,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 910fee1470..2ae5483358 100644
--- a/auth/api/iam/api.go
+++ b/auth/api/iam/api.go
@@ -265,17 +265,8 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho
for key, value := range httpRequest.URL.Query() {
params[key] = value[0]
}
+ // todo: store session in database? Isn't session specific for a particular flow?
session := createSession(params, *ownDID)
- if session.RedirectURI == "" {
- // TODO: Spec says that the redirect URI is optional, but it's not clear what to do if it's not provided.
- // Threat models say it's unsafe to omit redirect_uri.
- // See https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.1
- return nil, oauth.OAuth2Error{
- Code: oauth.InvalidRequest,
- Description: "redirect_uri is required",
- }
- }
- // todo: store session in database?
switch session.ResponseType {
case responseTypeCode:
@@ -303,9 +294,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/api_test.go b/auth/api/iam/api_test.go
index 7bd85af959..a661584075 100644
--- a/auth/api/iam/api_test.go
+++ b/auth/api/iam/api_test.go
@@ -223,19 +223,23 @@ func TestWrapper_PresentationDefinition(t *testing.T) {
}
func TestWrapper_HandleAuthorizeRequest(t *testing.T) {
- metadata := oauth.AuthorizationServerMetadata{
- AuthorizationEndpoint: "https://example.com/holder/authorize",
+ serverMetadata := oauth.AuthorizationServerMetadata{
+ AuthorizationEndpoint: "https://example.com/holder/authorize",
+ ClientIdSchemesSupported: []string{"did"},
}
- t.Run("ok - from holder", func(t *testing.T) {
+ clientMetadata := oauth.OAuthClientMetadata{
+ VPFormats: oauth.DefaultOpenIDSupportedFormats(),
+ }
+ t.Run("ok - code response type - from holder", func(t *testing.T) {
ctx := newTestClient(t)
ctx.vdr.EXPECT().IsOwner(gomock.Any(), verifierDID).Return(true, nil)
- ctx.verifierRole.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(&metadata, nil)
+ ctx.verifierRole.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(&serverMetadata, nil)
ctx.verifierRole.EXPECT().ClientMetadataURL(verifierDID).Return(test.MustParseURL("https://example.com/.well-known/authorization-server/iam/verifier"), nil)
res, err := ctx.client.HandleAuthorizeRequest(requestContext(map[string]string{
clientIDParam: holderDID.String(),
redirectURIParam: "https://example.com",
- responseTypeParam: "code",
+ responseTypeParam: responseTypeCode,
scopeParam: "test",
}), HandleAuthorizeRequestRequestObject{
Id: "verifier",
@@ -255,16 +259,42 @@ func TestWrapper_HandleAuthorizeRequest(t *testing.T) {
assert.Contains(t, location, "response_type=vp_token")
})
- t.Run("missing redirect_uri", func(t *testing.T) {
+ t.Run("ok - vp_token response type - from verifier", func(t *testing.T) {
ctx := newTestClient(t)
- ctx.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(true, nil)
+ _ = ctx.client.storageEngine.GetSessionDatabase().GetStore(oAuthFlowTimeout, oauthClientStateKey...).Put("state", OAuthSession{
+ // this is the state from the holder that was stored at the creation of the first authorization request to the verifier
+ ClientID: holderDID.String(),
+ Scope: "test",
+ OwnDID: holderDID,
+ ClientState: "state",
+ RedirectURI: "https://example.com/iam/holder/cb",
+ ResponseType: "code",
+ })
+ ctx.vdr.EXPECT().IsOwner(gomock.Any(), holderDID).Return(true, nil)
+ ctx.holderRole.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/iam/verifier").Return(&clientMetadata, nil)
+ ctx.holderRole.EXPECT().PresentationDefinition(gomock.Any(), "https://example.com/iam/verifier/presentation_definition?scope=test").Return(&pe.PresentationDefinition{}, nil)
+ ctx.holderRole.EXPECT().BuildPresentation(gomock.Any(), holderDID, pe.PresentationDefinition{}, clientMetadata.VPFormats, "nonce").Return(&vc.VerifiablePresentation{}, &pe.PresentationSubmission{}, nil)
+ ctx.holderRole.EXPECT().PostAuthorizationResponse(gomock.Any(), vc.VerifiablePresentation{}, pe.PresentationSubmission{}, "https://example.com/iam/verifier/response", "state").Return("https://example.com/iam/holder/redirect", nil)
- res, err := ctx.client.HandleAuthorizeRequest(requestContext(map[string]string{}), HandleAuthorizeRequestRequestObject{
- Id: webIDPart,
+ res, err := ctx.client.HandleAuthorizeRequest(requestContext(map[string]string{
+ clientIDParam: verifierDID.String(),
+ clientIDSchemeParam: didScheme,
+ clientMetadataURIParam: "https://example.com/.well-known/authorization-server/iam/verifier",
+ nonceParam: "nonce",
+ presentationDefUriParam: "https://example.com/iam/verifier/presentation_definition?scope=test",
+ responseURIParam: "https://example.com/iam/verifier/response",
+ responseModeParam: responseModeDirectPost,
+ responseTypeParam: responseTypeVPToken,
+ scopeParam: "test",
+ stateParam: "state",
+ }), HandleAuthorizeRequestRequestObject{
+ Id: "holder",
})
- requireOAuthError(t, err, oauth.InvalidRequest, "redirect_uri is required")
- assert.Nil(t, res)
+ require.NoError(t, err)
+ assert.IsType(t, HandleAuthorizeRequest302Response{}, res)
+ location := res.(HandleAuthorizeRequest302Response).Headers.Location
+ assert.Equal(t, location, "https://example.com/iam/holder/redirect")
})
t.Run("unsupported response type", func(t *testing.T) {
ctx := newTestClient(t)
@@ -420,12 +450,13 @@ type testCtx struct {
client *Wrapper
authnServices *auth.MockAuthenticationServices
vdr *vdr.MockVDR
- policy *policy.MockBackend
+ policy *policy.MockPDPBackend
resolver *resolver.MockDIDResolver
relyingParty *oauthServices.MockRelyingParty
vcVerifier *verifier.MockVerifier
vcr *vcr.MockVCR
verifierRole *oauthServices.MockVerifier
+ holderRole *oauthServices.MockHolder
}
func newTestClient(t testing.TB) *testCtx {
@@ -435,11 +466,12 @@ func newTestClient(t testing.TB) *testCtx {
storageEngine := storage.NewTestStorageEngine(t)
authnServices := auth.NewMockAuthenticationServices(ctrl)
authnServices.EXPECT().PublicURL().Return(publicURL).AnyTimes()
- policyInstance := policy.NewMockBackend(ctrl)
+ policyInstance := policy.NewMockPDPBackend(ctrl)
mockResolver := resolver.NewMockDIDResolver(ctrl)
relyingPary := oauthServices.NewMockRelyingParty(ctrl)
vcVerifier := verifier.NewMockVerifier(ctrl)
verifierRole := oauthServices.NewMockVerifier(ctrl)
+ holderRole := oauthServices.NewMockHolder(ctrl)
mockVDR := vdr.NewMockVDR(ctrl)
mockVCR := vcr.NewMockVCR(ctrl)
@@ -447,6 +479,7 @@ func newTestClient(t testing.TB) *testCtx {
authnServices.EXPECT().RelyingParty().Return(relyingPary).AnyTimes()
mockVCR.EXPECT().Verifier().Return(vcVerifier).AnyTimes()
authnServices.EXPECT().Verifier().Return(verifierRole).AnyTimes()
+ authnServices.EXPECT().Holder().Return(holderRole).AnyTimes()
mockVDR.EXPECT().Resolver().Return(mockResolver).AnyTimes()
return &testCtx{
@@ -458,6 +491,7 @@ func newTestClient(t testing.TB) *testCtx {
resolver: mockResolver,
vdr: mockVDR,
verifierRole: verifierRole,
+ holderRole: holderRole,
vcr: mockVCR,
client: &Wrapper{
auth: authnServices,
diff --git a/auth/api/iam/openid4vp.go b/auth/api/iam/openid4vp.go
index cef85def7f..1354e27438 100644
--- a/auth/api/iam/openid4vp.go
+++ b/auth/api/iam/openid4vp.go
@@ -26,6 +26,7 @@ import (
"fmt"
"net/http"
"net/url"
+ "slices"
"strings"
"github.com/google/uuid"
@@ -34,8 +35,10 @@ import (
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/auth/oauth"
+ oauthServices "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"
@@ -45,18 +48,20 @@ import (
var oauthNonceKey = []string{"oauth", "nonce"}
+// handleAuthorizeRequestFromHolder handles an Authorization Request as specified by OpenID4VP: https://openid.net/specs/openid-4-verifiable-presentations-1_0.html.
+// we expect a generic OAuth2 request like this:
+// GET /iam/123/authorize?response_type=token&client_id=did:web:example.com:iam:456&state=xyz
+//
+// &redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1
+// Host: server.com
+//
+// The following parameters are expected
+// response_type, REQUIRED. Value MUST be set to "code". (Already checked by caller)
+// client_id, REQUIRED. This must be a did:web
+// redirect_uri, REQUIRED. This must be the client or other node url (client for regular flow, node for popup)
+// scope, OPTIONAL. The scope that maps to a presentation definition, if not set we just want an empty VP
+// state, RECOMMENDED. Opaque value used to maintain state between the request and the callback.
func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, verifier did.DID, params map[string]string) (HandleAuthorizeRequestResponseObject, error) {
- // we expect a generic OAuth2 request like this:
- // GET /iam/123/authorize?response_type=token&client_id=did:web:example.com:iam:456&state=xyz
- // &redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1
- // Host: server.com
- // The following parameters are expected
- // response_type, REQUIRED. Value MUST be set to "token".
- // client_id, REQUIRED. This must be a did:web
- // redirect_uri, REQUIRED. This must be the client or other node url (client for regular flow, node for popup)
- // scope, OPTIONAL. The scope that maps to a presentation definition, if not set we just want an empty VP
- // state, RECOMMENDED. Opaque value used to maintain state between the request and the callback.
-
// first we check the redirect URL because later errors will redirect to this URL
// from RFC6749:
// If the request fails due to a missing, invalid, or mismatching
@@ -71,16 +76,13 @@ func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, verifier
}
redirectURL, err := url.Parse(redirectURI)
if err != nil {
- // todo render error page instead of technical error
+ // todo render error page instead of technical error (via errorWriter)
return nil, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "invalid redirect_uri parameter"}
}
// now we have a valid redirectURL, so all future errors will redirect to this URL using the Oauth2ErrorWriter
// GET authorization server metadata for wallet
- walletID, ok := params[clientIDParam]
- if !ok {
- return nil, oauthError(oauth.InvalidRequest, "missing client_id parameter", redirectURL)
- }
+ walletID := params[clientIDParam]
// the walletDID must be a did:web
walletDID, err := did.ParseDID(walletID)
if err != nil || walletDID.Method != "web" {
@@ -105,6 +107,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
@@ -128,12 +132,17 @@ func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, verifier
return nil, oauthError(oauth.ServerError, "failed to construct metadata URL", redirectURL)
}
+ // check metadata for supported client_id_schemes
+ if !slices.Contains(metadata.ClientIdSchemesSupported, didScheme) {
+ return nil, oauthError(oauth.InvalidRequest, "wallet metadata does not contain did in client_id_schemes_supported", redirectURL)
+ }
+
// todo: because of the did scheme, the request needs to be signed using JAR according to ยง5.7 of the openid4vp spec
authServerURL := httpNuts.AddQueryParams(*walletURL, map[string]string{
responseTypeParam: responseTypeVPToken,
- clientIDParam: verifier.String(),
clientIDSchemeParam: didScheme,
+ clientIDParam: verifier.String(),
responseURIParam: callbackURL.String(),
presentationDefUriParam: presentationDefinitionURI.String(),
clientMetadataURIParam: metadataURL.String(),
@@ -159,6 +168,124 @@ func (r Wrapper) handleAuthorizeRequestFromHolder(ctx context.Context, verifier
}, nil
}
+// handleAuthorizeRequestFromVerifier handles an Authorization Request for a wallet from a verifier as specified by OpenID4VP: https://openid.net/specs/openid-4-verifiable-presentations-1_0.html.
+// 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"
+// presentation_definition_uri, REQUIRED. For getting the presentation definition
+
+// there are way more error conditions that listed at: https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#name-error-response
+// missing or invalid parameters are all mapped to invalid_request
+// any operation that fails is mapped to server_error, this includes unreachable or broken backends.
+func (r Wrapper) handleAuthorizeRequestFromVerifier(ctx context.Context, walletDID did.DID, params map[string]string) (HandleAuthorizeRequestResponseObject, error) {
+ responseMode := params[responseModeParam]
+ if responseMode != responseModeDirectPost {
+ return nil, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "invalid response_mode parameter"}
+ }
+ // check the response URL because later errors will redirect to this URL
+ responseURI, responseOK := params[responseURIParam]
+ if !responseOK {
+ return nil, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "missing response_uri parameter"}
+ }
+ clientIDScheme := params[clientIDSchemeParam]
+ if clientIDScheme != didScheme {
+ return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "invalid client_id_scheme parameter"}, responseURI)
+ }
+ verifierID := params[clientIDParam]
+ // the verifier must be a did:web
+ verifierDID, err := did.ParseDID(verifierID)
+ if err != nil || verifierDID.Method != "web" {
+ return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "invalid client_id parameter (only did:web is supported)"}, responseURI)
+ }
+ nonce, ok := params[nonceParam]
+ if !ok {
+ return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "missing nonce parameter"}, responseURI)
+ }
+ // get verifier metadata
+ clientMetadataURI := params[clientMetadataURIParam]
+ // we ignore any client_metadata, but officially an error must be returned when that param is present.
+ metadata, err := r.auth.Holder().ClientMetadata(ctx, clientMetadataURI)
+ if err != nil {
+ return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.ServerError, Description: "failed to get client metadata (verifier)"}, responseURI)
+ }
+ // get presentation_definition from presentation_definition_uri
+ presentationDefinitionURI := params[presentationDefUriParam]
+ presentationDefinition, err := r.auth.Holder().PresentationDefinition(ctx, presentationDefinitionURI)
+ if err != nil {
+ return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidPresentationDefinitionURI, Description: fmt.Sprintf("failed to retrieve presentation definition on %s", presentationDefinitionURI)}, responseURI)
+ }
+
+ // at this point in the flow it would be possible to ask the user to confirm the credentials to use
+
+ // all params checked, delegate responsibility to the holder
+ vp, submission, err := r.auth.Holder().BuildPresentation(ctx, walletDID, *presentationDefinition, metadata.VPFormats, nonce)
+ if err != nil {
+ if errors.Is(err, oauthServices.ErrNoCredentials) {
+ return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "no credentials available"}, responseURI)
+ }
+ return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.ServerError, Description: err.Error()}, responseURI)
+ }
+
+ // any error here is a server error, might need a fixup to prevent exposing to a user
+ return r.sendAndHandleDirectPost(ctx, *vp, *submission, responseURI, params[stateParam])
+}
+
+// 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).
+func (r Wrapper) sendAndHandleDirectPost(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI string, state string) (HandleAuthorizeRequestResponseObject, error) {
+ redirectURI, err := r.auth.Holder().PostAuthorizationResponse(ctx, vp, presentationSubmission, verifierResponseURI, state)
+ if err != nil {
+ return nil, err
+ }
+
+ return HandleAuthorizeRequest302Response{
+ HandleAuthorizeRequest302ResponseHeaders{
+ Location: redirectURI,
+ },
+ }, 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, oauth.OAuth2Error{Code: oauth.ServerError, Description: "something went wrong"}
+ }
+
+ // 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,
diff --git a/auth/api/iam/openid4vp_test.go b/auth/api/iam/openid4vp_test.go
index db20b8db81..40ef2227b7 100644
--- a/auth/api/iam/openid4vp_test.go
+++ b/auth/api/iam/openid4vp_test.go
@@ -26,22 +26,26 @@ import (
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/auth"
"github.com/nuts-foundation/nuts-node/auth/oauth"
+ "net/http"
+ "net/url"
+ "testing"
+
+ oauth2 "github.com/nuts-foundation/nuts-node/auth/services/oauth"
"github.com/nuts-foundation/nuts-node/policy"
"github.com/nuts-foundation/nuts-node/storage"
+ "github.com/nuts-foundation/nuts-node/test"
"github.com/nuts-foundation/nuts-node/vcr"
"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"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
- "net/http"
- "net/url"
- "testing"
)
-var holderDID = did.MustParseDID("did:web:example.com:holder")
-var issuerDID = did.MustParseDID("did:web:example.com:issuer")
+var holderDID = did.MustParseDID("did:web:example.com:iam:holder")
+var issuerDID = did.MustParseDID("did:web:example.com:iam:issuer")
func TestWrapper_handleAuthorizeRequestFromHolder(t *testing.T) {
defaultParams := func() map[string]string {
@@ -53,23 +57,27 @@ func TestWrapper_handleAuthorizeRequestFromHolder(t *testing.T) {
}
}
- t.Run("missing client_id", func(t *testing.T) {
+ t.Run("invalid client_id", func(t *testing.T) {
ctx := newTestClient(t)
params := defaultParams()
- delete(params, clientIDParam)
+ params[clientIDParam] = "did:nuts:1"
_, err := ctx.client.handleAuthorizeRequestFromHolder(context.Background(), verifierDID, params)
- requireOAuthError(t, err, oauth.InvalidRequest, "missing client_id parameter")
+ requireOAuthError(t, err, oauth.InvalidRequest, "invalid client_id parameter (only did:web is supported)")
})
- t.Run("invalid client_id", func(t *testing.T) {
+ t.Run("missing did in supported_client_id_schemes", func(t *testing.T) {
ctx := newTestClient(t)
params := defaultParams()
- params[clientIDParam] = "did:nuts:1"
+ ctx.verifierRole.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(&oauth.AuthorizationServerMetadata{
+ AuthorizationEndpoint: "http://example.com",
+ ClientIdSchemesSupported: []string{"not_did"},
+ }, nil)
+ ctx.verifierRole.EXPECT().ClientMetadataURL(verifierDID).Return(test.MustParseURL("https://example.com/.well-known/authorization-server/iam/verifier"), nil)
_, err := ctx.client.handleAuthorizeRequestFromHolder(context.Background(), verifierDID, params)
- requireOAuthError(t, err, oauth.InvalidRequest, "invalid client_id parameter (only did:web is supported)")
+ requireOAuthError(t, err, oauth.InvalidRequest, "wallet metadata does not contain did in client_id_schemes_supported")
})
t.Run("error on authorization server metadata", func(t *testing.T) {
ctx := newTestClient(t)
@@ -100,6 +108,189 @@ func TestWrapper_handleAuthorizeRequestFromHolder(t *testing.T) {
})
}
+func TestWrapper_handleAuthorizeRequestFromVerifier(t *testing.T) {
+ responseURI := "https://example.com/iam/verifier/response"
+ clientMetadata := oauth.OAuthClientMetadata{
+ VPFormats: oauth.DefaultOpenIDSupportedFormats(),
+ }
+ defaultParams := func() map[string]string {
+ return map[string]string{
+ clientIDParam: verifierDID.String(),
+ clientIDSchemeParam: didScheme,
+ clientMetadataURIParam: "https://example.com/.well-known/authorization-server/iam/verifier",
+ nonceParam: "nonce",
+ presentationDefUriParam: "https://example.com/iam/verifier/presentation_definition?scope=test",
+ responseModeParam: responseModeDirectPost,
+ responseURIParam: responseURI,
+ responseTypeParam: responseTypeVPToken,
+ scopeParam: "test",
+ }
+ }
+
+ t.Run("invalid client_id", func(t *testing.T) {
+ ctx := newTestClient(t)
+ params := defaultParams()
+ params[clientIDParam] = "did:nuts:1"
+ expectPostError(t, ctx, oauth.InvalidRequest, "invalid client_id parameter (only did:web is supported)", responseURI)
+
+ _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params)
+
+ require.NoError(t, err)
+ })
+ t.Run("invalid client_id_scheme", func(t *testing.T) {
+ ctx := newTestClient(t)
+ params := defaultParams()
+ params[clientIDSchemeParam] = "other"
+ expectPostError(t, ctx, oauth.InvalidRequest, "invalid client_id_scheme parameter", responseURI)
+
+ _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params)
+
+ require.NoError(t, err)
+ })
+ t.Run("missing client_metadata_uri", func(t *testing.T) {
+ ctx := newTestClient(t)
+ params := defaultParams()
+ delete(params, clientMetadataURIParam)
+ ctx.holderRole.EXPECT().ClientMetadata(gomock.Any(), "").Return(nil, assert.AnError)
+ expectPostError(t, ctx, oauth.ServerError, "failed to get client metadata (verifier)", responseURI)
+
+ _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params)
+
+ require.NoError(t, err)
+ })
+ t.Run("missing nonce", func(t *testing.T) {
+ ctx := newTestClient(t)
+ params := defaultParams()
+ delete(params, nonceParam)
+ expectPostError(t, ctx, oauth.InvalidRequest, "missing nonce parameter", responseURI)
+
+ _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params)
+
+ require.NoError(t, err)
+ })
+ t.Run("invalid presentation_definition_uri", func(t *testing.T) {
+ ctx := newTestClient(t)
+ params := defaultParams()
+ params[presentationDefUriParam] = "://example.com"
+ ctx.holderRole.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/iam/verifier").Return(nil, assert.AnError)
+ expectPostError(t, ctx, oauth.ServerError, "failed to get client metadata (verifier)", responseURI)
+
+ _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params)
+
+ require.NoError(t, err)
+ })
+ t.Run("missing response_mode", func(t *testing.T) {
+ ctx := newTestClient(t)
+ params := defaultParams()
+ delete(params, responseModeParam)
+
+ _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params)
+
+ assert.EqualError(t, err, "invalid_request - invalid response_mode parameter")
+ })
+ t.Run("missing response_uri", func(t *testing.T) {
+ ctx := newTestClient(t)
+ params := defaultParams()
+ delete(params, responseURIParam)
+
+ _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params)
+
+ assert.EqualError(t, err, "invalid_request - missing response_uri parameter")
+ })
+ t.Run("missing state and missing response_uri", func(t *testing.T) {
+ ctx := newTestClient(t)
+ params := defaultParams()
+ delete(params, responseURIParam)
+
+ _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params)
+
+ require.Error(t, err)
+ })
+ t.Run("invalid presentation_definition_uri", func(t *testing.T) {
+ ctx := newTestClient(t)
+ params := defaultParams()
+ ctx.holderRole.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/iam/verifier").Return(&clientMetadata, nil)
+ ctx.holderRole.EXPECT().PresentationDefinition(gomock.Any(), "https://example.com/iam/verifier/presentation_definition?scope=test").Return(nil, assert.AnError)
+ expectPostError(t, ctx, oauth.InvalidPresentationDefinitionURI, "failed to retrieve presentation definition on https://example.com/iam/verifier/presentation_definition?scope=test", responseURI)
+
+ _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params)
+
+ require.NoError(t, err)
+ })
+ t.Run("failed to create verifiable presentation", func(t *testing.T) {
+ ctx := newTestClient(t)
+ params := defaultParams()
+ ctx.holderRole.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/iam/verifier").Return(&clientMetadata, nil)
+ ctx.holderRole.EXPECT().PresentationDefinition(gomock.Any(), "https://example.com/iam/verifier/presentation_definition?scope=test").Return(&pe.PresentationDefinition{}, nil)
+ ctx.holderRole.EXPECT().BuildPresentation(gomock.Any(), holderDID, pe.PresentationDefinition{}, clientMetadata.VPFormats, "nonce").Return(nil, nil, assert.AnError)
+ expectPostError(t, ctx, oauth.ServerError, assert.AnError.Error(), responseURI)
+
+ _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params)
+
+ require.NoError(t, err)
+ })
+ t.Run("missing credentials in wallet", func(t *testing.T) {
+ ctx := newTestClient(t)
+ params := defaultParams()
+ ctx.holderRole.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/iam/verifier").Return(&clientMetadata, nil)
+ ctx.holderRole.EXPECT().PresentationDefinition(gomock.Any(), "https://example.com/iam/verifier/presentation_definition?scope=test").Return(&pe.PresentationDefinition{}, nil)
+ ctx.holderRole.EXPECT().BuildPresentation(gomock.Any(), holderDID, pe.PresentationDefinition{}, clientMetadata.VPFormats, "nonce").Return(nil, nil, oauth2.ErrNoCredentials)
+ expectPostError(t, ctx, oauth.InvalidRequest, "no credentials available", responseURI)
+
+ _, err := ctx.client.handleAuthorizeRequestFromVerifier(context.Background(), holderDID, params)
+
+ require.NoError(t, err)
+ })
+}
+
+// expectPostError is a convenience method to add an expectation to the holderRole mock.
+// it checks if the right error is posted to the verifier.
+func expectPostError(t *testing.T, ctx *testCtx, errorCode oauth.ErrorCode, description string, expectedResponseURI string) {
+ ctx.holderRole.EXPECT().PostError(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, err oauth.OAuth2Error, responseURI string) (string, error) {
+ assert.Equal(t, errorCode, err.Code)
+ assert.Equal(t, description, err.Description)
+ assert.Equal(t, expectedResponseURI, responseURI)
+ return "redirect", nil
+ })
+}
+
+func TestWrapper_sendAndHandleDirectPost(t *testing.T) {
+ t.Run("failed to post response", func(t *testing.T) {
+ ctx := newTestClient(t)
+ ctx.holderRole.EXPECT().PostAuthorizationResponse(gomock.Any(), gomock.Any(), gomock.Any(), "response", "").Return("", assert.AnError)
+ _, err := ctx.client.sendAndHandleDirectPost(context.Background(), vc.VerifiablePresentation{}, pe.PresentationSubmission{}, "response", "")
+
+ require.Error(t, err)
+ })
+}
+
+func TestWrapper_sendAndHandleDirectPostError(t *testing.T) {
+ t.Run("failed to post error with redirect available", func(t *testing.T) {
+ ctx := newTestClient(t)
+ ctx.holderRole.EXPECT().PostError(gomock.Any(), gomock.Any(), "response").Return("", assert.AnError)
+ redirectURI := test.MustParseURL("https://example.com/redirect")
+ expected := HandleAuthorizeRequest302Response{
+ Headers: HandleAuthorizeRequest302ResponseHeaders{
+ Location: "https://example.com/redirect?error=server_error&error_description=failed+to+post+error+to+verifier+%40+response",
+ },
+ }
+
+ redirect, err := ctx.client.sendAndHandleDirectPostError(context.Background(), oauth.OAuth2Error{RedirectURI: redirectURI}, "response")
+
+ require.NoError(t, err)
+ assert.Equal(t, expected, redirect)
+ })
+ t.Run("failed to post error without redirect available", func(t *testing.T) {
+ ctx := newTestClient(t)
+ ctx.holderRole.EXPECT().PostError(gomock.Any(), gomock.Any(), "response").Return("", assert.AnError)
+
+ _, err := ctx.client.sendAndHandleDirectPostError(context.Background(), oauth.OAuth2Error{}, "response")
+
+ require.Error(t, err)
+ require.Equal(t, "server_error - something went wrong", err.Error())
+ })
+}
+
func TestWrapper_sendPresentationRequest(t *testing.T) {
instance := New(nil, nil, nil, nil, nil)
@@ -153,7 +344,7 @@ func TestWrapper_handlePresentationRequest(t *testing.T) {
mockVDR := vdr.NewMockVDR(ctrl)
mockVCR := vcr.NewMockVCR(ctrl)
mockWallet := holder.NewMockWallet(ctrl)
- mockPolicy := policy.NewMockBackend(ctrl)
+ mockPolicy := policy.NewMockPDPBackend(ctrl)
mockVCR.EXPECT().Wallet().Return(mockWallet)
mockAuth := auth.NewMockAuthenticationServices(ctrl)
mockWallet.EXPECT().List(gomock.Any(), holderDID).Return(walletCredentials, nil)
diff --git a/auth/api/iam/types.go b/auth/api/iam/types.go
index 8259046e58..95093b5262 100644
--- a/auth/api/iam/types.go
+++ b/auth/api/iam/types.go
@@ -48,6 +48,9 @@ type TokenResponse = oauth.TokenResponse
// OAuthAuthorizationServerMetadata is an alias
type OAuthAuthorizationServerMetadata = oauth.AuthorizationServerMetadata
+// OAuthClientMetadata is an alias
+type OAuthClientMetadata = oauth.OAuthClientMetadata
+
const (
sessionExpiry = 5 * time.Minute
)
@@ -158,74 +161,3 @@ const presentationSubmissionParam = "presentation_submission"
// vpTokenParam is the name of the OpenID4VP vp_token parameter.
// Specified by https://openid.bitbucket.io/connect/openid-4-verifiable-presentations-1_0.html#name-response-type-vp_token
const vpTokenParam = "vp_token"
-
-// OAuthClientMetadata defines the OAuth Client metadata.
-// Specified by https://www.rfc-editor.org/rfc/rfc7591.html and elsewhere.
-type OAuthClientMetadata struct {
- // RedirectURIs lists all URIs that the client may use in any redirect-based flow.
- // From https://www.rfc-editor.org/rfc/rfc7591.html
- RedirectURIs []string `json:"redirect_uris,omitempty"`
-
- // TODO: What do we use? Must provide a value if its not "client_secret_basic"
- // TokenEndpointAuthMethod indicator of the requested authentication method for the token endpoint.
- // If unspecified or omitted, the default is "client_secret_basic", denoting the HTTP Basic authentication scheme as specified in Section 2.3.1 of OAuth 2.0.
- // Examples are: none, client_secret_post, client_secret_basic, tls_client_auth.
- // From https://www.rfc-editor.org/rfc/rfc7591.html
- // TODO: Can "tls_client_auth" replace /n2n/ for pre-authorized_code flow? https://www.rfc-editor.org/rfc/rfc8705.html
- TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
-
- // GrantTypes lists all supported grant_types. Defaults to "authorization_code" if omitted.
- // From https://www.rfc-editor.org/rfc/rfc7591.html
- GrantTypes []string `json:"grant_types,omitempty"`
-
- // ResponseTypes lists all supported response_types. Defaults to "code". Must contain the values corresponding to listed GrantTypes.
- // From https://www.rfc-editor.org/rfc/rfc7591.html
- ResponseTypes []string `json:"response_types,omitempty"`
-
- // Scope contains a space-separated list of scopes the client can request.
- // From https://www.rfc-editor.org/rfc/rfc7591.html
- // TODO: I don't see the use for this. The idea is that an AS does not assign scopes to a client that it does not support (or wants to request at any time), but seems like unnecessary complexity for minimal safety.
- Scope string `json:"scope,omitempty"`
-
- // Contacts contains an array of strings representing ways to contact people responsible for this client, typically email addresses.
- // From https://www.rfc-editor.org/rfc/rfc7591.html
- // TODO: remove? Can plug DID docs contact info.
- Contacts []string `json:"contacts,omitempty"`
-
- // JwksURI URL string referencing the client's JSON Web Key (JWK) Set [RFC7517] document, which contains the client's public keys.
- // From https://www.rfc-editor.org/rfc/rfc7591.html
- // TODO: remove? Can list the DID's keys. Could be useful if authorization without DIDs/VCs is needed.
- // TODO: In EBSI it is a required field for the Service Wallet Metadata https://api-conformance.ebsi.eu/docs/ct/providers-and-wallets-metadata#service-wallet-metadata
- JwksURI string `json:"jwks_uri,omitempty"`
- // Jwks includes the JWK Set of a client. Mutually exclusive with JwksURI.
- // From https://www.rfc-editor.org/rfc/rfc7591.html
- Jwks any `json:"jwks,omitempty"`
-
- // SoftwareID is a unique identifier string (e.g., a Universally Unique Identifier (UUID)) assigned by the client developer.
- // From https://www.rfc-editor.org/rfc/rfc7591.html
- SoftwareID string `json:"software_id,omitempty"`
- // SoftwareVersion is a version identifier string for the client software identified by "software_id".
- // From https://www.rfc-editor.org/rfc/rfc7591.html
- // TODO: Including a software_id + software_version could provide us with some upgrade paths in the future.
- SoftwareVersion string `json:"software_version,omitempty"`
-
- // TODO: ignored values: client_name, client_uri, logo_uri, tos_uri, policy_uri.
- // TODO: Things like client_name and logo may enhance the user experience when asking to accept authorization requests, but this should probably be added on the server size for that?
-
- /*********** OpenID4VCI ***********/
-
- // CredentialOfferEndpoint contains a URL where the pre-authorized_code flow offers a credential.
- // https://openid.bitbucket.io/connect/openid-4-verifiable-credential-issuance-1_0.html#name-client-metadata
- // TODO: openid4vci duplicate. Also defined on /.well-known/openid-credential-wallet to be /n2n/identity/{did}/openid4vci/credential_offer
- CredentialOfferEndpoint string `json:"credential_offer_endpoint,omitempty"`
-
- /*********** OpenID4VP ***********/
- // VPFormats lists the vp_formats supported by the client. See additional comments on vpFormatsSupported.
- // https://openid.bitbucket.io/connect/openid-4-verifiable-presentations-1_0.html#name-verifier-metadata-client-me
- VPFormats any `json:"vp_formats,omitempty"`
-
- // ClientIdScheme is a string identifying the Client Identifier scheme. The value range defined by this specification is
- // pre-registered, redirect_uri, entity_id, did. If omitted, the default value is pre-registered.
- // https://openid.bitbucket.io/connect/openid-4-verifiable-presentations-1_0.html#name-verifier-metadata-client-me
- ClientIdScheme string `json:"client_id_scheme,omitempty"`
-}
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 db9d23668d..070f757992 100644
--- a/auth/auth.go
+++ b/auth/auth.go
@@ -48,6 +48,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
@@ -111,6 +112,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 == "" {
@@ -151,12 +156,14 @@ func (auth *Auth) Configure(config core.ServerConfig) error {
return 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..b47375a145 100644
--- a/auth/client/iam/client.go
+++ b/auth/client/iam/client.go
@@ -90,43 +90,32 @@ func (hb HTTPClient) OAuthAuthorizationServerMetadata(ctx context.Context, webDI
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)
+// 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.OAuthClientMetadata, 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
}
- presentationDefinitionURL.RawQuery = url.Values{"scope": []string{scopes}}.Encode()
+ var metadata oauth.OAuthClientMetadata
+ return &metadata, hb.doRequest(request, &metadata)
+}
+// 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, presentationDefinitionURL url.URL) (*pe.PresentationDefinition, error) {
// create a GET request with scope query param
request, err := http.NewRequestWithContext(ctx, http.MethodGet, presentationDefinitionURL.String(), 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 {
- rse := httpErr.(core.HttpError)
- if ok, oauthErr := oauth.TestOAuthErrorCode(rse.ResponseBody, oauth.InvalidScope); ok {
- return nil, oauthErr
- }
- return nil, httpErr
- }
-
var presentationDefinition pe.PresentationDefinition
- 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, &presentationDefinition); err != nil {
- return nil, fmt.Errorf("unable to unmarshal response: %w", err)
- }
-
- return &presentationDefinition, nil
+ return &presentationDefinition, hb.doRequest(request, &presentationDefinition)
}
func (hb HTTPClient) AccessToken(ctx context.Context, tokenEndpoint string, vp vc.VerifiablePresentation, submission pe.PresentationSubmission, scopes string) (oauth.TokenResponse, error) {
@@ -185,3 +174,64 @@ 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, err oauth.OAuth2Error, verifierCallbackURL url.URL) (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(err.Code))
+ data.Set(oauth.ErrorDescriptionParam, err.Description)
+
+ return hb.postFormExpectRedirect(ctx, data, verifierCallbackURL)
+}
+
+// PostAuthorizationResponse posts the authorization response to the verifier response URL and returns the callback URL.
+func (hb HTTPClient) PostAuthorizationResponse(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI url.URL, state 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))
+ data.Set(oauth.StateParam, state)
+
+ return hb.postFormExpectRedirect(ctx, data, verifierResponseURI)
+}
+
+func (hb HTTPClient) postFormExpectRedirect(ctx context.Context, form url.Values, redirectURL url.URL) (string, error) {
+ request, err := http.NewRequestWithContext(ctx, http.MethodPost, redirectURL.String(), strings.NewReader(form.Encode()))
+ if err != nil {
+ return "", err
+ }
+ request.Header.Add("Accept", "application/json")
+ request.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+ var redirect oauth.Redirect
+ if err := hb.doRequest(request, &redirect); err != nil {
+ return "", err
+ }
+ return redirect.RedirectURI, nil
+}
+
+func (hb HTTPClient) doRequest(request *http.Request, target interface{}) error {
+ response, err := hb.httpClient.Do(request)
+ if err != nil {
+ return fmt.Errorf("failed to call endpoint: %w", err)
+ }
+ if httpErr := core.TestResponseCode(http.StatusOK, response); httpErr != nil {
+ rse := httpErr.(core.HttpError)
+ if ok, oauthErr := oauth.TestOAuthErrorCode(rse.ResponseBody, oauth.InvalidScope); ok {
+ return oauthErr
+ }
+ return httpErr
+ }
+
+ var data []byte
+
+ if data, err = io.ReadAll(response.Body); err != nil {
+ return fmt.Errorf("unable to read response: %w", err)
+ }
+ if err = json.Unmarshal(data, &target); err != nil {
+ return fmt.Errorf("unable to unmarshal response: %w", err)
+ }
+
+ return nil
+}
diff --git a/auth/client/iam/client_test.go b/auth/client/iam/client_test.go
index 6228c7b2dd..2ec42f7971 100644
--- a/auth/client/iam/client_test.go
+++ b/auth/client/iam/client_test.go
@@ -20,6 +20,14 @@ package iam
import (
"context"
+ "github.com/nuts-foundation/nuts-node/test"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+ "time"
+
+ 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"
@@ -29,11 +37,6 @@ import (
"github.com/nuts-foundation/nuts-node/vdr/didweb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- "net/http"
- "net/http/httptest"
- "net/url"
- "testing"
- "time"
)
func TestHTTPClient_OAuthAuthorizationServerMetadata(t *testing.T) {
@@ -110,75 +113,63 @@ func TestHTTPClient_PresentationDefinition(t *testing.T) {
t.Run("ok", func(t *testing.T) {
handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: definition}
tlsServer, client := testServerAndClient(t, &handler)
+ pdUrl := test.MustParseURL(tlsServer.URL)
- response, err := client.PresentationDefinition(ctx, tlsServer.URL, "test")
-
- require.NoError(t, err)
- require.NotNil(t, definition)
- assert.Equal(t, definition, *response)
- require.NotNil(t, handler.Request)
- })
- t.Run("ok - multiple scopes", func(t *testing.T) {
- handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: definition}
- tlsServer, client := testServerAndClient(t, &handler)
-
- response, err := client.PresentationDefinition(ctx, tlsServer.URL, "first second")
+ response, err := client.PresentationDefinition(ctx, *pdUrl)
require.NoError(t, err)
require.NotNil(t, definition)
assert.Equal(t, definition, *response)
require.NotNil(t, handler.Request)
- assert.Equal(t, url.Values{"scope": []string{"first second"}}, handler.Request.URL.Query())
- })
- t.Run("error - invalid_scope", func(t *testing.T) {
- handler := http2.Handler{StatusCode: http.StatusBadRequest, ResponseData: oauth.OAuth2Error{Code: oauth.InvalidScope}}
- tlsServer, client := testServerAndClient(t, &handler)
-
- response, err := client.PresentationDefinition(ctx, tlsServer.URL, "test")
-
- require.Error(t, err)
- assert.EqualError(t, err, "invalid_scope")
- assert.Nil(t, response)
})
t.Run("error - not found", func(t *testing.T) {
handler := http2.Handler{StatusCode: http.StatusNotFound}
tlsServer, client := testServerAndClient(t, &handler)
+ pdUrl := test.MustParseURL(tlsServer.URL)
- response, err := client.PresentationDefinition(ctx, tlsServer.URL, "test")
+ _, err := client.PresentationDefinition(ctx, *pdUrl)
require.Error(t, err)
assert.EqualError(t, err, "server returned HTTP 404 (expected: 200)")
- assert.Nil(t, response)
})
- t.Run("error - invalid URL", func(t *testing.T) {
- handler := http2.Handler{StatusCode: http.StatusNotFound}
- _, client := testServerAndClient(t, &handler)
+ t.Run("error - invalid response", func(t *testing.T) {
+ handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: "}"}
+ tlsServer, client := testServerAndClient(t, &handler)
+ pdUrl := test.MustParseURL(tlsServer.URL)
- response, err := client.PresentationDefinition(ctx, ":", "test")
+ _, err := client.PresentationDefinition(ctx, *pdUrl)
require.Error(t, err)
- assert.EqualError(t, err, "parse \":\": missing protocol scheme")
- assert.Nil(t, response)
+ assert.EqualError(t, err, "unable to unmarshal response: invalid character '}' looking for beginning of value")
})
- t.Run("error - unknown host", func(t *testing.T) {
- handler := http2.Handler{StatusCode: http.StatusNotFound}
- _, client := testServerAndClient(t, &handler)
+}
- response, err := client.PresentationDefinition(ctx, "http://localhost", "test")
+func TestHTTPClient_ClientMetadata(t *testing.T) {
+ ctx := context.Background()
+ metadata := oauth.OAuthClientMetadata{
+ SoftwareID: "id",
+ }
- require.Error(t, err)
- assert.ErrorContains(t, err, "connection refused")
- assert.Nil(t, response)
- })
- t.Run("error - invalid response", func(t *testing.T) {
- handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: "}"}
+ t.Run("ok", func(t *testing.T) {
+ handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: metadata}
tlsServer, client := testServerAndClient(t, &handler)
- response, err := client.PresentationDefinition(ctx, tlsServer.URL, "test")
+ response, err := client.ClientMetadata(ctx, tlsServer.URL)
+
+ require.NoError(t, err)
+ require.NotNil(t, response)
+ assert.Equal(t, metadata, *response)
+ require.NotNil(t, handler.Request)
+ })
+
+ t.Run("error - incorrect url", func(t *testing.T) {
+ handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: metadata}
+ _, client := testServerAndClient(t, &handler)
+
+ _, err := client.ClientMetadata(ctx, ":")
require.Error(t, err)
- assert.EqualError(t, err, "unable to unmarshal response: invalid character '}' looking for beginning of value")
- assert.Nil(t, response)
+ assert.EqualError(t, err, "parse \":\": missing protocol scheme")
})
}
@@ -239,6 +230,88 @@ func TestHTTPClient_AccessToken(t *testing.T) {
})
}
+func TestHTTPClient_PostError(t *testing.T) {
+ redirectReturn := oauth.Redirect{
+ RedirectURI: "http://test.test",
+ }
+ //bytes, _ := json.Marshal(redirectReturn)
+ t.Run("ok", func(t *testing.T) {
+ ctx := context.Background()
+ handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: redirectReturn}
+ tlsServer, client := testServerAndClient(t, &handler)
+ tlsServerURL := test.MustParseURL(tlsServer.URL)
+
+ redirectURI, err := client.PostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "test"}, *tlsServerURL)
+
+ require.NoError(t, err)
+ assert.Equal(t, redirectReturn.RedirectURI, redirectURI)
+ })
+}
+
+func TestHTTPClient_PostAuthorizationResponse(t *testing.T) {
+ presentation := vc.VerifiablePresentation{ID: &ssi.URI{URL: url.URL{Scheme: "https", Host: "test.test"}}}
+ submission := pe.PresentationSubmission{Id: "id"}
+ redirectReturn := oauth.Redirect{
+ RedirectURI: "http://test.test",
+ }
+ t.Run("ok", func(t *testing.T) {
+ ctx := context.Background()
+ handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: redirectReturn}
+ tlsServer, client := testServerAndClient(t, &handler)
+ tlsServerURL := test.MustParseURL(tlsServer.URL)
+
+ redirectURI, err := client.PostAuthorizationResponse(ctx, presentation, submission, *tlsServerURL, "")
+
+ require.NoError(t, err)
+ assert.Equal(t, redirectReturn.RedirectURI, redirectURI)
+ })
+}
+
+func TestHTTPClient_postFormExpectRedirect(t *testing.T) {
+ redirectReturn := oauth.Redirect{
+ RedirectURI: "http://test.test",
+ }
+ data := url.Values{}
+ data.Set("test", "test")
+
+ t.Run("error - unknown host", func(t *testing.T) {
+ ctx := context.Background()
+ handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: redirectReturn}
+ _, client := testServerAndClient(t, &handler)
+ tlsServerURL := test.MustParseURL("http://localhost")
+
+ redirectURI, err := client.postFormExpectRedirect(ctx, data, *tlsServerURL)
+
+ require.Error(t, err)
+ assert.ErrorContains(t, err, "connection refused")
+ assert.Empty(t, redirectURI)
+ })
+ t.Run("error - invalid response", func(t *testing.T) {
+ ctx := context.Background()
+ handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: "}"}
+ tlsServer, client := testServerAndClient(t, &handler)
+ tlsServerURL := test.MustParseURL(tlsServer.URL)
+
+ redirectURI, err := client.postFormExpectRedirect(ctx, data, *tlsServerURL)
+
+ require.Error(t, err)
+ assert.EqualError(t, err, "unable to unmarshal response: invalid character '}' looking for beginning of value")
+ assert.Empty(t, redirectURI)
+ })
+ t.Run("error - server error", func(t *testing.T) {
+ ctx := context.Background()
+ handler := http2.Handler{StatusCode: http.StatusBadGateway, ResponseData: "offline"}
+ tlsServer, client := testServerAndClient(t, &handler)
+ tlsServerURL := test.MustParseURL(tlsServer.URL)
+
+ redirectURI, err := client.postFormExpectRedirect(ctx, data, *tlsServerURL)
+
+ require.Error(t, err)
+ assert.EqualError(t, err, "server returned HTTP 502 (expected: 200)")
+ assert.Empty(t, redirectURI)
+ })
+}
+
func testServerAndClient(t *testing.T, handler http.Handler) (*httptest.Server, *HTTPClient) {
tlsServer := http2.TestTLSServer(t, handler)
return tlsServer, &HTTPClient{
diff --git a/auth/interface.go b/auth/interface.go
index c19f897d44..6f2fbb6b94 100644
--- a/auth/interface.go
+++ b/auth/interface.go
@@ -31,6 +31,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 89ef4f95c4..c6bb8acc10 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))
+}
+
// PublicURL mocks base method.
func (m *MockAuthenticationServices) PublicURL() *url.URL {
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..fbff181d01 100644
--- a/auth/oauth/types.go
+++ b/auth/oauth/types.go
@@ -38,6 +38,8 @@ type TokenResponse struct {
const (
// AuthzServerWellKnown is the well-known base path for the oauth authorization server metadata as defined in RFC8414
AuthzServerWellKnown = "/.well-known/oauth-authorization-server"
+ // ClientMetadataPath is the path to the client metadata relative to the complete did:web URL
+ ClientMetadataPath = "/oauth-client"
// openidCredIssuerWellKnown is the well-known base path for the openID credential issuer metadata as defined in OpenID4VCI specification
openidCredIssuerWellKnown = "/.well-known/openid-credential-issuer"
// openidCredWalletWellKnown is the well-known path element we created for openid4vci to retrieve the oauth client metadata
@@ -48,12 +50,23 @@ const (
AssertionParam = "assertion"
// ScopeParam is the parameter name for the scope parameter
ScopeParam = "scope"
+ // StateParam is the parameter name for the state parameter
+ StateParam = "state"
// 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 +143,80 @@ 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"`
}
+
+// OAuthClientMetadata defines the OAuth Client metadata.
+// Specified by https://www.rfc-editor.org/rfc/rfc7591.html and elsewhere.
+type OAuthClientMetadata struct {
+ // RedirectURIs lists all URIs that the client may use in any redirect-based flow.
+ // From https://www.rfc-editor.org/rfc/rfc7591.html
+ RedirectURIs []string `json:"redirect_uris,omitempty"`
+
+ // TODO: What do we use? Must provide a value if its not "client_secret_basic"
+ // TokenEndpointAuthMethod indicator of the requested authentication method for the token endpoint.
+ // If unspecified or omitted, the default is "client_secret_basic", denoting the HTTP Basic authentication scheme as specified in Section 2.3.1 of OAuth 2.0.
+ // Examples are: none, client_secret_post, client_secret_basic, tls_client_auth.
+ // From https://www.rfc-editor.org/rfc/rfc7591.html
+ // TODO: Can "tls_client_auth" replace /n2n/ for pre-authorized_code flow? https://www.rfc-editor.org/rfc/rfc8705.html
+ TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
+
+ // GrantTypes lists all supported grant_types. Defaults to "authorization_code" if omitted.
+ // From https://www.rfc-editor.org/rfc/rfc7591.html
+ GrantTypes []string `json:"grant_types,omitempty"`
+
+ // ResponseTypes lists all supported response_types. Defaults to "code". Must contain the values corresponding to listed GrantTypes.
+ // From https://www.rfc-editor.org/rfc/rfc7591.html
+ ResponseTypes []string `json:"response_types,omitempty"`
+
+ // Scope contains a space-separated list of scopes the client can request.
+ // From https://www.rfc-editor.org/rfc/rfc7591.html
+ // TODO: I don't see the use for this. The idea is that an AS does not assign scopes to a client that it does not support (or wants to request at any time), but seems like unnecessary complexity for minimal safety.
+ Scope string `json:"scope,omitempty"`
+
+ // Contacts contains an array of strings representing ways to contact people responsible for this client, typically email addresses.
+ // From https://www.rfc-editor.org/rfc/rfc7591.html
+ // TODO: remove? Can plug DID docs contact info.
+ Contacts []string `json:"contacts,omitempty"`
+
+ // JwksURI URL string referencing the client's JSON Web Key (JWK) Set [RFC7517] document, which contains the client's public keys.
+ // From https://www.rfc-editor.org/rfc/rfc7591.html
+ // TODO: remove? Can list the DID's keys. Could be useful if authorization without DIDs/VCs is needed.
+ // TODO: In EBSI it is a required field for the Service Wallet Metadata https://api-conformance.ebsi.eu/docs/ct/providers-and-wallets-metadata#service-wallet-metadata
+ JwksURI string `json:"jwks_uri,omitempty"`
+ // Jwks includes the JWK Set of a client. Mutually exclusive with JwksURI.
+ // From https://www.rfc-editor.org/rfc/rfc7591.html
+ Jwks any `json:"jwks,omitempty"`
+
+ // SoftwareID is a unique identifier string (e.g., a Universally Unique Identifier (UUID)) assigned by the client developer.
+ // From https://www.rfc-editor.org/rfc/rfc7591.html
+ SoftwareID string `json:"software_id,omitempty"`
+ // SoftwareVersion is a version identifier string for the client software identified by "software_id".
+ // From https://www.rfc-editor.org/rfc/rfc7591.html
+ // TODO: Including a software_id + software_version could provide us with some upgrade paths in the future.
+ SoftwareVersion string `json:"software_version,omitempty"`
+
+ // TODO: ignored values: client_name, client_uri, logo_uri, tos_uri, policy_uri.
+ // TODO: Things like client_name and logo may enhance the user experience when asking to accept authorization requests, but this should probably be added on the server size for that?
+
+ /*********** OpenID4VCI ***********/
+
+ // CredentialOfferEndpoint contains a URL where the pre-authorized_code flow offers a credential.
+ // https://openid.bitbucket.io/connect/openid-4-verifiable-credential-issuance-1_0.html#name-client-metadata
+ // TODO: openid4vci duplicate. Also defined on /.well-known/openid-credential-wallet to be /n2n/identity/{did}/openid4vci/credential_offer
+ CredentialOfferEndpoint string `json:"credential_offer_endpoint,omitempty"`
+
+ /*********** OpenID4VP ***********/
+ // VPFormats lists the vp_formats supported by the client. See additional comments on vpFormatsSupported.
+ // https://openid.bitbucket.io/connect/openid-4-verifiable-presentations-1_0.html#name-verifier-metadata-client-me
+ VPFormats map[string]map[string][]string `json:"vp_formats,omitempty"`
+
+ // ClientIdScheme is a string identifying the Client Identifier scheme. The value range defined by this specification is
+ // pre-registered, redirect_uri, entity_id, did. If omitted, the default value is pre-registered.
+ // https://openid.bitbucket.io/connect/openid-4-verifiable-presentations-1_0.html#name-verifier-metadata-client-me
+ ClientIdScheme string `json:"client_id_scheme,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..5f77e208ec
--- /dev/null
+++ b/auth/services/oauth/holder.go
@@ -0,0 +1,147 @@
+/*
+ * 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/core"
+ "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)
+
+// ErrNoCredentials is returned when no matching credentials are found in the wallet based on a PresentationDefinition
+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, acceptedFormats map[string]map[string][]string, 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, fmt.Errorf("failed to retrieve wallet credentials: %w", err)
+ }
+
+ expires := time.Now().Add(time.Minute * 15) //todo
+ // build VP
+ submissionBuilder := presentationDefinition.PresentationSubmissionBuilder()
+ submissionBuilder.AddWallet(walletDID, credentials)
+ format := pe.ChooseVPFormat(acceptedFormats)
+ 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.OAuthClientMetadata, error) {
+ iamClient := iam.NewHTTPClient(v.strictMode, v.httpClientTimeout, v.httpClientTLS)
+
+ 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)
+
+ responseURL, err := core.ParsePublicURL(verifierResponseURI, v.strictMode)
+ if err != nil {
+ return "", fmt.Errorf("failed to post error to verifier: %w", err)
+ }
+ redirectURL, err := iamClient.PostError(ctx, auth2Error, *responseURL)
+ if err != nil {
+ return "", fmt.Errorf("failed to post error to verifier: %w", err)
+ }
+
+ return redirectURL, nil
+}
+
+func (v *HolderService) PostAuthorizationResponse(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI string, state string) (string, error) {
+ iamClient := iam.NewHTTPClient(v.strictMode, v.httpClientTimeout, v.httpClientTLS)
+
+ responseURL, err := core.ParsePublicURL(verifierResponseURI, v.strictMode)
+ if err != nil {
+ return "", fmt.Errorf("failed to post error to verifier: %w", err)
+ }
+ redirectURL, err := iamClient.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 (s *HolderService) PresentationDefinition(ctx context.Context, presentationDefinitionParam string) (*pe.PresentationDefinition, error) {
+ presentationDefinitionURL, err := core.ParsePublicURL(presentationDefinitionParam, s.strictMode)
+ if err != nil {
+ return nil, err
+ }
+
+ iamClient := iam.NewHTTPClient(s.strictMode, s.httpClientTimeout, s.httpClientTLS)
+ presentationDefinition, err := iamClient.PresentationDefinition(ctx, *presentationDefinitionURL)
+ if err != nil {
+ return nil, fmt.Errorf("failed to retrieve presentation definition: %w", err)
+ }
+ return presentationDefinition, nil
+}
diff --git a/auth/services/oauth/holder_test.go b/auth/services/oauth/holder_test.go
new file mode 100644
index 0000000000..0d7696046a
--- /dev/null
+++ b/auth/services/oauth/holder_test.go
@@ -0,0 +1,310 @@
+/*
+ * 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"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ ssi "github.com/nuts-foundation/go-did"
+ "github.com/nuts-foundation/go-did/did"
+ "github.com/nuts-foundation/go-did/vc"
+ "github.com/nuts-foundation/nuts-node/audit"
+ "github.com/nuts-foundation/nuts-node/auth/oauth"
+ http2 "github.com/nuts-foundation/nuts-node/test/http"
+ vcr "github.com/nuts-foundation/nuts-node/vcr/api/vcr/v2"
+ "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"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/mock/gomock"
+)
+
+func TestHolderService_ClientMetadata(t *testing.T) {
+ t.Run("ok", func(t *testing.T) {
+ ctx := createOAuthHolderContext(t)
+ endpoint := fmt.Sprintf("%s/.well-known/oauth-authorization-server", ctx.tlsServer.URL)
+
+ clientMetadata, err := ctx.holder.ClientMetadata(ctx.audit, endpoint)
+
+ require.NoError(t, err)
+ assert.NotNil(t, clientMetadata)
+ })
+ t.Run("error", func(t *testing.T) {
+ ctx := createOAuthHolderContext(t)
+ endpoint := fmt.Sprintf("%s/.well-known/oauth-authorization-server", ctx.tlsServer.URL)
+ ctx.metadata = nil
+
+ clientMetadata, err := ctx.holder.ClientMetadata(ctx.audit, endpoint)
+
+ assert.Error(t, err)
+ assert.Nil(t, clientMetadata)
+ })
+}
+
+func TestHolderService_PostError(t *testing.T) {
+ t.Run("ok", func(t *testing.T) {
+ ctx := createOAuthHolderContext(t)
+ endpoint := fmt.Sprintf("%s/error", ctx.tlsServer.URL)
+ oauthError := oauth.OAuth2Error{
+ Code: oauth.InvalidRequest,
+ Description: "missing required parameter",
+ }
+
+ redirect, err := ctx.holder.PostError(ctx.audit, oauthError, endpoint)
+
+ require.NoError(t, err)
+ assert.Equal(t, "redirect", redirect)
+ })
+ t.Run("error", func(t *testing.T) {
+ ctx := createOAuthHolderContext(t)
+ endpoint := fmt.Sprintf("%s/error", ctx.tlsServer.URL)
+ ctx.errorResponse = nil
+
+ redirect, err := ctx.holder.PostError(ctx.audit, oauth.OAuth2Error{}, endpoint)
+
+ assert.Error(t, err)
+ assert.Empty(t, redirect)
+ })
+}
+
+func TestHolderService_PostResponse(t *testing.T) {
+ t.Run("ok", func(t *testing.T) {
+ ctx := createOAuthHolderContext(t)
+ endpoint := fmt.Sprintf("%s/response", ctx.tlsServer.URL)
+ vp := vc.VerifiablePresentation{Type: []ssi.URI{ssi.MustParseURI("VerifiablePresentation")}}
+ // marshal and unmarshal to make sure Raw() works
+ bytes, _ := json.Marshal(vp)
+ _ = json.Unmarshal(bytes, &vp)
+
+ redirect, err := ctx.holder.PostAuthorizationResponse(
+ ctx.audit,
+ vp,
+ pe.PresentationSubmission{Id: "id"},
+ endpoint,
+ "state",
+ )
+
+ require.NoError(t, err)
+ assert.Equal(t, "redirect", redirect)
+ })
+ t.Run("error", func(t *testing.T) {
+ ctx := createOAuthHolderContext(t)
+ endpoint := fmt.Sprintf("%s/response", ctx.tlsServer.URL)
+ ctx.response = nil
+
+ redirect, err := ctx.holder.PostAuthorizationResponse(ctx.audit, vc.VerifiablePresentation{}, pe.PresentationSubmission{}, endpoint, "")
+
+ assert.Error(t, err)
+ assert.Empty(t, redirect)
+ })
+}
+
+func TestHolderService_BuildPresentation(t *testing.T) {
+ credentials := []vcr.VerifiableCredential{credential.ValidNutsOrganizationCredential(t)}
+ walletDID := did.MustParseDID("did:web:example.com:iam:wallet")
+ presentationDefinition := pe.PresentationDefinition{InputDescriptors: []*pe.InputDescriptor{{Constraints: &pe.Constraints{Fields: []pe.Field{{Path: []string{"$.type"}}}}}}}
+ vpFormats := oauth.DefaultOpenIDSupportedFormats()
+
+ t.Run("ok", func(t *testing.T) {
+ ctx := createHolderContext(t, nil)
+ ctx.wallet.EXPECT().List(gomock.Any(), walletDID).Return(credentials, nil)
+ ctx.wallet.EXPECT().BuildPresentation(gomock.Any(), credentials, gomock.Any(), &walletDID, false).Return(&vc.VerifiablePresentation{}, nil)
+
+ vp, submission, err := ctx.holder.BuildPresentation(context.Background(), walletDID, presentationDefinition, vpFormats, "")
+
+ assert.NoError(t, err)
+ require.NotNil(t, vp)
+ require.NotNil(t, submission)
+ })
+ // wallet failure, build failure, no credentials
+ t.Run("error - wallet failure", func(t *testing.T) {
+ ctx := createHolderContext(t, nil)
+ ctx.wallet.EXPECT().List(gomock.Any(), walletDID).Return(nil, assert.AnError)
+
+ vp, submission, err := ctx.holder.BuildPresentation(context.Background(), walletDID, presentationDefinition, vpFormats, "")
+
+ assert.Error(t, err)
+ assert.Nil(t, vp)
+ assert.Nil(t, submission)
+ })
+ t.Run("error - build failure", func(t *testing.T) {
+ ctx := createHolderContext(t, nil)
+ ctx.wallet.EXPECT().List(gomock.Any(), walletDID).Return(credentials, nil)
+ ctx.wallet.EXPECT().BuildPresentation(gomock.Any(), credentials, gomock.Any(), &walletDID, false).Return(nil, assert.AnError)
+
+ vp, submission, err := ctx.holder.BuildPresentation(context.Background(), walletDID, presentationDefinition, vpFormats, "")
+
+ assert.Error(t, err)
+ assert.Nil(t, vp)
+ assert.Nil(t, submission)
+ })
+ t.Run("error - no matching credentials", func(t *testing.T) {
+ ctx := createHolderContext(t, nil)
+ ctx.wallet.EXPECT().List(gomock.Any(), walletDID).Return(credentials, nil)
+
+ vp, submission, err := ctx.holder.BuildPresentation(context.Background(), walletDID, pe.PresentationDefinition{}, vpFormats, "")
+
+ assert.Equal(t, ErrNoCredentials, err)
+ assert.Nil(t, vp)
+ assert.Nil(t, submission)
+ })
+}
+
+func TestHolderService_PresentationDefinition(t *testing.T) {
+ t.Run("ok", func(t *testing.T) {
+ ctx := createOAuthHolderContext(t)
+ endpoint := fmt.Sprintf("%s/presentation_definition", ctx.tlsServer.URL)
+
+ pd, err := ctx.holder.PresentationDefinition(context.Background(), endpoint)
+
+ assert.NoError(t, err)
+ assert.NotNil(t, pd)
+ })
+ t.Run("error", func(t *testing.T) {
+ ctx := createOAuthHolderContext(t)
+ endpoint := fmt.Sprintf("%s/presentation_definition", ctx.tlsServer.URL)
+ ctx.presentationDefinition = nil
+
+ pd, err := ctx.holder.PresentationDefinition(context.Background(), endpoint)
+
+ assert.Error(t, err)
+ assert.Nil(t, pd)
+ })
+}
+
+type holderTestContext struct {
+ ctrl *gomock.Controller
+ audit context.Context
+ holder Holder
+ wallet *holder.MockWallet
+}
+
+func createHolderContext(t *testing.T, tlsConfig *tls.Config) *holderTestContext {
+ ctrl := gomock.NewController(t)
+
+ wallet := holder.NewMockWallet(ctrl)
+
+ if tlsConfig == nil {
+ tlsConfig = &tls.Config{}
+ }
+ tlsConfig.InsecureSkipVerify = true
+
+ return &holderTestContext{
+ audit: audit.TestContext(),
+ ctrl: ctrl,
+ holder: &HolderService{
+ httpClientTLS: tlsConfig,
+ wallet: wallet,
+ },
+ wallet: wallet,
+ }
+}
+
+type holderOAuthTestContext struct {
+ *holderTestContext
+ authzServerMetadata *oauth.AuthorizationServerMetadata
+ handler http.HandlerFunc
+ tlsServer *httptest.Server
+ verifierDID did.DID
+ metadata func(writer http.ResponseWriter)
+ errorResponse func(writer http.ResponseWriter)
+ response func(writer http.ResponseWriter)
+ presentationDefinition func(writer http.ResponseWriter)
+}
+
+func createOAuthHolderContext(t *testing.T) *holderOAuthTestContext {
+ clientMetadata := &oauth.AuthorizationServerMetadata{VPFormats: oauth.DefaultOpenIDSupportedFormats()}
+ ctx := &holderOAuthTestContext{
+ holderTestContext: createHolderContext(t, nil),
+ metadata: func(writer http.ResponseWriter) {
+ writer.Header().Add("Content-Type", "application/json")
+ writer.WriteHeader(http.StatusOK)
+ bytes, _ := json.Marshal(*clientMetadata)
+ _, _ = writer.Write(bytes)
+ return
+ },
+ errorResponse: func(writer http.ResponseWriter) {
+ writer.Header().Add("Content-Type", "application/json")
+ writer.WriteHeader(http.StatusOK)
+ bytes, _ := json.Marshal(oauth.Redirect{
+ RedirectURI: "redirect",
+ })
+ _, _ = writer.Write(bytes)
+ return
+ },
+ presentationDefinition: func(writer http.ResponseWriter) {
+ writer.Header().Add("Content-Type", "application/json")
+ writer.WriteHeader(http.StatusOK)
+ bytes, _ := json.Marshal(pe.PresentationDefinition{})
+ _, _ = writer.Write(bytes)
+ return
+ },
+ response: func(writer http.ResponseWriter) {
+ writer.Header().Add("Content-Type", "application/json")
+ writer.WriteHeader(http.StatusOK)
+ bytes, _ := json.Marshal(oauth.Redirect{
+ RedirectURI: "redirect",
+ })
+ _, _ = writer.Write(bytes)
+ return
+ },
+ }
+
+ ctx.handler = func(writer http.ResponseWriter, request *http.Request) {
+ switch request.URL.Path {
+ case "/.well-known/oauth-authorization-server":
+ if ctx.metadata != nil {
+ ctx.metadata(writer)
+ return
+ }
+ case "/error":
+ if ctx.errorResponse != nil {
+ assert.Equal(t, string(oauth.InvalidRequest), request.FormValue("error"))
+ ctx.errorResponse(writer)
+ return
+ }
+ case "/presentation_definition":
+ if ctx.presentationDefinition != nil {
+ ctx.presentationDefinition(writer)
+ return
+ }
+ case "/response":
+ if ctx.response != nil {
+ assert.NotEmpty(t, request.FormValue(oauth.VpTokenParam))
+ assert.NotEmpty(t, request.FormValue(oauth.PresentationSubmissionParam))
+ assert.NotEmpty(t, request.FormValue(oauth.StateParam))
+ ctx.errorResponse(writer)
+ return
+ }
+ }
+ writer.WriteHeader(http.StatusNotFound)
+ }
+ ctx.tlsServer = http2.TestTLSServer(t, ctx.handler)
+ ctx.verifierDID = didweb.ServerURLToDIDWeb(t, ctx.tlsServer.URL)
+
+ return ctx
+}
diff --git a/auth/services/oauth/interface.go b/auth/services/oauth/interface.go
index 28c237c7a6..e7de3fa5e0 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,7 @@ type RelyingParty interface {
CreateJwtGrant(ctx context.Context, request services.CreateJwtGrantRequest) (*services.JwtBearerTokenResult, error)
// CreateAuthorizationRequest creates an OAuth2.0 authorizationRequest redirect URL that redirects to the authorization server.
CreateAuthorizationRequest(ctx context.Context, requestHolder did.DID, verifier did.DID, scopes string, clientState string) (*url.URL, error)
+
// RequestRFC003AccessToken is called by the local EHR node to request an access token from a remote Nuts node using Nuts RFC003.
RequestRFC003AccessToken(ctx context.Context, jwtGrantToken string, authServerEndpoint url.URL) (*oauth.TokenResponse, error)
// RequestRFC021AccessToken is called by the local EHR node to request an access token from a remote Nuts node using Nuts RFC021.
@@ -55,3 +59,17 @@ 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, acceptedFormats map[string]map[string][]string, nonce string) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error)
+ // ClientMetadata returns the metadata of the remote verifier.
+ ClientMetadata(ctx context.Context, endpoint string) (*oauth.OAuthClientMetadata, error)
+ // PostError posts an error to the verifier. If it fails, an error is returned.
+ PostError(ctx context.Context, auth2Error oauth.OAuth2Error, verifierResponseURI string) (string, error)
+ // PostAuthorizationResponse posts the authorization response to the verifier. If it fails, an error is returned.
+ PostAuthorizationResponse(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI string, state string) (string, error)
+ // PresentationDefinition returns the presentation definition from the given endpoint.
+ PresentationDefinition(ctx context.Context, presentationDefinitionURI string) (*pe.PresentationDefinition, error)
+}
diff --git a/auth/services/oauth/mock.go b/auth/services/oauth/mock.go
index 9825de4f95..3f8ecce34a 100644
--- a/auth/services/oauth/mock.go
+++ b/auth/services/oauth/mock.go
@@ -15,8 +15,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"
)
@@ -222,3 +224,102 @@ 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, acceptedFormats map[string]map[string][]string, nonce string) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "BuildPresentation", ctx, walletDID, presentationDefinition, acceptedFormats, 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, acceptedFormats, 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, acceptedFormats, nonce)
+}
+
+// ClientMetadata mocks base method.
+func (m *MockHolder) ClientMetadata(ctx context.Context, endpoint string) (*oauth.OAuthClientMetadata, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "ClientMetadata", ctx, endpoint)
+ ret0, _ := ret[0].(*oauth.OAuthClientMetadata)
+ 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, state string) (string, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "PostAuthorizationResponse", ctx, vp, presentationSubmission, verifierResponseURI, state)
+ 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, state 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, state)
+}
+
+// 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)
+}
+
+// PresentationDefinition mocks base method.
+func (m *MockHolder) PresentationDefinition(ctx context.Context, presentationDefinitionURI string) (*pe.PresentationDefinition, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "PresentationDefinition", ctx, presentationDefinitionURI)
+ ret0, _ := ret[0].(*pe.PresentationDefinition)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// PresentationDefinition indicates an expected call of PresentationDefinition.
+func (mr *MockHolderMockRecorder) PresentationDefinition(ctx, presentationDefinitionURI any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PresentationDefinition", reflect.TypeOf((*MockHolder)(nil).PresentationDefinition), ctx, presentationDefinitionURI)
+}
diff --git a/auth/services/oauth/relying_party.go b/auth/services/oauth/relying_party.go
index 9da8482646..3505b8d24f 100644
--- a/auth/services/oauth/relying_party.go
+++ b/auth/services/oauth/relying_party.go
@@ -23,14 +23,13 @@ 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"
"github.com/nuts-foundation/nuts-node/auth/client/iam"
"github.com/nuts-foundation/nuts-node/auth/oauth"
@@ -41,6 +40,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"
)
@@ -165,7 +165,7 @@ func (s *relyingParty) RequestRFC021AccessToken(ctx context.Context, requester d
}
// get the presentation definition from the verifier
- presentationDefinition, err := iamClient.PresentationDefinition(ctx, metadata.PresentationDefinitionEndpoint, scopes)
+ presentationDefinition, err := s.presentationDefinition(ctx, metadata.PresentationDefinitionEndpoint, scopes)
if err != nil {
return nil, fmt.Errorf("failed to retrieve presentation definition: %w", err)
}
@@ -193,7 +193,7 @@ func (s *relyingParty) RequestRFC021AccessToken(ctx context.Context, requester d
if presentationDefinition.Format != nil {
formatCandidates = formatCandidates.Match(credential.DIFClaimFormats(*presentationDefinition.Format))
}
- format := chooseVPFormat(formatCandidates.Map)
+ format := pe.ChooseVPFormat(formatCandidates.Map)
if format == "" {
return nil, errors.New("requester, verifier (authorization server metadata) and presentation definition don't share a supported VP format")
}
@@ -243,18 +243,18 @@ func (s *relyingParty) authorizationServerMetadata(ctx context.Context, webdid d
return metadata, nil
}
-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
+func (s *relyingParty) presentationDefinition(ctx context.Context, presentationDefinitionURL string, scopes string) (*pe.PresentationDefinition, error) {
+ parsedURL, err := url.Parse(presentationDefinitionURL)
+ if err != nil {
+ return nil, err
}
- if _, ok := formats[vc.JSONLDPresentationProofFormat]; ok {
- return vc.JSONLDPresentationProofFormat
+ parsedURL.RawQuery = url.Values{"scope": []string{scopes}}.Encode()
+ iamClient := iam.NewHTTPClient(s.strictMode, s.httpClientTimeout, s.httpClientTLS)
+ presentationDefinition, err := iamClient.PresentationDefinition(ctx, *parsedURL)
+ if err != nil {
+ return nil, err
}
- return ""
+ return presentationDefinition, nil
}
var timeFunc = time.Now
diff --git a/auth/services/oauth/relying_party_test.go b/auth/services/oauth/relying_party_test.go
index 385b9c66fe..5ed0d484c6 100644
--- a/auth/services/oauth/relying_party_test.go
+++ b/auth/services/oauth/relying_party_test.go
@@ -271,37 +271,6 @@ func TestRelyingParty_AuthorizationRequest(t *testing.T) {
assert.ErrorContains(t, err, "no authorization endpoint found in metadata for")
})
}
-func Test_chooseVPFormat(t *testing.T) {
- t.Run("ok", func(t *testing.T) {
- formats := map[string]map[string][]string{
- "jwt_vp": {
- "alg": {"ES256K"},
- },
- }
-
- format := chooseVPFormat(formats)
-
- assert.Equal(t, "jwt_vp", format)
- })
- t.Run("no supported format", func(t *testing.T) {
- formats := map[string]map[string][]string{}
-
- format := chooseVPFormat(formats)
-
- assert.Empty(t, format)
- })
- t.Run(" jwt_vp_json returns jwt_vp", func(t *testing.T) {
- formats := map[string]map[string][]string{
- "jwt_vp_json": {
- "alg": {"ES256K"},
- },
- }
-
- format := chooseVPFormat(formats)
-
- assert.Equal(t, "jwt_vp", format)
- })
-}
func TestService_CreateJwtBearerToken(t *testing.T) {
usi := vc.VerifiablePresentation{}
@@ -540,6 +509,11 @@ func createOAuthRPContext(t *testing.T) *rpOAuthTestContext {
}
case "/presentation_definition":
if ctx.presentationDefinition != nil {
+ scopes := request.URL.Query().Get("scope")
+ if scopes == "" {
+ writer.WriteHeader(http.StatusBadRequest)
+ return
+ }
ctx.presentationDefinition(writer)
return
}
diff --git a/auth/services/oauth/verifier.go b/auth/services/oauth/verifier.go
index cf9d029fdb..2bfca568b7 100644
--- a/auth/services/oauth/verifier.go
+++ b/auth/services/oauth/verifier.go
@@ -66,6 +66,5 @@ func (v *VerifierServiceProvider) ClientMetadataURL(webdid did.DID) (*url.URL, e
}
// we use the authorization server endpoint as the client metadata endpoint, contents are the same
// coming from a did:web, it's impossible to get a false URL
- metadataURL, _ := oauth.IssuerIdToWellKnown(didURL.String(), oauth.AuthzServerWellKnown, v.strictMode)
- return metadataURL, nil
+ return didURL.JoinPath(oauth.ClientMetadataPath), nil
}
diff --git a/auth/services/oauth/verifier_test.go b/auth/services/oauth/verifier_test.go
index b05d7e17e0..16dc4195ab 100644
--- a/auth/services/oauth/verifier_test.go
+++ b/auth/services/oauth/verifier_test.go
@@ -66,7 +66,7 @@ func TestVerifierServiceProvider_ClientMetadataURL(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, url)
- assert.Equal(t, "https://example.com/.well-known/oauth-authorization-server/iam/holder", url.String())
+ assert.Equal(t, "https://example.com/iam/holder/oauth-client", url.String())
})
t.Run("error - invalid DID", func(t *testing.T) {
_, err := verifier.ClientMetadataURL(did.DID{})
diff --git a/docs/_static/auth/iam.yaml b/docs/_static/auth/iam.yaml
index 7bbdd3b049..bed2533975 100644
--- a/docs/_static/auth/iam.yaml
+++ b/docs/_static/auth/iam.yaml
@@ -303,7 +303,7 @@ paths:
example: did:web:example.com
scope:
type: string
- description: The scope that will be The service for which this access token can be used.
+ description: The scope that will be the service for which this access token can be used.
example: eOverdracht-sender
userID:
type: string
diff --git a/policy/mock.go b/policy/mock.go
index cc32c7811f..26c7e4987e 100644
--- a/policy/mock.go
+++ b/policy/mock.go
@@ -5,6 +5,7 @@
//
// mockgen -destination=policy/mock.go -package=policy -source=policy/interface.go
//
+
// Package policy is a generated GoMock package.
package policy
@@ -18,31 +19,31 @@ import (
gomock "go.uber.org/mock/gomock"
)
-// MockBackend is a mock of PDPBackend interface.
-type MockBackend struct {
+// MockPDPBackend is a mock of PDPBackend interface.
+type MockPDPBackend struct {
ctrl *gomock.Controller
- recorder *MockBackendMockRecorder
+ recorder *MockPDPBackendMockRecorder
}
-// MockBackendMockRecorder is the mock recorder for MockBackend.
-type MockBackendMockRecorder struct {
- mock *MockBackend
+// MockPDPBackendMockRecorder is the mock recorder for MockPDPBackend.
+type MockPDPBackendMockRecorder struct {
+ mock *MockPDPBackend
}
-// NewMockBackend creates a new mock instance.
-func NewMockBackend(ctrl *gomock.Controller) *MockBackend {
- mock := &MockBackend{ctrl: ctrl}
- mock.recorder = &MockBackendMockRecorder{mock}
+// NewMockPDPBackend creates a new mock instance.
+func NewMockPDPBackend(ctrl *gomock.Controller) *MockPDPBackend {
+ mock := &MockPDPBackend{ctrl: ctrl}
+ mock.recorder = &MockPDPBackendMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
-func (m *MockBackend) EXPECT() *MockBackendMockRecorder {
+func (m *MockPDPBackend) EXPECT() *MockPDPBackendMockRecorder {
return m.recorder
}
// Authorized mocks base method.
-func (m *MockBackend) Authorized(ctx context.Context, requestInfo client.AuthorizedRequest) (bool, error) {
+func (m *MockPDPBackend) Authorized(ctx context.Context, requestInfo client.AuthorizedRequest) (bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Authorized", ctx, requestInfo)
ret0, _ := ret[0].(bool)
@@ -51,13 +52,13 @@ func (m *MockBackend) Authorized(ctx context.Context, requestInfo client.Authori
}
// Authorized indicates an expected call of Authorized.
-func (mr *MockBackendMockRecorder) Authorized(ctx, requestInfo any) *gomock.Call {
+func (mr *MockPDPBackendMockRecorder) Authorized(ctx, requestInfo any) *gomock.Call {
mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Authorized", reflect.TypeOf((*MockBackend)(nil).Authorized), ctx, requestInfo)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Authorized", reflect.TypeOf((*MockPDPBackend)(nil).Authorized), ctx, requestInfo)
}
// PresentationDefinition mocks base method.
-func (m *MockBackend) PresentationDefinition(ctx context.Context, authorizer did.DID, scope string) (*pe.PresentationDefinition, error) {
+func (m *MockPDPBackend) PresentationDefinition(ctx context.Context, authorizer did.DID, scope string) (*pe.PresentationDefinition, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "PresentationDefinition", ctx, authorizer, scope)
ret0, _ := ret[0].(*pe.PresentationDefinition)
@@ -66,7 +67,7 @@ func (m *MockBackend) PresentationDefinition(ctx context.Context, authorizer did
}
// PresentationDefinition indicates an expected call of PresentationDefinition.
-func (mr *MockBackendMockRecorder) PresentationDefinition(ctx, authorizer, scope any) *gomock.Call {
+func (mr *MockPDPBackendMockRecorder) PresentationDefinition(ctx, authorizer, scope any) *gomock.Call {
mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PresentationDefinition", reflect.TypeOf((*MockBackend)(nil).PresentationDefinition), ctx, authorizer, scope)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PresentationDefinition", reflect.TypeOf((*MockPDPBackend)(nil).PresentationDefinition), ctx, authorizer, scope)
}
diff --git a/policy/policy_test.go b/policy/policy_test.go
index 1cfb00be5e..54bedc44dc 100644
--- a/policy/policy_test.go
+++ b/policy/policy_test.go
@@ -104,11 +104,11 @@ func TestRouterForwarding(t *testing.T) {
testDID := did.MustParseDID("did:web:example.com:test")
presentationDefinition := pe.PresentationDefinition{}
router := Router{
- backend: NewMockBackend(ctrl),
+ backend: NewMockPDPBackend(ctrl),
}
t.Run("Authorized", func(t *testing.T) {
- router.backend.(*MockBackend).EXPECT().Authorized(ctx, gomock.Any()).Return(true, nil)
+ router.backend.(*MockPDPBackend).EXPECT().Authorized(ctx, gomock.Any()).Return(true, nil)
result, err := router.Authorized(ctx, client.AuthorizedRequest{})
@@ -117,7 +117,7 @@ func TestRouterForwarding(t *testing.T) {
})
t.Run("PresentationDefinition", func(t *testing.T) {
- router.backend.(*MockBackend).EXPECT().PresentationDefinition(ctx, testDID, "test").Return(&presentationDefinition, nil)
+ router.backend.(*MockPDPBackend).EXPECT().PresentationDefinition(ctx, testDID, "test").Return(&presentationDefinition, nil)
result, err := router.PresentationDefinition(ctx, testDID, "test")
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 ""
+}