From 4c5afc8c6e506a0af93e058af7de29877f6b249c Mon Sep 17 00:00:00 2001 From: Gerard Snaauw <33763579+gerardsn@users.noreply.github.com> Date: Wed, 15 May 2024 09:24:20 +0200 Subject: [PATCH] Support request_uri_method=post (#3102) * add request_uri_method post * speedup e2e tests * support authz request on did:jwk * add tests * pr feedback * fix replace all mistake * add docs * pr feedback 2 --- auth/api/iam/api.go | 172 +++++++++++------- auth/api/iam/api_test.go | 153 +++++++++++++--- auth/api/iam/generated.go | 92 +++++----- auth/api/iam/jar.go | 26 +-- auth/api/iam/jar_test.go | 93 ++++++++-- auth/api/iam/metadata.go | 45 ++++- auth/api/iam/metadata_test.go | 33 +++- auth/api/iam/openid4vp.go | 30 ++- auth/api/iam/openid4vp_test.go | 7 +- auth/api/iam/user.go | 11 +- auth/api/iam/user_test.go | 10 +- auth/client/iam/client.go | 37 +++- auth/client/iam/client_test.go | 50 ++++- auth/client/iam/interface.go | 9 +- auth/client/iam/mock.go | 27 ++- auth/client/iam/openid4vp.go | 24 ++- auth/client/iam/openid4vp_test.go | 48 ++++- auth/oauth/error.go | 13 +- auth/oauth/types.go | 4 + docs/_static/auth/iam.partial.yaml | 22 +-- docs/pages/deployment/oauth.rst | 1 + .../docker-compose.yml | 4 + .../rfc019_selfsigned/docker-compose.yml | 2 + 23 files changed, 664 insertions(+), 249 deletions(-) diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index a06d8ab50c..98d46d575b 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -27,14 +27,15 @@ import ( "encoding/json" "errors" "fmt" - "github.com/google/uuid" "html/template" "net/http" "net/url" "strings" "time" + "github.com/google/uuid" "github.com/labstack/echo/v4" + "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/audit" @@ -322,17 +323,20 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho // Workaround: deepmap codegen doesn't support dynamic query parameters. // See https://github.com/deepmap/oapi-codegen/issues/1129 httpRequest := ctx.Value(httpRequestContextKey{}).(*http.Request) - queryParams := httpRequest.URL.Query() + return r.handleAuthorizeRequest(ctx, *ownDID, *httpRequest.URL) +} +// handleAuthorizeRequest handles calls to the authorization endpoint for starting an authorization code flow. +// The caller must ensure ownDID is actually owned by this node. +func (r Wrapper) handleAuthorizeRequest(ctx context.Context, ownDID did.DID, request url.URL) (HandleAuthorizeRequestResponseObject, error) { // parse and validate as JAR (RFC9101, JWT Authorization Request) - authzParams, err := r.jar.Parse(ctx, *ownDID, queryParams) + requestObject, err := r.jar.Parse(ctx, ownDID, request.Query()) if err != nil { + // already an oauth.OAuth2Error return nil, err } - session := createSession(authzParams, *ownDID) - - switch session.ResponseType { + switch requestObject.get(oauth.ResponseTypeParam) { case responseTypeCode: // Options: // - Regular authorization code flow for EHR data access through access token, authentication of end-user using OpenID4VP. @@ -345,10 +349,10 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho // 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 + clientId := requestObject.get(oauth.ClientIDParam) if strings.HasPrefix(clientId, "did:web:") { // client is a cloud wallet with user - return r.handleAuthorizeRequestFromHolder(ctx, *ownDID, authzParams) + return r.handleAuthorizeRequestFromHolder(ctx, ownDID, requestObject) } else { return nil, oauth.OAuth2Error{ Code: oauth.InvalidRequest, @@ -362,13 +366,13 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho // Requests to user wallets can then be rendered as QR-code (or use a cloud wallet). // Note that it can't be called from the outside, but only by internal dispatch (since Echo doesn't handle openid4vp:, obviously). walletOwnerType := pe.WalletOwnerOrganization - if strings.HasPrefix(httpRequest.URL.String(), "openid4vp:") { + if strings.HasPrefix(request.String(), "openid4vp:") { walletOwnerType = pe.WalletOwnerUser } - return r.handleAuthorizeRequestFromVerifier(ctx, *ownDID, authzParams, walletOwnerType) + return r.handleAuthorizeRequestFromVerifier(ctx, ownDID, requestObject, walletOwnerType) default: // TODO: This should be a redirect? - redirectURI, _ := url.Parse(session.RedirectURI) + redirectURI, _ := url.Parse(requestObject.get(oauth.RedirectURIParam)) return nil, oauth.OAuth2Error{ Code: oauth.UnsupportedResponseType, RedirectURI: redirectURI, @@ -376,43 +380,100 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho } } -// GetRequestJWT returns the Request Object referenced as 'request_uri' in an authorization request. +// RequestJWTByGet returns the Request Object referenced as 'request_uri' in an authorization request. // RFC9101: The OAuth 2.0 Authorization Framework: JWT-Secured Authorization Request (JAR). -func (r Wrapper) GetRequestJWT(ctx context.Context, request GetRequestJWTRequestObject) (GetRequestJWTResponseObject, error) { +func (r Wrapper) RequestJWTByGet(ctx context.Context, request RequestJWTByGetRequestObject) (RequestJWTByGetResponseObject, error) { ro := new(jarRequest) + // TODO: burn request object to prevent DoS through signing requests https://github.com/nuts-foundation/nuts-node/issues/3063 err := r.authzRequestObjectStore().Get(request.Id, ro) if err != nil { - return nil, err + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "request object not found", + } } // compare raw strings, don't waste a db call to see if we own the request.Did. if ro.Client.String() != request.Did { - return nil, errors.New("invalid request") + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "client_id does not match request", + } } - if ro.RequestURIMethod != "get" { - // TODO: wallet does not support `request_uri_method=post`. Signing the current jarRequest would leave it without 'aud'. - // is this acceptable or should it fail? + if ro.RequestURIMethod != "get" { // case sensitive + // TODO: wallet does not support `request_uri_method=post`. Unclear if this should fail, or fallback to using staticAuthorizationServerMetadata(). return nil, oauth.OAuth2Error{ Code: oauth.InvalidRequest, Description: "used request_uri_method 'get' on a 'post' request_uri", InternalError: errors.New("wrong 'request_uri_method' authorization server or wallet probably does not support 'request_uri_method'"), } } + + // TODO: supported signature types should be checked token, err := r.jar.Sign(ctx, ro.Claims) if err != nil { - // TODO: oauth.OAuth2Error? - return nil, err + return nil, oauth.OAuth2Error{ + Code: oauth.ServerError, + Description: "unable to create Request Object", + InternalError: fmt.Errorf("failed to sign authorization Request Object: %w", err), + } } - return GetRequestJWT200ApplicationoauthAuthzReqJwtResponse{ + return RequestJWTByGet200ApplicationoauthAuthzReqJwtResponse{ Body: bytes.NewReader([]byte(token)), ContentLength: int64(len(token)), }, nil } -// PostRequestJWT returns the Request Object referenced as 'request_uri' in an authorization request. +// RequestJWTByPost returns the Request Object referenced as 'request_uri' in an authorization request. // Extension of OpenID 4 Verifiable Presentations (OpenID4VP) on // RFC9101: The OAuth 2.0 Authorization Framework: JWT-Secured Authorization Request (JAR). -func (r Wrapper) PostRequestJWT(ctx context.Context, request PostRequestJWTRequestObject) (PostRequestJWTResponseObject, error) { - return nil, errors.New("not implemented") +func (r Wrapper) RequestJWTByPost(ctx context.Context, request RequestJWTByPostRequestObject) (RequestJWTByPostResponseObject, error) { + ro := new(jarRequest) + // TODO: burn request object to prevent DoS through signing requests https://github.com/nuts-foundation/nuts-node/issues/3063 + err := r.authzRequestObjectStore().Get(request.Id, ro) + if err != nil { + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "request object not found", + } + } + // compare raw strings, don't waste a db call to see if we own the request.Did. + if ro.Client.String() != request.Did { + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "client_id does not match request", + } + } + if ro.RequestURIMethod != "post" { // case sensitive + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "used request_uri_method 'post' on a 'get' request_uri", + } + } + + walletMetadata := staticAuthorizationServerMetadata() + if request.Body != nil { + if request.Body.WalletMetadata != nil { + walletMetadata = *request.Body.WalletMetadata + } + if request.Body.WalletNonce != nil { + ro.Claims[oauth.WalletNonceParam] = *request.Body.WalletNonce + } + } + ro.Claims[jwt.AudienceKey] = walletMetadata.Issuer + + // TODO: supported signature types should be checked + token, err := r.jar.Sign(ctx, ro.Claims) + if err != nil { + return nil, oauth.OAuth2Error{ + Code: oauth.ServerError, + Description: "unable to create Request Object", + InternalError: fmt.Errorf("failed to sign authorization Request Object: %w", err), + } + } + return RequestJWTByPost200ApplicationoauthAuthzReqJwtResponse{ + Body: bytes.NewReader([]byte(token)), + ContentLength: int64(len(token)), + }, nil } // OAuthAuthorizationServerMetadata returns the Authorization Server's metadata @@ -438,17 +499,7 @@ func (r Wrapper) oauthAuthorizationServerMetadata(ctx context.Context, didAsStri if err != nil { return nil, err } - identity, err := didweb.DIDToURL(*ownDID) - if err != nil { - return nil, err - } - oauth2BaseURL, err := createOAuth2BaseURL(*ownDID) - if err != nil { - // can't fail, already did DIDToURL above - return nil, err - } - md := authorizationServerMetadata(*identity, *oauth2BaseURL) - return &md, nil + return authorizationServerMetadata(*ownDID) } func (r Wrapper) GetTenantWebDID(_ context.Context, request GetTenantWebDIDRequestObject) (GetTenantWebDIDResponseObject, error) { @@ -641,22 +692,6 @@ func (r Wrapper) RequestUserAccessToken(ctx context.Context, request RequestUser }, nil } -func createSession(params oauthParameters, ownDID did.DID) *OAuthSession { - session := OAuthSession{} - session.ClientID = params.get(oauth.ClientIDParam) - session.Scope = params.get(oauth.ScopeParam) - session.ClientState = params.get(oauth.StateParam) - session.RedirectURI = params.get(oauth.RedirectURIParam) - session.OwnDID = &ownDID - session.ResponseType = params.get(oauth.ResponseTypeParam) - session.PKCEParams = PKCEParams{ - Challenge: params.get(oauth.CodeChallengeParam), - ChallengeMethod: params.get(oauth.CodeChallengeMethodParam), - } - - return &session -} - func (r Wrapper) StatusList(ctx context.Context, request StatusListRequestObject) (StatusListResponseObject, error) { requestDID, err := did.ParseDID(request.Did) if err != nil { @@ -843,15 +878,26 @@ func (r Wrapper) openidIssuerEndpoints(ctx context.Context, issuerDid did.DID) ( // - jwt.Audience // - nonce // any of these params can be overridden by the requestObjectModifier. -func (r Wrapper) CreateAuthorizationRequest(ctx context.Context, client did.DID, server did.DID, modifier requestObjectModifier) (*url.URL, error) { - // we want to make a call according to §4.1.1 of RFC6749, https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.1 - // The URL should be listed in the verifier metadata under the "authorization_endpoint" key - metadata, err := r.auth.IAMClient().AuthorizationServerMetadata(ctx, server) - if err != nil { - return nil, fmt.Errorf("failed to retrieve remote OAuth Authorization Server metadata: %w", err) - } - if len(metadata.AuthorizationEndpoint) == 0 { - return nil, fmt.Errorf("no authorization endpoint found in metadata for %s", server) +func (r Wrapper) CreateAuthorizationRequest(ctx context.Context, client did.DID, server *did.DID, modifier requestObjectModifier) (*url.URL, error) { + metadata := new(oauth.AuthorizationServerMetadata) + if server != nil { + // we want to make a call according to §4.1.1 of RFC6749, https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.1 + // The URL should be listed in the verifier metadata under the "authorization_endpoint" key + var err error + metadata, err = r.auth.IAMClient().AuthorizationServerMetadata(ctx, *server) + if err != nil { + return nil, fmt.Errorf("failed to retrieve remote OAuth Authorization Server metadata: %w", err) + } + if len(metadata.AuthorizationEndpoint) == 0 { + return nil, fmt.Errorf("no authorization endpoint found in metadata for %s", *server) + } + } else { + // if the server is unknown/nil we are talking to a wallet. + // use static configuration while we try to determine the wallet that will answer the authorization request. (user wallet / QR code flow) + *metadata = staticAuthorizationServerMetadata() + // TODO: metadata.RequireSignedRequestObject == false. + // This means we send both a request_uri and add all params to the authorization request as query params. + // The resulting url is too long and will be rejected by mobile devices. } endpoint, err := url.Parse(metadata.AuthorizationEndpoint) if err != nil { @@ -860,8 +906,8 @@ func (r Wrapper) CreateAuthorizationRequest(ctx context.Context, client did.DID, // request_uri requestURIID := nutsCrypto.GenerateNonce() - requestObj := r.jar.Create(client, &server, modifier) - if err = r.authzRequestObjectStore().Put(requestURIID, requestObj); err != nil { + requestObj := r.jar.Create(client, server, modifier) + if err := r.authzRequestObjectStore().Put(requestURIID, requestObj); err != nil { return nil, err } baseURL, err := createOAuth2BaseURL(client) @@ -883,7 +929,7 @@ func (r Wrapper) CreateAuthorizationRequest(ctx context.Context, client did.DID, // else; unclear if AS has support for RFC9101, so also add all modifiers to the query itself // left here for completeness, node 2 node interaction always uses JAR since the AS metadata has it hardcoded // TODO: in the user flow we have no AS metadata, meaning that we add all params to the query. - // This is most likely going to fail on mobile devices due to request url length. + // This is most likely going to fail on mobile devices due to request url length. modifier(params) redirectURL := nutsHttp.AddQueryParams(*endpoint, params) return &redirectURL, nil diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 91144593d0..b7ef1ebf68 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -942,20 +942,108 @@ func TestWrapper_StatusList(t *testing.T) { } func TestWrapper_GetRequestJWT(t *testing.T) { + cont := context.Background() + requestID := "thisID" + expectedToken := "validToken" t.Run("ok", func(t *testing.T) { - cont := context.Background() - requestID := "thisID" - expectedToken := "validToken" + ctx := newTestClient(t) + ro := jar{}.Create(webDID, &holderDID, func(claims map[string]string) {}) + require.NoError(t, ctx.client.authzRequestObjectStore().Put(requestID, ro)) + ctx.jar.EXPECT().Sign(cont, ro.Claims).Return(expectedToken, nil) + + response, err := ctx.client.RequestJWTByGet(cont, RequestJWTByGetRequestObject{Did: webDID.String(), Id: requestID}) + + assert.NoError(t, err) + assert.Equal(t, RequestJWTByGet200ApplicationoauthAuthzReqJwtResponse{ + Body: bytes.NewReader([]byte(expectedToken)), + ContentLength: 10, + }, response) + }) + t.Run("error - not found", func(t *testing.T) { + ctx := newTestClient(t) + + response, err := ctx.client.RequestJWTByGet(nil, RequestJWTByGetRequestObject{Id: "unknownID"}) + + assert.Nil(t, response) + assert.EqualError(t, err, "invalid_request - request object not found") + }) + t.Run("error - clientID does not match request", func(t *testing.T) { + ctx := newTestClient(t) ro := jar{}.Create(webDID, &holderDID, func(claims map[string]string) {}) + require.NoError(t, ctx.client.authzRequestObjectStore().Put(requestID, ro)) + + response, err := ctx.client.RequestJWTByGet(cont, RequestJWTByGetRequestObject{Did: holderDID.String(), Id: requestID}) + + assert.Nil(t, response) + assert.EqualError(t, err, "invalid_request - client_id does not match request") + }) + t.Run("error - wrong request_uri_method used", func(t *testing.T) { + ctx := newTestClient(t) + ro := jar{}.Create(webDID, &holderDID, func(claims map[string]string) {}) + ro.RequestURIMethod = "post" + require.NoError(t, ctx.client.authzRequestObjectStore().Put(requestID, ro)) + + response, err := ctx.client.RequestJWTByGet(cont, RequestJWTByGetRequestObject{Did: webDID.String(), Id: requestID}) + + assert.Nil(t, response) + assert.EqualError(t, err, "invalid_request - wrong 'request_uri_method' authorization server or wallet probably does not support 'request_uri_method' - used request_uri_method 'get' on a 'post' request_uri") + }) + t.Run("error - signing failed", func(t *testing.T) { + ctx := newTestClient(t) + ro := jar{}.Create(webDID, &holderDID, func(claims map[string]string) {}) + require.NoError(t, ctx.client.authzRequestObjectStore().Put(requestID, ro)) + ctx.jar.EXPECT().Sign(cont, ro.Claims).Return("", errors.New("fail")) + + response, err := ctx.client.RequestJWTByGet(cont, RequestJWTByGetRequestObject{Did: webDID.String(), Id: requestID}) + + assert.Nil(t, response) + assert.EqualError(t, err, "server_error - failed to sign authorization Request Object: fail - unable to create Request Object") + }) +} +func TestWrapper_PostRequestJWT(t *testing.T) { + cont := context.Background() + requestID := "thisID" + expectedToken := "validToken" + newReqObj := func(issuer, nonce string) jarRequest { + ro := jar{}.Create(webDID, nil, func(claims map[string]string) {}) + if issuer != "" { + ro.Claims[jwt.AudienceKey] = issuer + } + if nonce != "" { + ro.Claims[oauth.WalletNonceParam] = nonce + } + return ro + } + t.Run("ok", func(t *testing.T) { ctx := newTestClient(t) + ro := newReqObj("https://self-issued.me/v2", "") + require.NoError(t, ctx.client.authzRequestObjectStore().Put(requestID, ro)) ctx.jar.EXPECT().Sign(cont, ro.Claims).Return(expectedToken, nil) + + response, err := ctx.client.RequestJWTByPost(cont, RequestJWTByPostRequestObject{Did: webDID.String(), Id: requestID}) + + assert.NoError(t, err) + assert.Equal(t, RequestJWTByPost200ApplicationoauthAuthzReqJwtResponse{ + Body: bytes.NewReader([]byte(expectedToken)), + ContentLength: 10, + }, response) + }) + t.Run("ok - with metadata and nonce", func(t *testing.T) { + wallet_nonce := "wallet_nonce" + ctx := newTestClient(t) + ro := newReqObj("mario", wallet_nonce) require.NoError(t, ctx.client.authzRequestObjectStore().Put(requestID, ro)) + ctx.jar.EXPECT().Sign(cont, ro.Claims).Return(expectedToken, nil) + body := RequestJWTByPostFormdataRequestBody(RequestJWTByPostFormdataBody{ + WalletMetadata: &oauth.AuthorizationServerMetadata{Issuer: "mario"}, + WalletNonce: &wallet_nonce, + }) - response, err := ctx.client.GetRequestJWT(cont, GetRequestJWTRequestObject{Did: webDID.String(), Id: requestID}) + response, err := ctx.client.RequestJWTByPost(cont, RequestJWTByPostRequestObject{Did: webDID.String(), Id: requestID, Body: &body}) assert.NoError(t, err) - assert.Equal(t, GetRequestJWT200ApplicationoauthAuthzReqJwtResponse{ + assert.Equal(t, RequestJWTByPost200ApplicationoauthAuthzReqJwtResponse{ Body: bytes.NewReader([]byte(expectedToken)), ContentLength: 10, }, response) @@ -963,20 +1051,42 @@ func TestWrapper_GetRequestJWT(t *testing.T) { t.Run("error - not found", func(t *testing.T) { ctx := newTestClient(t) - response, err := ctx.client.GetRequestJWT(nil, GetRequestJWTRequestObject{Id: "unknownID"}) + response, err := ctx.client.RequestJWTByPost(nil, RequestJWTByPostRequestObject{Id: "unknownID"}) assert.Nil(t, response) - assert.ErrorIs(t, err, storage.ErrNotFound) + assert.EqualError(t, err, "invalid_request - request object not found") }) -} + t.Run("error - clientID does not match request", func(t *testing.T) { + ctx := newTestClient(t) + require.NoError(t, ctx.client.authzRequestObjectStore().Put(requestID, newReqObj("", ""))) -func TestWrapper_PostRequestJWT(t *testing.T) { - ctx := newTestClient(t) + response, err := ctx.client.RequestJWTByPost(cont, RequestJWTByPostRequestObject{Did: holderDID.String(), Id: requestID}) + + assert.Nil(t, response) + assert.EqualError(t, err, "invalid_request - client_id does not match request") + }) + t.Run("error - wrong request_uri_method used", func(t *testing.T) { + ctx := newTestClient(t) + ro := newReqObj("", "") + ro.RequestURIMethod = "get" + require.NoError(t, ctx.client.authzRequestObjectStore().Put(requestID, ro)) - response, err := ctx.client.PostRequestJWT(nil, PostRequestJWTRequestObject{Id: "unknownID"}) + response, err := ctx.client.RequestJWTByPost(cont, RequestJWTByPostRequestObject{Did: webDID.String(), Id: requestID}) - assert.Nil(t, response) - assert.EqualError(t, err, "not implemented") + assert.Nil(t, response) + assert.EqualError(t, err, "invalid_request - used request_uri_method 'post' on a 'get' request_uri") + }) + t.Run("error - signing failed", func(t *testing.T) { + ctx := newTestClient(t) + ro := newReqObj("https://self-issued.me/v2", "") + require.NoError(t, ctx.client.authzRequestObjectStore().Put(requestID, ro)) + ctx.jar.EXPECT().Sign(cont, ro.Claims).Return("", errors.New("fail")) + + response, err := ctx.client.RequestJWTByPost(cont, RequestJWTByPostRequestObject{Did: webDID.String(), Id: requestID}) + + assert.Nil(t, response) + assert.EqualError(t, err, "server_error - failed to sign authorization Request Object: fail - unable to create Request Object") + }) } func TestWrapper_CreateAuthorizationRequest(t *testing.T) { @@ -1001,7 +1111,7 @@ func TestWrapper_CreateAuthorizationRequest(t *testing.T) { return expectedJarReq }) - redirectURL, err := ctx.client.CreateAuthorizationRequest(context.Background(), clientDID, serverDID, modifier) + redirectURL, err := ctx.client.CreateAuthorizationRequest(context.Background(), clientDID, &serverDID, modifier) // return assert.NoError(t, err) @@ -1014,29 +1124,28 @@ func TestWrapper_CreateAuthorizationRequest(t *testing.T) { require.NoError(t, ctx.client.authzRequestObjectStore().Get(requestURIID, &jarReq)) assert.Equal(t, expectedJarReq, jarReq) }) - t.Run("ok - RequireSignedRequestObject=false", func(t *testing.T) { + t.Run("ok - no server -> RequireSignedRequestObject=false", func(t *testing.T) { var expectedJarReq jarRequest ctx := newTestClient(t) - ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), serverDID).Return(&oauth.AuthorizationServerMetadata{AuthorizationEndpoint: serverMetadata.AuthorizationEndpoint}, nil) - ctx.jar.EXPECT().Create(clientDID, &serverDID, gomock.Any()).DoAndReturn(func(client did.DID, server *did.DID, modifier requestObjectModifier) jarRequest { + ctx.jar.EXPECT().Create(clientDID, nil, gomock.Any()).DoAndReturn(func(client did.DID, server *did.DID, modifier requestObjectModifier) jarRequest { expectedJarReq = createJarRequest(client, server, modifier) assert.Equal(t, "value", expectedJarReq.Claims.get("custom")) return expectedJarReq }) - redirectURL, err := ctx.client.CreateAuthorizationRequest(context.Background(), clientDID, serverDID, modifier) + redirectURL, err := ctx.client.CreateAuthorizationRequest(context.Background(), clientDID, nil, modifier) assert.NoError(t, err) assert.Equal(t, "value", redirectURL.Query().Get("custom")) assert.Equal(t, clientDID.String(), redirectURL.Query().Get(oauth.ClientIDParam)) - assert.Equal(t, "get", redirectURL.Query().Get(oauth.RequestURIMethodParam)) + assert.Equal(t, "post", redirectURL.Query().Get(oauth.RequestURIMethodParam)) assert.NotEmpty(t, redirectURL.Query().Get(oauth.RequestURIParam)) }) t.Run("error - missing authorization endpoint", func(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), serverDID).Return(&oauth.AuthorizationServerMetadata{}, nil) - _, err := ctx.client.CreateAuthorizationRequest(context.Background(), clientDID, serverDID, modifier) + _, err := ctx.client.CreateAuthorizationRequest(context.Background(), clientDID, &serverDID, modifier) assert.Error(t, err) assert.ErrorContains(t, err, "no authorization endpoint found in metadata for") @@ -1045,7 +1154,7 @@ func TestWrapper_CreateAuthorizationRequest(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), serverDID).Return(nil, assert.AnError) - _, err := ctx.client.CreateAuthorizationRequest(context.Background(), clientDID, serverDID, modifier) + _, err := ctx.client.CreateAuthorizationRequest(context.Background(), clientDID, &serverDID, modifier) assert.Error(t, err) }) @@ -1053,7 +1162,7 @@ func TestWrapper_CreateAuthorizationRequest(t *testing.T) { ctx := newTestClient(t) ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), serverDID).Return(&oauth.AuthorizationServerMetadata{AuthorizationEndpoint: ":"}, nil) - _, err := ctx.client.CreateAuthorizationRequest(context.Background(), clientDID, serverDID, modifier) + _, err := ctx.client.CreateAuthorizationRequest(context.Background(), clientDID, &serverDID, modifier) assert.Error(t, err) assert.ErrorContains(t, err, "failed to parse authorization endpoint URL") diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index a91d48978e..5ec71b0ebd 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -243,8 +243,8 @@ type PresentationDefinitionParams struct { WalletOwnerType *WalletOwnerType `form:"wallet_owner_type,omitempty" json:"wallet_owner_type,omitempty"` } -// PostRequestJWTFormdataBody defines parameters for PostRequestJWT. -type PostRequestJWTFormdataBody struct { +// RequestJWTByPostFormdataBody defines parameters for RequestJWTByPost. +type RequestJWTByPostFormdataBody struct { // WalletMetadata OAuth2 Authorization Server Metadata // Contain properties from several specifications and may grow over time WalletMetadata *OAuthAuthorizationServerMetadata `form:"wallet_metadata,omitempty" json:"wallet_metadata,omitempty"` @@ -299,8 +299,8 @@ type RequestServiceAccessTokenJSONRequestBody = ServiceAccessTokenRequest // RequestUserAccessTokenJSONRequestBody defines body for RequestUserAccessToken for application/json ContentType. type RequestUserAccessTokenJSONRequestBody = UserAccessTokenRequest -// PostRequestJWTFormdataRequestBody defines body for PostRequestJWT for application/x-www-form-urlencoded ContentType. -type PostRequestJWTFormdataRequestBody PostRequestJWTFormdataBody +// RequestJWTByPostFormdataRequestBody defines body for RequestJWTByPost for application/x-www-form-urlencoded ContentType. +type RequestJWTByPostFormdataRequestBody RequestJWTByPostFormdataBody // HandleAuthorizeResponseFormdataRequestBody defines body for HandleAuthorizeResponse for application/x-www-form-urlencoded ContentType. type HandleAuthorizeResponseFormdataRequestBody HandleAuthorizeResponseFormdataBody @@ -591,10 +591,10 @@ type ServerInterface interface { PresentationDefinition(ctx echo.Context, did string, params PresentationDefinitionParams) error // Get Request Object referenced in an authorization request to the Authorization Server. // (GET /oauth2/{did}/request.jwt/{id}) - GetRequestJWT(ctx echo.Context, did string, id string) error + RequestJWTByGet(ctx echo.Context, did string, id string) error // Provide missing information to Client to finish Authorization request's Request Object, which is then returned. // (POST /oauth2/{did}/request.jwt/{id}) - PostRequestJWT(ctx echo.Context, did string, id string) error + RequestJWTByPost(ctx echo.Context, did string, id string) error // Used by wallets to post the authorization response or error to. // (POST /oauth2/{did}/response) HandleAuthorizeResponse(ctx echo.Context, did string) error @@ -925,8 +925,8 @@ func (w *ServerInterfaceWrapper) PresentationDefinition(ctx echo.Context) error return err } -// GetRequestJWT converts echo context to params. -func (w *ServerInterfaceWrapper) GetRequestJWT(ctx echo.Context) error { +// RequestJWTByGet converts echo context to params. +func (w *ServerInterfaceWrapper) RequestJWTByGet(ctx echo.Context) error { var err error // ------------- Path parameter "did" ------------- var did string @@ -944,12 +944,12 @@ func (w *ServerInterfaceWrapper) GetRequestJWT(ctx echo.Context) error { ctx.Set(JwtBearerAuthScopes, []string{}) // Invoke the callback with all the unmarshaled arguments - err = w.Handler.GetRequestJWT(ctx, did, id) + err = w.Handler.RequestJWTByGet(ctx, did, id) return err } -// PostRequestJWT converts echo context to params. -func (w *ServerInterfaceWrapper) PostRequestJWT(ctx echo.Context) error { +// RequestJWTByPost converts echo context to params. +func (w *ServerInterfaceWrapper) RequestJWTByPost(ctx echo.Context) error { var err error // ------------- Path parameter "did" ------------- var did string @@ -967,7 +967,7 @@ func (w *ServerInterfaceWrapper) PostRequestJWT(ctx echo.Context) error { ctx.Set(JwtBearerAuthScopes, []string{}) // Invoke the callback with all the unmarshaled arguments - err = w.Handler.PostRequestJWT(ctx, did, id) + err = w.Handler.RequestJWTByPost(ctx, did, id) return err } @@ -1068,8 +1068,8 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/oauth2/:did/callback", wrapper.Callback) router.GET(baseURL+"/oauth2/:did/oauth-client", wrapper.OAuthClientMetadata) router.GET(baseURL+"/oauth2/:did/presentation_definition", wrapper.PresentationDefinition) - router.GET(baseURL+"/oauth2/:did/request.jwt/:id", wrapper.GetRequestJWT) - router.POST(baseURL+"/oauth2/:did/request.jwt/:id", wrapper.PostRequestJWT) + router.GET(baseURL+"/oauth2/:did/request.jwt/:id", wrapper.RequestJWTByGet) + router.POST(baseURL+"/oauth2/:did/request.jwt/:id", wrapper.RequestJWTByPost) router.POST(baseURL+"/oauth2/:did/response", wrapper.HandleAuthorizeResponse) router.POST(baseURL+"/oauth2/:did/token", wrapper.HandleTokenRequest) router.GET(baseURL+"/statuslist/:did/:page", wrapper.StatusList) @@ -1650,21 +1650,21 @@ func (response PresentationDefinitiondefaultApplicationProblemPlusJSONResponse) return json.NewEncoder(w).Encode(response.Body) } -type GetRequestJWTRequestObject struct { +type RequestJWTByGetRequestObject struct { Did string `json:"did"` Id string `json:"id"` } -type GetRequestJWTResponseObject interface { - VisitGetRequestJWTResponse(w http.ResponseWriter) error +type RequestJWTByGetResponseObject interface { + VisitRequestJWTByGetResponse(w http.ResponseWriter) error } -type GetRequestJWT200ApplicationoauthAuthzReqJwtResponse struct { +type RequestJWTByGet200ApplicationoauthAuthzReqJwtResponse struct { Body io.Reader ContentLength int64 } -func (response GetRequestJWT200ApplicationoauthAuthzReqJwtResponse) VisitGetRequestJWTResponse(w http.ResponseWriter) error { +func (response RequestJWTByGet200ApplicationoauthAuthzReqJwtResponse) VisitRequestJWTByGetResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/oauth-authz-req+jwt") if response.ContentLength != 0 { w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength)) @@ -1678,7 +1678,7 @@ func (response GetRequestJWT200ApplicationoauthAuthzReqJwtResponse) VisitGetRequ return err } -type GetRequestJWTdefaultApplicationProblemPlusJSONResponse struct { +type RequestJWTByGetdefaultApplicationProblemPlusJSONResponse struct { Body struct { // Detail A human-readable explanation specific to this occurrence of the problem. Detail string `json:"detail"` @@ -1692,29 +1692,29 @@ type GetRequestJWTdefaultApplicationProblemPlusJSONResponse struct { StatusCode int } -func (response GetRequestJWTdefaultApplicationProblemPlusJSONResponse) VisitGetRequestJWTResponse(w http.ResponseWriter) error { +func (response RequestJWTByGetdefaultApplicationProblemPlusJSONResponse) VisitRequestJWTByGetResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/problem+json") w.WriteHeader(response.StatusCode) return json.NewEncoder(w).Encode(response.Body) } -type PostRequestJWTRequestObject struct { +type RequestJWTByPostRequestObject struct { Did string `json:"did"` Id string `json:"id"` - Body *PostRequestJWTFormdataRequestBody + Body *RequestJWTByPostFormdataRequestBody } -type PostRequestJWTResponseObject interface { - VisitPostRequestJWTResponse(w http.ResponseWriter) error +type RequestJWTByPostResponseObject interface { + VisitRequestJWTByPostResponse(w http.ResponseWriter) error } -type PostRequestJWT200ApplicationoauthAuthzReqJwtResponse struct { +type RequestJWTByPost200ApplicationoauthAuthzReqJwtResponse struct { Body io.Reader ContentLength int64 } -func (response PostRequestJWT200ApplicationoauthAuthzReqJwtResponse) VisitPostRequestJWTResponse(w http.ResponseWriter) error { +func (response RequestJWTByPost200ApplicationoauthAuthzReqJwtResponse) VisitRequestJWTByPostResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/oauth-authz-req+jwt") if response.ContentLength != 0 { w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength)) @@ -1728,7 +1728,7 @@ func (response PostRequestJWT200ApplicationoauthAuthzReqJwtResponse) VisitPostRe return err } -type PostRequestJWTdefaultApplicationProblemPlusJSONResponse struct { +type RequestJWTByPostdefaultApplicationProblemPlusJSONResponse struct { Body struct { // Detail A human-readable explanation specific to this occurrence of the problem. Detail string `json:"detail"` @@ -1742,7 +1742,7 @@ type PostRequestJWTdefaultApplicationProblemPlusJSONResponse struct { StatusCode int } -func (response PostRequestJWTdefaultApplicationProblemPlusJSONResponse) VisitPostRequestJWTResponse(w http.ResponseWriter) error { +func (response RequestJWTByPostdefaultApplicationProblemPlusJSONResponse) VisitRequestJWTByPostResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/problem+json") w.WriteHeader(response.StatusCode) @@ -1888,10 +1888,10 @@ type StrictServerInterface interface { PresentationDefinition(ctx context.Context, request PresentationDefinitionRequestObject) (PresentationDefinitionResponseObject, error) // Get Request Object referenced in an authorization request to the Authorization Server. // (GET /oauth2/{did}/request.jwt/{id}) - GetRequestJWT(ctx context.Context, request GetRequestJWTRequestObject) (GetRequestJWTResponseObject, error) + RequestJWTByGet(ctx context.Context, request RequestJWTByGetRequestObject) (RequestJWTByGetResponseObject, error) // Provide missing information to Client to finish Authorization request's Request Object, which is then returned. // (POST /oauth2/{did}/request.jwt/{id}) - PostRequestJWT(ctx context.Context, request PostRequestJWTRequestObject) (PostRequestJWTResponseObject, error) + RequestJWTByPost(ctx context.Context, request RequestJWTByPostRequestObject) (RequestJWTByPostResponseObject, error) // Used by wallets to post the authorization response or error to. // (POST /oauth2/{did}/response) HandleAuthorizeResponse(ctx context.Context, request HandleAuthorizeResponseRequestObject) (HandleAuthorizeResponseResponseObject, error) @@ -2350,41 +2350,41 @@ func (sh *strictHandler) PresentationDefinition(ctx echo.Context, did string, pa return nil } -// GetRequestJWT operation middleware -func (sh *strictHandler) GetRequestJWT(ctx echo.Context, did string, id string) error { - var request GetRequestJWTRequestObject +// RequestJWTByGet operation middleware +func (sh *strictHandler) RequestJWTByGet(ctx echo.Context, did string, id string) error { + var request RequestJWTByGetRequestObject request.Did = did request.Id = id handler := func(ctx echo.Context, request interface{}) (interface{}, error) { - return sh.ssi.GetRequestJWT(ctx.Request().Context(), request.(GetRequestJWTRequestObject)) + return sh.ssi.RequestJWTByGet(ctx.Request().Context(), request.(RequestJWTByGetRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "GetRequestJWT") + handler = middleware(handler, "RequestJWTByGet") } response, err := handler(ctx, request) if err != nil { return err - } else if validResponse, ok := response.(GetRequestJWTResponseObject); ok { - return validResponse.VisitGetRequestJWTResponse(ctx.Response()) + } else if validResponse, ok := response.(RequestJWTByGetResponseObject); ok { + return validResponse.VisitRequestJWTByGetResponse(ctx.Response()) } else if response != nil { return fmt.Errorf("unexpected response type: %T", response) } return nil } -// PostRequestJWT operation middleware -func (sh *strictHandler) PostRequestJWT(ctx echo.Context, did string, id string) error { - var request PostRequestJWTRequestObject +// RequestJWTByPost operation middleware +func (sh *strictHandler) RequestJWTByPost(ctx echo.Context, did string, id string) error { + var request RequestJWTByPostRequestObject request.Did = did request.Id = id if form, err := ctx.FormParams(); err == nil { - var body PostRequestJWTFormdataRequestBody + var body RequestJWTByPostFormdataRequestBody if err := runtime.BindForm(&body, form, nil, nil); err != nil { return err } @@ -2394,18 +2394,18 @@ func (sh *strictHandler) PostRequestJWT(ctx echo.Context, did string, id string) } handler := func(ctx echo.Context, request interface{}) (interface{}, error) { - return sh.ssi.PostRequestJWT(ctx.Request().Context(), request.(PostRequestJWTRequestObject)) + return sh.ssi.RequestJWTByPost(ctx.Request().Context(), request.(RequestJWTByPostRequestObject)) } for _, middleware := range sh.middlewares { - handler = middleware(handler, "PostRequestJWT") + handler = middleware(handler, "RequestJWTByPost") } response, err := handler(ctx, request) if err != nil { return err - } else if validResponse, ok := response.(PostRequestJWTResponseObject); ok { - return validResponse.VisitPostRequestJWTResponse(ctx.Response()) + } else if validResponse, ok := response.(RequestJWTByPostResponseObject); ok { + return validResponse.VisitRequestJWTByPostResponse(ctx.Response()) } else if response != nil { return fmt.Errorf("unexpected response type: %T", response) } diff --git a/auth/api/iam/jar.go b/auth/api/iam/jar.go index 22a2026421..16cbd87e22 100644 --- a/auth/api/iam/jar.go +++ b/auth/api/iam/jar.go @@ -118,20 +118,24 @@ func (j jar) Parse(ctx context.Context, ownDID did.DID, q url.Values) (oauthPara return nil, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "claims 'request' and 'request_uri' are mutually exclusive"} } } else if requestURI := q.Get(oauth.RequestURIParam); requestURI != "" { - if q.Get(oauth.RequestURIMethodParam) == "post" { // case-sensitive match - baseURL, err := createOAuth2BaseURL(ownDID) + switch q.Get(oauth.RequestURIMethodParam) { + case "", "get": // empty string means client does not support request_uri_method, use 'get' + rawRequestObject, err = j.auth.IAMClient().RequestObjectByGet(ctx, requestURI) if err != nil { - // can't fail + return nil, oauth.OAuth2Error{Code: oauth.InvalidRequestURI, Description: "failed to get Request Object", InternalError: err} + } + case "post": + md, err := authorizationServerMetadata(ownDID) + if err != nil { + // DB error return nil, err } - walletMetadata := authorizationServerMetadata(ownDID.URI().URL, *baseURL) - // TODO: create wallet_metadata and post to requestURI. - _ = walletMetadata - // TODO: do we need wallet_nonce? only way to reach nuts_node is server2server comms. - } - rawRequestObject, err = j.auth.IAMClient().RequestObject(ctx, requestURI) - if err != nil { - return nil, oauth.OAuth2Error{Code: oauth.InvalidRequestURI, Description: "failed to get Request Object", InternalError: err} + rawRequestObject, err = j.auth.IAMClient().RequestObjectByPost(ctx, requestURI, *md) + if err != nil { + return nil, oauth.OAuth2Error{Code: oauth.InvalidRequestURI, Description: "failed to get Request Object", InternalError: err} + } + default: + return nil, oauth.OAuth2Error{Code: oauth.InvalidRequestURIMethod, Description: "unsupported request_uri_method"} } } else { // require_signed_request_object is true, so we reject anything that isn't diff --git a/auth/api/iam/jar_test.go b/auth/api/iam/jar_test.go index cdc4e05a92..1a13c22075 100644 --- a/auth/api/iam/jar_test.go +++ b/auth/api/iam/jar_test.go @@ -113,18 +113,61 @@ func TestJar_Parse(t *testing.T) { require.NoError(t, err) token := string(bytes) ctx := newJarTestCtx(t) - t.Run("ok - 'request_uri'", func(t *testing.T) { - ctx.iamClient.EXPECT().RequestObject(context.Background(), "request_uri").Return(token, nil) - ctx.keyResolver.EXPECT().ResolveKeyByID(key.KID(), nil, resolver.AssertionMethod).Return(key.Public(), nil) + t.Run("request_uri_method", func(t *testing.T) { + t.Run("ok - get", func(t *testing.T) { + ctx.iamClient.EXPECT().RequestObjectByGet(context.Background(), "request_uri").Return(token, nil) + ctx.keyResolver.EXPECT().ResolveKeyByID(key.KID(), nil, resolver.AssertionMethod).Return(key.Public(), nil) - res, err := ctx.jar.Parse(context.Background(), verifierDID, - map[string][]string{ - oauth.ClientIDParam: {holderDID.String()}, - oauth.RequestURIParam: {"request_uri"}, - }) + res, err := ctx.jar.Parse(context.Background(), verifierDID, + map[string][]string{ + oauth.ClientIDParam: {holderDID.String()}, + oauth.RequestURIParam: {"request_uri"}, + oauth.RequestURIMethodParam: {"get"}, + }) - assert.NoError(t, err) - require.NotNil(t, res) + assert.NoError(t, err) + require.NotNil(t, res) + }) + t.Run("ok - param not supported", func(t *testing.T) { + ctx.iamClient.EXPECT().RequestObjectByGet(context.Background(), "request_uri").Return(token, nil) + ctx.keyResolver.EXPECT().ResolveKeyByID(key.KID(), nil, resolver.AssertionMethod).Return(key.Public(), nil) + + res, err := ctx.jar.Parse(context.Background(), verifierDID, + map[string][]string{ + oauth.ClientIDParam: {holderDID.String()}, + oauth.RequestURIParam: {"request_uri"}, + oauth.RequestURIMethodParam: {""}, + }) + + assert.NoError(t, err) + require.NotNil(t, res) + }) + t.Run("ok - post", func(t *testing.T) { + md, _ := authorizationServerMetadata(verifierDID) + ctx.iamClient.EXPECT().RequestObjectByPost(context.Background(), "request_uri", *md).Return(token, nil) + ctx.keyResolver.EXPECT().ResolveKeyByID(key.KID(), nil, resolver.AssertionMethod).Return(key.Public(), nil) + + res, err := ctx.jar.Parse(context.Background(), verifierDID, + map[string][]string{ + oauth.ClientIDParam: {holderDID.String()}, + oauth.RequestURIParam: {"request_uri"}, + oauth.RequestURIMethodParam: {"post"}, + }) + + assert.NoError(t, err) + require.NotNil(t, res) + }) + t.Run("error - unsupported method", func(t *testing.T) { + res, err := ctx.jar.Parse(context.Background(), verifierDID, + map[string][]string{ + oauth.ClientIDParam: {holderDID.String()}, + oauth.RequestURIParam: {"request_uri"}, + oauth.RequestURIMethodParam: {"invalid"}, + }) + + assert.EqualError(t, err, "invalid_request_uri_method - unsupported request_uri_method") + assert.Nil(t, res) + }) }) t.Run("ok - 'request'", func(t *testing.T) { ctx.keyResolver.EXPECT().ResolveKeyByID(key.KID(), nil, resolver.AssertionMethod).Return(key.Public(), nil) @@ -138,15 +181,29 @@ func TestJar_Parse(t *testing.T) { assert.NoError(t, err) require.NotNil(t, res) }) - t.Run("error - server error", func(t *testing.T) { - ctx.iamClient.EXPECT().RequestObject(context.Background(), "request_uri").Return("", errors.New("server error")) - res, err := ctx.jar.Parse(context.Background(), verifierDID, - map[string][]string{ - oauth.RequestURIParam: {"request_uri"}, - }) + t.Run("server error", func(t *testing.T) { + t.Run("get", func(t *testing.T) { + ctx.iamClient.EXPECT().RequestObjectByGet(context.Background(), "request_uri").Return("", errors.New("server error")) + res, err := ctx.jar.Parse(context.Background(), verifierDID, + map[string][]string{ + oauth.RequestURIParam: {"request_uri"}, + }) - requireOAuthError(t, err, oauth.InvalidRequestURI, "failed to get Request Object") - assert.Nil(t, res) + requireOAuthError(t, err, oauth.InvalidRequestURI, "failed to get Request Object") + assert.Nil(t, res) + }) + t.Run("post", func(t *testing.T) { + md, _ := authorizationServerMetadata(verifierDID) + ctx.iamClient.EXPECT().RequestObjectByPost(context.Background(), "request_uri", *md).Return("", errors.New("server error")) + res, err := ctx.jar.Parse(context.Background(), verifierDID, + map[string][]string{ + oauth.RequestURIParam: {"request_uri"}, + oauth.RequestURIMethodParam: {"post"}, + }) + + requireOAuthError(t, err, oauth.InvalidRequestURI, "failed to get Request Object") + assert.Nil(t, res) + }) }) t.Run("error - both 'request' and 'request_uri'", func(t *testing.T) { res, err := ctx.jar.Parse(context.Background(), verifierDID, diff --git a/auth/api/iam/metadata.go b/auth/api/iam/metadata.go index 425229897e..dc14b738af 100644 --- a/auth/api/iam/metadata.go +++ b/auth/api/iam/metadata.go @@ -22,30 +22,63 @@ import ( "net/url" "strings" + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto/jwx" + "github.com/nuts-foundation/nuts-node/vdr/didweb" ) -func authorizationServerMetadata(identity url.URL, oauth2BaseURL url.URL) oauth.AuthorizationServerMetadata { +func authorizationServerMetadata(ownedDID did.DID) (*oauth.AuthorizationServerMetadata, error) { presentationDefinitionURISupported := true - return oauth.AuthorizationServerMetadata{ - AuthorizationEndpoint: oauth2BaseURL.JoinPath("authorize").String(), + metadata := &oauth.AuthorizationServerMetadata{ + AuthorizationEndpoint: "openid4vp:", ClientIdSchemesSupported: clientIdSchemesSupported, DPoPSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(), GrantTypesSupported: grantTypesSupported, - Issuer: identity.String(), + Issuer: ownedDID.String(), // todo: according to RFC8414 this should be a URL starting with https PreAuthorizedGrantAnonymousAccessSupported: true, PresentationDefinitionUriSupported: &presentationDefinitionURISupported, - PresentationDefinitionEndpoint: oauth2BaseURL.JoinPath("presentation_definition").String(), RequireSignedRequestObject: true, ResponseModesSupported: responseModesSupported, ResponseTypesSupported: responseTypesSupported, - TokenEndpoint: oauth2BaseURL.JoinPath("token").String(), VPFormats: oauth.DefaultOpenIDSupportedFormats(), VPFormatsSupported: oauth.DefaultOpenIDSupportedFormats(), RequestObjectSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(), } + if ownedDID.Method == "web" { + // add endpoints for did:web + identity, err := didweb.DIDToURL(ownedDID) + if err != nil { + return nil, err + } + oauth2BaseURL, err := createOAuth2BaseURL(ownedDID) + if err != nil { + // can't fail, already did DIDToURL above + return nil, err + } + metadata.Issuer = identity.String() + metadata.AuthorizationEndpoint = oauth2BaseURL.JoinPath("authorize").String() + metadata.PresentationDefinitionEndpoint = oauth2BaseURL.JoinPath("presentation_definition").String() + metadata.TokenEndpoint = oauth2BaseURL.JoinPath("token").String() + } + return metadata, nil +} + +// staticAuthorizationServerMetadata is used in the OpenID4VP flow when authorization server (wallet) issuer is unknown. +// Note: several specs (OpenID4VP, SIOPv2, OpenID core) define a static authorization server configuration that currently are conflicting. +func staticAuthorizationServerMetadata() oauth.AuthorizationServerMetadata { + return oauth.AuthorizationServerMetadata{ + Issuer: "https://self-issued.me/v2", + AuthorizationEndpoint: "openid4vp:", + ResponseTypesSupported: []string{responseTypeVPToken}, + VPFormatsSupported: map[string]map[string][]string{ + "jwt_vp_json": {"alg_values_supported": []string{string(jwa.ES256)}}, + "jwt_vc_json": {"alg_values_supported": []string{string(jwa.ES256)}}, + }, + RequestObjectSigningAlgValuesSupported: []string{string(jwa.ES256)}, + } } // clientMetadata should only be used for dids managed by the node. It assumes the provided identity URL is correct. diff --git a/auth/api/iam/metadata_test.go b/auth/api/iam/metadata_test.go index 5ce95deea5..4d380d0679 100644 --- a/auth/api/iam/metadata_test.go +++ b/auth/api/iam/metadata_test.go @@ -22,6 +22,7 @@ import ( "net/url" "testing" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto/jwx" @@ -30,27 +31,43 @@ import ( ) func Test_authorizationServerMetadata(t *testing.T) { - identity := test.MustParseURL("https://example.com/iam/123") - oauth2Base := test.MustParseURL("https://example.com/oauth2/did:web:example.com:iam:123") presentationDefinitionURISupported := true - expected := oauth.AuthorizationServerMetadata{ - AuthorizationEndpoint: oauth2Base.String() + "/authorize", + didExample := did.MustParseDID("did:example:test") + baseExpected := oauth.AuthorizationServerMetadata{ + AuthorizationEndpoint: "openid4vp:", ClientIdSchemesSupported: []string{"did"}, DPoPSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(), GrantTypesSupported: []string{"authorization_code", "vp_token", "urn:ietf:params:oauth:grant-type:pre-authorized_code"}, - Issuer: identity.String(), + Issuer: didExample.String(), PreAuthorizedGrantAnonymousAccessSupported: true, PresentationDefinitionUriSupported: &presentationDefinitionURISupported, - PresentationDefinitionEndpoint: oauth2Base.String() + "/presentation_definition", RequireSignedRequestObject: true, ResponseTypesSupported: []string{"code", "vp_token"}, ResponseModesSupported: []string{"query", "direct_post"}, - TokenEndpoint: oauth2Base.String() + "/token", VPFormats: oauth.DefaultOpenIDSupportedFormats(), VPFormatsSupported: oauth.DefaultOpenIDSupportedFormats(), RequestObjectSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(), } - assert.Equal(t, expected, authorizationServerMetadata(*identity, *oauth2Base)) + t.Run("base", func(t *testing.T) { + md, err := authorizationServerMetadata(didExample) + assert.NoError(t, err) + assert.Equal(t, baseExpected, *md) + }) + t.Run("did:web", func(t *testing.T) { + didWeb := did.MustParseDID("did:web:example.com:iam:123") + identity := test.MustParseURL("https://example.com/iam/123") + oauth2Base := test.MustParseURL("https://example.com/oauth2/did:web:example.com:iam:123") + + webExpected := baseExpected + webExpected.Issuer = identity.String() + webExpected.AuthorizationEndpoint = oauth2Base.String() + "/authorize" + webExpected.PresentationDefinitionEndpoint = oauth2Base.String() + "/presentation_definition" + webExpected.TokenEndpoint = oauth2Base.String() + "/token" + + md, err := authorizationServerMetadata(didWeb) + assert.NoError(t, err) + assert.Equal(t, webExpected, *md) + }) } func Test_clientMetadata(t *testing.T) { diff --git a/auth/api/iam/openid4vp.go b/auth/api/iam/openid4vp.go index 9380f3f3bc..ed7f82f048 100644 --- a/auth/api/iam/openid4vp.go +++ b/auth/api/iam/openid4vp.go @@ -209,8 +209,14 @@ func (r Wrapper) nextOpenID4VPFlow(ctx context.Context, state string, session OA values[oauth.NonceParam] = nonce values[oauth.StateParam] = state } - walletDID, _ := did.ParseDID(session.ClientID) - authServerURL, err := r.CreateAuthorizationRequest(ctx, *session.OwnDID, *walletDID, modifier) + var authServerURL *url.URL + if *walletOwnerType == pe.WalletOwnerUser { + // User wallet, make an openid4vp: request URL + authServerURL, err = r.CreateAuthorizationRequest(ctx, *session.OwnDID, nil, modifier) + } else { + walletDID, _ := did.ParseDID(session.ClientID) + authServerURL, err = r.CreateAuthorizationRequest(ctx, *session.OwnDID, walletDID, modifier) + } if err != nil { return nil, oauth.OAuth2Error{ Code: oauth.ServerError, @@ -219,13 +225,6 @@ func (r Wrapper) nextOpenID4VPFlow(ctx context.Context, state string, session OA RedirectURI: session.redirectURI(), } } - if *walletOwnerType == pe.WalletOwnerUser { - // User wallet, make an openid4vp:// request URL - var newRequestURL url.URL - newRequestURL.Scheme = "openid4vp" - newRequestURL.RawQuery = authServerURL.RawQuery - authServerURL = &newRequestURL - } // use nonce and state to store authorization request in session store if err = r.oauthNonceStore().Put(nonce, state); err != nil { @@ -338,7 +337,7 @@ func (r Wrapper) handleAuthorizeRequestFromVerifier(ctx context.Context, tenantD } // any error here is a server error, might need a fixup to prevent exposing to a user - return r.sendAndHandleDirectPost(ctx, tenantDID, *vp, *submission, responseURI, state) + return r.sendAndHandleDirectPost(ctx, userSession.Wallet.DID, *vp, *submission, responseURI, state) } func (r Wrapper) getClientMetadataFromRequest(ctx context.Context, params oauthParameters) (*oauth.OAuthClientMetadata, *oauth.OAuth2Error) { @@ -384,7 +383,7 @@ func (r Wrapper) getPresentationDefinitionFromRequest(ctx context.Context, param // 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, walletDID did.DID, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI string, state string) (HandleAuthorizeRequestResponseObject, error) { +func (r Wrapper) sendAndHandleDirectPost(ctx context.Context, userWalletDID did.DID, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI string, state string) (HandleAuthorizeRequestResponseObject, error) { redirectURI, err := r.auth.IAMClient().PostAuthorizationResponse(ctx, vp, presentationSubmission, verifierResponseURI, state) if err != nil { return nil, err @@ -398,13 +397,8 @@ func (r Wrapper) sendAndHandleDirectPost(ctx context.Context, walletDID did.DID, } // Dispatch a new HTTP request to the local OpenID4VP wallet's authorization endpoint that includes request parameters, // but with openid4vp: as scheme. - originalRequest := ctx.Value(httpRequestContextKey{}).(*http.Request) - dispatchHttpRequest := *originalRequest - dispatchHttpRequest.URL = parsedRedirectURI - ctx = context.WithValue(ctx, httpRequestContextKey{}, &dispatchHttpRequest) - response, err := r.HandleAuthorizeRequest(ctx, HandleAuthorizeRequestRequestObject{ - Did: walletDID.String(), - }) + // The context contains data from the previous request. Usage by the handler will probably result in incorrect behavior. + response, err := r.handleAuthorizeRequest(ctx, userWalletDID, *parsedRedirectURI) if err != nil { return nil, err } diff --git a/auth/api/iam/openid4vp_test.go b/auth/api/iam/openid4vp_test.go index 49def05dc9..13a06079e9 100644 --- a/auth/api/iam/openid4vp_test.go +++ b/auth/api/iam/openid4vp_test.go @@ -454,12 +454,7 @@ func TestWrapper_HandleAuthorizeResponse(t *testing.T) { putNonce(ctx, challenge) ctx.vdr.EXPECT().IsOwner(gomock.Any(), verifierDID).Return(true, nil) ctx.vcVerifier.EXPECT().VerifyVP(gomock.Any(), true, true, nil).Return(nil, nil) - redirectURL, _ := url.Parse("https://redirect-url") - ctx.iamClient.EXPECT().AuthorizationServerMetadata(gomock.Any(), holderDID).Return(&oauth.AuthorizationServerMetadata{ - AuthorizationEndpoint: redirectURL.String(), - ClientIdSchemesSupported: []string{"did"}, - }, nil) - ctx.jar.EXPECT().Create(verifierDID, &holderDID, gomock.Any()) + ctx.jar.EXPECT().Create(verifierDID, nil, gomock.Any()) response, err := ctx.client.HandleAuthorizeResponse(context.Background(), baseRequest()) diff --git a/auth/api/iam/user.go b/auth/api/iam/user.go index 1dd8c41ff9..d612e5cae0 100644 --- a/auth/api/iam/user.go +++ b/auth/api/iam/user.go @@ -24,6 +24,10 @@ import ( "encoding/json" "errors" "fmt" + "net/http" + "strings" + "time" + "github.com/lestrrat-go/jwx/v2/jwk" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/vc" @@ -31,9 +35,6 @@ import ( "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr/credential" issuer "github.com/nuts-foundation/nuts-node/vcr/issuer" - "net/http" - "strings" - "time" "github.com/labstack/echo/v4" "github.com/nuts-foundation/go-did/did" @@ -152,7 +153,7 @@ func (r Wrapper) handleUserLanding(echoCtx echo.Context) error { values[oauth.ScopeParam] = accessTokenRequest.Body.Scope } // TODO: First create user session, or AuthorizationRequest first? (which one is more expensive? both sign stuff) - redirectURL, err := r.CreateAuthorizationRequest(echoCtx.Request().Context(), redirectSession.OwnDID, *verifier, modifier) + redirectURL, err := r.CreateAuthorizationRequest(echoCtx.Request().Context(), redirectSession.OwnDID, verifier, modifier) if err != nil { return err } @@ -194,7 +195,7 @@ func (r Wrapper) loadUserSession(cookies CookieReader, tenantDID did.DID, preAut } // Note that the session itself does not have an expiration field: // it depends on the session store to clean up when it expires. - if !session.TenantDID.Equals(tenantDID) { + if !session.TenantDID.Equals(tenantDID) && !session.Wallet.DID.Equals(tenantDID) { return nil, fmt.Errorf("session belongs to another tenant (%s)", session.TenantDID) } // If the existing session was created for a pre-authorized user, the call to RequestUserAccessToken() must be diff --git a/auth/api/iam/user_test.go b/auth/api/iam/user_test.go index 01a54b8c43..ea6748cb66 100644 --- a/auth/api/iam/user_test.go +++ b/auth/api/iam/user_test.go @@ -20,12 +20,13 @@ package iam import ( "context" - "github.com/nuts-foundation/nuts-node/auth/oauth" "net/http" "strings" "testing" "time" + "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwk" ssi "github.com/nuts-foundation/go-did" @@ -274,10 +275,15 @@ func TestWrapper_loadUserSession(t *testing.T) { _ = ctx.client.userSessionStore().Put(sessionCookie.Value, expected) ctrl := gomock.NewController(t) echoCtx := mock.NewMockContext(ctrl) - echoCtx.EXPECT().Cookie(sessionCookie.Name).Return(&sessionCookie, nil) + echoCtx.EXPECT().Cookie(sessionCookie.Name).Return(&sessionCookie, nil).Times(2) + // organisation wallet actual, err := ctx.client.loadUserSession(echoCtx, walletDID, user) + assert.NoError(t, err) + assert.Equal(t, expected, *actual) + // user wallet + actual, err = ctx.client.loadUserSession(echoCtx, userDID, user) assert.NoError(t, err) assert.Equal(t, expected, *actual) }) diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index 4ff9e798a2..6daed69778 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -23,6 +23,11 @@ import ( "context" "encoding/json" "fmt" + "io" + "net/http" + "net/url" + "strings" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/log" @@ -30,10 +35,6 @@ import ( "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/didweb" - "io" - "net/http" - "net/url" - "strings" ) // HTTPClient holds the server address and other basic settings for the http client @@ -88,7 +89,8 @@ func (hb HTTPClient) PresentationDefinition(ctx context.Context, presentationDef return &presentationDefinition, hb.doRequest(ctx, request, &presentationDefinition) } -func (hb HTTPClient) RequestObject(ctx context.Context, requestURI string) (string, error) { +// RequestObjectByGet retrieves the Authorization Request Object from the requestURI using the GET method +func (hb HTTPClient) RequestObjectByGet(ctx context.Context, requestURI string) (string, error) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURI, nil) if err != nil { return "", err @@ -110,6 +112,31 @@ func (hb HTTPClient) RequestObject(ctx context.Context, requestURI string) (stri return string(data), err } +// RequestObjectByPost retrieves the Authorization Request Object from the requestURI using the POST method. +// additional request parameters (wallet_metadata and wallet_nonce) are provided as url.Values. +func (hb HTTPClient) RequestObjectByPost(ctx context.Context, requestURI string, form url.Values) (string, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURI, strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + request.Header.Add("Accept", "application/oauth-authz-req+jwt") + request.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + response, err := hb.httpClient.Do(request.WithContext(ctx)) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + if httpErr := core.TestResponseCode(http.StatusOK, response); httpErr != nil { + return "", httpErr + } + + data, err := core.LimitedReadAll(response.Body) + if err != nil { + return "", fmt.Errorf("unable to read response: %w", err) + } + return string(data), err +} + func (hb HTTPClient) AccessToken(ctx context.Context, tokenEndpoint string, data url.Values, dpopHeader string) (oauth.TokenResponse, error) { var token oauth.TokenResponse tokenURL, err := url.Parse(tokenEndpoint) diff --git a/auth/client/iam/client_test.go b/auth/client/iam/client_test.go index acb3a91888..980cb98836 100644 --- a/auth/client/iam/client_test.go +++ b/auth/client/iam/client_test.go @@ -20,6 +20,11 @@ package iam import ( "context" + "net/http" + "net/http/httptest" + "net/url" + "testing" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -31,10 +36,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" ) func TestHTTPClient_OAuthAuthorizationServerMetadata(t *testing.T) { @@ -241,7 +242,42 @@ func TestHTTPClient_RequestObject(t *testing.T) { handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: responseData} tlsServer, client := testServerAndClient(t, &handler) - response, err := client.RequestObject(ctx, tlsServer.URL) + response, err := client.RequestObjectByGet(ctx, tlsServer.URL) + + require.NoError(t, err) + assert.Equal(t, responseData, response) + }) + t.Run("error - invalid request_uri", func(t *testing.T) { + _, client := testServerAndClient(t, &http2.Handler{}) + + response, err := client.RequestObjectByGet(ctx, ":") + + assert.EqualError(t, err, "parse \":\": missing protocol scheme") + assert.Empty(t, response) + }) + t.Run("error - not found", func(t *testing.T) { + handler := http2.Handler{StatusCode: http.StatusNotFound, ResponseData: "throw this away"} + tlsServer, client := testServerAndClient(t, &handler) + + response, err := client.RequestObjectByGet(ctx, tlsServer.URL) + + var httpErr core.HttpError + require.ErrorAs(t, err, &httpErr) + assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) + assert.Empty(t, response) + + }) +} + +func TestHTTPClient_RequestObjectPost(t *testing.T) { + ctx := context.Background() + // params are checked server side, so we don't need to provide valid values here + t.Run("ok", func(t *testing.T) { + responseData := "signed request object" + handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: responseData} + tlsServer, client := testServerAndClient(t, &handler) + + response, err := client.RequestObjectByPost(ctx, tlsServer.URL, url.Values{}) require.NoError(t, err) assert.Equal(t, responseData, response) @@ -249,7 +285,7 @@ func TestHTTPClient_RequestObject(t *testing.T) { t.Run("error - invalid request_uri", func(t *testing.T) { _, client := testServerAndClient(t, &http2.Handler{}) - response, err := client.RequestObject(ctx, ":") + response, err := client.RequestObjectByPost(ctx, ":", url.Values{}) assert.EqualError(t, err, "parse \":\": missing protocol scheme") assert.Empty(t, response) @@ -258,7 +294,7 @@ func TestHTTPClient_RequestObject(t *testing.T) { handler := http2.Handler{StatusCode: http.StatusNotFound, ResponseData: "throw this away"} tlsServer, client := testServerAndClient(t, &handler) - response, err := client.RequestObject(ctx, tlsServer.URL) + response, err := client.RequestObjectByPost(ctx, tlsServer.URL, url.Values{}) var httpErr core.HttpError require.ErrorAs(t, err, &httpErr) diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index 0c84a392a5..ed29a9ac29 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -20,6 +20,7 @@ package iam import ( "context" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" @@ -50,6 +51,10 @@ type Client interface { OpenIdCredentialIssuerMetadata(ctx context.Context, webDID did.DID) (*oauth.OpenIDCredentialIssuerMetadata, error) VerifiableCredentials(ctx context.Context, credentialEndpoint string, accessToken string, proofJWT string) (*CredentialResponse, error) - // RequestObject is returned from the authorization request's 'request_uri' defined in RFC9101. - RequestObject(ctx context.Context, requestURI string) (string, error) + // RequestObjectByGet retrieves the RequestObjectByGet from the authorization request's 'request_uri' endpoint using a GET method as defined in RFC9101/OpenID4VP. + // This method is used when there is no 'request_uri_method', or its value is 'get'. + RequestObjectByGet(ctx context.Context, requestURI string) (string, error) + // RequestObjectByPost retrieves the RequestObjectByGet from the authorization request's 'request_uri' endpoint using a POST method as defined in RFC9101/OpenID4VP. + // This method is used when the 'request_uri_method' is 'post'. + RequestObjectByPost(ctx context.Context, requestURI string, walletMetadata oauth.AuthorizationServerMetadata) (string, error) } diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go index 1b551f69dd..6b58014a54 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -163,19 +163,34 @@ func (mr *MockClientMockRecorder) PresentationDefinition(ctx, endpoint any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PresentationDefinition", reflect.TypeOf((*MockClient)(nil).PresentationDefinition), ctx, endpoint) } -// RequestObject mocks base method. -func (m *MockClient) RequestObject(ctx context.Context, requestURI string) (string, error) { +// RequestObjectByGet mocks base method. +func (m *MockClient) RequestObjectByGet(ctx context.Context, requestURI string) (string, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RequestObject", ctx, requestURI) + ret := m.ctrl.Call(m, "RequestObjectByGet", ctx, requestURI) ret0, _ := ret[0].(string) ret1, _ := ret[1].(error) return ret0, ret1 } -// RequestObject indicates an expected call of RequestObject. -func (mr *MockClientMockRecorder) RequestObject(ctx, requestURI any) *gomock.Call { +// RequestObjectByGet indicates an expected call of RequestObjectByGet. +func (mr *MockClientMockRecorder) RequestObjectByGet(ctx, requestURI any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestObject", reflect.TypeOf((*MockClient)(nil).RequestObject), ctx, requestURI) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestObjectByGet", reflect.TypeOf((*MockClient)(nil).RequestObjectByGet), ctx, requestURI) +} + +// RequestObjectByPost mocks base method. +func (m *MockClient) RequestObjectByPost(ctx context.Context, requestURI string, walletMetadata oauth.AuthorizationServerMetadata) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RequestObjectByPost", ctx, requestURI, walletMetadata) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// RequestObjectByPost indicates an expected call of RequestObjectByPost. +func (mr *MockClientMockRecorder) RequestObjectByPost(ctx, requestURI, walletMetadata any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestObjectByPost", reflect.TypeOf((*MockClient)(nil).RequestObjectByPost), ctx, requestURI, walletMetadata) } // RequestRFC021AccessToken mocks base method. diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index 5191853cee..2b18338398 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -23,7 +23,6 @@ import ( "context" "encoding/json" "fmt" - "github.com/nuts-foundation/nuts-node/crypto/dpop" "net/http" "net/url" "time" @@ -34,6 +33,7 @@ import ( "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/core" nutsCrypto "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/crypto/dpop" nutsHttp "github.com/nuts-foundation/nuts-node/http" "github.com/nuts-foundation/nuts-node/vcr/holder" "github.com/nuts-foundation/nuts-node/vcr/pe" @@ -133,14 +133,30 @@ func (c *OpenID4VPClient) AuthorizationServerMetadata(ctx context.Context, webdi return metadata, nil } -func (c *OpenID4VPClient) RequestObject(ctx context.Context, requestURI string) (string, error) { +func (c *OpenID4VPClient) RequestObjectByGet(ctx context.Context, requestURI string) (string, error) { iamClient := c.httpClient parsedURL, err := core.ParsePublicURL(requestURI, c.strictMode) if err != nil { return "", fmt.Errorf("invalid request_uri: %w", err) } - // the wallet/client acts as authorization server - requestObject, err := iamClient.RequestObject(ctx, parsedURL.String()) + + requestObject, err := iamClient.RequestObjectByGet(ctx, parsedURL.String()) + if err != nil { + return "", fmt.Errorf("failed to retrieve JAR Request Object: %w", err) + } + return requestObject, nil +} +func (c *OpenID4VPClient) RequestObjectByPost(ctx context.Context, requestURI string, walletMetadata oauth.AuthorizationServerMetadata) (string, error) { + iamClient := c.httpClient + parsedURL, err := core.ParsePublicURL(requestURI, c.strictMode) + if err != nil { + return "", fmt.Errorf("invalid request_uri: %w", err) + } + + // TODO: consider adding a 'wallet_nonce' + metadataBytes, _ := json.Marshal(walletMetadata) + form := url.Values{oauth.WalletMetadataParam: {string(metadataBytes)}} + requestObject, err := iamClient.RequestObjectByPost(ctx, parsedURL.String(), form) if err != nil { return "", fmt.Errorf("failed to retrieve JAR Request Object: %w", err) } diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index b34a062411..a755346d23 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -23,12 +23,13 @@ import ( "crypto/tls" "encoding/json" "fmt" - "github.com/nuts-foundation/nuts-node/core" "net/http" "net/http/httptest" "testing" "time" + "github.com/nuts-foundation/nuts-node/core" + ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -320,22 +321,61 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { }) } -func TestIAMClient_RequestObject(t *testing.T) { +func TestIAMClient_RequestObjectByGet(t *testing.T) { + t.Run("ok", func(t *testing.T) { + ctx := createClientServerTestContext(t) + requestURI := ctx.tlsServer.URL + "/request.jwt" + + response, err := ctx.client.RequestObjectByGet(context.Background(), requestURI) + + require.NoError(t, err) + assert.Equal(t, "Request Object", response) + }) + t.Run("error - invalid request_uri", func(t *testing.T) { + ctx := createClientServerTestContext(t) + + response, err := ctx.client.RequestObjectByGet(context.Background(), ":") + + assert.EqualError(t, err, "invalid request_uri: parse \":\": missing protocol scheme") + assert.Empty(t, response) + }) + t.Run("error - failed to get access token", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ctx.requestObjectJWT = nil + requestURI := ctx.tlsServer.URL + "/request.jwt" + + response, err := ctx.client.RequestObjectByGet(context.Background(), requestURI) + + assert.EqualError(t, err, "failed to retrieve JAR Request Object: server returned HTTP 404 (expected: 200)") + assert.Empty(t, response) + }) +} + +func TestIAMClient_RequestObjectByPost(t *testing.T) { + metadata := oauth.AuthorizationServerMetadata{Issuer: "me"} t.Run("ok", func(t *testing.T) { ctx := createClientServerTestContext(t) requestURI := ctx.tlsServer.URL + "/request.jwt" - response, err := ctx.client.RequestObject(context.Background(), requestURI) + response, err := ctx.client.RequestObjectByPost(context.Background(), requestURI, metadata) require.NoError(t, err) assert.Equal(t, "Request Object", response) }) + t.Run("error - invalid request_uri", func(t *testing.T) { + ctx := createClientServerTestContext(t) + + response, err := ctx.client.RequestObjectByPost(context.Background(), ":", metadata) + + assert.EqualError(t, err, "invalid request_uri: parse \":\": missing protocol scheme") + assert.Empty(t, response) + }) t.Run("error - failed to get access token", func(t *testing.T) { ctx := createClientServerTestContext(t) ctx.requestObjectJWT = nil requestURI := ctx.tlsServer.URL + "/request.jwt" - response, err := ctx.client.RequestObject(context.Background(), requestURI) + response, err := ctx.client.RequestObjectByPost(context.Background(), requestURI, metadata) assert.EqualError(t, err, "failed to retrieve JAR Request Object: server returned HTTP 404 (expected: 200)") assert.Empty(t, response) diff --git a/auth/oauth/error.go b/auth/oauth/error.go index a68517669a..6269d86546 100644 --- a/auth/oauth/error.go +++ b/auth/oauth/error.go @@ -22,13 +22,14 @@ import ( "bytes" "encoding/json" "errors" - "github.com/labstack/echo/v4" - "github.com/nuts-foundation/nuts-node/auth/log" - "github.com/nuts-foundation/nuts-node/core" "html/template" "net/http" "net/url" "strings" + + "github.com/labstack/echo/v4" + "github.com/nuts-foundation/nuts-node/auth/log" + "github.com/nuts-foundation/nuts-node/core" ) // ErrorCode specifies error codes as defined by the OAuth2 specifications. @@ -56,10 +57,12 @@ const ( 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" - // InvalidRequestObject is returned when the JAR Request Object signature validation or decryption fails. RFC9101 + // InvalidRequestObject is returned when the JAR Request Object signature validation or decryption fails. (RFC9101) InvalidRequestObject ErrorCode = "invalid_request_object" - // InvalidRequestURI is returned whn the request_uri in the authorization request returns an error or contains invalid data. RFC9101 + // InvalidRequestURI is returned whn the request_uri in the authorization request returns an error or contains invalid data. (RFC9101) InvalidRequestURI ErrorCode = "invalid_request_uri" + // InvalidRequestURIMethod is returned when the request_uri_method is not 'get' or 'post'. (OpenID4VP) + InvalidRequestURIMethod ErrorCode = "invalid_request_uri_method" ) // 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 ed7a86d1db..ab4adf2d7b 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -154,6 +154,10 @@ const ( PresentationSubmissionParam = "presentation_submission" // VpTokenParam is the parameter name for the vp_token parameter VpTokenParam = "vp_token" + // WalletMetadataParam is used by the wallet to provide its metadata in an authorization request when RequestURIMethodParam is 'post' + WalletMetadataParam = "wallet_metadata" + // WalletNonceParam is a wallet generated nonce to prevent authorization request replay when RequestURIMethodParam is 'post' + WalletNonceParam = "wallet_nonce" // VpTokenGrantType is the grant_type for the vp_token-bearer grant type VpTokenGrantType = "vp_token-bearer" // CNonceParam is the parameter name for the c_nonce parameter. Defined in OpenID4VCI. diff --git a/docs/_static/auth/iam.partial.yaml b/docs/_static/auth/iam.partial.yaml index 6b6227f17b..92c6b36646 100644 --- a/docs/_static/auth/iam.partial.yaml +++ b/docs/_static/auth/iam.partial.yaml @@ -45,7 +45,7 @@ paths: /oauth2/{did}/token: post: summary: Used by to request access- or refresh tokens. - description: | + description: | Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-token-endpoint. Requires the use of PKCE as specified by https://datatracker.ietf.org/doc/html/rfc7636 and optionally DPoP as specified by https://datatracker.ietf.org/doc/html/rfc9449. operationId: handleTokenRequest @@ -162,7 +162,7 @@ paths: Get the Request Object containing the OAuth 2.0 authorization request parameters, including extension parameters. The Request Object is a JWT with signature (JWS). See [RFC9101] The OAuth 2.0 Authorization Framework: JWT-Secured Authorization Request (JAR) for details. - operationId: getRequestJWT + operationId: requestJWTByGet tags: - oauth2 responses: @@ -176,7 +176,7 @@ paths: $ref: '../common/error_response.yaml' post: summary: Provide missing information to Client to finish Authorization request's Request Object, which is then returned. - operationId: postRequestJWT + operationId: requestJWTByPost tags: - oauth2 requestBody: @@ -277,7 +277,7 @@ paths: required: true schema: type: string - description: | + description: | The scope for which a presentation definition is requested. Multiple scopes can be specified by separating them with a space. example: usecase patient:x:read - name: wallet_owner_type @@ -296,7 +296,7 @@ paths: /oauth2/{did}/response: post: summary: Used by wallets to post the authorization response or error to. - description: | + description: | Specified by https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#name-response-mode-direct_postjw The response is either an error response with error, error_description and state filled or a submission with vp_token and presentation_submission filled. When an error is posted, the state is used to fetch the holder's callbackURI from the verifiers client state. @@ -344,10 +344,10 @@ paths: # TODO: What format to use? (codegenerator breaks on aliases) # See issue https://github.com/nuts-foundation/nuts-node/issues/2365 # create aliases for the specced path -# /iam/{did}/oauth-authorization-server: -# $ref: '#/paths/~1.well-known~1oauth-authorization-server~1iam~1{did}' -# /iam/{did}/.well-known/oauth-authorization-server: -# $ref: '#/paths/~1.well-known~1oauth-authorization-server~1iam~1{did}' + # /iam/{did}/oauth-authorization-server: + # $ref: '#/paths/~1.well-known~1oauth-authorization-server~1iam~1{did}' + # /iam/{did}/.well-known/oauth-authorization-server: + # $ref: '#/paths/~1.well-known~1oauth-authorization-server~1iam~1{did}' /.well-known/oauth-authorization-server/iam/{id}: get: tags: @@ -458,7 +458,7 @@ paths: summary: Get the StatusList2021Credential for the given DID and page description: > Returns the StatusList2021Credential as specified in https://www.w3.org/TR/2023/WD-vc-status-list-20230427/ - + error returns: * 404 - id or page not found; possibly be non-existing, deactivated, or not managed by this node * 500 - internal server error @@ -488,7 +488,7 @@ paths: * invalid_request - one of the provided parameters is wrong. * server_error - internal processing of the Oid4VCI flow has a system error. * access_denied - an access problem occurred with the internal processing of the Oid4VCI flow. - + If the system is somehow not able to return a redirect, the following HTTP status codes will be returned: * 500 - an system error occurred during processing diff --git a/docs/pages/deployment/oauth.rst b/docs/pages/deployment/oauth.rst index afec837a68..cb4e54c718 100644 --- a/docs/pages/deployment/oauth.rst +++ b/docs/pages/deployment/oauth.rst @@ -25,6 +25,7 @@ Authorization Code Flow For the authorization code flow, the Nuts node implements the following: - JAR (JWT Secured Authorization Request) for both the initial authorization request as well as the OpenID4VP authorization request. + All request use the ``request_uri`` parameter meaning that other request parameters cannot be inspected in the authorization request itself. - PKCE (Proof Key for Code Exchange) for the authorization code flow. The call of the initial authorization request is linked to the token request. - DPoP (Demonstrating Proof of Possession) for the token request. Each resources request will require a new DPoP Proof header. The resource server is also required to check this header in an additional step after the token introspection. diff --git a/e2e-tests/browser/openid4vp_employeecredential/docker-compose.yml b/e2e-tests/browser/openid4vp_employeecredential/docker-compose.yml index 3854f7175e..97c77341cf 100644 --- a/e2e-tests/browser/openid4vp_employeecredential/docker-compose.yml +++ b/e2e-tests/browser/openid4vp_employeecredential/docker-compose.yml @@ -11,6 +11,8 @@ 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" + healthcheck: + interval: 1s # Make test run quicker by checking health status more often nodeA: image: nginx:1.25.1 ports: @@ -33,6 +35,8 @@ 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" + healthcheck: + interval: 1s # Make test run quicker by checking health status more often nodeB: image: nginx:1.25.1 ports: diff --git a/e2e-tests/browser/rfc019_selfsigned/docker-compose.yml b/e2e-tests/browser/rfc019_selfsigned/docker-compose.yml index 978f0f7673..3cc2952be1 100644 --- a/e2e-tests/browser/rfc019_selfsigned/docker-compose.yml +++ b/e2e-tests/browser/rfc019_selfsigned/docker-compose.yml @@ -8,6 +8,8 @@ services: - 8081:8081 volumes: - "./config/node/nuts.yaml:/opt/nuts/nuts.yaml" + healthcheck: + interval: 1s # Make test run quicker by checking health status more often chrome-headless-shell: image: chromedp/headless-shell:latest ports: