diff --git a/auth/api/auth/v1/api.go b/auth/api/auth/v1/api.go
index f3942cbc6d..a608669f1b 100644
--- a/auth/api/auth/v1/api.go
+++ b/auth/api/auth/v1/api.go
@@ -311,15 +311,13 @@ func (w Wrapper) RequestAccessToken(ctx context.Context, request RequestAccessTo
func (w Wrapper) CreateAccessToken(ctx context.Context, request CreateAccessTokenRequestObject) (CreateAccessTokenResponseObject, error) {
if request.Body.GrantType != client.JwtBearerGrantType {
- errDesc := fmt.Sprintf("grant_type must be: '%s'", client.JwtBearerGrantType)
- errorResponse := oauth.ErrorResponse{Error: errOauthUnsupportedGrant, Description: &errDesc}
+ errorResponse := oauth.OAuth2Error{Code: errOauthUnsupportedGrant, Description: fmt.Sprintf("grant_type must be: '%s'", client.JwtBearerGrantType)}
return CreateAccessToken400JSONResponse(errorResponse), nil
}
const jwtPattern = `^[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*$`
if matched, err := regexp.Match(jwtPattern, []byte(request.Body.Assertion)); !matched || err != nil {
- errDesc := "Assertion must be a valid encoded jwt"
- errorResponse := AccessTokenRequestFailedResponse{Error: errOauthInvalidGrant, Description: &errDesc}
+ errorResponse := AccessTokenRequestFailedResponse{Code: errOauthInvalidGrant, Description: "Assertion must be a valid encoded jwt"}
return CreateAccessToken400JSONResponse(errorResponse), nil
}
diff --git a/auth/api/auth/v1/api_test.go b/auth/api/auth/v1/api_test.go
index 2ea6a3f2ea..7d4491fc13 100644
--- a/auth/api/auth/v1/api_test.go
+++ b/auth/api/auth/v1/api_test.go
@@ -55,6 +55,7 @@ type TestContext struct {
contractClientMock *services.MockContractNotary
authzServerMock *oauth.MockAuthorizationServer
relyingPartyMock *oauth.MockRelyingParty
+ verifierMock *oauth.MockVerifier
wrapper Wrapper
cedentialResolverMock *vcr.MockResolver
audit context.Context
@@ -65,6 +66,7 @@ type mockAuthClient struct {
contractNotary *services.MockContractNotary
authzServer *oauth.MockAuthorizationServer
relyingParty *oauth.MockRelyingParty
+ verifier *oauth.MockVerifier
}
func (m *mockAuthClient) V2APIEnabled() bool {
@@ -79,6 +81,10 @@ func (m *mockAuthClient) RelyingParty() oauth.RelyingParty {
return m.relyingParty
}
+func (m *mockAuthClient) Verifier() oauth.Verifier {
+ return m.verifier
+}
+
func (m *mockAuthClient) ContractNotary() services.ContractNotary {
return m.contractNotary
}
@@ -97,6 +103,7 @@ func createContext(t *testing.T) *TestContext {
contractNotary := services.NewMockContractNotary(ctrl)
authzServer := oauth.NewMockAuthorizationServer(ctrl)
relyingParty := oauth.NewMockRelyingParty(ctrl)
+ verifier := oauth.NewMockVerifier(ctrl)
mockCredentialResolver := vcr.NewMockResolver(ctrl)
authMock := &mockAuthClient{
@@ -104,6 +111,7 @@ func createContext(t *testing.T) *TestContext {
contractNotary: contractNotary,
authzServer: authzServer,
relyingParty: relyingParty,
+ verifier: verifier,
}
requestCtx := audit.TestContext()
@@ -115,6 +123,7 @@ func createContext(t *testing.T) *TestContext {
contractClientMock: contractNotary,
authzServerMock: authzServer,
relyingPartyMock: relyingParty,
+ verifierMock: verifier,
cedentialResolverMock: mockCredentialResolver,
wrapper: Wrapper{Auth: authMock, CredentialResolver: mockCredentialResolver},
audit: requestCtx,
@@ -554,7 +563,7 @@ func TestWrapper_CreateAccessToken(t *testing.T) {
params := CreateAccessTokenRequest{GrantType: "unknown type"}
errorDescription := "grant_type must be: 'urn:ietf:params:oauth:grant-type:jwt-bearer'"
- expectedResponse := CreateAccessToken400JSONResponse{Description: &errorDescription, Error: errOauthUnsupportedGrant}
+ expectedResponse := CreateAccessToken400JSONResponse{Description: errorDescription, Code: errOauthUnsupportedGrant}
response, err := ctx.wrapper.CreateAccessToken(ctx.audit, CreateAccessTokenRequestObject{Body: ¶ms})
@@ -568,7 +577,7 @@ func TestWrapper_CreateAccessToken(t *testing.T) {
params := CreateAccessTokenRequest{GrantType: "urn:ietf:params:oauth:grant-type:jwt-bearer", Assertion: "invalid jwt"}
errorDescription := "Assertion must be a valid encoded jwt"
- expectedResponse := CreateAccessToken400JSONResponse{Description: &errorDescription, Error: errOauthInvalidGrant}
+ expectedResponse := CreateAccessToken400JSONResponse{Description: errorDescription, Code: errOauthInvalidGrant}
response, err := ctx.wrapper.CreateAccessToken(ctx.audit, CreateAccessTokenRequestObject{Body: ¶ms})
@@ -582,11 +591,11 @@ func TestWrapper_CreateAccessToken(t *testing.T) {
params := CreateAccessTokenRequest{GrantType: "urn:ietf:params:oauth:grant-type:jwt-bearer", Assertion: validJwt}
errorDescription := "oh boy"
- expectedResponse := CreateAccessToken400JSONResponse{Description: &errorDescription, Error: errOauthInvalidRequest}
+ expectedResponse := CreateAccessToken400JSONResponse{Description: errorDescription, Code: errOauthInvalidRequest}
- ctx.authzServerMock.EXPECT().CreateAccessToken(ctx.audit, services.CreateAccessTokenRequest{RawJwtBearerToken: validJwt}).Return(nil, &oauth2.ErrorResponse{
- Description: &errorDescription,
- Error: errOauthInvalidRequest,
+ ctx.authzServerMock.EXPECT().CreateAccessToken(ctx.audit, services.CreateAccessTokenRequest{RawJwtBearerToken: validJwt}).Return(nil, &oauth2.OAuth2Error{
+ Description: errorDescription,
+ Code: errOauthInvalidRequest,
})
response, err := ctx.wrapper.CreateAccessToken(ctx.audit, CreateAccessTokenRequestObject{Body: ¶ms})
diff --git a/auth/api/auth/v1/client/types.go b/auth/api/auth/v1/client/types.go
index fdccb9ca60..3fc498f28f 100644
--- a/auth/api/auth/v1/client/types.go
+++ b/auth/api/auth/v1/client/types.go
@@ -36,4 +36,4 @@ type VerifiablePresentation = vc.VerifiablePresentation
type AccessTokenResponse = oauth.TokenResponse
// AccessTokenRequestFailedResponse is an alias to use from within the API
-type AccessTokenRequestFailedResponse = oauth.ErrorResponse
+type AccessTokenRequestFailedResponse = oauth.OAuth2Error
diff --git a/auth/api/auth/v1/types.go b/auth/api/auth/v1/types.go
index 47da04bf1d..55d2024e2b 100644
--- a/auth/api/auth/v1/types.go
+++ b/auth/api/auth/v1/types.go
@@ -32,4 +32,4 @@ type VerifiablePresentation = vc.VerifiablePresentation
// AccessTokenResponse is an alias to use from within the API
type AccessTokenResponse = oauth.TokenResponse
-type AccessTokenRequestFailedResponse = oauth.ErrorResponse
+type AccessTokenRequestFailedResponse = oauth.OAuth2Error
diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go
index 1d95b639b4..9cfe7455c0 100644
--- a/auth/api/iam/api.go
+++ b/auth/api/iam/api.go
@@ -81,7 +81,7 @@ func New(authInstance auth.AuthenticationServices, vcrInstance vcr.VCR, vdrInsta
}
}
-func (r *Wrapper) Routes(router core.EchoRouter) {
+func (r Wrapper) Routes(router core.EchoRouter) {
RegisterHandlers(router, NewStrictHandler(r, []StrictMiddlewareFunc{
func(f StrictHandlerFunc, operationID string) StrictHandlerFunc {
return func(ctx echo.Context, request interface{}) (response interface{}, err error) {
@@ -166,7 +166,7 @@ func (r Wrapper) HandleTokenRequest(ctx context.Context, request HandleTokenRequ
}
// IntrospectAccessToken allows the resource server (XIS/EHR) to introspect details of an access token issued by this node
-func (r Wrapper) IntrospectAccessToken(ctx context.Context, request IntrospectAccessTokenRequestObject) (IntrospectAccessTokenResponseObject, error) {
+func (r Wrapper) IntrospectAccessToken(_ context.Context, request IntrospectAccessTokenRequestObject) (IntrospectAccessTokenResponseObject, error) {
// Validate token
if request.Body.Token == "" {
// Return 200 + 'Active = false' when token is invalid or malformed
@@ -249,7 +249,6 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho
if err != nil {
return nil, err
}
- // Create session object to be passed to handler
// Workaround: deepmap codegen doesn't support dynamic query parameters.
// See https://github.com/deepmap/oapi-codegen/issues/1129
@@ -268,15 +267,31 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho
Description: "redirect_uri is required",
}
}
+ // todo: store session in database?
switch session.ResponseType {
case responseTypeCode:
// Options:
// - Regular authorization code flow for EHR data access through access token, authentication of end-user using OpenID4VP.
// - OpenID4VCI; authorization code flow for credential issuance to (end-user) wallet
- // - OpenID4VP, vp_token is sent in Token Response; authorization code flow for presentation exchange (not required a.t.m.)
- // TODO: Switch on parameters to right flow
- panic("not implemented")
+
+ // TODO: officially flow switching has to be determined by the client_id
+ // registered client_ids should list which flow they support
+ // client registration could be done via rfc7591....
+ // for now we switch on client_id format.
+ // when client_id is a did:web, it is a cloud/server wallet
+ // otherwise it's a normal registered client which we do not support yet
+ // Note: this is the user facing OpenID4VP flow with a "vp_token" responseType, the demo uses the "vp_token id_token" responseType
+ clientId := session.ClientID
+ if strings.HasPrefix(clientId, "did:web:") {
+ // client is a cloud wallet with user
+ return r.handleAuthorizeRequestFromHolder(ctx, *ownDID, params)
+ } else {
+ return nil, oauth.OAuth2Error{
+ Code: oauth.InvalidRequest,
+ Description: "client_id must be a did:web",
+ }
+ }
case responseTypeVPToken:
// Options:
// - OpenID4VP flow, vp_token is sent in Authorization Response
@@ -289,28 +304,20 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho
return r.handlePresentationRequest(params, session)
default:
// TODO: This should be a redirect?
+ redirectURI, _ := url.Parse(session.RedirectURI)
return nil, oauth.OAuth2Error{
Code: oauth.UnsupportedResponseType,
- RedirectURI: session.RedirectURI,
+ RedirectURI: redirectURI,
}
}
}
// OAuthAuthorizationServerMetadata returns the Authorization Server's metadata
func (r Wrapper) OAuthAuthorizationServerMetadata(ctx context.Context, request OAuthAuthorizationServerMetadataRequestObject) (OAuthAuthorizationServerMetadataResponseObject, error) {
- ownDID := r.idToDID(request.Id)
- owned, err := r.vdr.IsOwner(ctx, ownDID)
+ _, err := r.idToOwnedDID(ctx, request.Id)
if err != nil {
- if resolver.IsFunctionalResolveError(err) {
- return nil, core.NotFoundError("authz server metadata: %w", err)
- }
- log.Logger().WithField("did", ownDID.String()).Errorf("authz server metadata: failed to assert ownership of did: %s", err.Error())
- return nil, core.Error(500, "authz server metadata: %w", err)
- }
- if !owned {
- return nil, core.NotFoundError("authz server metadata: did not owned")
+ return nil, err
}
-
identity := r.auth.PublicURL().JoinPath("iam", request.Id)
return OAuthAuthorizationServerMetadata200JSONResponse(authorizationServerMetadata(*identity)), nil
@@ -432,6 +439,6 @@ func (r Wrapper) identityURL(id string) *url.URL {
return r.auth.PublicURL().JoinPath("iam", id)
}
-func (r *Wrapper) accessTokenStore() storage.SessionStore {
+func (r Wrapper) accessTokenStore() storage.SessionStore {
return r.storageEngine.GetSessionDatabase().GetStore(accessTokenValidity, "accesstoken")
}
diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go
index f178731e91..4dfa7585cb 100644
--- a/auth/api/iam/api_test.go
+++ b/auth/api/iam/api_test.go
@@ -22,6 +22,7 @@ import (
"context"
"encoding/json"
"errors"
+ "github.com/nuts-foundation/nuts-node/test"
"net/http"
"net/http/httptest"
"net/url"
@@ -50,6 +51,7 @@ import (
var webDID = did.MustParseDID("did:web:example.com:iam:123")
var webIDPart = "123"
+var verifierDID = did.MustParseDID("did:web:example.com:iam:verifier")
func TestWrapper_OAuthAuthorizationServerMetadata(t *testing.T) {
t.Run("ok", func(t *testing.T) {
@@ -70,8 +72,8 @@ func TestWrapper_OAuthAuthorizationServerMetadata(t *testing.T) {
res, err := ctx.client.OAuthAuthorizationServerMetadata(nil, OAuthAuthorizationServerMetadataRequestObject{Id: webIDPart})
- assert.Equal(t, 404, statusCodeFrom(err))
- assert.EqualError(t, err, "authz server metadata: did not owned")
+ assert.Equal(t, 400, statusCodeFrom(err))
+ assert.EqualError(t, err, "invalid_request - issuer DID not owned by the server")
assert.Nil(t, res)
})
t.Run("error - did does not exist", func(t *testing.T) {
@@ -81,8 +83,8 @@ func TestWrapper_OAuthAuthorizationServerMetadata(t *testing.T) {
res, err := ctx.client.OAuthAuthorizationServerMetadata(nil, OAuthAuthorizationServerMetadataRequestObject{Id: webIDPart})
- assert.Equal(t, 404, statusCodeFrom(err))
- assert.EqualError(t, err, "authz server metadata: unable to find the DID document")
+ assert.Equal(t, 400, statusCodeFrom(err))
+ assert.EqualError(t, err, "invalid_request - invalid issuer DID: unable to find the DID document")
assert.Nil(t, res)
})
t.Run("error - internal error 500", func(t *testing.T) {
@@ -93,14 +95,12 @@ func TestWrapper_OAuthAuthorizationServerMetadata(t *testing.T) {
res, err := ctx.client.OAuthAuthorizationServerMetadata(nil, OAuthAuthorizationServerMetadataRequestObject{Id: webIDPart})
assert.Equal(t, 500, statusCodeFrom(err))
- assert.EqualError(t, err, "authz server metadata: unknown error")
+ assert.EqualError(t, err, "DID resolution failed: unknown error")
assert.Nil(t, res)
})
}
func TestWrapper_GetWebDID(t *testing.T) {
- webDID := did.MustParseDID("did:web:example.com:iam:123")
- id := "123"
ctx := audit.TestContext()
expectedWebDIDDoc := did.Document{
ID: webDID,
@@ -113,7 +113,7 @@ func TestWrapper_GetWebDID(t *testing.T) {
test := newTestClient(t)
test.vdr.EXPECT().ResolveManaged(webDID).Return(&expectedWebDIDDoc, nil)
- response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{id})
+ response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{webIDPart})
assert.NoError(t, err)
assert.Equal(t, expectedWebDIDDoc, did.Document(response.(GetWebDID200JSONResponse)))
@@ -122,7 +122,7 @@ func TestWrapper_GetWebDID(t *testing.T) {
test := newTestClient(t)
test.vdr.EXPECT().ResolveManaged(webDID).Return(nil, resolver.ErrNotFound)
- response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{id})
+ response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{webIDPart})
assert.NoError(t, err)
assert.IsType(t, GetWebDID404Response{}, response)
@@ -131,7 +131,7 @@ func TestWrapper_GetWebDID(t *testing.T) {
test := newTestClient(t)
test.vdr.EXPECT().ResolveManaged(webDID).Return(nil, errors.New("failed"))
- response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{id})
+ response, err := test.client.GetWebDID(ctx, GetWebDIDRequestObject{webIDPart})
assert.EqualError(t, err, "unable to resolve DID")
assert.Nil(t, response)
@@ -148,6 +148,25 @@ func TestWrapper_GetOAuthClientMetadata(t *testing.T) {
require.NoError(t, err)
assert.IsType(t, OAuthClientMetadata200JSONResponse{}, res)
})
+ t.Run("error - did not managed by this node", func(t *testing.T) {
+ ctx := newTestClient(t)
+ ctx.vdr.EXPECT().IsOwner(nil, webDID)
+
+ res, err := ctx.client.OAuthClientMetadata(nil, OAuthClientMetadataRequestObject{Id: webIDPart})
+
+ assert.Equal(t, 400, statusCodeFrom(err))
+ assert.Nil(t, res)
+ })
+ t.Run("error - internal error 500", func(t *testing.T) {
+ ctx := newTestClient(t)
+ ctx.vdr.EXPECT().IsOwner(nil, webDID).Return(false, errors.New("unknown error"))
+
+ res, err := ctx.client.OAuthClientMetadata(nil, OAuthClientMetadataRequestObject{Id: webIDPart})
+
+ assert.Equal(t, 500, statusCodeFrom(err))
+ assert.EqualError(t, err, "DID resolution failed: unknown error")
+ assert.Nil(t, res)
+ })
}
func TestWrapper_PresentationDefinition(t *testing.T) {
webDID := did.MustParseDID("did:web:example.com:iam:123")
@@ -189,6 +208,38 @@ func TestWrapper_PresentationDefinition(t *testing.T) {
}
func TestWrapper_HandleAuthorizeRequest(t *testing.T) {
+ metadata := oauth.AuthorizationServerMetadata{
+ AuthorizationEndpoint: "https://example.com/holder/authorize",
+ }
+ t.Run("ok - 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().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",
+ scopeParam: "test",
+ }), HandleAuthorizeRequestRequestObject{
+ Id: "verifier",
+ })
+
+ require.NoError(t, err)
+ assert.IsType(t, HandleAuthorizeRequest302Response{}, res)
+ location := res.(HandleAuthorizeRequest302Response).Headers.Location
+ assert.Contains(t, location, "https://example.com/holder/authorize")
+ assert.Contains(t, location, "client_id=did%3Aweb%3Aexample.com%3Aiam%3Averifier")
+ assert.Contains(t, location, "client_id_scheme=did")
+ assert.Contains(t, location, "client_metadata_uri=https%3A%2F%2Fexample.com%2F.well-known%2Fauthorization-server%2Fiam%2Fverifier")
+ assert.Contains(t, location, "nonce=")
+ assert.Contains(t, location, "presentation_definition_uri=https%3A%2F%2Fexample.com%2Fiam%2Fverifier%2Fpresentation_definition%3Fscope%3Dtest")
+ assert.Contains(t, location, "response_uri=https%3A%2F%2Fexample.com%2Fiam%2Fverifier%2Fresponse")
+ assert.Contains(t, location, "response_mode=direct_post")
+ assert.Contains(t, location, "response_type=vp_token")
+
+ })
t.Run("missing redirect_uri", func(t *testing.T) {
ctx := newTestClient(t)
ctx.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(true, nil)
@@ -346,7 +397,7 @@ func statusCodeFrom(err error) int {
if errors.As(err, &SE) {
return SE.StatusCode()
}
- return 0
+ return 500
}
type testCtx struct {
@@ -356,8 +407,9 @@ type testCtx struct {
vdr *vdr.MockVDR
resolver *resolver.MockDIDResolver
relyingParty *oauthServices.MockRelyingParty
- verifier *verifier.MockVerifier
+ vcVerifier *verifier.MockVerifier
vcr *vcr.MockVCR
+ verifierRole *oauthServices.MockVerifier
}
func newTestClient(t testing.TB) *testCtx {
@@ -365,34 +417,34 @@ func newTestClient(t testing.TB) *testCtx {
require.NoError(t, err)
ctrl := gomock.NewController(t)
storageEngine := storage.NewTestStorageEngine(t)
- mockVerifier := verifier.NewMockVerifier(ctrl)
- mockVCR := vcr.NewMockVCR(ctrl)
- mockVCR.EXPECT().Verifier().Return(mockVerifier).AnyTimes()
authnServices := auth.NewMockAuthenticationServices(ctrl)
authnServices.EXPECT().PublicURL().Return(publicURL).AnyTimes()
authnServices.EXPECT().PresentationDefinitions().Return(pe.TestDefinitionResolver(t)).AnyTimes()
- resolver := resolver.NewMockDIDResolver(ctrl)
+ mockResolver := resolver.NewMockDIDResolver(ctrl)
relyingPary := oauthServices.NewMockRelyingParty(ctrl)
- verifier := verifier.NewMockVerifier(ctrl)
- vdr := vdr.NewMockVDR(ctrl)
- vcr := vcr.NewMockVCR(ctrl)
+ vcVerifier := verifier.NewMockVerifier(ctrl)
+ verifierRole := oauthServices.NewMockVerifier(ctrl)
+ mockVDR := vdr.NewMockVDR(ctrl)
+ mockVCR := vcr.NewMockVCR(ctrl)
authnServices.EXPECT().PublicURL().Return(publicURL).AnyTimes()
authnServices.EXPECT().RelyingParty().Return(relyingPary).AnyTimes()
- vcr.EXPECT().Verifier().Return(verifier).AnyTimes()
- vdr.EXPECT().Resolver().Return(resolver).AnyTimes()
+ mockVCR.EXPECT().Verifier().Return(vcVerifier).AnyTimes()
+ authnServices.EXPECT().Verifier().Return(verifierRole).AnyTimes()
+ mockVDR.EXPECT().Resolver().Return(mockResolver).AnyTimes()
return &testCtx{
ctrl: ctrl,
authnServices: authnServices,
relyingParty: relyingPary,
- resolver: resolver,
- vdr: vdr,
- verifier: mockVerifier,
+ vcVerifier: vcVerifier,
+ resolver: mockResolver,
+ vdr: mockVDR,
+ verifierRole: verifierRole,
vcr: mockVCR,
client: &Wrapper{
auth: authnServices,
- vdr: vdr,
+ vdr: mockVDR,
vcr: mockVCR,
storageEngine: storageEngine,
},
diff --git a/auth/api/iam/metadata.go b/auth/api/iam/metadata.go
index 979d3b24b2..12b3d84d7d 100644
--- a/auth/api/iam/metadata.go
+++ b/auth/api/iam/metadata.go
@@ -25,15 +25,6 @@ import (
"strings"
)
-const (
- // authzServerWellKnown is the well-known base path for the oauth authorization server metadata as defined in RFC8414
- authzServerWellKnown = "/.well-known/oauth-authorization-server"
- // 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
- openidCredWalletWellKnown = "/.well-known/openid-credential-wallet"
-)
-
// 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) {
diff --git a/auth/api/iam/metadata_test.go b/auth/api/iam/metadata_test.go
index 1195604e1d..b4d4f35e65 100644
--- a/auth/api/iam/metadata_test.go
+++ b/auth/api/iam/metadata_test.go
@@ -55,7 +55,7 @@ func TestIssuerIdToWellKnown(t *testing.T) {
t.Run("no IP allowed", func(t *testing.T) {
issuer := "https://127.0.0.1/iam/id"
- u, err := IssuerIdToWellKnown(issuer, authzServerWellKnown, true)
+ u, err := IssuerIdToWellKnown(issuer, oauth.AuthzServerWellKnown, true)
assert.ErrorContains(t, err, "hostname is IP")
assert.Nil(t, u)
diff --git a/auth/api/iam/openid4vp.go b/auth/api/iam/openid4vp.go
index 6480476800..2c92bb7bc6 100644
--- a/auth/api/iam/openid4vp.go
+++ b/auth/api/iam/openid4vp.go
@@ -27,7 +27,6 @@ import (
"net/http"
"net/url"
"strings"
- "time"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
@@ -35,16 +34,133 @@ import (
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/auth/oauth"
+ "github.com/nuts-foundation/nuts-node/crypto"
httpNuts "github.com/nuts-foundation/nuts-node/http"
+ "github.com/nuts-foundation/nuts-node/storage"
"github.com/nuts-foundation/nuts-node/vcr/credential"
"github.com/nuts-foundation/nuts-node/vcr/holder"
+ "github.com/nuts-foundation/nuts-node/vdr/didweb"
)
-const sessionExpiry = 5 * time.Minute
+var oauthNonceKey = []string{"oauth", "nonce"}
+
+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
+ // redirection URI, or if the client identifier is missing or invalid,
+ // the authorization server SHOULD inform the resource owner of the
+ // error and MUST NOT automatically redirect the user-agent to the
+ // invalid redirection URI.
+ redirectURI, ok := params[redirectURIParam]
+ if !ok {
+ // todo render error page instead of technical error
+ return nil, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "missing redirect_uri parameter"}
+ }
+ redirectURL, err := url.Parse(redirectURI)
+ if err != nil {
+ // todo render error page instead of technical error
+ 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)
+ }
+ // the walletDID must be a did:web
+ walletDID, err := did.ParseDID(walletID)
+ if err != nil || walletDID.Method != "web" {
+ return nil, oauthError(oauth.InvalidRequest, "invalid client_id parameter (only did:web is supported)", redirectURL)
+ }
+ metadata, err := r.auth.Verifier().AuthorizationServerMetadata(ctx, *walletDID)
+ if err != nil {
+ return nil, oauthError(oauth.ServerError, "failed to get metadata from wallet", redirectURL)
+ }
+ // own generic endpoint
+ ownURL, err := didweb.DIDToURL(verifier)
+ if err != nil {
+ return nil, oauthError(oauth.ServerError, "invalid verifier DID", redirectURL)
+ }
+ // generate presentation_definition_uri based on own presentation_definition endpoint + scope
+ pdURL := ownURL.JoinPath("presentation_definition")
+ presentationDefinitionURI := httpNuts.AddQueryParams(*pdURL, map[string]string{
+ "scope": params[scopeParam],
+ })
+
+ // redirect to wallet authorization endpoint, use direct_post mode
+ // like this:
+ // GET /authorize?
+ // response_type=vp_token
+ // &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
+ // &response_uri=https%3A%2F%2Fexample.com%2Fiam%2F123%2F%2Fresponse
+ // &presentation_definition_uri=...
+ // &response_mode=direct_post
+ // &nonce=n-0S6_WzA2Mj HTTP/1.1
+ walletURL, err := url.Parse(metadata.AuthorizationEndpoint)
+ if err != nil || len(metadata.AuthorizationEndpoint) == 0 {
+ return nil, oauthError(oauth.InvalidRequest, "invalid wallet endpoint", redirectURL)
+ }
+ nonce := crypto.GenerateNonce()
+ callbackURL := *ownURL
+ callbackURL.Path, err = url.JoinPath(callbackURL.Path, "response")
+ if err != nil {
+ return nil, oauthError(oauth.ServerError, "failed to construct redirect path", redirectURL)
+ }
+
+ metadataURL, err := r.auth.Verifier().ClientMetadataURL(verifier)
+ if err != nil {
+ return nil, oauthError(oauth.ServerError, "failed to construct metadata URL", 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,
+ responseURIParam: callbackURL.String(),
+ presentationDefUriParam: presentationDefinitionURI.String(),
+ clientMetadataURIParam: metadataURL.String(),
+ responseModeParam: responseModeDirectPost,
+ nonceParam: nonce,
+ })
+ openid4vpRequest := OAuthSession{
+ ClientID: verifier.String(),
+ Scope: params[scopeParam],
+ OwnDID: verifier,
+ ClientState: nonce,
+ RedirectURI: redirectURL.String(),
+ }
+ // use nonce to store authorization request in session store
+ if err = r.oauthNonceStore().Put(nonce, openid4vpRequest); err != nil {
+ return nil, oauthError(oauth.ServerError, "failed to store server state", redirectURL)
+ }
+
+ return HandleAuthorizeRequest302Response{
+ Headers: HandleAuthorizeRequest302ResponseHeaders{
+ Location: authServerURL.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,
+func (r Wrapper) sendPresentationRequest(ctx context.Context, response http.ResponseWriter, scope string,
redirectURL url.URL, verifierIdentifier url.URL, walletIdentifier url.URL) error {
// TODO: Lookup wallet metadata for correct authorization endpoint. But for Nuts nodes, we derive it from the walletIdentifier
authzEndpoint := walletIdentifier.JoinPath("/authorize")
@@ -91,7 +207,7 @@ func (r *Wrapper) handlePresentationRequest(params map[string]string, session *O
return nil, oauth.OAuth2Error{
Code: oauth.InvalidRequest,
Description: "response_mode must be direct_post",
- RedirectURI: session.RedirectURI,
+ RedirectURI: session.redirectURI(),
}
}
@@ -102,7 +218,7 @@ func (r *Wrapper) handlePresentationRequest(params map[string]string, session *O
return nil, oauth.OAuth2Error{
Code: oauth.InvalidRequest,
Description: fmt.Sprintf("unsupported scope for presentation exchange: %s", params[scopeParam]),
- RedirectURI: session.RedirectURI,
+ RedirectURI: session.redirectURI(),
}
}
@@ -174,7 +290,7 @@ func (r *Wrapper) handlePresentationRequest(params map[string]string, session *O
}
// handleAuthConsent handles the authorization consent form submission.
-func (r *Wrapper) handlePresentationRequestAccept(c echo.Context) error {
+func (r Wrapper) handlePresentationRequestAccept(c echo.Context) error {
// TODO: Needs authentication?
sessionID := c.FormValue("sessionID")
if sessionID == "" {
@@ -234,7 +350,7 @@ func (r *Wrapper) handlePresentationRequestAccept(c echo.Context) error {
return c.Redirect(http.StatusFound, session.CreateRedirectURI(resultParams))
}
-func (r *Wrapper) handlePresentationRequestCompleted(ctx echo.Context) error {
+func (r Wrapper) handlePresentationRequestCompleted(ctx echo.Context) error {
// TODO: response direct_post mode
vpToken := ctx.QueryParams()[vpTokenParam]
if len(vpToken) == 0 {
@@ -262,6 +378,10 @@ func (r *Wrapper) handlePresentationRequestCompleted(ctx echo.Context) error {
return ctx.HTML(http.StatusOK, buf.String())
}
+func (r Wrapper) oauthNonceStore() storage.SessionStore {
+ return r.storageEngine.GetSessionDatabase().GetStore(oAuthFlowTimeout, oauthNonceKey...)
+}
+
func assertParamPresent(params map[string]string, param ...string) error {
for _, curr := range param {
if len(params[curr]) == 0 {
@@ -279,3 +399,11 @@ func assertParamNotPresent(params map[string]string, param ...string) error {
}
return nil
}
+
+func oauthError(code oauth.ErrorCode, description string, redirectURL *url.URL) oauth.OAuth2Error {
+ return oauth.OAuth2Error{
+ Code: code,
+ Description: description,
+ RedirectURI: redirectURL,
+ }
+}
diff --git a/auth/api/iam/openid4vp_demo.go b/auth/api/iam/openid4vp_demo.go
index ddf14d2eae..8ee7290964 100644
--- a/auth/api/iam/openid4vp_demo.go
+++ b/auth/api/iam/openid4vp_demo.go
@@ -27,7 +27,7 @@ import (
"strings"
)
-func (r *Wrapper) handleOpenID4VPDemoLanding(echoCtx echo.Context) error {
+func (r Wrapper) handleOpenID4VPDemoLanding(echoCtx echo.Context) error {
requestURL := *echoCtx.Request().URL
requestURL.Host = echoCtx.Request().Host
requestURL.Scheme = "http"
@@ -47,7 +47,7 @@ func (r *Wrapper) handleOpenID4VPDemoLanding(echoCtx echo.Context) error {
return echoCtx.HTML(http.StatusOK, buf.String())
}
-func (r *Wrapper) handleOpenID4VPDemoSendRequest(echoCtx echo.Context) error {
+func (r Wrapper) handleOpenID4VPDemoSendRequest(echoCtx echo.Context) error {
verifierID := echoCtx.FormValue("verifier_id")
if verifierID == "" {
return errors.New("missing verifier_id")
diff --git a/auth/api/iam/openid4vp_test.go b/auth/api/iam/openid4vp_test.go
index a12a8a578d..248e3072fa 100644
--- a/auth/api/iam/openid4vp_test.go
+++ b/auth/api/iam/openid4vp_test.go
@@ -43,6 +43,63 @@ import (
var holderDID = did.MustParseDID("did:web:example.com:holder")
var issuerDID = did.MustParseDID("did:web:example.com:issuer")
+func TestWrapper_handleAuthorizeRequestFromHolder(t *testing.T) {
+ defaultParams := func() map[string]string {
+ return map[string]string{
+ clientIDParam: holderDID.String(),
+ redirectURIParam: "https://example.com",
+ responseTypeParam: "code",
+ scopeParam: "test",
+ }
+ }
+
+ t.Run("missing client_id", func(t *testing.T) {
+ ctx := newTestClient(t)
+ params := defaultParams()
+ delete(params, clientIDParam)
+
+ _, err := ctx.client.handleAuthorizeRequestFromHolder(context.Background(), verifierDID, params)
+
+ requireOAuthError(t, err, oauth.InvalidRequest, "missing client_id parameter")
+ })
+ t.Run("invalid client_id", func(t *testing.T) {
+ ctx := newTestClient(t)
+ params := defaultParams()
+ params[clientIDParam] = "did:nuts:1"
+
+ _, err := ctx.client.handleAuthorizeRequestFromHolder(context.Background(), verifierDID, params)
+
+ requireOAuthError(t, err, oauth.InvalidRequest, "invalid client_id parameter (only did:web is supported)")
+ })
+ t.Run("error on authorization server metadata", func(t *testing.T) {
+ ctx := newTestClient(t)
+ ctx.verifierRole.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(nil, assert.AnError)
+
+ _, err := ctx.client.handleAuthorizeRequestFromHolder(context.Background(), verifierDID, defaultParams())
+
+ requireOAuthError(t, err, oauth.ServerError, "failed to get metadata from wallet")
+ })
+ t.Run("failed to generate verifier web url", func(t *testing.T) {
+ ctx := newTestClient(t)
+ verifierDID := did.MustParseDID("did:notweb:example.com:verifier")
+ ctx.verifierRole.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(&oauth.AuthorizationServerMetadata{}, nil)
+
+ _, err := ctx.client.handleAuthorizeRequestFromHolder(context.Background(), verifierDID, defaultParams())
+
+ requireOAuthError(t, err, oauth.ServerError, "invalid verifier DID")
+ })
+ t.Run("incorrect holder AuthorizationEndpoint URL", func(t *testing.T) {
+ ctx := newTestClient(t)
+ ctx.verifierRole.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(&oauth.AuthorizationServerMetadata{
+ AuthorizationEndpoint: "://example.com",
+ }, nil)
+
+ _, err := ctx.client.handleAuthorizeRequestFromHolder(context.Background(), verifierDID, defaultParams())
+
+ requireOAuthError(t, err, oauth.InvalidRequest, "invalid wallet endpoint")
+ })
+}
+
func TestWrapper_sendPresentationRequest(t *testing.T) {
instance := New(nil, nil, nil, nil)
diff --git a/auth/api/iam/s2s_vptoken.go b/auth/api/iam/s2s_vptoken.go
index 36cb1882f5..a66b24457c 100644
--- a/auth/api/iam/s2s_vptoken.go
+++ b/auth/api/iam/s2s_vptoken.go
@@ -45,7 +45,7 @@ const s2sMaxClockSkew = 5 * time.Second
// handleS2SAccessTokenRequest handles the /token request with vp_token bearer grant type, intended for service-to-service exchanges.
// It performs cheap checks first (parameter presence and validity, matching VCs to the presentation definition), then the more expensive ones (checking signatures).
-func (r *Wrapper) handleS2SAccessTokenRequest(issuer did.DID, scope string, submissionJSON string, assertionJSON string) (HandleTokenRequestResponseObject, error) {
+func (r Wrapper) handleS2SAccessTokenRequest(issuer did.DID, scope string, submissionJSON string, assertionJSON string) (HandleTokenRequestResponseObject, error) {
pexEnvelope, err := pe.ParseEnvelope([]byte(assertionJSON))
if err != nil {
return nil, oauth.OAuth2Error{
@@ -128,7 +128,7 @@ func (r Wrapper) requestServiceAccessToken(ctx context.Context, requestHolder di
return RequestAccessToken200JSONResponse(*tokenResult), nil
}
-func (r *Wrapper) createS2SAccessToken(issuer did.DID, issueTime time.Time, presentations []vc.VerifiablePresentation, submission pe.PresentationSubmission, definition PresentationDefinition, scope string, credentialSubjectDID did.DID, credentialMap map[string]vc.VerifiableCredential) (*oauth.TokenResponse, error) {
+func (r Wrapper) createS2SAccessToken(issuer did.DID, issueTime time.Time, presentations []vc.VerifiablePresentation, submission pe.PresentationSubmission, definition PresentationDefinition, scope string, credentialSubjectDID did.DID, credentialMap map[string]vc.VerifiableCredential) (*oauth.TokenResponse, error) {
fieldsMap, err := definition.ResolveConstraintsFields(credentialMap)
if err != nil {
return nil, fmt.Errorf("unable to resolve Presentation Definition Constraints Fields: %w", err)
@@ -225,7 +225,7 @@ func validatePresentationSigner(presentation vc.VerifiablePresentation, expected
}
// validateS2SPresentationNonce checks if the nonce has been used before; 'nonce' claim for JWTs or LDProof's 'nonce' for JSON-LD.
-func (r *Wrapper) validateS2SPresentationNonce(presentation vc.VerifiablePresentation) error {
+func (r Wrapper) validateS2SPresentationNonce(presentation vc.VerifiablePresentation) error {
var nonce string
switch presentation.Format() {
case vc.JWTPresentationProofFormat:
@@ -272,7 +272,7 @@ func (r *Wrapper) validateS2SPresentationNonce(presentation vc.VerifiablePresent
}
// validatePresentationAudience checks if the presentation audience (aud claim for JWTs, domain property for JSON-LD proofs) contains the issuer DID.
-func (r *Wrapper) validatePresentationAudience(presentation vc.VerifiablePresentation, issuer did.DID) error {
+func (r Wrapper) validatePresentationAudience(presentation vc.VerifiablePresentation, issuer did.DID) error {
var audience []string
switch presentation.Format() {
case vc.JWTPresentationProofFormat:
diff --git a/auth/api/iam/s2s_vptoken_test.go b/auth/api/iam/s2s_vptoken_test.go
index fe64d4ed70..a1866469b3 100644
--- a/auth/api/iam/s2s_vptoken_test.go
+++ b/auth/api/iam/s2s_vptoken_test.go
@@ -24,6 +24,7 @@ import (
"crypto/rand"
"encoding/json"
"errors"
+ "go.uber.org/mock/gomock"
"net/http"
"testing"
"time"
@@ -41,7 +42,6 @@ import (
"github.com/nuts-foundation/nuts-node/vdr/resolver"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- "go.uber.org/mock/gomock"
)
func TestWrapper_requestServiceAccessToken(t *testing.T) {
@@ -116,7 +116,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) {
t.Run("JSON-LD VP", func(t *testing.T) {
ctx := newTestClient(t)
- ctx.verifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil)
+ ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil)
resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw())
@@ -163,7 +163,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) {
presentation := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) {
require.NoError(t, token.Set(jwt.AudienceKey, issuerDID.String()))
}, verifiableCredential)
- ctx.verifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil)
+ ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil)
resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw())
@@ -196,7 +196,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) {
t.Run("nonce", func(t *testing.T) {
t.Run("replay attack (nonce is reused)", func(t *testing.T) {
ctx := newTestClient(t)
- ctx.verifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil)
+ ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil)
_, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw())
require.NoError(t, err)
@@ -289,7 +289,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) {
})
t.Run("VP verification fails", func(t *testing.T) {
ctx := newTestClient(t)
- ctx.verifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(nil, errors.New("invalid"))
+ ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(nil, errors.New("invalid"))
resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw())
diff --git a/auth/api/iam/session.go b/auth/api/iam/session.go
index d79e161d2f..19311840ba 100644
--- a/auth/api/iam/session.go
+++ b/auth/api/iam/session.go
@@ -57,3 +57,8 @@ func (s OAuthSession) CreateRedirectURI(params map[string]string) string {
r := http.AddQueryParams(*redirectURI, params)
return r.String()
}
+
+func (s OAuthSession) redirectURI() *url.URL {
+ redirectURL, _ := url.Parse(s.RedirectURI)
+ return redirectURL
+}
diff --git a/auth/api/iam/types.go b/auth/api/iam/types.go
index e699ba0be0..8259046e58 100644
--- a/auth/api/iam/types.go
+++ b/auth/api/iam/types.go
@@ -24,6 +24,7 @@ import (
"github.com/nuts-foundation/nuts-node/auth/oauth"
"github.com/nuts-foundation/nuts-node/vcr/pe"
"github.com/nuts-foundation/nuts-node/vdr/resolver"
+ "time"
)
// DIDDocument is an alias
@@ -47,6 +48,10 @@ type TokenResponse = oauth.TokenResponse
// OAuthAuthorizationServerMetadata is an alias
type OAuthAuthorizationServerMetadata = oauth.AuthorizationServerMetadata
+const (
+ sessionExpiry = 5 * time.Minute
+)
+
const (
// responseTypeParam is the name of the response_type parameter.
// Specified by https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.1
@@ -69,11 +74,14 @@ const (
// responseTypeVPIDToken is defined in the OpenID4VP flow that combines its vp_token with SIOPv2's id_token
// https://openid.bitbucket.io/connect/openid-4-verifiable-presentations-1_0.html#appendix-B
responseTypeVPIDToken = "vp_token id_token"
+ nonceParam = "nonce"
)
var responseTypesSupported = []string{responseTypeCode, responseTypeVPToken, responseTypeVPIDToken}
const (
+ // didScheme is the client_id_scheme value for DIDs
+ didScheme = "did"
// responseModeParam is the name of the OAuth2 response_mode parameter.
// Specified by https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html
responseModeParam = "response_mode"
@@ -132,6 +140,9 @@ const stateParam = "state"
// Specified by https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2
const redirectURIParam = "redirect_uri"
+// responseURIParam is the name of the OpenID4VP response_uri parameter.
+const responseURIParam = "response_uri"
+
// presentationDefParam is the name of the OpenID4VP presentation_definition parameter.
// Specified by https://openid.bitbucket.io/connect/openid-4-verifiable-presentations-1_0.html#name-presentation_definition-par
const presentationDefParam = "presentation_definition"
diff --git a/auth/auth.go b/auth/auth.go
index c7dbeefd8d..4b7ed93c22 100644
--- a/auth/auth.go
+++ b/auth/auth.go
@@ -49,6 +49,7 @@ type Auth struct {
jsonldManager jsonld.JSONLD
authzServer oauth.AuthorizationServer
relyingParty oauth.RelyingParty
+ verifier oauth.Verifier
contractNotary services.ContractNotary
serviceResolver didman.CompoundServiceResolver
keyStore crypto.KeyStore
@@ -114,6 +115,10 @@ func (auth *Auth) RelyingParty() oauth.RelyingParty {
return auth.relyingParty
}
+func (auth *Auth) Verifier() oauth.Verifier {
+ return auth.verifier
+}
+
// 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 == "" {
@@ -166,6 +171,7 @@ func (auth *Auth) Configure(config core.ServerConfig) error {
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)
if err := auth.authzServer.Configure(auth.config.ClockSkew, config.Strictmode); err != nil {
return err
diff --git a/auth/interface.go b/auth/interface.go
index f508d3e958..1749b017a1 100644
--- a/auth/interface.go
+++ b/auth/interface.go
@@ -34,6 +34,8 @@ type AuthenticationServices interface {
AuthzServer() oauth.AuthorizationServer
// RelyingParty returns the oauth.RelyingParty
RelyingParty() oauth.RelyingParty
+ // Verifier returns the oauth.Verifier service provider
+ Verifier() oauth.Verifier
// ContractNotary returns an instance of ContractNotary
ContractNotary() services.ContractNotary
// V2APIEnabled returns true if the V2 API is enabled.
diff --git a/auth/mock.go b/auth/mock.go
index 36835e7e1d..a1af1bdd49 100644
--- a/auth/mock.go
+++ b/auth/mock.go
@@ -124,3 +124,17 @@ func (mr *MockAuthenticationServicesMockRecorder) V2APIEnabled() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "V2APIEnabled", reflect.TypeOf((*MockAuthenticationServices)(nil).V2APIEnabled))
}
+
+// Verifier mocks base method.
+func (m *MockAuthenticationServices) Verifier() oauth.Verifier {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "Verifier")
+ ret0, _ := ret[0].(oauth.Verifier)
+ return ret0
+}
+
+// Verifier indicates an expected call of Verifier.
+func (mr *MockAuthenticationServicesMockRecorder) Verifier() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Verifier", reflect.TypeOf((*MockAuthenticationServices)(nil).Verifier))
+}
diff --git a/auth/oauth/error.go b/auth/oauth/error.go
index 173992f764..1ea92ae377 100644
--- a/auth/oauth/error.go
+++ b/auth/oauth/error.go
@@ -62,7 +62,7 @@ type OAuth2Error struct {
// It should not be set if the user-agent is not a browser, or there is no redirect_uri (because the request was malformed), this field is empty.
// When the field is set, the user-agent is redirected to the specified URI with the error code and description as query parameters.
// If it's not set, the error code and description are returned in the response body (plain text or JSON).
- RedirectURI string `json:"-"`
+ RedirectURI *url.URL `json:"-"`
}
// StatusCode returns the HTTP status code to be returned to the client, in case the user-agent can't be redirected with HTTP 302 - Found.
@@ -104,8 +104,7 @@ func (p Oauth2ErrorWriter) Write(echoContext echo.Context, _ int, _ string, err
// Somebody forgot to set a code
oauthErr.Code = ServerError
}
- redirectURI, _ := url.Parse(oauthErr.RedirectURI)
- if oauthErr.RedirectURI == "" || redirectURI == nil {
+ if oauthErr.RedirectURI == nil {
// Can't redirect the user-agent back, render error as JSON or plain text (depending on accept/content-type)
accept := echoContext.Request().Header.Get("Accept")
if strings.Contains(accept, "application/json") {
@@ -125,13 +124,13 @@ func (p Oauth2ErrorWriter) Write(echoContext echo.Context, _ int, _ string, err
return echoContext.String(oauthErr.StatusCode(), strings.Join(parts, " - "))
}
// Redirect the user-agent back to the client
- query := redirectURI.Query()
+ query := oauthErr.RedirectURI.Query()
query.Set("error", string(oauthErr.Code))
if oauthErr.Description != "" {
query.Set("error_description", oauthErr.Description)
}
- redirectURI.RawQuery = query.Encode()
- return echoContext.Redirect(http.StatusFound, redirectURI.String())
+ oauthErr.RedirectURI.RawQuery = query.Encode()
+ return echoContext.Redirect(http.StatusFound, oauthErr.RedirectURI.String())
}
// TestOAuthErrorCode tests if the response is an OAuth2 error with the given code.
diff --git a/auth/oauth/error_test.go b/auth/oauth/error_test.go
index 0e95bf536e..ff175c0b85 100644
--- a/auth/oauth/error_test.go
+++ b/auth/oauth/error_test.go
@@ -21,6 +21,7 @@ package oauth
import (
"errors"
"github.com/labstack/echo/v4"
+ "github.com/nuts-foundation/nuts-node/test"
"github.com/stretchr/testify/assert"
"io"
"net/http"
@@ -48,7 +49,7 @@ func Test_oauth2ErrorWriter_Write(t *testing.T) {
err := Oauth2ErrorWriter{}.Write(ctx, 0, "", OAuth2Error{
Code: InvalidRequest,
Description: "failure",
- RedirectURI: "https://example.com",
+ RedirectURI: test.MustParseURL("https://example.com"),
})
assert.NoError(t, err)
diff --git a/auth/oauth/types.go b/auth/oauth/types.go
index abc14df714..a95422bb85 100644
--- a/auth/oauth/types.go
+++ b/auth/oauth/types.go
@@ -130,9 +130,3 @@ 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"`
}
-
-// ErrorResponse models an error returned from an OAuth flow according to RFC6749 (https://tools.ietf.org/html/rfc6749#page-45)
-type ErrorResponse struct {
- Description *string `json:"error_description,omitempty"`
- Error string `json:"error"`
-}
diff --git a/auth/services/oauth/authz_server.go b/auth/services/oauth/authz_server.go
index 4fd32ae59c..a12763182f 100644
--- a/auth/services/oauth/authz_server.go
+++ b/auth/services/oauth/authz_server.go
@@ -183,14 +183,13 @@ func (s *authzServer) Configure(clockSkewInMilliseconds int, secureMode bool) er
}
// CreateAccessToken extracts the claims out of the request, checks the validity and builds the access token
-func (s *authzServer) CreateAccessToken(ctx context.Context, request services.CreateAccessTokenRequest) (*oauth.TokenResponse, *oauth.ErrorResponse) {
- var oauthError *oauth.ErrorResponse
+func (s *authzServer) CreateAccessToken(ctx context.Context, request services.CreateAccessTokenRequest) (*oauth.TokenResponse, *oauth.OAuth2Error) {
+ var oauthError *oauth.OAuth2Error
var result *oauth.TokenResponse
validationCtx, err := s.validateAccessTokenRequest(ctx, request.RawJwtBearerToken)
if err != nil {
- errStr := err.Error()
- oauthError = &oauth.ErrorResponse{Error: "invalid_request", Description: &errStr}
+ oauthError = &oauth.OAuth2Error{Code: "invalid_request", Description: err.Error()}
} else {
var accessToken string
var rawToken services.NutsAccessToken
@@ -202,11 +201,10 @@ func (s *authzServer) CreateAccessToken(ctx context.Context, request services.Cr
ExpiresIn: &expires,
}
} else {
- oauthError = &oauth.ErrorResponse{Error: "server_error"}
+ oauthError = &oauth.OAuth2Error{Code: "server_error"}
if !s.secureMode {
// Only set details when secure mode is disabled
- errStr := err.Error()
- oauthError.Description = &errStr
+ oauthError.Description = err.Error()
}
}
}
diff --git a/auth/services/oauth/authz_server_test.go b/auth/services/oauth/authz_server_test.go
index 4a78c2af73..b9ea959a20 100644
--- a/auth/services/oauth/authz_server_test.go
+++ b/auth/services/oauth/authz_server_test.go
@@ -117,7 +117,7 @@ func TestAuth_CreateAccessToken(t *testing.T) {
assert.Nil(t, response)
require.NotNil(t, err.Description)
- assert.Contains(t, *err.Description, "jwt bearer token validation failed")
+ assert.Contains(t, err.Description, "jwt bearer token validation failed")
})
t.Run("broken identity token", func(t *testing.T) {
@@ -135,7 +135,7 @@ func TestAuth_CreateAccessToken(t *testing.T) {
assert.Nil(t, response)
require.NotNil(t, err.Description)
- assert.Contains(t, *err.Description, "identity validation failed")
+ assert.Contains(t, err.Description, "identity validation failed")
})
t.Run("JWT validity too long", func(t *testing.T) {
@@ -151,7 +151,7 @@ func TestAuth_CreateAccessToken(t *testing.T) {
assert.Nil(t, response)
require.NotNil(t, err.Description)
- assert.Contains(t, *err.Description, "JWT validity too long")
+ assert.Contains(t, err.Description, "JWT validity too long")
})
t.Run("invalid identity token", func(t *testing.T) {
@@ -170,7 +170,7 @@ func TestAuth_CreateAccessToken(t *testing.T) {
assert.Nil(t, response)
require.NotNil(t, err.Description)
- assert.Contains(t, *err.Description, "identity validation failed: because of reasons")
+ assert.Contains(t, err.Description, "identity validation failed: because of reasons")
})
t.Run("error detail masking", func(t *testing.T) {
@@ -201,7 +201,7 @@ func TestAuth_CreateAccessToken(t *testing.T) {
assert.Nil(t, response)
require.NotNil(t, err.Description)
- assert.Contains(t, *err.Description, "could not build accessToken: signing error")
+ assert.Contains(t, err.Description, "could not build accessToken: signing error")
})
t.Run("mask internal errors when secureMode=true", func(t *testing.T) {
ctx := setup(createContext(t))
@@ -213,8 +213,8 @@ func TestAuth_CreateAccessToken(t *testing.T) {
response, err := ctx.oauthService.CreateAccessToken(ctx.audit, services.CreateAccessTokenRequest{RawJwtBearerToken: tokenCtx.rawJwtBearerToken})
assert.Nil(t, response)
- assert.Nil(t, err.Description)
- assert.Equal(t, err.Error, "server_error")
+ assert.Empty(t, err.Description)
+ assert.Equal(t, "server_error", string(err.Code))
})
})
diff --git a/auth/services/oauth/interface.go b/auth/services/oauth/interface.go
index 8006684314..28c237c7a6 100644
--- a/auth/services/oauth/interface.go
+++ b/auth/services/oauth/interface.go
@@ -44,6 +44,14 @@ type AuthorizationServer interface {
// CreateAccessToken is called by remote Nuts nodes to create an access token,
// which can be used to access the local organization's XIS resources.
// It returns an oauth.ErrorResponse rather than a regular Go error, because the errors that may be returned are tightly specified.
- CreateAccessToken(ctx context.Context, request services.CreateAccessTokenRequest) (*oauth.TokenResponse, *oauth.ErrorResponse)
+ CreateAccessToken(ctx context.Context, request services.CreateAccessTokenRequest) (*oauth.TokenResponse, *oauth.OAuth2Error)
IntrospectAccessToken(ctx context.Context, token string) (*services.NutsAccessToken, error)
}
+
+// Verifier implements the OpenID4VP Verifier role.
+type Verifier interface {
+ // AuthorizationServerMetadata returns the metadata of the remote wallet.
+ AuthorizationServerMetadata(ctx context.Context, webdid did.DID) (*oauth.AuthorizationServerMetadata, error)
+ // ClientMetadataURL constructs the URL to the client metadata of the local verifier.
+ ClientMetadataURL(webdid did.DID) (*url.URL, error)
+}
diff --git a/auth/services/oauth/mock.go b/auth/services/oauth/mock.go
index c817d302c9..babd76913b 100644
--- a/auth/services/oauth/mock.go
+++ b/auth/services/oauth/mock.go
@@ -140,11 +140,11 @@ func (mr *MockAuthorizationServerMockRecorder) Configure(clockSkewInMilliseconds
}
// CreateAccessToken mocks base method.
-func (m *MockAuthorizationServer) CreateAccessToken(ctx context.Context, request services.CreateAccessTokenRequest) (*oauth.TokenResponse, *oauth.ErrorResponse) {
+func (m *MockAuthorizationServer) CreateAccessToken(ctx context.Context, request services.CreateAccessTokenRequest) (*oauth.TokenResponse, *oauth.OAuth2Error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CreateAccessToken", ctx, request)
ret0, _ := ret[0].(*oauth.TokenResponse)
- ret1, _ := ret[1].(*oauth.ErrorResponse)
+ ret1, _ := ret[1].(*oauth.OAuth2Error)
return ret0, ret1
}
@@ -168,3 +168,56 @@ func (mr *MockAuthorizationServerMockRecorder) IntrospectAccessToken(ctx, token
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IntrospectAccessToken", reflect.TypeOf((*MockAuthorizationServer)(nil).IntrospectAccessToken), ctx, token)
}
+
+// MockVerifier is a mock of Verifier interface.
+type MockVerifier struct {
+ ctrl *gomock.Controller
+ recorder *MockVerifierMockRecorder
+}
+
+// MockVerifierMockRecorder is the mock recorder for MockVerifier.
+type MockVerifierMockRecorder struct {
+ mock *MockVerifier
+}
+
+// NewMockVerifier creates a new mock instance.
+func NewMockVerifier(ctrl *gomock.Controller) *MockVerifier {
+ mock := &MockVerifier{ctrl: ctrl}
+ mock.recorder = &MockVerifierMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockVerifier) EXPECT() *MockVerifierMockRecorder {
+ return m.recorder
+}
+
+// AuthorizationServerMetadata mocks base method.
+func (m *MockVerifier) AuthorizationServerMetadata(ctx context.Context, webdid did.DID) (*oauth.AuthorizationServerMetadata, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "AuthorizationServerMetadata", ctx, webdid)
+ ret0, _ := ret[0].(*oauth.AuthorizationServerMetadata)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// AuthorizationServerMetadata indicates an expected call of AuthorizationServerMetadata.
+func (mr *MockVerifierMockRecorder) AuthorizationServerMetadata(ctx, webdid any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthorizationServerMetadata", reflect.TypeOf((*MockVerifier)(nil).AuthorizationServerMetadata), ctx, webdid)
+}
+
+// ClientMetadataURL mocks base method.
+func (m *MockVerifier) ClientMetadataURL(webdid did.DID) (*url.URL, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "ClientMetadataURL", webdid)
+ ret0, _ := ret[0].(*url.URL)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// ClientMetadataURL indicates an expected call of ClientMetadataURL.
+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)
+}
diff --git a/auth/services/oauth/relying_party.go b/auth/services/oauth/relying_party.go
index c2cd0afe43..9da8482646 100644
--- a/auth/services/oauth/relying_party.go
+++ b/auth/services/oauth/relying_party.go
@@ -38,7 +38,7 @@ import (
"github.com/nuts-foundation/nuts-node/core"
nutsCrypto "github.com/nuts-foundation/nuts-node/crypto"
"github.com/nuts-foundation/nuts-node/didman"
- http2 "github.com/nuts-foundation/nuts-node/http"
+ 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/signature/proof"
@@ -127,7 +127,7 @@ func (s *relyingParty) CreateAuthorizationRequest(ctx context.Context, requestHo
return nil, fmt.Errorf("failed to parse authorization endpoint URL: %w", err)
}
// todo: redirect_uri
- redirectURL := http2.AddQueryParams(*endpoint, map[string]string{
+ redirectURL := nutsHttp.AddQueryParams(*endpoint, map[string]string{
"client_id": requestHolder.String(),
"response_type": "code",
"scope": scopes,
@@ -234,6 +234,15 @@ func (s *relyingParty) RequestRFC021AccessToken(ctx context.Context, requester d
}, nil
}
+func (s *relyingParty) authorizationServerMetadata(ctx context.Context, webdid did.DID) (*oauth.AuthorizationServerMetadata, error) {
+ iamClient := iam.NewHTTPClient(s.strictMode, s.httpClientTimeout, s.httpClientTLS)
+ metadata, err := iamClient.OAuthAuthorizationServerMetadata(ctx, webdid)
+ if err != nil {
+ return nil, fmt.Errorf("failed to retrieve remote OAuth Authorization Server metadata: %w", err)
+ }
+ return metadata, nil
+}
+
func chooseVPFormat(formats map[string]map[string][]string) string {
// They are in preferred order
if _, ok := formats[vc.JWTPresentationProofFormat]; ok {
diff --git a/auth/services/oauth/relying_party_test.go b/auth/services/oauth/relying_party_test.go
index 34fb2042e5..385b9c66fe 100644
--- a/auth/services/oauth/relying_party_test.go
+++ b/auth/services/oauth/relying_party_test.go
@@ -24,21 +24,21 @@ import (
"encoding/json"
"errors"
"fmt"
- "github.com/nuts-foundation/nuts-node/audit"
- "github.com/nuts-foundation/nuts-node/auth/oauth"
- http2 "github.com/nuts-foundation/nuts-node/test/http"
"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/audit"
+ "github.com/nuts-foundation/nuts-node/auth/oauth"
"github.com/nuts-foundation/nuts-node/auth/services"
"github.com/nuts-foundation/nuts-node/crypto"
"github.com/nuts-foundation/nuts-node/didman"
+ "github.com/nuts-foundation/nuts-node/test"
+ 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"
@@ -61,7 +61,7 @@ func TestRelyingParty_RequestRFC003AccessToken(t *testing.T) {
httpServer := httptest.NewServer(httpHandler)
t.Cleanup(httpServer.Close)
- response, err := ctx.relyingParty.RequestRFC003AccessToken(context.Background(), bearerToken, mustParseURL(httpServer.URL))
+ response, err := ctx.relyingParty.RequestRFC003AccessToken(context.Background(), bearerToken, *test.MustParseURL(httpServer.URL))
assert.NoError(t, err)
assert.NotNil(t, response)
@@ -74,7 +74,7 @@ func TestRelyingParty_RequestRFC003AccessToken(t *testing.T) {
})
t.Cleanup(server.Close)
- response, err := ctx.relyingParty.RequestRFC003AccessToken(context.Background(), bearerToken, mustParseURL(server.URL))
+ response, err := ctx.relyingParty.RequestRFC003AccessToken(context.Background(), bearerToken, *test.MustParseURL(server.URL))
assert.Nil(t, response)
assert.EqualError(t, err, "remote server/nuts node returned error creating access token: server returned HTTP 502 (expected: 200)")
@@ -94,7 +94,7 @@ func TestRelyingParty_RequestRFC003AccessToken(t *testing.T) {
t.Run("HTTPS in strict mode", func(t *testing.T) {
ctx.relyingParty.strictMode = true
- response, err := ctx.relyingParty.RequestRFC003AccessToken(context.Background(), bearerToken, mustParseURL(httpsServer.URL))
+ response, err := ctx.relyingParty.RequestRFC003AccessToken(context.Background(), bearerToken, *test.MustParseURL(httpsServer.URL))
assert.NoError(t, err)
assert.NotNil(t, response)
@@ -102,7 +102,7 @@ func TestRelyingParty_RequestRFC003AccessToken(t *testing.T) {
t.Run("HTTP allowed in non-strict mode", func(t *testing.T) {
ctx.relyingParty.strictMode = false
- response, err := ctx.relyingParty.RequestRFC003AccessToken(context.Background(), bearerToken, mustParseURL(httpServer.URL))
+ response, err := ctx.relyingParty.RequestRFC003AccessToken(context.Background(), bearerToken, *test.MustParseURL(httpServer.URL))
assert.NoError(t, err)
assert.NotNil(t, response)
@@ -110,7 +110,7 @@ func TestRelyingParty_RequestRFC003AccessToken(t *testing.T) {
t.Run("HTTP not allowed in strict mode", func(t *testing.T) {
ctx.relyingParty.strictMode = true
- response, err := ctx.relyingParty.RequestRFC003AccessToken(context.Background(), bearerToken, mustParseURL(httpServer.URL))
+ response, err := ctx.relyingParty.RequestRFC003AccessToken(context.Background(), bearerToken, *test.MustParseURL(httpServer.URL))
assert.EqualError(t, err, fmt.Sprintf("authorization server endpoint must be HTTPS when in strict mode: %s", httpServer.URL))
assert.Nil(t, response)
@@ -560,11 +560,3 @@ func createOAuthRPContext(t *testing.T) *rpOAuthTestContext {
return ctx
}
-
-func mustParseURL(str string) url.URL {
- u, err := url.Parse(str)
- if err != nil {
- panic(err)
- }
- return *u
-}
diff --git a/auth/services/oauth/verifier.go b/auth/services/oauth/verifier.go
new file mode 100644
index 0000000000..cf9d029fdb
--- /dev/null
+++ b/auth/services/oauth/verifier.go
@@ -0,0 +1,71 @@
+/*
+ * 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"
+ "fmt"
+ "github.com/nuts-foundation/nuts-node/auth/client/iam"
+ "github.com/nuts-foundation/nuts-node/vdr/didweb"
+ "net/url"
+ "time"
+
+ "github.com/nuts-foundation/go-did/did"
+ "github.com/nuts-foundation/nuts-node/auth/oauth"
+)
+
+var _ Verifier = (*VerifierServiceProvider)(nil)
+
+type VerifierServiceProvider struct {
+ strictMode bool
+ httpClientTimeout time.Duration
+ httpClientTLS *tls.Config
+}
+
+// NewVerifier returns an implementation of Verifier
+func NewVerifier(strictMode bool, httpClientTimeout time.Duration, httpClientTLS *tls.Config) *VerifierServiceProvider {
+ return &VerifierServiceProvider{
+ strictMode: strictMode,
+ httpClientTimeout: httpClientTimeout,
+ httpClientTLS: httpClientTLS,
+ }
+}
+
+func (v *VerifierServiceProvider) AuthorizationServerMetadata(ctx context.Context, webdid did.DID) (*oauth.AuthorizationServerMetadata, error) {
+ iamClient := iam.NewHTTPClient(v.strictMode, v.httpClientTimeout, v.httpClientTLS)
+ // the wallet/holder acts as authorization server
+ metadata, err := iamClient.OAuthAuthorizationServerMetadata(ctx, webdid)
+ if err != nil {
+ return nil, fmt.Errorf("failed to retrieve remote OAuth Authorization Server metadata: %w", err)
+ }
+ return metadata, nil
+}
+
+func (v *VerifierServiceProvider) ClientMetadataURL(webdid did.DID) (*url.URL, error) {
+ didURL, err := didweb.DIDToURL(webdid)
+ if err != nil {
+ return nil, fmt.Errorf("failed to convert DID to URL: %w", err)
+ }
+ // 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
+}
diff --git a/auth/services/oauth/verifier_test.go b/auth/services/oauth/verifier_test.go
new file mode 100644
index 0000000000..b05d7e17e0
--- /dev/null
+++ b/auth/services/oauth/verifier_test.go
@@ -0,0 +1,118 @@
+/*
+ * 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"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/nuts-foundation/go-did/did"
+ "github.com/nuts-foundation/nuts-node/auth/oauth"
+ http2 "github.com/nuts-foundation/nuts-node/test/http"
+ "github.com/nuts-foundation/nuts-node/vdr/didweb"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/mock/gomock"
+)
+
+func TestVerifier_AuthorizationServerMetadata(t *testing.T) {
+ t.Run("ok", func(t *testing.T) {
+ ctx := createVContext(t)
+
+ metadata, err := ctx.verifier.AuthorizationServerMetadata(context.Background(), ctx.verifierDID)
+
+ require.NoError(t, err)
+ require.NotNil(t, metadata)
+ assert.Equal(t, ctx.authzServerMetadata, *metadata)
+ })
+ t.Run("error - failed to get metadata", func(t *testing.T) {
+ ctx := createVContext(t)
+ ctx.metadata = nil
+
+ _, err := ctx.verifier.AuthorizationServerMetadata(context.Background(), ctx.verifierDID)
+
+ require.Error(t, err)
+ assert.EqualError(t, err, "failed to retrieve remote OAuth Authorization Server metadata: server returned HTTP 404 (expected: 200)")
+ })
+}
+
+func TestVerifierServiceProvider_ClientMetadataURL(t *testing.T) {
+ verifier := NewVerifier(false, 0, &tls.Config{InsecureSkipVerify: true})
+ webdid := did.MustParseDID("did:web:example.com:iam:holder")
+
+ t.Run("ok", func(t *testing.T) {
+ url, err := verifier.ClientMetadataURL(webdid)
+
+ require.NoError(t, err)
+ require.NotNil(t, url)
+ assert.Equal(t, "https://example.com/.well-known/oauth-authorization-server/iam/holder", url.String())
+ })
+ t.Run("error - invalid DID", func(t *testing.T) {
+ _, err := verifier.ClientMetadataURL(did.DID{})
+
+ require.Error(t, err)
+ assert.EqualError(t, err, "failed to convert DID to URL: URL does not represent a Web DID\nunsupported DID method: ")
+ })
+}
+
+type vTestContext struct {
+ ctrl *gomock.Controller
+ verifier Verifier
+ authzServerMetadata oauth.AuthorizationServerMetadata
+ handler http.HandlerFunc
+ tlsServer *httptest.Server
+ verifierDID did.DID
+ metadata func(writer http.ResponseWriter)
+}
+
+func createVContext(t *testing.T) *vTestContext {
+ ctrl := gomock.NewController(t)
+ authzServerMetadata := oauth.AuthorizationServerMetadata{}
+ ctx := &vTestContext{
+ ctrl: ctrl,
+ metadata: func(writer http.ResponseWriter) {
+ writer.Header().Add("Content-Type", "application/json")
+ writer.WriteHeader(http.StatusOK)
+ bytes, _ := json.Marshal(authzServerMetadata)
+ _, _ = 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
+ }
+ }
+ writer.WriteHeader(http.StatusNotFound)
+ }
+ ctx.tlsServer = http2.TestTLSServer(t, ctx.handler)
+ ctx.verifierDID = didweb.ServerURLToDIDWeb(t, ctx.tlsServer.URL)
+ ctx.authzServerMetadata = authzServerMetadata
+ ctx.verifier = NewVerifier(false, 0, &tls.Config{InsecureSkipVerify: true})
+
+ return ctx
+}
diff --git a/e2e-tests/oauth-flow/rfc021/docker-compose.yml b/e2e-tests/oauth-flow/rfc021/docker-compose.yml
index c34f0bad59..eea54d2446 100644
--- a/e2e-tests/oauth-flow/rfc021/docker-compose.yml
+++ b/e2e-tests/oauth-flow/rfc021/docker-compose.yml
@@ -14,6 +14,7 @@ services:
# did:web resolver uses the OS CA bundle, but e2e tests use a self-signed CA which can be found in truststore.pem
# So we need to mount that file to the OS CA bundle location, otherwise did:web resolving will fail due to untrusted certs.
- "../../tls-certs/truststore.pem:/etc/ssl/certs/Nuts_RootCA.pem:ro"
+ - "../../tls-certs/truststore.pem:/etc/ssl/certs/truststore.pem:ro"
- "./node-A/presentationexchangemapping.json:/opt/nuts/presentationexchangemapping.json:ro"
healthcheck:
interval: 1s # Make test run quicker by checking health status more often
diff --git a/echo/echo_integration_test.go b/echo/echo_integration_test.go
index cc1e2c06e9..4bf6c51814 100644
--- a/echo/echo_integration_test.go
+++ b/echo/echo_integration_test.go
@@ -66,9 +66,6 @@ func TestStatusCodes(t *testing.T) {
}
})
t.Run("400s", func(t *testing.T) {
- hook := logTest.NewGlobal()
- baseUrl, _ := node.StartServer(t)
-
testCases := []operation{
{module: "Crypto", operation: "SignJwt", url: "/internal/crypto/v1/sign_jwt", body: map[string]interface{}{"kid": "fpp", "claims": map[string]interface{}{"foo": "bar"}}},
{module: "Network", operation: "GetTransaction", url: "/internal/network/v1/transaction/invalidhash"},
@@ -76,6 +73,8 @@ func TestStatusCodes(t *testing.T) {
}
for _, testCase := range testCases {
+ hook := logTest.NewGlobal()
+ baseUrl, _ := node.StartServer(t)
var resp *http.Response
var err error
if testCase.body != nil {
diff --git a/golden_hammer/module_test.go b/golden_hammer/module_test.go
index 45c8a975f3..e6033532c0 100644
--- a/golden_hammer/module_test.go
+++ b/golden_hammer/module_test.go
@@ -30,7 +30,6 @@ import (
"github.com/nuts-foundation/nuts-node/test/pki"
"github.com/nuts-foundation/nuts-node/vcr/openid4vci"
"github.com/nuts-foundation/nuts-node/vdr"
- "github.com/nuts-foundation/nuts-node/vdr/didnuts/didstore"
"github.com/nuts-foundation/nuts-node/vdr/resolver"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -108,59 +107,51 @@ func TestGoldenHammer_Fix(t *testing.T) {
t.Run("nothing to fix", func(t *testing.T) {
// vendor and care organization DIDs already have the required service, so there's nothing to fix
- ctrl := gomock.NewController(t)
- didResolver := resolver.NewMockDIDResolver(ctrl)
- didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(&vendorDocumentWithBaseURL, nil, nil).MinTimes(1)
- didResolver.EXPECT().Resolve(clientADID, gomock.Any()).Return(&clientDocumentWithBaseURL, nil, nil).MinTimes(1)
- didResolver.EXPECT().Resolve(clientBDID, gomock.Any()).Return(&clientDocumentWithBaseURL, nil, nil).MinTimes(1)
- vdr := vdr.NewMockVDR(ctrl)
- vdr.EXPECT().Resolver().Return(didResolver).MinTimes(1)
- vdr.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{vendorDID, clientADID, clientBDID}, nil)
- service := New(vdr, nil)
+ ctx := newMockContext(t)
+ ctx.didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(&vendorDocumentWithBaseURL, nil, nil).MinTimes(1)
+ ctx.didResolver.EXPECT().Resolve(clientADID, gomock.Any()).Return(&clientDocumentWithBaseURL, nil, nil).MinTimes(1)
+ ctx.didResolver.EXPECT().Resolve(clientBDID, gomock.Any()).Return(&clientDocumentWithBaseURL, nil, nil).MinTimes(1)
+ ctx.vdr.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{vendorDID, clientADID, clientBDID}, nil)
- err := service.registerServiceBaseURLs()
+ err := ctx.hammer.registerServiceBaseURLs()
assert.NoError(t, err)
t.Run("second time list of fixed DIDs is cached (no DID resolving)", func(t *testing.T) {
- vdr.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{vendorDID, clientADID, clientBDID}, nil)
+ ctx.vdr.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{vendorDID, clientADID, clientBDID}, nil)
- err := service.registerServiceBaseURLs()
+ err := ctx.hammer.registerServiceBaseURLs()
assert.NoError(t, err)
})
})
t.Run("to be registered on vendor DID and client DIDs", func(t *testing.T) {
- ctrl := gomock.NewController(t)
- didResolver := didstore.NewMockStore(ctrl)
+ ctx := newMockContext(t)
docClientA := clientDocumentWithoutBaseURL
docClientA.ID = clientADID
docClientB := clientDocumentWithoutBaseURL
docClientB.ID = clientBDID
- vdr := vdr.NewMockVDR(ctrl)
- vdr.EXPECT().Resolver().Return(didResolver).MinTimes(1)
// Order DIDs such that care organization DID is first, to test ordering
- vdr.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{clientADID, vendorDID, clientBDID}, nil)
- didmanAPI := didman.NewMockDidman(ctrl)
+ ctx.vdr.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{clientADID, vendorDID, clientBDID}, nil)
gomock.InOrder(
// DID documents are listed first to check if they should be fixed
- didResolver.EXPECT().Resolve(clientADID, gomock.Any()).Return(&docClientA, nil, nil),
- didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(&vendorDocumentWithoutBaseURL, nil, nil),
- didResolver.EXPECT().Resolve(clientBDID, gomock.Any()).Return(&docClientB, nil, nil),
+ ctx.didResolver.EXPECT().Resolve(clientADID, gomock.Any()).Return(&docClientA, nil, nil),
+ ctx.didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(&vendorDocumentWithoutBaseURL, nil, nil),
+ ctx.didResolver.EXPECT().Resolve(clientBDID, gomock.Any()).Return(&docClientB, nil, nil),
// Vendor document is fixed first
- didmanAPI.EXPECT().AddEndpoint(gomock.Any(), vendorDID, resolver.BaseURLServiceType, *expectedBaseURL).Return(nil, nil),
+ ctx.didmanAPI.EXPECT().AddEndpoint(gomock.Any(), vendorDID, resolver.BaseURLServiceType, *expectedBaseURL).Return(nil, nil),
// Then client A
- didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(&vendorDocumentWithBaseURL, nil, nil),
- didmanAPI.EXPECT().AddEndpoint(gomock.Any(), clientADID, resolver.BaseURLServiceType, *serviceRef).Return(nil, nil),
+ ctx.didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(&vendorDocumentWithBaseURL, nil, nil),
+ ctx.didmanAPI.EXPECT().AddEndpoint(gomock.Any(), clientADID, resolver.BaseURLServiceType, *serviceRef).Return(nil, nil),
// Then client B
- didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(&vendorDocumentWithBaseURL, nil, nil),
- didmanAPI.EXPECT().AddEndpoint(gomock.Any(), clientBDID, resolver.BaseURLServiceType, *serviceRef).Return(nil, nil),
+ ctx.didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(&vendorDocumentWithBaseURL, nil, nil),
+ ctx.didmanAPI.EXPECT().AddEndpoint(gomock.Any(), clientBDID, resolver.BaseURLServiceType, *serviceRef).Return(nil, nil),
)
- service := New(vdr, didmanAPI)
+ service := ctx.hammer
service.tlsConfig = tlsServer.TLS
service.tlsConfig.InsecureSkipVerify = true
service.tlsConfig.Certificates = []tls.Certificate{pki.Certificate()}
@@ -170,13 +161,10 @@ func TestGoldenHammer_Fix(t *testing.T) {
assert.NoError(t, err)
})
t.Run("vendor identifier can't be resolved from TLS", func(t *testing.T) {
- ctrl := gomock.NewController(t)
- didResolver := didstore.NewMockStore(ctrl)
- didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(&vendorDocumentWithoutBaseURL, nil, nil).MinTimes(1)
- vdr := vdr.NewMockVDR(ctrl)
- vdr.EXPECT().Resolver().Return(didResolver).MinTimes(1)
- vdr.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{vendorDID}, nil)
- service := New(vdr, nil)
+ ctx := newMockContext(t)
+ ctx.didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(&vendorDocumentWithoutBaseURL, nil, nil).MinTimes(1)
+ ctx.vdr.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{vendorDID}, nil)
+ service := ctx.hammer
service.tlsConfig = &tls.Config{
Certificates: []tls.Certificate{pki.Certificate()},
}
@@ -188,26 +176,22 @@ func TestGoldenHammer_Fix(t *testing.T) {
t.Run("to be registered on client DIDs", func(t *testing.T) {
// vendor DID document already contains the service, but its care organization DID documents not yet,
// so they need to be registered.
- ctrl := gomock.NewController(t)
- didResolver := didstore.NewMockStore(ctrl)
- didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(&vendorDocumentWithBaseURL, nil, nil).MinTimes(1)
+ ctx := newMockContext(t)
+ ctx.didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(&vendorDocumentWithBaseURL, nil, nil).MinTimes(1)
docClientA := clientDocumentWithoutBaseURL
docClientA.ID = clientADID
docClientB := clientDocumentWithoutBaseURL
docClientB.ID = clientBDID
- didResolver.EXPECT().Resolve(clientADID, gomock.Any()).Return(&docClientA, nil, nil).MinTimes(1)
- didResolver.EXPECT().Resolve(clientBDID, gomock.Any()).Return(&docClientB, nil, nil).MinTimes(1)
+ ctx.didResolver.EXPECT().Resolve(clientADID, gomock.Any()).Return(&docClientA, nil, nil).MinTimes(1)
+ ctx.didResolver.EXPECT().Resolve(clientBDID, gomock.Any()).Return(&docClientB, nil, nil).MinTimes(1)
// Client C is owned, but not linked to the vendor (via NutsComm service), so do not register the service on that one
- didResolver.EXPECT().Resolve(clientCDID, gomock.Any()).Return(&did.Document{ID: clientCDID}, nil, nil).MinTimes(1)
- vdr := vdr.NewMockVDR(ctrl)
- vdr.EXPECT().Resolver().Return(didResolver).MinTimes(1)
- vdr.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{vendorDID, clientADID, clientBDID, clientCDID}, nil)
- didmanAPI := didman.NewMockDidman(ctrl)
+ ctx.didResolver.EXPECT().Resolve(clientCDID, gomock.Any()).Return(&did.Document{ID: clientCDID}, nil, nil).MinTimes(1)
+ ctx.vdr.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{vendorDID, clientADID, clientBDID, clientCDID}, nil)
// AddEndpoint is not called for vendor DID (URL already present), but for client DIDs.
// Not for clientC, since it's not linked to the vendor (doesn't have a NutsComm endpoint).
- didmanAPI.EXPECT().AddEndpoint(gomock.Any(), clientADID, resolver.BaseURLServiceType, *serviceRef).Return(nil, nil)
- didmanAPI.EXPECT().AddEndpoint(gomock.Any(), clientBDID, resolver.BaseURLServiceType, *serviceRef).Return(nil, nil)
- service := New(vdr, didmanAPI)
+ ctx.didmanAPI.EXPECT().AddEndpoint(gomock.Any(), clientADID, resolver.BaseURLServiceType, *serviceRef).Return(nil, nil)
+ ctx.didmanAPI.EXPECT().AddEndpoint(gomock.Any(), clientBDID, resolver.BaseURLServiceType, *serviceRef).Return(nil, nil)
+ service := ctx.hammer
service.tlsConfig = tlsServer.TLS
service.tlsConfig.InsecureSkipVerify = true
service.tlsConfig.Certificates = []tls.Certificate{pki.Certificate()}
@@ -217,13 +201,10 @@ func TestGoldenHammer_Fix(t *testing.T) {
assert.NoError(t, err)
})
t.Run("resolve error", func(t *testing.T) {
- ctrl := gomock.NewController(t)
- didResolver := resolver.NewMockDIDResolver(ctrl)
- didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(nil, nil, fmt.Errorf("resolve error"))
- vdr := vdr.NewMockVDR(ctrl)
- vdr.EXPECT().Resolver().Return(didResolver)
- vdr.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{vendorDID}, nil)
- service := New(vdr, nil)
+ ctx := newMockContext(t)
+ ctx.didResolver.EXPECT().Resolve(vendorDID, gomock.Any()).Return(nil, nil, fmt.Errorf("resolve error"))
+ ctx.vdr.EXPECT().ListOwned(gomock.Any()).Return([]did.DID{vendorDID}, nil)
+ service := ctx.hammer
service.tlsConfig = &tls.Config{
Certificates: []tls.Certificate{pki.Certificate()},
}
@@ -240,13 +221,12 @@ func TestGoldenHammer_Lifecycle(t *testing.T) {
defer goleak.VerifyNone(t, goleak.IgnoreCurrent())
fixCalled := &atomic.Int64{}
- ctrl := gomock.NewController(t)
- vdr := vdr.NewMockVDR(ctrl)
- vdr.EXPECT().ListOwned(gomock.Any()).DoAndReturn(func(_ context.Context) ([]did.DID, error) {
+ ctx := newMockContext(t)
+ ctx.vdr.EXPECT().ListOwned(gomock.Any()).DoAndReturn(func(_ context.Context) ([]did.DID, error) {
fixCalled.Add(1)
return []did.DID{}, nil
}).MinTimes(1)
- service := New(vdr, nil)
+ service := ctx.hammer
service.config.Interval = time.Millisecond
service.config.Enabled = true
@@ -271,6 +251,29 @@ func TestGoldenHammer_Lifecycle(t *testing.T) {
})
}
+type mockContext struct {
+ ctrl *gomock.Controller
+ didmanAPI *didman.MockDidman
+ didResolver *resolver.MockDIDResolver
+ hammer *GoldenHammer
+ vdr *vdr.MockVDR
+}
+
+func newMockContext(t *testing.T) mockContext {
+ ctrl := gomock.NewController(t)
+ mockVdr := vdr.NewMockVDR(ctrl)
+ mockDidmanAPI := didman.NewMockDidman(ctrl)
+ didResolver := resolver.NewMockDIDResolver(ctrl)
+ mockVdr.EXPECT().Resolver().Return(didResolver).AnyTimes()
+
+ return mockContext{
+ ctrl: ctrl,
+ didmanAPI: mockDidmanAPI,
+ didResolver: didResolver,
+ hammer: New(mockVdr, mockDidmanAPI),
+ vdr: mockVdr,
+ }
+}
func TestGoldenHammer_Name(t *testing.T) {
service := New(nil, nil)
diff --git a/test/url.go b/test/url.go
new file mode 100644
index 0000000000..200f6d23b4
--- /dev/null
+++ b/test/url.go
@@ -0,0 +1,29 @@
+/*
+ * 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 test
+
+import "net/url"
+
+func MustParseURL(str string) *url.URL {
+ u, err := url.Parse(str)
+ if err != nil {
+ panic(err)
+ }
+ return u
+}