From b8f5a3b52718c66922e634f212e857263b8b5429 Mon Sep 17 00:00:00 2001 From: Wout Slakhorst Date: Tue, 7 May 2024 09:04:02 +0200 Subject: [PATCH] Add DPoP to access token request and introspection (#3033) --- auth/api/iam/access_token.go | 104 +++ auth/api/iam/api.go | 54 +- auth/api/iam/api_test.go | 74 +- auth/api/iam/dpop.go | 135 +++ auth/api/iam/dpop_test.go | 253 ++++++ auth/api/iam/generated.go | 315 ++++++- auth/api/iam/metadata.go | 12 +- auth/api/iam/metadata_test.go | 18 +- auth/api/iam/openid4vp.go | 28 +- auth/api/iam/openid4vp_test.go | 46 +- auth/api/iam/s2s_vptoken.go | 76 +- auth/api/iam/s2s_vptoken_test.go | 75 +- auth/api/iam/session.go | 11 +- auth/api/iam/types.go | 5 + auth/api/iam/user.go | 7 + auth/auth.go | 3 +- auth/client/iam/client.go | 5 +- auth/client/iam/client_test.go | 12 +- auth/client/iam/interface.go | 6 +- auth/client/iam/mock.go | 16 +- auth/client/iam/openid4vp.go | 72 +- auth/client/iam/openid4vp_test.go | 57 +- auth/oauth/error.go | 2 + auth/oauth/openid.go | 11 +- auth/oauth/types.go | 3 + crypto/dpop.go | 38 + crypto/dpop/dpop.go | 259 ++++++ crypto/dpop/dpop_test.go | 343 ++++++++ crypto/dpop_test.go | 71 ++ crypto/interface.go | 4 + crypto/jwx.go | 37 +- crypto/jwx/algorithm.go | 38 + crypto/jwx_test.go | 16 +- crypto/memory.go | 5 + crypto/mock.go | 31 + docs/_static/auth/iam.yaml | 286 +++++-- docs/index.rst | 1 + docs/pages/deployment/oauth.rst | 61 ++ e2e-tests/browser/client/iam/generated.go | 809 +++++++++++++++++- e2e-tests/oauth-flow/openid4vp/do-test.sh | 21 +- .../oauth-flow/openid4vp/resource/nginx.conf | 25 +- e2e-tests/oauth-flow/rfc021/do-test.sh | 26 +- e2e-tests/oauth-flow/rfc021/node-A/nginx.conf | 6 + e2e-tests/oauth-flow/rfc021/node-A/nuts.yaml | 1 + e2e-tests/oauth-flow/rfc021/node-B/nuts.yaml | 1 + e2e-tests/oauth-flow/scripts/oauth2.js | 34 +- e2e-tests/util.sh | 2 +- http/cmd/cmd.go | 2 +- http/requestlogger.go | 9 +- 49 files changed, 3095 insertions(+), 431 deletions(-) create mode 100644 auth/api/iam/access_token.go create mode 100644 auth/api/iam/dpop.go create mode 100644 auth/api/iam/dpop_test.go create mode 100644 crypto/dpop.go create mode 100644 crypto/dpop/dpop.go create mode 100644 crypto/dpop/dpop_test.go create mode 100644 crypto/dpop_test.go create mode 100644 crypto/jwx/algorithm.go create mode 100644 docs/pages/deployment/oauth.rst diff --git a/auth/api/iam/access_token.go b/auth/api/iam/access_token.go new file mode 100644 index 0000000000..2fe02e40c7 --- /dev/null +++ b/auth/api/iam/access_token.go @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package iam + +import ( + "fmt" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/crypto" + "time" + + "github.com/nuts-foundation/nuts-node/crypto/dpop" + "github.com/nuts-foundation/nuts-node/vcr/pe" +) + +type AccessToken struct { + // DPoP is the proof-of-possession of the key for the DID of the entity requesting the access token. + DPoP *dpop.DPoP `json:"dpop"` + // Token is the access token + Token string `json:"token"` + // Issuer and Subject of a token are always the same. + Issuer string `json:"issuer"` + // TODO: should client_id be extracted to the PDPMap using the presentation definition? + // ClientId is the DID of the entity requesting the access token. The Client needs to proof its id through proof-of-possession of the key for the DID. + ClientId string `json:"client_id"` + // IssuedAt is the time the token is issued + IssuedAt time.Time `json:"issued_at"` + // Expiration is the time the token expires + Expiration time.Time `json:"expiration"` + // Scope the token grants access to. Not necessarily the same as the requested scope + Scope string `json:"scope"` + // InputDescriptorConstraintIdMap maps the ID field of a PresentationDefinition input descriptor constraint to the value provided in the VPToken for the constraint. + // The Policy Decision Point can use this map to make decisions without having to deal with PEX/VCs/VPs/SignatureValidation + InputDescriptorConstraintIdMap map[string]any `json:"inputdescriptor_constraint_id_map,omitempty"` + + // additional fields to support unforeseen policy decision requirements + + // VPToken contains the VPs provided in the 'assertion' field of the s2s AT request. + VPToken []VerifiablePresentation `json:"vp_token,omitempty"` + // PresentationSubmissions as provided in by the wallet to fulfill the required Presentation Definition(s). + PresentationSubmissions map[string]pe.PresentationSubmission `json:"presentation_submissions,omitempty"` + // PresentationDefinitions that were required by the verifier to fulfill the request. + PresentationDefinitions pe.WalletOwnerMapping `json:"presentation_definitions,omitempty"` +} + +// createAccessToken is used in both the s2s and openid4vp flows +func (r Wrapper) createAccessToken(issuer did.DID, walletDID did.DID, issueTime time.Time, scope string, pexState PEXConsumer, dpopToken *dpop.DPoP) (*oauth.TokenResponse, error) { + credentialMap, err := pexState.credentialMap() + if err != nil { + return nil, err + } + fieldsMap, err := resolveInputDescriptorValues(pexState.RequiredPresentationDefinitions, credentialMap) + if err != nil { + return nil, err + } + + accessToken := AccessToken{ + DPoP: dpopToken, + Token: crypto.GenerateNonce(), + Issuer: issuer.String(), + IssuedAt: issueTime, + ClientId: walletDID.String(), + Expiration: issueTime.Add(accessTokenValidity), + Scope: scope, + PresentationSubmissions: pexState.Submissions, + PresentationDefinitions: pexState.RequiredPresentationDefinitions, + InputDescriptorConstraintIdMap: fieldsMap, + } + for _, envelope := range pexState.SubmittedEnvelopes { + accessToken.VPToken = append(accessToken.VPToken, envelope.Presentations...) + } + + err = r.accessTokenServerStore().Put(accessToken.Token, accessToken) + if err != nil { + return nil, fmt.Errorf("unable to store access token: %w", err) + } + expiresIn := int(accessTokenValidity.Seconds()) + tokenType := AccessTokenTypeDPoP + if dpopToken == nil { + tokenType = AccessTokenTypeBearer + } + return &oauth.TokenResponse{ + AccessToken: accessToken.Token, + ExpiresIn: &expiresIn, + Scope: &scope, + TokenType: tokenType, + }, nil +} diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 0ab3bca40a..a06d8ab50c 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -21,7 +21,9 @@ package iam import ( "bytes" "context" + "crypto" "embed" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -41,8 +43,8 @@ import ( "github.com/nuts-foundation/nuts-node/auth/log" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/core" - cryptoNuts "github.com/nuts-foundation/nuts-node/crypto" - httpNuts "github.com/nuts-foundation/nuts-node/http" + nutsCrypto "github.com/nuts-foundation/nuts-node/crypto" + nutsHttp "github.com/nuts-foundation/nuts-node/http" "github.com/nuts-foundation/nuts-node/jsonld" "github.com/nuts-foundation/nuts-node/policy" "github.com/nuts-foundation/nuts-node/storage" @@ -92,14 +94,14 @@ type Wrapper struct { JSONLDManager jsonld.JSONLD vcr vcr.VCR vdr vdr.VDR - jwtSigner cryptoNuts.JWTSigner + jwtSigner nutsCrypto.JWTSigner keyResolver resolver.KeyResolver jar JAR } func New( authInstance auth.AuthenticationServices, vcrInstance vcr.VCR, vdrInstance vdr.VDR, storageEngine storage.Engine, - policyBackend policy.PDPBackend, jwtSigner cryptoNuts.JWTSigner, jsonldManager jsonld.JSONLD) *Wrapper { + policyBackend policy.PDPBackend, jwtSigner nutsCrypto.JWTSigner, jsonldManager jsonld.JSONLD) *Wrapper { templates := template.New("oauth2 templates") _, err := templates.ParseFS(assetsFS, "assets/*.html") if err != nil { @@ -162,7 +164,7 @@ func middleware(ctx echo.Context, operationID string) { } // ResolveStatusCode maps errors returned by this API to specific HTTP status codes. -func (w Wrapper) ResolveStatusCode(err error) int { +func (r Wrapper) ResolveStatusCode(err error) int { return core.ResolveStatusCode(err, map[error]int{ vcrTypes.ErrNotFound: http.StatusNotFound, resolver.ErrDIDNotManagedByThisNode: http.StatusBadRequest, @@ -270,11 +272,23 @@ func (r Wrapper) IntrospectAccessToken(_ context.Context, request IntrospectAcce return IntrospectAccessToken200JSONResponse{}, nil } + // Optional: + // Use DPoP from token to generate JWK thumbprint for public key + // deserialization of the DPoP struct from the accessTokenServerStore triggers validation of the DPoP header + // SHA256 hashing won't fail. + var cnf *Cnf + if token.DPoP != nil { + hash, _ := token.DPoP.Headers.JWK().Thumbprint(crypto.SHA256) + base64Hash := base64.RawURLEncoding.EncodeToString(hash) + cnf = &Cnf{Jkt: base64Hash} + } + // Create and return introspection response iat := int(token.IssuedAt.Unix()) exp := int(token.Expiration.Unix()) response := IntrospectAccessToken200JSONResponse{ Active: true, + Cnf: cnf, Iat: &iat, Exp: &exp, Iss: &token.Issuer, @@ -556,7 +570,11 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS return nil, core.InvalidInputError("invalid verifier: %w", err) } - tokenResult, err := r.auth.IAMClient().RequestRFC021AccessToken(ctx, *requestHolder, *requestVerifier, request.Body.Scope) + useDPoP := true + if request.Body.TokenType != nil && strings.ToLower(string(*request.Body.TokenType)) == strings.ToLower(AccessTokenTypeBearer) { + useDPoP = false + } + tokenResult, err := r.auth.IAMClient().RequestRFC021AccessToken(ctx, *requestHolder, *requestVerifier, request.Body.Scope, useDPoP) if err != nil { // this can be an internal server error, a 400 oauth error or a 412 precondition failed if the wallet does not contain the required credentials return nil, err @@ -590,10 +608,10 @@ func (r Wrapper) RequestUserAccessToken(ctx context.Context, request RequestUser } // session ID for calling app (supports polling for token) - sessionID := cryptoNuts.GenerateNonce() + sessionID := nutsCrypto.GenerateNonce() // generate a redirect token valid for 5 seconds - token := cryptoNuts.GenerateNonce() + token := nutsCrypto.GenerateNonce() err = r.userRedirectStore().Put(token, RedirectSession{ AccessTokenRequest: request, SessionID: sessionID, @@ -614,7 +632,7 @@ func (r Wrapper) RequestUserAccessToken(ctx context.Context, request RequestUser } webURL = webURL.JoinPath("user") // redirect to generic user page, context of token will render correct page - redirectURL := httpNuts.AddQueryParams(*webURL, map[string]string{ + redirectURL := nutsHttp.AddQueryParams(*webURL, map[string]string{ "token": token, }) return RequestUserAccessToken200JSONResponse{ @@ -687,7 +705,7 @@ func (r Wrapper) RequestOid4vciCredentialIssuance(ctx context.Context, request R authorizationDetails, _ = json.Marshal(request.Body.AuthorizationDetails) } // Generate the state and PKCE - state := cryptoNuts.GenerateNonce() + state := nutsCrypto.GenerateNonce() pkceParams := generatePKCEParams() if err != nil { log.Logger().WithError(err).Errorf("failed to create the PKCE parameters") @@ -719,7 +737,7 @@ func (r Wrapper) RequestOid4vciCredentialIssuance(ctx context.Context, request R return nil, err } // Build the redirect URL, the client browser should be redirected to. - redirectUrl := httpNuts.AddQueryParams(*endpoint, map[string]string{ + redirectUrl := nutsHttp.AddQueryParams(*endpoint, map[string]string{ "response_type": "code", "state": state, "client_id": requestHolder.String(), @@ -762,7 +780,7 @@ func (r Wrapper) CallbackOid4vciCredentialIssuance(ctx context.Context, request if err != nil { return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("cannot fetch the right endpoints: %s", err.Error())), oid4vciSession.remoteRedirectUri()) } - response, err := r.auth.IAMClient().AccessToken(ctx, code, *issuerDid, oid4vciSession.RedirectUri, *holderDid, pkceParams.Verifier) + response, err := r.auth.IAMClient().AccessToken(ctx, code, *issuerDid, oid4vciSession.RedirectUri, *holderDid, pkceParams.Verifier, false) if err != nil { return nil, withCallbackURI(oauthError(oauth.AccessDenied, fmt.Sprintf("error while fetching the access_token from endpoint: %s, error: %s", tokenEndpoint, err.Error())), oid4vciSession.remoteRedirectUri()) } @@ -841,7 +859,7 @@ func (r Wrapper) CreateAuthorizationRequest(ctx context.Context, client did.DID, } // request_uri - requestURIID := cryptoNuts.GenerateNonce() + requestURIID := nutsCrypto.GenerateNonce() requestObj := r.jar.Create(client, &server, modifier) if err = r.authzRequestObjectStore().Put(requestURIID, requestObj); err != nil { return nil, err @@ -859,7 +877,7 @@ func (r Wrapper) CreateAuthorizationRequest(ctx context.Context, client did.DID, oauth.RequestURIParam: requestURI.String(), } if metadata.RequireSignedRequestObject { - redirectURL := httpNuts.AddQueryParams(*endpoint, params) + redirectURL := nutsHttp.AddQueryParams(*endpoint, params) return &redirectURL, nil } // else; unclear if AS has support for RFC9101, so also add all modifiers to the query itself @@ -867,7 +885,7 @@ func (r Wrapper) CreateAuthorizationRequest(ctx context.Context, client did.DID, // 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. modifier(params) - redirectURL := httpNuts.AddQueryParams(*endpoint, params) + redirectURL := nutsHttp.AddQueryParams(*endpoint, params) return &redirectURL, nil } @@ -926,6 +944,12 @@ func (r Wrapper) accessTokenServerStore() storage.SessionStore { return r.storageEngine.GetSessionDatabase().GetStore(accessTokenValidity, "serveraccesstoken") } +// useNonceOnceStore is used to store nonces that are used once, e.g. DPoP jti +// it uses the access token validity as the expiration time +func (r Wrapper) useNonceOnceStore() storage.SessionStore { + return r.storageEngine.GetSessionDatabase().GetStore(accessTokenValidity, "nonceonce") +} + // accessTokenServerStore is used by the Auth server to store issued access tokens func (r Wrapper) authzRequestObjectStore() storage.SessionStore { return r.storageEngine.GetSessionDatabase().GetStore(accessTokenValidity, oauthRequestObjectKey...) diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 42ad8e110b..91144593d0 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -24,7 +24,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/vcr/credential" "net/http" "net/http/httptest" "net/url" @@ -50,6 +49,7 @@ import ( "github.com/nuts-foundation/nuts-node/policy" "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr" + "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/holder" "github.com/nuts-foundation/nuts-node/vcr/issuer" "github.com/nuts-foundation/nuts-node/vcr/pe" @@ -486,11 +486,13 @@ func TestWrapper_Callback(t *testing.T) { }) t.Run("ok - success flow", func(t *testing.T) { ctx := newTestClient(t) - putState(ctx, "state", session) + withDPoP := session + withDPoP.UseDPoP = true + putState(ctx, "state", withDPoP) putToken(ctx, token) codeVerifier := getState(ctx, state).PKCEParams.Verifier ctx.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(true, nil).Times(2) - ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, verifierDID, "https://example.com/oauth2/did:web:example.com:iam:123/callback", holderDID, codeVerifier).Return(&oauth.TokenResponse{AccessToken: "access"}, nil) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, verifierDID, "https://example.com/oauth2/did:web:example.com:iam:123/callback", holderDID, codeVerifier, true).Return(&oauth.TokenResponse{AccessToken: "access"}, nil) res, err := ctx.client.Callback(nil, CallbackRequestObject{ Did: webDID.String(), @@ -510,6 +512,32 @@ func TestWrapper_Callback(t *testing.T) { assert.Equal(t, oauth.AccessTokenRequestStatusActive, tokenResponse.Get("status")) assert.Equal(t, "access", tokenResponse.AccessToken) }) + t.Run("ok - no DPoP", func(t *testing.T) { + ctx := newTestClient(t) + _ = ctx.client.oauthClientStateStore().Put(state, OAuthSession{ + OwnDID: &holderDID, + PKCEParams: generatePKCEParams(), + RedirectURI: "https://example.com/iam/holder/cb", + SessionID: "token", + UseDPoP: false, + VerifierDID: &verifierDID, + }) + putToken(ctx, token) + codeVerifier := getState(ctx, state).PKCEParams.Verifier + ctx.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(true, nil).Times(2) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, verifierDID, "https://example.com/oauth2/did:web:example.com:iam:123/callback", holderDID, codeVerifier, false).Return(&oauth.TokenResponse{AccessToken: "access"}, nil) + + res, err := ctx.client.Callback(nil, CallbackRequestObject{ + Did: webDID.String(), + Params: CallbackParams{ + Code: &code, + State: &state, + }, + }) + + require.NoError(t, err) + assert.NotNil(t, res) + }) t.Run("unknown did", func(t *testing.T) { ctx := newTestClient(t) ctx.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(false, nil) @@ -549,6 +577,7 @@ func TestWrapper_RetrieveAccessToken(t *testing.T) { func TestWrapper_IntrospectAccessToken(t *testing.T) { // mvp to store access token ctx := newTestClient(t) + dpopToken, _, thumbprint := newSignedTestDPoP() // validate all fields are there after introspection t.Run("error - no token provided", func(t *testing.T) { @@ -578,7 +607,7 @@ func TestWrapper_IntrospectAccessToken(t *testing.T) { assert.Equal(t, res, IntrospectAccessToken200JSONResponse{}) }) t.Run("ok", func(t *testing.T) { - token := AccessToken{Expiration: time.Now().Add(time.Second)} + token := AccessToken{Expiration: time.Now().Add(time.Second), DPoP: dpopToken} require.NoError(t, ctx.client.accessTokenServerStore().Put("token", token)) res, err := ctx.client.IntrospectAccessToken(context.Background(), IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "token"}}) @@ -632,6 +661,7 @@ func TestWrapper_IntrospectAccessToken(t *testing.T) { pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "test"}, } token := AccessToken{ + DPoP: dpopToken, Token: "token", Issuer: "resource-owner", ClientId: "client", @@ -648,6 +678,7 @@ func TestWrapper_IntrospectAccessToken(t *testing.T) { expectedResponse, err := json.Marshal(IntrospectAccessToken200JSONResponse{ Active: true, ClientId: ptrTo("client"), + Cnf: &Cnf{Jkt: thumbprint}, Exp: ptrTo(int(tNow.Add(time.Minute).Unix())), Iat: ptrTo(int(tNow.Unix())), Iss: ptrTo("resource-owner"), @@ -742,10 +773,21 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { verifierDID := did.MustParseDID("did:web:test.test:iam:456") body := &RequestServiceAccessTokenJSONRequestBody{Verifier: verifierDID.String(), Scope: "first second"} - t.Run("ok - service flow", func(t *testing.T) { + t.Run("ok", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(nil, walletDID).Return(true, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, walletDID, verifierDID, "first second", true).Return(&oauth.TokenResponse{}, nil) + + _, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{Did: walletDID.String(), Body: body}) + + require.NoError(t, err) + }) + t.Run("ok - no DPoP", func(t *testing.T) { ctx := newTestClient(t) + tokenTypeBearer := ServiceAccessTokenRequestTokenType("bearer") + body := &RequestServiceAccessTokenJSONRequestBody{Verifier: verifierDID.String(), Scope: "first second", TokenType: &tokenTypeBearer} ctx.vdr.EXPECT().IsOwner(nil, walletDID).Return(true, nil) - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, walletDID, verifierDID, "first second").Return(&oauth.TokenResponse{}, nil) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, walletDID, verifierDID, "first second", false).Return(&oauth.TokenResponse{}, nil) _, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{Did: walletDID.String(), Body: body}) @@ -780,7 +822,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { t.Run("error - verifier error", func(t *testing.T) { ctx := newTestClient(t) ctx.vdr.EXPECT().IsOwner(nil, walletDID).Return(true, nil) - ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, walletDID, verifierDID, "first second").Return(nil, core.Error(http.StatusPreconditionFailed, "no matching credentials")) + ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, walletDID, verifierDID, "first second", true).Return(nil, core.Error(http.StatusPreconditionFailed, "no matching credentials")) _, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{Did: walletDID.String(), Body: body}) @@ -792,13 +834,14 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) { func TestWrapper_RequestUserAccessToken(t *testing.T) { walletDID := did.MustParseDID("did:web:test.test:iam:123") verifierDID := did.MustParseDID("did:web:test.test:iam:456") + tokenType := UserAccessTokenRequestTokenType("dpop") userDetails := UserDetails{ Id: "test", Name: "Titus Tester", Role: "Test Manager", } redirectURI := "https://test.test/oauth2/" + walletDID.String() + "/cb" - body := &RequestUserAccessTokenJSONRequestBody{Verifier: verifierDID.String(), Scope: "first second", PreauthorizedUser: &userDetails, RedirectUri: redirectURI} + body := &RequestUserAccessTokenJSONRequestBody{Verifier: verifierDID.String(), Scope: "first second", PreauthorizedUser: &userDetails, RedirectUri: redirectURI, TokenType: &tokenType} t.Run("ok", func(t *testing.T) { ctx := newTestClient(t) @@ -818,6 +861,9 @@ func TestWrapper_RequestUserAccessToken(t *testing.T) { err = ctx.client.userRedirectStore().Get(redirectURI.Query().Get("token"), &target) require.NoError(t, err) assert.Equal(t, walletDID, target.OwnDID) + require.NotNil(t, target.AccessTokenRequest) + require.NotNil(t, target.AccessTokenRequest.Body.TokenType) + assert.Equal(t, tokenType, *target.AccessTokenRequest.Body.TokenType) // assert flow var tokenResponse TokenResponse @@ -1196,7 +1242,7 @@ func TestWrapper_CallbackOid4vciCredentialIssuance(t *testing.T) { t.Run("ok", func(t *testing.T) { ctx := newTestClient(t) ctx.client.storageEngine.GetSessionDatabase().GetStore(15*time.Minute, "oid4vci").Put(state, &session) - ctx.iamClient.EXPECT().AccessToken(nil, code, issuerDID, redirectURI, holderDID, pkceParams.Verifier).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().AccessToken(nil, code, issuerDID, redirectURI, holderDID, pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI("kid"), nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(&credentialResponse, nil) @@ -1248,7 +1294,7 @@ func TestWrapper_CallbackOid4vciCredentialIssuance(t *testing.T) { t.Run("fail_access_token", func(t *testing.T) { ctx := newTestClient(t) ctx.client.storageEngine.GetSessionDatabase().GetStore(15*time.Minute, "oid4vci").Put(state, &session) - ctx.iamClient.EXPECT().AccessToken(nil, code, issuerDID, redirectURI, holderDID, pkceParams.Verifier).Return(nil, errors.New("FAIL")) + ctx.iamClient.EXPECT().AccessToken(nil, code, issuerDID, redirectURI, holderDID, pkceParams.Verifier, false).Return(nil, errors.New("FAIL")) callback, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ Params: CallbackOid4vciCredentialIssuanceParams{ @@ -1264,7 +1310,7 @@ func TestWrapper_CallbackOid4vciCredentialIssuance(t *testing.T) { t.Run("fail_credential_response", func(t *testing.T) { ctx := newTestClient(t) require.NoError(t, ctx.client.storageEngine.GetSessionDatabase().GetStore(15*time.Minute, "oid4vci").Put(state, &session)) - ctx.iamClient.EXPECT().AccessToken(nil, code, issuerDID, redirectURI, holderDID, pkceParams.Verifier).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().AccessToken(nil, code, issuerDID, redirectURI, holderDID, pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI("kid"), nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(nil, errors.New("FAIL")) @@ -1283,7 +1329,7 @@ func TestWrapper_CallbackOid4vciCredentialIssuance(t *testing.T) { t.Run("fail_verify", func(t *testing.T) { ctx := newTestClient(t) require.NoError(t, ctx.client.storageEngine.GetSessionDatabase().GetStore(15*time.Minute, "oid4vci").Put(state, &session)) - ctx.iamClient.EXPECT().AccessToken(nil, code, issuerDID, redirectURI, holderDID, pkceParams.Verifier).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().AccessToken(nil, code, issuerDID, redirectURI, holderDID, pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI("kid"), nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("signed-proof", nil) ctx.iamClient.EXPECT().VerifiableCredentials(nil, credEndpoint, accessToken, "signed-proof").Return(&credentialResponse, nil) @@ -1302,7 +1348,7 @@ func TestWrapper_CallbackOid4vciCredentialIssuance(t *testing.T) { t.Run("error - key not found", func(t *testing.T) { ctx := newTestClient(t) require.NoError(t, ctx.client.storageEngine.GetSessionDatabase().GetStore(15*time.Minute, "oid4vci").Put(state, &session)) - ctx.iamClient.EXPECT().AccessToken(nil, code, issuerDID, redirectURI, holderDID, pkceParams.Verifier).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().AccessToken(nil, code, issuerDID, redirectURI, holderDID, pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return(ssi.URI{}, nil, resolver.ErrKeyNotFound) callback, err := ctx.client.CallbackOid4vciCredentialIssuance(nil, CallbackOid4vciCredentialIssuanceRequestObject{ @@ -1319,7 +1365,7 @@ func TestWrapper_CallbackOid4vciCredentialIssuance(t *testing.T) { t.Run("error - signature failure", func(t *testing.T) { ctx := newTestClient(t) require.NoError(t, ctx.client.storageEngine.GetSessionDatabase().GetStore(15*time.Minute, "oid4vci").Put(state, &session)) - ctx.iamClient.EXPECT().AccessToken(nil, code, issuerDID, redirectURI, holderDID, pkceParams.Verifier).Return(tokenResponse, nil) + ctx.iamClient.EXPECT().AccessToken(nil, code, issuerDID, redirectURI, holderDID, pkceParams.Verifier, false).Return(tokenResponse, nil) ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI("kid"), nil, nil) ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return("", errors.New("signature failed")) diff --git a/auth/api/iam/dpop.go b/auth/api/iam/dpop.go new file mode 100644 index 0000000000..1957347628 --- /dev/null +++ b/auth/api/iam/dpop.go @@ -0,0 +1,135 @@ +/* + * Nuts node + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package iam + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "net/http" + + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/auth/log" + "github.com/nuts-foundation/nuts-node/auth/oauth" + "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/crypto/dpop" + nutsHash "github.com/nuts-foundation/nuts-node/crypto/hash" + "github.com/nuts-foundation/nuts-node/storage" + "github.com/nuts-foundation/nuts-node/vdr/resolver" +) + +func (r Wrapper) CreateDPoPProof(ctx context.Context, request CreateDPoPProofRequestObject) (CreateDPoPProofResponseObject, error) { + // check method and url + if request.Body.Htm == "" { + return nil, core.InvalidInputError("missing method") + } + if request.Body.Htu == "" { + return nil, core.InvalidInputError("missing url") + } + // check access token status + if request.Body.Token == "" { + return nil, core.InvalidInputError("missing token") + } + + // extract DID from request path + ownDID, err := r.toOwnedDID(ctx, request.Did) + if err != nil { + return nil, err + } + // create new DPoP header + httpRequest, err := http.NewRequest(request.Body.Htm, request.Body.Htu, nil) + if err != nil { + return nil, core.InvalidInputError(err.Error()) + } + dpop, err := r.DPoPProof(ctx, *ownDID, *httpRequest, request.Body.Token) + return CreateDPoPProof200JSONResponse{Dpop: dpop}, err +} + +func (r Wrapper) ValidateDPoPProof(_ context.Context, request ValidateDPoPProofRequestObject) (ValidateDPoPProofResponseObject, error) { + dpopToken, err := dpop.Parse(request.Body.DpopProof) + if err != nil { + reason := fmt.Sprintf("failed to parse DPoP header: %s", err.Error()) + return ValidateDPoPProof200JSONResponse{Reason: &reason}, nil + } + if ok, err := dpopToken.Match(request.Body.Thumbprint, request.Body.Method, request.Body.Url); !ok { + reason := err.Error() + return ValidateDPoPProof200JSONResponse{Reason: &reason}, nil + } + // check if ath claim matches hash of access_token + ath, ok := dpopToken.Token.Get(dpop.ATHKey) + if !ok { + reason := "missing ath claim" + return ValidateDPoPProof200JSONResponse{Reason: &reason}, nil + } + hash := nutsHash.SHA256Sum([]byte(request.Body.Token)) + if ath != base64.RawURLEncoding.EncodeToString(hash.Slice()) { + reason := "ath/token claim mismatch" + return ValidateDPoPProof200JSONResponse{Reason: &reason}, nil + } + // check if the jti is already used, if not add it to the store for the duration of the access token lifetime + var target struct{} + if err := r.useNonceOnceStore().Get(dpopToken.Token.JwtID(), &target); err != nil { + if !errors.Is(err, storage.ErrNotFound) { + log.Logger().WithError(err).Error("ValidateDPoPProof: failed to retrieve jti usage state") + return nil, err + } + if err := r.useNonceOnceStore().Put(dpopToken.Token.JwtID(), target); err != nil { + log.Logger().WithError(err).Error("ValidateDPoPProof: failed to store jti usage state") + return nil, err + } + } else { + // jti already used + reason := "jti already used" + return ValidateDPoPProof200JSONResponse{Reason: &reason}, nil + } + + return ValidateDPoPProof200JSONResponse{Valid: true}, nil +} + +func (r *Wrapper) DPoPProof(ctx context.Context, requester did.DID, request http.Request, accessToken string) (string, error) { + // find the key to sign the DPoP token with + keyResolver := resolver.DIDKeyResolver{r.vdr.Resolver()} + keyID, _, err := keyResolver.ResolveKey(requester, nil, resolver.AssertionMethod) + if err != nil { + return "", err + } + + token := dpop.New(request) + token.GenerateProof(accessToken) + return r.jwtSigner.SignDPoP(ctx, *token, keyID.String()) +} + +func dpopFromRequest(httpRequest http.Request) (*dpop.DPoP, error) { + dpopHeader := httpRequest.Header.Get("DPoP") + // optional header + if dpopHeader == "" { + return nil, nil + } + // parse and validate DPoP header + dpopProof, err := dpop.Parse(dpopHeader) + if err != nil { + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidDPopProof, + Description: "DPoP header is invalid", + InternalError: err, + } + } + return dpopProof, nil +} diff --git a/auth/api/iam/dpop_test.go b/auth/api/iam/dpop_test.go new file mode 100644 index 0000000000..4a2664d033 --- /dev/null +++ b/auth/api/iam/dpop_test.go @@ -0,0 +1,253 @@ +/* + * Nuts node + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package iam + +import ( + "context" + crypto2 "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/base64" + "net/http" + "testing" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + ssi "github.com/nuts-foundation/go-did" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/auth/oauth" + cryptoNuts "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/crypto/dpop" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func TestWrapper_CreateDPoPProof(t *testing.T) { + accesstoken := "token" + request, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://example.com", nil) + requestBody := CreateDPoPProofJSONRequestBody{ + Htm: "GET", + Token: accesstoken, + Htu: "https://example.com", + } + requestObject := CreateDPoPProofRequestObject{ + Body: &requestBody, + Did: webDID.String(), + } + didDocument := did.Document{ID: holderDID} + vmId := did.MustParseDIDURL(webDID.String() + "#key1") + key := cryptoNuts.NewTestKey(vmId.String()) + vm, _ := did.NewVerificationMethod(vmId, ssi.JsonWebKey2020, webDID, key.Public()) + didDocument.AddAssertionMethod(vm) + dpopToken := dpop.New(*request) + dpopToken.GenerateProof(accesstoken) + t.Run("ok", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(true, nil) + ctx.resolver.EXPECT().Resolve(webDID, gomock.Any()).Return(&didDocument, nil, nil) + ctx.jwtSigner.EXPECT().SignDPoP(gomock.Any(), gomock.Any(), vmId.String()).DoAndReturn(func(_ context.Context, token dpop.DPoP, _ string) (string, error) { + assert.Equal(t, dpopToken.String(), token.String()) + return "dpop", nil + }) + + res, err := ctx.client.CreateDPoPProof(context.Background(), requestObject) + + require.NoError(t, err) + assert.Equal(t, "dpop", res.(CreateDPoPProof200JSONResponse).Dpop) + }) + t.Run("missing method", func(t *testing.T) { + ctx := newTestClient(t) + requestBody.Htm = "" + defer (func() { requestBody.Htm = "GET" })() + + _, err := ctx.client.CreateDPoPProof(context.Background(), requestObject) + + assert.EqualError(t, err, "missing method") + }) + t.Run("invalid method", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(true, nil) + requestBody.Htm = "\\" + defer (func() { requestBody.Htm = "GET" })() + + _, err := ctx.client.CreateDPoPProof(context.Background(), requestObject) + + assert.EqualError(t, err, "net/http: invalid method \"\\\\\"") + }) + t.Run("missing token", func(t *testing.T) { + ctx := newTestClient(t) + requestBody.Token = "" + defer (func() { requestBody.Token = accesstoken })() + + _, err := ctx.client.CreateDPoPProof(context.Background(), requestObject) + + assert.EqualError(t, err, "missing token") + }) + t.Run("missing url", func(t *testing.T) { + ctx := newTestClient(t) + requestBody.Htu = "" + defer (func() { requestBody.Htu = "https://example.com" })() + + _, err := ctx.client.CreateDPoPProof(context.Background(), requestObject) + + assert.EqualError(t, err, "missing url") + }) + t.Run("did not owned", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(false, nil) + + _, err := ctx.client.CreateDPoPProof(context.Background(), requestObject) + + assert.EqualError(t, err, "DID document not managed by this node") + }) + t.Run("proof error", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(true, nil) + ctx.resolver.EXPECT().Resolve(webDID, gomock.Any()).Return(&didDocument, nil, nil) + ctx.jwtSigner.EXPECT().SignDPoP(gomock.Any(), gomock.Any(), vmId.String()).Return("dpop", assert.AnError) + + _, err := ctx.client.CreateDPoPProof(context.Background(), requestObject) + + assert.Equal(t, assert.AnError, err) + }) +} + +func TestWrapper_ValidateDPoPProof(t *testing.T) { + accessToken := "token" + dpopToken, dpopProof, thumbprint := newSignedTestDPoP() + request := ValidateDPoPProofRequestObject{ + Body: &ValidateDPoPProofJSONRequestBody{ + DpopProof: dpopProof.String(), + Method: "POST", + Thumbprint: thumbprint, + Token: accessToken, + Url: "https://server.example.com/token", + }, + } + + t.Run("ok", func(t *testing.T) { + ctx := newTestClient(t) + + resp, err := ctx.client.ValidateDPoPProof(nil, request) + + require.NoError(t, err) + require.IsType(t, ValidateDPoPProof200JSONResponse{}, resp) + assert.True(t, resp.(ValidateDPoPProof200JSONResponse).Valid) + }) + t.Run("no match", func(t *testing.T) { + ctx := newTestClient(t) + request.Body.Method = "GET" + defer (func() { request.Body.Method = "POST" })() + + resp, err := ctx.client.ValidateDPoPProof(nil, request) + + require.NoError(t, err) + require.IsType(t, ValidateDPoPProof200JSONResponse{}, resp) + assert.False(t, resp.(ValidateDPoPProof200JSONResponse).Valid) + assert.Equal(t, "method mismatch, token: POST, given: GET", *resp.(ValidateDPoPProof200JSONResponse).Reason) + }) + t.Run("missing ath header", func(t *testing.T) { + ctx := newTestClient(t) + request.Body.DpopProof = dpopToken.String() + defer (func() { request.Body.DpopProof = dpopProof.String() })() + + resp, err := ctx.client.ValidateDPoPProof(nil, request) + + require.NoError(t, err) + require.IsType(t, ValidateDPoPProof200JSONResponse{}, resp) + assert.False(t, resp.(ValidateDPoPProof200JSONResponse).Valid) + assert.Equal(t, "missing ath claim", *resp.(ValidateDPoPProof200JSONResponse).Reason) + }) + t.Run("parsing failed", func(t *testing.T) { + ctx := newTestClient(t) + request.Body.DpopProof = "invalid" + defer (func() { request.Body.DpopProof = dpopProof.String() })() + + resp, err := ctx.client.ValidateDPoPProof(nil, request) + + require.NoError(t, err) + require.IsType(t, ValidateDPoPProof200JSONResponse{}, resp) + assert.False(t, resp.(ValidateDPoPProof200JSONResponse).Valid) + assert.Equal(t, "failed to parse DPoP header: invalid DPoP token\ninvalid compact serialization format: invalid number of segments", *resp.(ValidateDPoPProof200JSONResponse).Reason) + }) + t.Run("invalid accestoken", func(t *testing.T) { + ctx := newTestClient(t) + request.Body.Token = "invalid" + defer (func() { request.Body.Token = accessToken })() + + resp, err := ctx.client.ValidateDPoPProof(nil, request) + + require.NoError(t, err) + require.IsType(t, ValidateDPoPProof200JSONResponse{}, resp) + assert.False(t, resp.(ValidateDPoPProof200JSONResponse).Valid) + assert.Equal(t, "ath/token claim mismatch", *resp.(ValidateDPoPProof200JSONResponse).Reason) + }) + t.Run("already used once", func(t *testing.T) { + ctx := newTestClient(t) + _ = ctx.client.useNonceOnceStore().Put(dpopProof.Token.JwtID(), struct{}{}) + + resp, err := ctx.client.ValidateDPoPProof(nil, request) + + require.NoError(t, err) + require.IsType(t, ValidateDPoPProof200JSONResponse{}, resp) + assert.False(t, resp.(ValidateDPoPProof200JSONResponse).Valid) + assert.Equal(t, "jti already used", *resp.(ValidateDPoPProof200JSONResponse).Reason) + }) +} + +func Test_dpopFromRequest(t *testing.T) { + t.Run("without DPoP header", func(t *testing.T) { + httpRequest, _ := http.NewRequest("POST", "https://server.example.com/token", nil) + + resp, err := dpopFromRequest(*httpRequest) + + require.NoError(t, err) + assert.Nil(t, resp) + }) + t.Run("invalid DPoP header", func(t *testing.T) { + httpRequest, _ := http.NewRequest("POST", "https://server.example.com/token", nil) + httpRequest.Header.Set("DPoP", "invalid") + + _, err := dpopFromRequest(*httpRequest) + + require.Error(t, err) + _ = assertOAuthErrorWithCode(t, err, oauth.InvalidDPopProof, "DPoP header is invalid") + }) +} + +func newTestDPoP() *dpop.DPoP { + httpRequest, _ := http.NewRequest("POST", "https://server.example.com/token", nil) + return dpop.New(*httpRequest) +} + +func newSignedTestDPoP() (*dpop.DPoP, *dpop.DPoP, string) { + dpopToken := newTestDPoP() + withProof := newTestDPoP() + keyPair, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + jwkKey, _ := jwk.FromRaw(keyPair) + jwkKey.Set(jwk.AlgorithmKey, jwa.ES256) + _ = withProof.GenerateProof("token") + _, _ = withProof.Sign(jwkKey) + _, _ = dpopToken.Sign(jwkKey) + thumbprintBytes, _ := dpopToken.Headers.JWK().Thumbprint(crypto2.SHA256) + thumbprint := base64.RawURLEncoding.EncodeToString(thumbprintBytes) + return dpopToken, withProof, thumbprint +} diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 08694fec0c..a91d48978e 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -19,6 +19,63 @@ const ( JwtBearerAuthScopes = "jwtBearerAuth.Scopes" ) +// Defines values for ServiceAccessTokenRequestTokenType. +const ( + ServiceAccessTokenRequestTokenTypeBearer ServiceAccessTokenRequestTokenType = "Bearer" + ServiceAccessTokenRequestTokenTypeDPoP ServiceAccessTokenRequestTokenType = "DPoP" +) + +// Defines values for UserAccessTokenRequestTokenType. +const ( + UserAccessTokenRequestTokenTypeBearer UserAccessTokenRequestTokenType = "Bearer" + UserAccessTokenRequestTokenTypeDPoP UserAccessTokenRequestTokenType = "DPoP" +) + +// DPoPRequest defines model for DPoPRequest. +type DPoPRequest struct { + // Htm The HTTP method for which the DPoP proof is requested. + Htm string `json:"htm"` + + // Htu The URL for which the DPoP proof is requested. Query params and fragments are ignored during validation. + Htu string `json:"htu"` + + // Token The access token for which the DPoP proof is requested. + Token string `json:"token"` +} + +// DPoPResponse defines model for DPoPResponse. +type DPoPResponse struct { + // Dpop The DPoP proof as specified by https://datatracker.ietf.org/doc/html/rfc9449 for resource requests + Dpop string `json:"dpop"` +} + +// DPoPValidateRequest defines model for DPoPValidateRequest. +type DPoPValidateRequest struct { + // DpopProof The DPoP Proof as specified by https://datatracker.ietf.org/doc/html/rfc9449 for resource requests + DpopProof string `json:"dpop_proof"` + + // Method The HTTP method against which the DPoP proof is validated. + Method string `json:"method"` + + // Thumbprint The thumbprint of the public key used to sign the DPoP proof. Base64url encoded, no padding. + Thumbprint string `json:"thumbprint"` + + // Token The access token against which the DPoP proof is validated. + Token string `json:"token"` + + // Url The URL against which the DPoP proof is validated. Query params and fragments are ignored during validation. + Url string `json:"url"` +} + +// DPoPValidateResponse defines model for DPoPValidateResponse. +type DPoPValidateResponse struct { + // Reason The reason why the DPoP Proof header is invalid. + Reason *string `json:"reason,omitempty"` + + // Valid True if the DPoP Proof header is valid for the access token and HTTP request, false if it is not. + Valid bool `json:"valid"` +} + // RedirectResponseWithID defines model for RedirectResponseWithID. type RedirectResponseWithID struct { // RedirectUri The URL to which the user-agent will be redirected after the authorization request. @@ -31,6 +88,19 @@ type RedirectResponseWithID struct { // RequestObjectResponse A JSON Web Token (JWT) whose JWT Claims Set holds the JSON-encoded OAuth 2.0 authorization request parameters. type RequestObjectResponse = string +// ServiceAccessTokenRequest Request for an access token for a service. +type ServiceAccessTokenRequest struct { + // Scope The scope that will be the service for which this access token can be used. + Scope string `json:"scope"` + + // TokenType The type of access token that is prefered, default: DPoP + TokenType *ServiceAccessTokenRequestTokenType `json:"tokenType,omitempty"` + Verifier string `json:"verifier"` +} + +// ServiceAccessTokenRequestTokenType The type of access token that is prefered, default: DPoP +type ServiceAccessTokenRequestTokenType string + // TokenIntrospectionRequest Token introspection request as described in RFC7662 section 2.1 // Alongside the defined properties, it can return values (additionalProperties) from the Verifiable Credentials that resulted from the Presentation Exchange. type TokenIntrospectionRequest struct { @@ -48,6 +118,9 @@ type TokenIntrospectionResponse struct { // ClientId The client (DID) the access token was issued to ClientId *string `json:"client_id,omitempty"` + // Cnf The 'confirmation' claim is used in JWTs to proof the possession of a key. + Cnf *Cnf `json:"cnf,omitempty"` + // Exp Expiration date in seconds since UNIX epoch Exp *int `json:"exp,omitempty"` @@ -75,6 +148,29 @@ type TokenIntrospectionResponse struct { AdditionalProperties map[string]interface{} `json:"-"` } +// UserAccessTokenRequest Request for an access token for a user. +type UserAccessTokenRequest struct { + // PreauthorizedUser Claims about the authorized user. + PreauthorizedUser *UserDetails `json:"preauthorized_user,omitempty"` + + // RedirectUri The URL to which the user-agent will be redirected after the authorization request. + // This is the URL of the calling application. + // The OAuth2 flow will finish at the /callback URL of the node and the node will redirect the user to this redirect_uri. + RedirectUri string `json:"redirect_uri"` + + // Scope The scope that will be the service for which this access token can be used. + Scope string `json:"scope"` + + // TokenType The type of access token that is prefered. Supported values: [Bearer, DPoP], default: DPoP + TokenType *UserAccessTokenRequestTokenType `json:"token_type,omitempty"` + + // Verifier The DID of the verifier, the relying party for which this access token is requested. + Verifier string `json:"verifier"` +} + +// UserAccessTokenRequestTokenType The type of access token that is prefered. Supported values: [Bearer, DPoP], default: DPoP +type UserAccessTokenRequestTokenType string + // UserDetails Claims about the authorized user. type UserDetails struct { // Id Machine-readable identifier, uniquely identifying the user in the issuing system. @@ -87,6 +183,12 @@ type UserDetails struct { Role string `json:"role"` } +// Cnf The 'confirmation' claim is used in JWTs to proof the possession of a key. +type Cnf struct { + // Jkt JWK thumbprint + Jkt string `json:"jkt"` +} + // CallbackOid4vciCredentialIssuanceParams defines parameters for CallbackOid4vciCredentialIssuance. type CallbackOid4vciCredentialIssuanceParams struct { // Code The oauth2 code response. @@ -115,30 +217,6 @@ type RequestOid4vciCredentialIssuanceJSONBody struct { RedirectUri string `json:"redirect_uri"` } -// RequestServiceAccessTokenJSONBody defines parameters for RequestServiceAccessToken. -type RequestServiceAccessTokenJSONBody struct { - // Scope The scope that will be the service for which this access token can be used. - Scope string `json:"scope"` - Verifier string `json:"verifier"` -} - -// RequestUserAccessTokenJSONBody defines parameters for RequestUserAccessToken. -type RequestUserAccessTokenJSONBody struct { - // PreauthorizedUser Claims about the authorized user. - PreauthorizedUser *UserDetails `json:"preauthorized_user,omitempty"` - - // RedirectUri The URL to which the user-agent will be redirected after the authorization request. - // This is the URL of the calling application. - // The OAuth2 flow will finish at the /callback URL of the node and the node will redirect the user to this redirect_uri. - RedirectUri string `json:"redirect_uri"` - - // Scope The scope that will be the service for which this access token can be used. - Scope string `json:"scope"` - - // Verifier The DID of the verifier, the relying party for which this access token is requested. - Verifier string `json:"verifier"` -} - // HandleAuthorizeRequestParams defines parameters for HandleAuthorizeRequest. type HandleAuthorizeRequestParams struct { Params *map[string]string `form:"params,omitempty" json:"params,omitempty"` @@ -206,14 +284,20 @@ type HandleTokenRequestFormdataBody struct { // IntrospectAccessTokenFormdataRequestBody defines body for IntrospectAccessToken for application/x-www-form-urlencoded ContentType. type IntrospectAccessTokenFormdataRequestBody = TokenIntrospectionRequest +// ValidateDPoPProofJSONRequestBody defines body for ValidateDPoPProof for application/json ContentType. +type ValidateDPoPProofJSONRequestBody = DPoPValidateRequest + +// CreateDPoPProofJSONRequestBody defines body for CreateDPoPProof for application/json ContentType. +type CreateDPoPProofJSONRequestBody = DPoPRequest + // RequestOid4vciCredentialIssuanceJSONRequestBody defines body for RequestOid4vciCredentialIssuance for application/json ContentType. type RequestOid4vciCredentialIssuanceJSONRequestBody RequestOid4vciCredentialIssuanceJSONBody // RequestServiceAccessTokenJSONRequestBody defines body for RequestServiceAccessToken for application/json ContentType. -type RequestServiceAccessTokenJSONRequestBody RequestServiceAccessTokenJSONBody +type RequestServiceAccessTokenJSONRequestBody = ServiceAccessTokenRequest // RequestUserAccessTokenJSONRequestBody defines body for RequestUserAccessToken for application/json ContentType. -type RequestUserAccessTokenJSONRequestBody RequestUserAccessTokenJSONBody +type RequestUserAccessTokenJSONRequestBody = UserAccessTokenRequest // PostRequestJWTFormdataRequestBody defines body for PostRequestJWT for application/x-www-form-urlencoded ContentType. type PostRequestJWTFormdataRequestBody PostRequestJWTFormdataBody @@ -273,6 +357,14 @@ func (a *TokenIntrospectionResponse) UnmarshalJSON(b []byte) error { delete(object, "client_id") } + if raw, found := object["cnf"]; found { + err = json.Unmarshal(raw, &a.Cnf) + if err != nil { + return fmt.Errorf("error reading 'cnf': %w", err) + } + delete(object, "cnf") + } + if raw, found := object["exp"]; found { err = json.Unmarshal(raw, &a.Exp) if err != nil { @@ -375,6 +467,13 @@ func (a TokenIntrospectionResponse) MarshalJSON() ([]byte, error) { } } + if a.Cnf != nil { + object["cnf"], err = json.Marshal(a.Cnf) + if err != nil { + return nil, fmt.Errorf("error marshaling 'cnf': %w", err) + } + } + if a.Exp != nil { object["exp"], err = json.Marshal(a.Exp) if err != nil { @@ -463,6 +562,12 @@ type ServerInterface interface { // Get the access token from the Nuts node that was requested through /request-user-access-token. // (GET /internal/auth/v2/accesstoken/{sessionID}) RetrieveAccessToken(ctx echo.Context, sessionID string) error + // Handle some of the validation of a DPoP proof as specified by RFC9449. + // (POST /internal/auth/v2/dpop_validate) + ValidateDPoPProof(ctx echo.Context) error + // Create a DPoP proof as specified by RFC9449 for a given access token. It is to be used as HTTP header when accessing resources. + // (POST /internal/auth/v2/{did}/dpop) + CreateDPoPProof(ctx echo.Context, did string) error // Start the Oid4VCI authorization flow. // (POST /internal/auth/v2/{did}/request-credential) RequestOid4vciCredentialIssuance(ctx echo.Context, did string) error @@ -634,6 +739,32 @@ func (w *ServerInterfaceWrapper) RetrieveAccessToken(ctx echo.Context) error { return err } +// ValidateDPoPProof converts echo context to params. +func (w *ServerInterfaceWrapper) ValidateDPoPProof(ctx echo.Context) error { + var err error + + ctx.Set(JwtBearerAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.ValidateDPoPProof(ctx) + return err +} + +// CreateDPoPProof converts echo context to params. +func (w *ServerInterfaceWrapper) CreateDPoPProof(ctx echo.Context) error { + var err error + // ------------- Path parameter "did" ------------- + var did string + + did = ctx.Param("did") + + ctx.Set(JwtBearerAuthScopes, []string{}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.CreateDPoPProof(ctx, did) + return err +} + // RequestOid4vciCredentialIssuance converts echo context to params. func (w *ServerInterfaceWrapper) RequestOid4vciCredentialIssuance(ctx echo.Context) error { var err error @@ -928,6 +1059,8 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/iam/:id/did.json", wrapper.GetTenantWebDID) router.POST(baseURL+"/internal/auth/v2/accesstoken/introspect", wrapper.IntrospectAccessToken) router.GET(baseURL+"/internal/auth/v2/accesstoken/:sessionID", wrapper.RetrieveAccessToken) + router.POST(baseURL+"/internal/auth/v2/dpop_validate", wrapper.ValidateDPoPProof) + router.POST(baseURL+"/internal/auth/v2/:did/dpop", wrapper.CreateDPoPProof) router.POST(baseURL+"/internal/auth/v2/:did/request-credential", wrapper.RequestOid4vciCredentialIssuance) router.POST(baseURL+"/internal/auth/v2/:did/request-service-access-token", wrapper.RequestServiceAccessToken) router.POST(baseURL+"/internal/auth/v2/:did/request-user-access-token", wrapper.RequestUserAccessToken) @@ -1173,6 +1306,70 @@ func (response RetrieveAccessTokendefaultApplicationProblemPlusJSONResponse) Vis return json.NewEncoder(w).Encode(response.Body) } +type ValidateDPoPProofRequestObject struct { + Body *ValidateDPoPProofJSONRequestBody +} + +type ValidateDPoPProofResponseObject interface { + VisitValidateDPoPProofResponse(w http.ResponseWriter) error +} + +type ValidateDPoPProof200JSONResponse DPoPValidateResponse + +func (response ValidateDPoPProof200JSONResponse) VisitValidateDPoPProofResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ValidateDPoPProofdefaultApplicationProblemPlusJSONResponse struct { + Body struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + StatusCode int +} + +func (response ValidateDPoPProofdefaultApplicationProblemPlusJSONResponse) VisitValidateDPoPProofResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + + return json.NewEncoder(w).Encode(response.Body) +} + +type CreateDPoPProofRequestObject struct { + Did string `json:"did"` + Body *CreateDPoPProofJSONRequestBody +} + +type CreateDPoPProofResponseObject interface { + VisitCreateDPoPProofResponse(w http.ResponseWriter) error +} + +type CreateDPoPProof200JSONResponse DPoPResponse + +func (response CreateDPoPProof200JSONResponse) VisitCreateDPoPProofResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type CreateDPoPProof401Response struct { +} + +func (response CreateDPoPProof401Response) VisitCreateDPoPProofResponse(w http.ResponseWriter) error { + w.WriteHeader(401) + return nil +} + type RequestOid4vciCredentialIssuanceRequestObject struct { Did string `json:"did"` Body *RequestOid4vciCredentialIssuanceJSONRequestBody @@ -1662,6 +1859,12 @@ type StrictServerInterface interface { // Get the access token from the Nuts node that was requested through /request-user-access-token. // (GET /internal/auth/v2/accesstoken/{sessionID}) RetrieveAccessToken(ctx context.Context, request RetrieveAccessTokenRequestObject) (RetrieveAccessTokenResponseObject, error) + // Handle some of the validation of a DPoP proof as specified by RFC9449. + // (POST /internal/auth/v2/dpop_validate) + ValidateDPoPProof(ctx context.Context, request ValidateDPoPProofRequestObject) (ValidateDPoPProofResponseObject, error) + // Create a DPoP proof as specified by RFC9449 for a given access token. It is to be used as HTTP header when accessing resources. + // (POST /internal/auth/v2/{did}/dpop) + CreateDPoPProof(ctx context.Context, request CreateDPoPProofRequestObject) (CreateDPoPProofResponseObject, error) // Start the Oid4VCI authorization flow. // (POST /internal/auth/v2/{did}/request-credential) RequestOid4vciCredentialIssuance(ctx context.Context, request RequestOid4vciCredentialIssuanceRequestObject) (RequestOid4vciCredentialIssuanceResponseObject, error) @@ -1891,6 +2094,66 @@ func (sh *strictHandler) RetrieveAccessToken(ctx echo.Context, sessionID string) return nil } +// ValidateDPoPProof operation middleware +func (sh *strictHandler) ValidateDPoPProof(ctx echo.Context) error { + var request ValidateDPoPProofRequestObject + + var body ValidateDPoPProofJSONRequestBody + if err := ctx.Bind(&body); err != nil { + return err + } + request.Body = &body + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.ValidateDPoPProof(ctx.Request().Context(), request.(ValidateDPoPProofRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ValidateDPoPProof") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(ValidateDPoPProofResponseObject); ok { + return validResponse.VisitValidateDPoPProofResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// CreateDPoPProof operation middleware +func (sh *strictHandler) CreateDPoPProof(ctx echo.Context, did string) error { + var request CreateDPoPProofRequestObject + + request.Did = did + + var body CreateDPoPProofJSONRequestBody + if err := ctx.Bind(&body); err != nil { + return err + } + request.Body = &body + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.CreateDPoPProof(ctx.Request().Context(), request.(CreateDPoPProofRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "CreateDPoPProof") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(CreateDPoPProofResponseObject); ok { + return validResponse.VisitCreateDPoPProofResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + // RequestOid4vciCredentialIssuance operation middleware func (sh *strictHandler) RequestOid4vciCredentialIssuance(ctx echo.Context, did string) error { var request RequestOid4vciCredentialIssuanceRequestObject diff --git a/auth/api/iam/metadata.go b/auth/api/iam/metadata.go index 2b1b214e69..9f016e66c7 100644 --- a/auth/api/iam/metadata.go +++ b/auth/api/iam/metadata.go @@ -24,14 +24,16 @@ import ( "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/crypto/jwx" ) func authorizationServerMetadata(identity url.URL, oauth2BaseURL url.URL) oauth.AuthorizationServerMetadata { return oauth.AuthorizationServerMetadata{ - AuthorizationEndpoint: oauth2BaseURL.JoinPath("authorize").String(), - ClientIdSchemesSupported: clientIdSchemesSupported, - GrantTypesSupported: grantTypesSupported, - Issuer: identity.String(), + AuthorizationEndpoint: oauth2BaseURL.JoinPath("authorize").String(), + ClientIdSchemesSupported: clientIdSchemesSupported, + DPoPSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(), + GrantTypesSupported: grantTypesSupported, + Issuer: identity.String(), PreAuthorizedGrantAnonymousAccessSupported: true, PresentationDefinitionEndpoint: oauth2BaseURL.JoinPath("presentation_definition").String(), RequireSignedRequestObject: true, @@ -40,7 +42,7 @@ func authorizationServerMetadata(identity url.URL, oauth2BaseURL url.URL) oauth. TokenEndpoint: oauth2BaseURL.JoinPath("token").String(), VPFormats: oauth.DefaultOpenIDSupportedFormats(), VPFormatsSupported: oauth.DefaultOpenIDSupportedFormats(), - RequestObjectSigningAlgValuesSupported: oauth.AlgValuesSupported, + RequestObjectSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(), } } diff --git a/auth/api/iam/metadata_test.go b/auth/api/iam/metadata_test.go index 00cf3b3516..77e2bd3fa3 100644 --- a/auth/api/iam/metadata_test.go +++ b/auth/api/iam/metadata_test.go @@ -21,6 +21,7 @@ package iam import ( "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/test" "github.com/stretchr/testify/assert" "net/url" @@ -31,19 +32,20 @@ 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") expected := oauth.AuthorizationServerMetadata{ - Issuer: identity.String(), - AuthorizationEndpoint: oauth2Base.String() + "/authorize", - ResponseTypesSupported: []string{"code", "vp_token", "vp_token id_token"}, - ResponseModesSupported: []string{"query", "direct_post"}, - TokenEndpoint: oauth2Base.String() + "/token", - GrantTypesSupported: []string{"authorization_code", "vp_token", "urn:ietf:params:oauth:grant-type:pre-authorized_code"}, + AuthorizationEndpoint: oauth2Base.String() + "/authorize", + ClientIdSchemesSupported: []string{"did"}, + DPoPSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(), + GrantTypesSupported: []string{"authorization_code", "vp_token", "urn:ietf:params:oauth:grant-type:pre-authorized_code"}, + Issuer: identity.String(), PreAuthorizedGrantAnonymousAccessSupported: true, PresentationDefinitionEndpoint: oauth2Base.String() + "/presentation_definition", RequireSignedRequestObject: true, + ResponseTypesSupported: []string{"code", "vp_token", "vp_token id_token"}, + ResponseModesSupported: []string{"query", "direct_post"}, + TokenEndpoint: oauth2Base.String() + "/token", VPFormats: oauth.DefaultOpenIDSupportedFormats(), VPFormatsSupported: oauth.DefaultOpenIDSupportedFormats(), - ClientIdSchemesSupported: []string{"did"}, - RequestObjectSigningAlgValuesSupported: oauth.AlgValuesSupported, + RequestObjectSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(), } assert.Equal(t, expected, authorizationServerMetadata(*identity, *oauth2Base)) } diff --git a/auth/api/iam/openid4vp.go b/auth/api/iam/openid4vp.go index 5e33a6a218..ce6c9fa58e 100644 --- a/auth/api/iam/openid4vp.go +++ b/auth/api/iam/openid4vp.go @@ -22,16 +22,14 @@ import ( "context" "errors" "fmt" - "github.com/lestrrat-go/jwx/v2/jwk" - "github.com/lestrrat-go/jwx/v2/jwt" - "github.com/nuts-foundation/nuts-node/vdr/didjwk" - "github.com/nuts-foundation/nuts-node/vdr/resolver" "net/http" "net/url" "slices" "strings" "time" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" @@ -42,6 +40,8 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/holder" "github.com/nuts-foundation/nuts-node/vcr/pe" + "github.com/nuts-foundation/nuts-node/vdr/didjwk" + "github.com/nuts-foundation/nuts-node/vdr/resolver" ) var oauthNonceKey = []string{"oauth", "nonce"} @@ -665,12 +665,28 @@ func (r Wrapper) handleAccessTokenRequest(ctx context.Context, request HandleTok if !validatePKCEParams(oauthSession.PKCEParams) { return nil, oauthError(oauth.InvalidGrant, "invalid code_verifier") } + + // Parse optional DPoP header + httpRequest := ctx.Value(httpRequestContextKey{}).(*http.Request) + dpopProof, err := dpopFromRequest(*httpRequest) + if err != nil { + return nil, err + } + var submissions []PresentationSubmission + for _, submission := range oauthSession.OpenID4VPVerifier.Submissions { + submissions = append(submissions, submission) + } + presentationDefinitions := make([]PresentationDefinition, 0) + for _, curr := range oauthSession.OpenID4VPVerifier.RequiredPresentationDefinitions { + presentationDefinitions = append(presentationDefinitions, curr) + } + // All done, issue access token walletDID, err := did.ParseDID(oauthSession.ClientID) if err != nil { return nil, err } - response, err := r.createAccessToken(*oauthSession.OwnDID, *walletDID, time.Now(), oauthSession.Scope, *oauthSession.OpenID4VPVerifier) + response, err := r.createAccessToken(*oauthSession.OwnDID, *walletDID, time.Now(), oauthSession.Scope, *oauthSession.OpenID4VPVerifier, dpopProof) if err != nil { return nil, oauthError(oauth.ServerError, fmt.Sprintf("failed to create access token: %s", err.Error())) } @@ -735,7 +751,7 @@ func (r Wrapper) handleCallback(ctx context.Context, request CallbackRequestObje checkURL = checkURL.JoinPath(oauth.CallbackPath) // use code to request access token from remote token endpoint - tokenResponse, err := r.auth.IAMClient().AccessToken(ctx, *request.Params.Code, *oauthSession.VerifierDID, checkURL.String(), *oauthSession.OwnDID, oauthSession.PKCEParams.Verifier) + tokenResponse, err := r.auth.IAMClient().AccessToken(ctx, *request.Params.Code, *oauthSession.VerifierDID, checkURL.String(), *oauthSession.OwnDID, oauthSession.PKCEParams.Verifier, oauthSession.UseDPoP) if err != nil { return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("failed to retrieve access token: %s", err.Error())), appCallbackURI) } diff --git a/auth/api/iam/openid4vp_test.go b/auth/api/iam/openid4vp_test.go index a6da32a4fc..1105ce988d 100644 --- a/auth/api/iam/openid4vp_test.go +++ b/auth/api/iam/openid4vp_test.go @@ -19,7 +19,6 @@ package iam import ( - "bytes" "context" "encoding/json" "github.com/lestrrat-go/jwx/v2/jwt" @@ -602,18 +601,27 @@ func Test_handleAccessTokenRequest(t *testing.T) { PKCEParams: generatePKCEParams(), } + // valid dpop token + dpop := "eyJhbGciOiJFUzI1NiIsImp3ayI6eyJhbGciOiJFUzI1NiIsImNydiI6IlAtMjU2Iiwia3R5IjoiRUMiLCJ4IjoiUHNnZ0pLTlpoQjBpSDUxNmZOdi1ucVpyRkZwZVJJOE9saDRsQVZpSHRkMCIsInkiOiJoblo4eGJpNVQxQTBBMjlYRDY2anl4cVYyZlE5TWE1SWJxeXpXRkx1X1VVIn0sInR5cCI6ImRwb3Arand0In0.eyJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9zZXJ2ZXIuZXhhbXBsZS5jb20vdG9rZW4iLCJpYXQiOjE3MTI2NTU2MDksImp0aSI6Ikk1a3JELXg4YWVia0hyV1o3Qy15MUEifQ.IN-y9dvoPC0b05w9bsHXLJsCiHQBxKHFtfghSmUDlt5dMG9at_0W6perVxHCo7N_LfwJ1FrgUG5V2nRw9HMW0g" + httpRequest := &http.Request{ + Header: http.Header{ + "Dpop": []string{dpop}, + }, + } + contextWithValue := context.WithValue(context.Background(), httpRequestContextKey{}, httpRequest) + t.Run("ok", func(t *testing.T) { ctx := newTestClient(t) requestBody := HandleTokenRequestFormdataRequestBody{Code: &code, ClientId: &clientID, CodeVerifier: &validSession.PKCEParams.Verifier} putCodeSession(ctx, code, validSession) - response, err := ctx.client.handleAccessTokenRequest(context.Background(), requestBody) + response, err := ctx.client.handleAccessTokenRequest(contextWithValue, requestBody) require.NoError(t, err) token, ok := response.(HandleTokenRequest200JSONResponse) require.True(t, ok) assert.NotEmpty(t, token.AccessToken) - assert.Equal(t, "bearer", token.TokenType) + assert.Equal(t, "DPoP", token.TokenType) assert.Equal(t, 900, *token.ExpiresIn) assert.Equal(t, "scope", *token.Scope) @@ -735,7 +743,7 @@ func Test_handleCallback(t *testing.T) { putState(ctx, "state", session) codeVerifier := getState(ctx, state).PKCEParams.Verifier ctx.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(true, nil) - ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, verifierDID, "https://example.com/oauth2/"+webDID.String()+"/callback", holderDID, codeVerifier).Return(nil, assert.AnError) + ctx.iamClient.EXPECT().AccessToken(gomock.Any(), code, verifierDID, "https://example.com/oauth2/"+webDID.String()+"/callback", holderDID, codeVerifier, false).Return(nil, assert.AnError) _, err := ctx.client.handleCallback(nil, CallbackRequestObject{ Did: webDID.String(), @@ -889,29 +897,13 @@ func assertOAuthError(t *testing.T, err error, expectedDescription string) oauth return oauthErr } -type stubResponseWriter struct { - headers http.Header - body *bytes.Buffer - statusCode int -} - -func (s *stubResponseWriter) Header() http.Header { - if s.headers == nil { - s.headers = make(http.Header) - } - return s.headers - -} - -func (s *stubResponseWriter) Write(i []byte) (int, error) { - if s.body == nil { - s.body = new(bytes.Buffer) - } - return s.body.Write(i) -} - -func (s *stubResponseWriter) WriteHeader(statusCode int) { - s.statusCode = statusCode +func assertOAuthErrorWithCode(t *testing.T, err error, expectedCode oauth.ErrorCode, expectedDescription string) oauth.OAuth2Error { + require.Error(t, err) + oauthErr, ok := err.(oauth.OAuth2Error) + require.True(t, ok, "expected oauth error") + assert.Equal(t, expectedCode, oauthErr.Code) + assert.Equal(t, expectedDescription, oauthErr.Description) + return oauthErr } func putState(ctx *testCtx, state string, session OAuthSession) { diff --git a/auth/api/iam/s2s_vptoken.go b/auth/api/iam/s2s_vptoken.go index ae58d0b555..ba249b24d7 100644 --- a/auth/api/iam/s2s_vptoken.go +++ b/auth/api/iam/s2s_vptoken.go @@ -22,12 +22,12 @@ import ( "context" "errors" "fmt" + "net/http" "time" "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" "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/pe" @@ -89,6 +89,13 @@ func (r Wrapper) handleS2SAccessTokenRequest(ctx context.Context, issuer did.DID } } + // Parse optional DPoP header + httpRequest := ctx.Value(httpRequestContextKey{}).(*http.Request) + dpopProof, err := dpopFromRequest(*httpRequest) + if err != nil { + return nil, err + } + // Check signatures of VP and VCs. Trust should be established by the Presentation Definition. for _, presentation := range pexEnvelope.Presentations { _, err = r.vcr.Verifier().VerifyVP(presentation, true, true, nil) @@ -102,50 +109,13 @@ func (r Wrapper) handleS2SAccessTokenRequest(ctx context.Context, issuer did.DID } // All OK, allow access - response, err := r.createAccessToken(issuer, credentialSubjectID, time.Now(), scope, *pexConsumer) + response, err := r.createAccessToken(issuer, credentialSubjectID, time.Now(), scope, *pexConsumer, dpopProof) if err != nil { return nil, err } return HandleTokenRequest200JSONResponse(*response), nil } -func (r Wrapper) createAccessToken(issuer did.DID, walletDID did.DID, issueTime time.Time, scope string, pexState PEXConsumer) (*oauth.TokenResponse, error) { - credentialMap, err := pexState.credentialMap() - if err != nil { - return nil, err - } - fieldsMap, err := resolveInputDescriptorValues(pexState.RequiredPresentationDefinitions, credentialMap) - if err != nil { - return nil, err - } - - accessToken := AccessToken{ - Token: crypto.GenerateNonce(), - Issuer: issuer.String(), - ClientId: walletDID.String(), - IssuedAt: issueTime, - Expiration: issueTime.Add(accessTokenValidity), - Scope: scope, - PresentationSubmissions: pexState.Submissions, - PresentationDefinitions: pexState.RequiredPresentationDefinitions, - InputDescriptorConstraintIdMap: fieldsMap, - } - for _, envelope := range pexState.SubmittedEnvelopes { - accessToken.VPToken = append(accessToken.VPToken, envelope.Presentations...) - } - err = r.accessTokenServerStore().Put(accessToken.Token, accessToken) - if err != nil { - return nil, fmt.Errorf("unable to store access token: %w", err) - } - expiresIn := int(accessTokenValidity.Seconds()) - return &oauth.TokenResponse{ - AccessToken: accessToken.Token, - ExpiresIn: &expiresIn, - Scope: &scope, - TokenType: "bearer", - }, nil -} - func resolveInputDescriptorValues(presentationDefinitions pe.WalletOwnerMapping, credentialMap map[string]vc.VerifiableCredential) (map[string]any, error) { fieldsMap := make(map[string]any) for _, definition := range presentationDefinitions { @@ -201,7 +171,6 @@ func (r Wrapper) validateS2SPresentationNonce(presentation vc.VerifiablePresenta Description: "presentation has invalid/missing nonce", } } - nonceStore := r.storageEngine.GetSessionDatabase().GetStore(s2sMaxPresentationValidity+s2sMaxClockSkew, "s2s", "nonce") nonceError := nonceStore.Get(nonce, new(bool)) if nonceError != nil && errors.Is(nonceError, storage.ErrNotFound) { @@ -244,30 +213,3 @@ func extractNonce(presentation vc.VerifiablePresentation) (string, error) { } return nonce, nil } - -type AccessToken struct { - Token string `json:"token"` - // Issuer and Subject of a token are always the same. - Issuer string `json:"issuer"` - // TODO: should client_id be extracted to the PDPMap using the presentation definition? - // ClientId is the DID of the entity requesting the access token. The Client needs to proof its id through proof-of-possession of the key for the DID. - ClientId string `json:"client_id"` - // IssuedAt is the time the token is issued - IssuedAt time.Time `json:"issued_at"` - // Expiration is the time the token expires - Expiration time.Time `json:"expiration"` - // Scope the token grants access to. Not necessarily the same as the requested scope - Scope string `json:"scope"` - // InputDescriptorConstraintIdMap maps the ID field of a PresentationDefinition input descriptor constraint to the value provided in the VPToken for the constraint. - // The Policy Decision Point can use this map to make decisions without having to deal with PEX/VCs/VPs/SignatureValidation - InputDescriptorConstraintIdMap map[string]any `json:"inputdescriptor_constraint_id_map,omitempty"` - - // additional fields to support unforeseen policy decision requirements - - // VPToken contains the VPs provided in the 'assertion' field of the s2s AT request. - VPToken []VerifiablePresentation `json:"vp_token,omitempty"` - // PresentationSubmissions as provided in by the wallet to fulfill the required Presentation Definition(s). - PresentationSubmissions map[string]pe.PresentationSubmission `json:"presentation_submissions,omitempty"` - // PresentationDefinitions that were required by the verifier to fulfill the request. - PresentationDefinitions pe.WalletOwnerMapping `json:"presentation_definitions,omitempty"` -} diff --git a/auth/api/iam/s2s_vptoken_test.go b/auth/api/iam/s2s_vptoken_test.go index 21a07d38c4..847034ad16 100644 --- a/auth/api/iam/s2s_vptoken_test.go +++ b/auth/api/iam/s2s_vptoken_test.go @@ -25,8 +25,10 @@ import ( "crypto/rand" "encoding/json" "errors" + "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/policy" "go.uber.org/mock/gomock" + "net/http" "testing" "time" @@ -107,18 +109,24 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { proof.Domain = &issuerDIDStr }) presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, verifiableCredential) - + dpopHeader, _, _ := newSignedTestDPoP() + httpRequest := &http.Request{ + Header: http.Header{ + "Dpop": []string{dpopHeader.String()}, + }, + } + contextWithValue := context.WithValue(context.Background(), httpRequestContextKey{}, httpRequest) t.Run("JSON-LD VP", func(t *testing.T) { ctx := newTestClient(t) ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), issuerDID, requestedScope).Return(walletOwnerMapping, nil) - resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, presentation.Raw()) + resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, issuerDID, requestedScope, submissionJSON, presentation.Raw()) require.NoError(t, err) require.IsType(t, HandleTokenRequest200JSONResponse{}, resp) tokenResponse := TokenResponse(resp.(HandleTokenRequest200JSONResponse)) - assert.Equal(t, "bearer", tokenResponse.TokenType) + assert.Equal(t, "DPoP", tokenResponse.TokenType) assert.Equal(t, requestedScope, *tokenResponse.Scope) assert.Equal(t, int(accessTokenValidity.Seconds()), *tokenResponse.ExpiresIn) assert.NotEmpty(t, tokenResponse.AccessToken) @@ -161,12 +169,12 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), issuerDID, requestedScope).Return(walletOwnerMapping, nil) ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) - resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, presentation.Raw()) + resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, issuerDID, requestedScope, submissionJSON, presentation.Raw()) require.NoError(t, err) require.IsType(t, HandleTokenRequest200JSONResponse{}, resp) tokenResponse := TokenResponse(resp.(HandleTokenRequest200JSONResponse)) - assert.Equal(t, "bearer", tokenResponse.TokenType) + assert.Equal(t, "DPoP", tokenResponse.TokenType) assert.Equal(t, requestedScope, *tokenResponse.Scope) assert.Equal(t, int(accessTokenValidity.Seconds()), *tokenResponse.ExpiresIn) assert.NotEmpty(t, tokenResponse.AccessToken) @@ -195,7 +203,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), issuerDID, requestedScope).Return(walletOwnerMapping, nil).Times(2) - _, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, presentation.Raw()) + _, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, issuerDID, requestedScope, submissionJSON, presentation.Raw()) require.NoError(t, err) resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, presentation.Raw()) @@ -292,7 +300,7 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(nil, errors.New("invalid")) ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), issuerDID, requestedScope).Return(walletOwnerMapping, nil) - resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), issuerDID, requestedScope, submissionJSON, presentation.Raw()) + resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, issuerDID, requestedScope, submissionJSON, presentation.Raw()) assert.EqualError(t, err, "invalid_request - invalid - presentation(s) or contained credential(s) are invalid") assert.Nil(t, resp) @@ -363,6 +371,18 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { assert.EqualError(t, err, "invalid_request - presentation submission does not conform to presentation definition (id=)") assert.Nil(t, resp) }) + t.Run("invalid DPoP header", func(t *testing.T) { + ctx := newTestClient(t) + httpRequest := &http.Request{Header: http.Header{"Dpop": []string{"invalid"}}} + httpRequest.Header.Set("DPoP", "invalid") + contextWithValue := context.WithValue(context.Background(), httpRequestContextKey{}, httpRequest) + ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), issuerDID, requestedScope).Return(walletOwnerMapping, nil) + + resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, issuerDID, requestedScope, submissionJSON, presentation.Raw()) + + _ = assertOAuthErrorWithCode(t, err, oauth.InvalidDPopProof, "DPoP header is invalid") + assert.Nil(t, resp) + }) } func TestWrapper_createAccessToken(t *testing.T) { @@ -409,29 +429,30 @@ func TestWrapper_createAccessToken(t *testing.T) { }, }, } + dpopToken, _, _ := newSignedTestDPoP() + verifiablePresentation := test.ParsePresentation(t, presentation) + pexEnvelopeJSON, _ := json.Marshal(verifiablePresentation) + pexEnvelope, err := pe.ParseEnvelope(pexEnvelopeJSON) + pexConsumer := PEXConsumer{ + RequiredPresentationDefinitions: map[pe.WalletOwnerType]pe.PresentationDefinition{ + pe.WalletOwnerOrganization: definition, + }, + Submissions: map[string]pe.PresentationSubmission{ + "definitive": submission, + }, + SubmittedEnvelopes: map[string]pe.Envelope{ + "definitive": *pexEnvelope, + }, + } t.Run("ok", func(t *testing.T) { ctx := newTestClient(t) - verifiablePresentation := test.ParsePresentation(t, presentation) - pexEnvelopeJSON, _ := json.Marshal(verifiablePresentation) - pexEnvelope, err := pe.ParseEnvelope(pexEnvelopeJSON) - pexConsumer := PEXConsumer{ - RequiredPresentationDefinitions: map[pe.WalletOwnerType]pe.PresentationDefinition{ - pe.WalletOwnerOrganization: definition, - }, - Submissions: map[string]pe.PresentationSubmission{ - "definitive": submission, - }, - SubmittedEnvelopes: map[string]pe.Envelope{ - "definitive": *pexEnvelope, - }, - } require.NoError(t, err) - accessToken, err := ctx.client.createAccessToken(issuerDID, credentialSubjectID, time.Now(), "everything", pexConsumer) + accessToken, err := ctx.client.createAccessToken(issuerDID, credentialSubjectID, time.Now(), "everything", pexConsumer, dpopToken) require.NoError(t, err) assert.NotEmpty(t, accessToken.AccessToken) - assert.Equal(t, "bearer", accessToken.TokenType) + assert.Equal(t, "DPoP", accessToken.TokenType) assert.Equal(t, 900, *accessToken.ExpiresIn) assert.Equal(t, "everything", *accessToken.Scope) @@ -448,4 +469,12 @@ func TestWrapper_createAccessToken(t *testing.T) { assert.Equal(t, issuerDID.String(), storedToken.Issuer) assert.NotEmpty(t, storedToken.Expiration) }) + t.Run("ok - bearer token", func(t *testing.T) { + ctx := newTestClient(t) + accessToken, err := ctx.client.createAccessToken(issuerDID, credentialSubjectID, time.Now(), "everything", pexConsumer, nil) + + require.NoError(t, err) + assert.NotEmpty(t, accessToken.AccessToken) + assert.Equal(t, "Bearer", accessToken.TokenType) + }) } diff --git a/auth/api/iam/session.go b/auth/api/iam/session.go index 042af9c3fa..5b994ed699 100644 --- a/auth/api/iam/session.go +++ b/auth/api/iam/session.go @@ -34,15 +34,16 @@ import ( // Both the client and the server use this session to store information about the request. type OAuthSession struct { ClientID string `json:"client_id,omitempty"` - Scope string `json:"scope,omitempty"` - OwnDID *did.DID `json:"own_did,omitempty"` ClientState string `json:"client_state,omitempty"` - SessionID string `json:"session_id,omitempty"` + OpenID4VPVerifier *PEXConsumer `json:"openid4vp_verifier,omitempty"` + OwnDID *did.DID `json:"own_did,omitempty"` + PKCEParams PKCEParams `json:"pkce_params"` RedirectURI string `json:"redirect_uri,omitempty"` ResponseType string `json:"response_type,omitempty"` - PKCEParams PKCEParams `json:"pkce_params"` + Scope string `json:"scope,omitempty"` + SessionID string `json:"session_id,omitempty"` + UseDPoP bool `json:"use_dpop,omitempty"` VerifierDID *did.DID `json:"verifier_did,omitempty"` - OpenID4VPVerifier *PEXConsumer `json:"openid4vp_verifier,omitempty"` } // PEXConsumer consumes Presentation Submissions, according to https://identity.foundation/presentation-exchange/ diff --git a/auth/api/iam/types.go b/auth/api/iam/types.go index 76b3907736..39fd4d85c1 100644 --- a/auth/api/iam/types.go +++ b/auth/api/iam/types.go @@ -154,3 +154,8 @@ const presentationDefParam = "presentation_definition" // presentationDefUriParam is the name of the OpenID4VP presentation_definition_uri parameter. // Specified by https://openid.bitbucket.io/connect/openid-4-verifiable-presentations-1_0.html#name-presentation_definition_uri const presentationDefUriParam = "presentation_definition_uri" + +const ( + AccessTokenTypeBearer = "Bearer" + AccessTokenTypeDPoP = "DPoP" +) diff --git a/auth/api/iam/user.go b/auth/api/iam/user.go index 8c453d0349..c3e9400f7f 100644 --- a/auth/api/iam/user.go +++ b/auth/api/iam/user.go @@ -32,6 +32,7 @@ import ( "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" @@ -113,6 +114,11 @@ func (r Wrapper) handleUserLanding(echoCtx echo.Context) error { //rare, log just in case log.Logger().WithError(err).Warn("delete token failed") } + // use DPoP or not + useDPoP := true + if redirectSession.AccessTokenRequest.Body.TokenType != nil && strings.ToLower(string(*redirectSession.AccessTokenRequest.Body.TokenType)) == strings.ToLower(AccessTokenTypeBearer) { + useDPoP = false + } // create oauthSession with userID from request // generate new sessionID and clientState with crypto.GenerateNonce() oauthSession := OAuthSession{ @@ -121,6 +127,7 @@ func (r Wrapper) handleUserLanding(echoCtx echo.Context) error { PKCEParams: generatePKCEParams(), RedirectURI: accessTokenRequest.Body.RedirectUri, SessionID: redirectSession.SessionID, + UseDPoP: useDPoP, VerifierDID: verifier, } // store user session in session store under sessionID and clientState diff --git a/auth/auth.go b/auth/auth.go index 93d8b37fc3..aebe86b7a2 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -107,7 +107,8 @@ func (auth *Auth) RelyingParty() oauth.RelyingParty { } func (auth *Auth) IAMClient() iam.Client { - return iam.NewClient(auth.vcr.Wallet(), auth.strictMode, auth.httpClientTimeout) + keyResolver := resolver.DIDKeyResolver{Resolver: auth.vdrInstance.Resolver()} + return iam.NewClient(auth.vcr.Wallet(), keyResolver, auth.keyStore, auth.strictMode, auth.httpClientTimeout) } // Configure the Auth struct by creating a validator and create an Irma server diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index f01a0addb6..4ff9e798a2 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -110,7 +110,7 @@ func (hb HTTPClient) RequestObject(ctx context.Context, requestURI string) (stri return string(data), err } -func (hb HTTPClient) AccessToken(ctx context.Context, tokenEndpoint string, data url.Values) (oauth.TokenResponse, error) { +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) if err != nil { @@ -121,6 +121,9 @@ func (hb HTTPClient) AccessToken(ctx context.Context, tokenEndpoint string, data request, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL.String(), strings.NewReader(data.Encode())) request.Header.Add("Accept", "application/json") request.Header.Add("Content-Type", "application/x-www-form-urlencoded") + if dpopHeader != "" { + request.Header.Add("DPoP", dpopHeader) + } if err != nil { return token, err } diff --git a/auth/client/iam/client_test.go b/auth/client/iam/client_test.go index d9f64a5507..acb3a91888 100644 --- a/auth/client/iam/client_test.go +++ b/auth/client/iam/client_test.go @@ -99,14 +99,14 @@ func TestHTTPClient_AccessToken(t *testing.T) { tokenResponse := oauth.TokenResponse{ AccessToken: "token", } - + dpopHeader := "dpop" data := url.Values{} t.Run("ok", func(t *testing.T) { handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: tokenResponse} tlsServer, client := testServerAndClient(t, &handler) - response, err := client.AccessToken(ctx, tlsServer.URL, data) + response, err := client.AccessToken(ctx, tlsServer.URL, data, dpopHeader) require.NoError(t, err) require.NotNil(t, response) @@ -117,7 +117,7 @@ func TestHTTPClient_AccessToken(t *testing.T) { handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: tokenResponse} _, client := testServerAndClient(t, &handler) - _, err := client.AccessToken(ctx, ":", data) + _, err := client.AccessToken(ctx, ":", data, dpopHeader) require.Error(t, err) assert.EqualError(t, err, "parse \":\": missing protocol scheme") @@ -126,7 +126,7 @@ func TestHTTPClient_AccessToken(t *testing.T) { handler := http2.Handler{StatusCode: http.StatusBadRequest, ResponseData: oauth.OAuth2Error{Code: oauth.InvalidRequest}} tlsServer, client := testServerAndClient(t, &handler) - _, err := client.AccessToken(ctx, tlsServer.URL, data) + _, err := client.AccessToken(ctx, tlsServer.URL, data, dpopHeader) require.Error(t, err) // check if the error is an OAuth error @@ -138,7 +138,7 @@ func TestHTTPClient_AccessToken(t *testing.T) { handler := http2.Handler{StatusCode: http.StatusBadGateway, ResponseData: "offline"} tlsServer, client := testServerAndClient(t, &handler) - _, err := client.AccessToken(ctx, tlsServer.URL, data) + _, err := client.AccessToken(ctx, tlsServer.URL, data, dpopHeader) require.Error(t, err) // check if the error is a http error @@ -150,7 +150,7 @@ func TestHTTPClient_AccessToken(t *testing.T) { handler := http2.Handler{StatusCode: http.StatusOK, ResponseData: "}"} tlsServer, client := testServerAndClient(t, &handler) - _, err := client.AccessToken(ctx, tlsServer.URL, data) + _, err := client.AccessToken(ctx, tlsServer.URL, data, dpopHeader) require.Error(t, err) assert.EqualError(t, err, "unable to unmarshal response: invalid character '}' looking for beginning of value, }") diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go index 6982b90e33..0c84a392a5 100644 --- a/auth/client/iam/interface.go +++ b/auth/client/iam/interface.go @@ -28,10 +28,10 @@ import ( // Client defines OpenID4VP client methods using the IAM OpenAPI Spec. type Client interface { - // AccessToken requests an access token at the OAuth2 Token Endpoint. + // AccessToken requests an access token at the oauth2 token endpoint. // The token endpoint can be a regular OAuth2 token endpoint or OpenID4VCI-related endpoint. // The response will be unmarshalled into the given tokenResponseOut parameter. - AccessToken(ctx context.Context, code string, verifier did.DID, callbackURI string, clientID did.DID, codeVerifier string) (*oauth.TokenResponse, error) + AccessToken(ctx context.Context, code string, verifier did.DID, callbackURI string, clientID did.DID, codeVerifier string, useDPoP bool) (*oauth.TokenResponse, error) // AuthorizationServerMetadata returns the metadata of the remote wallet. AuthorizationServerMetadata(ctx context.Context, webdid did.DID) (*oauth.AuthorizationServerMetadata, error) // ClientMetadata returns the metadata of the remote verifier. @@ -43,7 +43,7 @@ type Client interface { // PresentationDefinition returns the presentation definition from the given endpoint. PresentationDefinition(ctx context.Context, endpoint string) (*pe.PresentationDefinition, error) // RequestRFC021AccessToken is called by the local EHR node to request an access token from a remote Nuts node using Nuts RFC021. - RequestRFC021AccessToken(ctx context.Context, requestHolder did.DID, verifier did.DID, scopes string) (*oauth.TokenResponse, error) + RequestRFC021AccessToken(ctx context.Context, requestHolder did.DID, verifier did.DID, scopes string, useDPoP bool) (*oauth.TokenResponse, error) OpenIdConfiguration(ctx context.Context, serverURL string) (*oauth.OpenIDConfigurationMetadata, error) diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go index 39d7d3f77e..1b551f69dd 100644 --- a/auth/client/iam/mock.go +++ b/auth/client/iam/mock.go @@ -44,18 +44,18 @@ func (m *MockClient) EXPECT() *MockClientMockRecorder { } // AccessToken mocks base method. -func (m *MockClient) AccessToken(ctx context.Context, code string, verifier did.DID, callbackURI string, clientID did.DID, codeVerifier string) (*oauth.TokenResponse, error) { +func (m *MockClient) AccessToken(ctx context.Context, code string, verifier did.DID, callbackURI string, clientID did.DID, codeVerifier string, useDPoP bool) (*oauth.TokenResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "AccessToken", ctx, code, verifier, callbackURI, clientID, codeVerifier) + ret := m.ctrl.Call(m, "AccessToken", ctx, code, verifier, callbackURI, clientID, codeVerifier, useDPoP) ret0, _ := ret[0].(*oauth.TokenResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // AccessToken indicates an expected call of AccessToken. -func (mr *MockClientMockRecorder) AccessToken(ctx, code, verifier, callbackURI, clientID, codeVerifier any) *gomock.Call { +func (mr *MockClientMockRecorder) AccessToken(ctx, code, verifier, callbackURI, clientID, codeVerifier, useDPoP any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AccessToken", reflect.TypeOf((*MockClient)(nil).AccessToken), ctx, code, verifier, callbackURI, clientID, codeVerifier) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AccessToken", reflect.TypeOf((*MockClient)(nil).AccessToken), ctx, code, verifier, callbackURI, clientID, codeVerifier, useDPoP) } // AuthorizationServerMetadata mocks base method. @@ -179,18 +179,18 @@ func (mr *MockClientMockRecorder) RequestObject(ctx, requestURI any) *gomock.Cal } // RequestRFC021AccessToken mocks base method. -func (m *MockClient) RequestRFC021AccessToken(ctx context.Context, requestHolder, verifier did.DID, scopes string) (*oauth.TokenResponse, error) { +func (m *MockClient) RequestRFC021AccessToken(ctx context.Context, requestHolder, verifier did.DID, scopes string, useDPoP bool) (*oauth.TokenResponse, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RequestRFC021AccessToken", ctx, requestHolder, verifier, scopes) + ret := m.ctrl.Call(m, "RequestRFC021AccessToken", ctx, requestHolder, verifier, scopes, useDPoP) ret0, _ := ret[0].(*oauth.TokenResponse) ret1, _ := ret[1].(error) return ret0, ret1 } // RequestRFC021AccessToken indicates an expected call of RequestRFC021AccessToken. -func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, requestHolder, verifier, scopes any) *gomock.Call { +func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, requestHolder, verifier, scopes, useDPoP any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestRFC021AccessToken", reflect.TypeOf((*MockClient)(nil).RequestRFC021AccessToken), ctx, requestHolder, verifier, scopes) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestRFC021AccessToken", reflect.TypeOf((*MockClient)(nil).RequestRFC021AccessToken), ctx, requestHolder, verifier, scopes, useDPoP) } // VerifiableCredentials mocks base method. diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go index c6593311a9..5191853cee 100644 --- a/auth/client/iam/openid4vp.go +++ b/auth/client/iam/openid4vp.go @@ -23,6 +23,8 @@ import ( "context" "encoding/json" "fmt" + "github.com/nuts-foundation/nuts-node/crypto/dpop" + "net/http" "net/url" "time" @@ -32,28 +34,33 @@ 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/http" + nutsHttp "github.com/nuts-foundation/nuts-node/http" "github.com/nuts-foundation/nuts-node/vcr/holder" "github.com/nuts-foundation/nuts-node/vcr/pe" + "github.com/nuts-foundation/nuts-node/vdr/resolver" ) var _ Client = (*OpenID4VPClient)(nil) type OpenID4VPClient struct { - httpClient HTTPClient - strictMode bool - wallet holder.Wallet + httpClient HTTPClient + jwtSigner nutsCrypto.JWTSigner + keyResolver resolver.KeyResolver + strictMode bool + wallet holder.Wallet } // NewClient returns an implementation of Holder -func NewClient(wallet holder.Wallet, strictMode bool, httpClientTimeout time.Duration) *OpenID4VPClient { +func NewClient(wallet holder.Wallet, keyResolver resolver.KeyResolver, jwtSigner nutsCrypto.JWTSigner, strictMode bool, httpClientTimeout time.Duration) *OpenID4VPClient { return &OpenID4VPClient{ httpClient: HTTPClient{ strictMode: strictMode, httpClient: core.NewStrictHTTPClient(strictMode, httpClientTimeout, nil), }, - strictMode: strictMode, - wallet: wallet, + keyResolver: keyResolver, + jwtSigner: jwtSigner, + strictMode: strictMode, + wallet: wallet, } } @@ -76,7 +83,7 @@ func (c *OpenID4VPClient) PostError(ctx context.Context, auth2Error oauth.OAuth2 } validURL := *responseURL if verifierClientState != "" { - validURL = http.AddQueryParams(*responseURL, map[string]string{ + validURL = nutsHttp.AddQueryParams(*responseURL, map[string]string{ oauth.StateParam: verifierClientState, }) } @@ -140,7 +147,7 @@ func (c *OpenID4VPClient) RequestObject(ctx context.Context, requestURI string) return requestObject, nil } -func (c *OpenID4VPClient) AccessToken(ctx context.Context, code string, verifier did.DID, callbackURI string, clientID did.DID, codeVerifier string) (*oauth.TokenResponse, error) { +func (c *OpenID4VPClient) AccessToken(ctx context.Context, code string, verifier did.DID, callbackURI string, clientID did.DID, codeVerifier string, useDPoP bool) (*oauth.TokenResponse, error) { iamClient := c.httpClient metadata, err := iamClient.OAuthAuthorizationServerMetadata(ctx, verifier) if err != nil { @@ -156,14 +163,28 @@ func (c *OpenID4VPClient) AccessToken(ctx context.Context, code string, verifier data.Set(oauth.CodeParam, code) data.Set(oauth.RedirectURIParam, callbackURI) data.Set(oauth.CodeVerifierParam, codeVerifier) - token, err := iamClient.AccessToken(ctx, metadata.TokenEndpoint, data) + + var dpopHeader string + if useDPoP { + // create DPoP header + request, err := http.NewRequestWithContext(ctx, http.MethodPost, metadata.TokenEndpoint, nil) + if err != nil { + return nil, err + } + dpopHeader, err = c.dpop(ctx, clientID, *request) + if err != nil { + return nil, fmt.Errorf("failed to create DPoP header: %w", err) + } + } + + token, err := iamClient.AccessToken(ctx, metadata.TokenEndpoint, data, dpopHeader) if err != nil { return nil, fmt.Errorf("remote server: error creating access token: %w", err) } return &token, nil } -func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, requester did.DID, verifier did.DID, scopes string) (*oauth.TokenResponse, error) { +func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, requester did.DID, verifier did.DID, scopes string, useDPoP bool) (*oauth.TokenResponse, error) { iamClient := c.httpClient metadata, err := iamClient.OAuthAuthorizationServerMetadata(ctx, verifier) if err != nil { @@ -175,7 +196,7 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, requeste if err != nil { return nil, err } - presentationDefinitionURL := http.AddQueryParams(*parsedURL, map[string]string{ + presentationDefinitionURL := nutsHttp.AddQueryParams(*parsedURL, map[string]string{ "scope": scopes, }) presentationDefinition, err := c.PresentationDefinition(ctx, presentationDefinitionURL.String()) @@ -200,8 +221,22 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, requeste data.Set(oauth.AssertionParam, assertion) data.Set(oauth.PresentationSubmissionParam, string(presentationSubmission)) data.Set(oauth.ScopeParam, scopes) + + // create DPoP header + var dpopHeader string + if useDPoP { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, metadata.TokenEndpoint, nil) + if err != nil { + return nil, err + } + dpopHeader, err = c.dpop(ctx, requester, *request) + if err != nil { + return nil, fmt.Errorf("failed tocreate DPoP header: %w", err) + } + } + log.Logger().Tracef("Requesting access token from '%s' for scope '%s'\n VP: %s\n Submission: %s", metadata.TokenEndpoint, scopes, assertion, string(presentationSubmission)) - token, err := iamClient.AccessToken(ctx, metadata.TokenEndpoint, data) + token, err := iamClient.AccessToken(ctx, metadata.TokenEndpoint, data, dpopHeader) if err != nil { // the error could be a http error, we just relay it here to make use of any 400 status codes. return nil, err @@ -239,3 +274,14 @@ func (c *OpenID4VPClient) VerifiableCredentials(ctx context.Context, credentialE } return rsp, nil } + +func (c *OpenID4VPClient) dpop(ctx context.Context, requester did.DID, request http.Request) (string, error) { + // find the key to sign the DPoP token with + keyID, _, err := c.keyResolver.ResolveKey(requester, nil, resolver.AssertionMethod) + if err != nil { + return "", err + } + + token := dpop.New(request) + return c.jwtSigner.SignDPoP(ctx, *token, keyID.String()) +} diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go index d7e821ef7c..b34a062411 100644 --- a/auth/client/iam/openid4vp_test.go +++ b/auth/client/iam/openid4vp_test.go @@ -50,11 +50,25 @@ func TestIAMClient_AccessToken(t *testing.T) { callbackURI := "https://test.test/iam/123/callback" clientID := did.MustParseDID("did:web:test.test:iam:123") codeVerifier := "code_verifier" + kid := clientID.URI() + kid.Fragment = "1" t.Run("ok", func(t *testing.T) { ctx := createClientServerTestContext(t) - response, err := ctx.client.AccessToken(context.Background(), code, ctx.verifierDID, callbackURI, clientID, codeVerifier) + response, err := ctx.client.AccessToken(context.Background(), code, ctx.verifierDID, callbackURI, clientID, codeVerifier, false) + + require.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, "token", response.AccessToken) + assert.Equal(t, "bearer", response.TokenType) + }) + t.Run("ok - with DPoP", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ctx.keyResolver.EXPECT().ResolveKey(clientID, nil, resolver.NutsSigningKeyType).Return(kid, nil, nil) + ctx.jwtSigner.EXPECT().SignDPoP(context.Background(), gomock.Any(), kid.String()).Return("dpop", nil) + + response, err := ctx.client.AccessToken(context.Background(), code, ctx.verifierDID, callbackURI, clientID, codeVerifier, true) require.NoError(t, err) require.NotNil(t, response) @@ -65,11 +79,21 @@ func TestIAMClient_AccessToken(t *testing.T) { ctx := createClientServerTestContext(t) ctx.token = nil - response, err := ctx.client.AccessToken(context.Background(), code, ctx.verifierDID, callbackURI, clientID, codeVerifier) + response, err := ctx.client.AccessToken(context.Background(), code, ctx.verifierDID, callbackURI, clientID, codeVerifier, false) assert.EqualError(t, err, "remote server: error creating access token: server returned HTTP 404 (expected: 200)") assert.Nil(t, response) }) + t.Run("error - failed to create DPoP header", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ctx.keyResolver.EXPECT().ResolveKey(clientID, nil, resolver.NutsSigningKeyType).Return(kid, nil, nil) + ctx.jwtSigner.EXPECT().SignDPoP(context.Background(), gomock.Any(), kid.String()).Return("", assert.AnError) + + response, err := ctx.client.AccessToken(context.Background(), code, ctx.verifierDID, callbackURI, clientID, codeVerifier, true) + + assert.Error(t, err) + assert.Nil(t, response) + }) } func TestIAMClient_ClientMetadata(t *testing.T) { @@ -205,13 +229,28 @@ func TestIAMClient_AuthorizationServerMetadata(t *testing.T) { func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { walletDID := did.MustParseDID("did:test:123") + kid := walletDID.URI() + kid.Fragment = "1" scopes := "first second" t.Run("ok", func(t *testing.T) { ctx := createClientServerTestContext(t) ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), walletDID, gomock.Any(), oauth.DefaultOpenIDSupportedFormats(), gomock.Any()).Return(&vc.VerifiablePresentation{}, &pe.PresentationSubmission{}, nil) - response, err := ctx.client.RequestRFC021AccessToken(context.Background(), walletDID, ctx.verifierDID, scopes) + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), walletDID, ctx.verifierDID, scopes, false) + + assert.NoError(t, err) + require.NotNil(t, response) + assert.Equal(t, "token", response.AccessToken) + assert.Equal(t, "bearer", response.TokenType) + }) + t.Run("ok with DPoPHeader", func(t *testing.T) { + ctx := createClientServerTestContext(t) + ctx.keyResolver.EXPECT().ResolveKey(walletDID, nil, resolver.NutsSigningKeyType).Return(kid, nil, nil) + ctx.jwtSigner.EXPECT().SignDPoP(context.Background(), gomock.Any(), kid.String()).Return("dpop", nil) + ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), walletDID, gomock.Any(), oauth.DefaultOpenIDSupportedFormats(), gomock.Any()).Return(&vc.VerifiablePresentation{}, &pe.PresentationSubmission{}, nil) + + response, err := ctx.client.RequestRFC021AccessToken(context.Background(), walletDID, ctx.verifierDID, scopes, true) assert.NoError(t, err) require.NotNil(t, response) @@ -232,7 +271,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { } ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), walletDID, gomock.Any(), oauth.DefaultOpenIDSupportedFormats(), gomock.Any()).Return(&vc.VerifiablePresentation{}, &pe.PresentationSubmission{}, nil) - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), walletDID, ctx.verifierDID, scopes) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), walletDID, ctx.verifierDID, scopes, false) require.Error(t, err) oauthError, ok := err.(oauth.OAuth2Error) @@ -243,7 +282,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx := createClientServerTestContext(t) ctx.presentationDefinition = nil - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), walletDID, ctx.verifierDID, scopes) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), walletDID, ctx.verifierDID, scopes, false) assert.Error(t, err) assert.EqualError(t, err, "failed to retrieve presentation definition: server returned HTTP 404 (expected: 200)") @@ -252,7 +291,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx := createClientServerTestContext(t) ctx.metadata = nil - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), walletDID, ctx.verifierDID, scopes) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), walletDID, ctx.verifierDID, scopes, false) assert.Error(t, err) assert.EqualError(t, err, "failed to retrieve remote OAuth Authorization Server metadata: server returned HTTP 404 (expected: 200)") @@ -265,7 +304,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { _, _ = writer.Write([]byte("{")) } - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), walletDID, ctx.verifierDID, scopes) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), walletDID, ctx.verifierDID, scopes, false) assert.Error(t, err) assert.EqualError(t, err, "failed to retrieve presentation definition: unable to unmarshal response: unexpected end of JSON input") @@ -275,7 +314,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) { ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), walletDID, gomock.Any(), oauth.DefaultOpenIDSupportedFormats(), gomock.Any()).Return(nil, nil, assert.AnError) - _, err := ctx.client.RequestRFC021AccessToken(context.Background(), walletDID, ctx.verifierDID, scopes) + _, err := ctx.client.RequestRFC021AccessToken(context.Background(), walletDID, ctx.verifierDID, scopes, false) assert.Error(t, err) }) @@ -322,6 +361,8 @@ func createClientTestContext(t *testing.T, tlsConfig *tls.Config) *clientTestCon strictMode: false, httpClient: core.NewStrictHTTPClient(false, 10*time.Second, tlsConfig), }, + jwtSigner: jwtSigner, + keyResolver: keyResolver, }, jwtSigner: jwtSigner, keyResolver: keyResolver, diff --git a/auth/oauth/error.go b/auth/oauth/error.go index de27542969..a68517669a 100644 --- a/auth/oauth/error.go +++ b/auth/oauth/error.go @@ -50,6 +50,8 @@ const ( UnsupportedResponseType ErrorCode = "unsupported_response_type" // ServerError is returned when the Authorization Server encounters an unexpected condition that prevents it from fulfilling the request. ServerError ErrorCode = "server_error" + // InvalidDPopProof is returned when the DPoP proof is invalid or missing. + InvalidDPopProof ErrorCode = "invalid_dpop_proof" // InvalidScope is returned when the requested scope is invalid, unknown or malformed. InvalidScope ErrorCode = "invalid_scope" // InvalidPresentationDefinitionURI is returned when the requested presentation definition URI is invalid or can't be reached. diff --git a/auth/oauth/openid.go b/auth/oauth/openid.go index de7473c273..97572de798 100644 --- a/auth/oauth/openid.go +++ b/auth/oauth/openid.go @@ -18,10 +18,9 @@ package oauth -// AlgValuesSupported contains a list of supported cipher suites for jwt_vc_json & jwt_vp_json presentation formats -// Recommended list of options https://www.iana.org/assignments/jose/jose.xhtml#web-signature-encryption-algorithms -// TODO: validate list, should reflect current recommendations from https://www.ncsc.nl -var AlgValuesSupported = []string{"PS256", "PS384", "PS512", "ES256", "ES384", "ES512"} +import ( + "github.com/nuts-foundation/nuts-node/crypto/jwx" +) // proofTypeValuesSupported contains a list of supported cipher suites for ldp_vc & ldp_vp presentation formats // Recommended list of options https://w3c-ccg.github.io/ld-cryptosuite-registry/ @@ -35,8 +34,8 @@ var proofTypeValuesSupported = []string{"JsonWebSignature2020"} // See https://github.com/nuts-foundation/nuts-node/issues/2447 func DefaultOpenIDSupportedFormats() map[string]map[string][]string { return map[string]map[string][]string{ - "jwt_vp_json": {"alg_values_supported": AlgValuesSupported}, - "jwt_vc_json": {"alg_values_supported": AlgValuesSupported}, + "jwt_vp_json": {"alg_values_supported": jwx.SupportedAlgorithmsAsStrings()}, + "jwt_vc_json": {"alg_values_supported": jwx.SupportedAlgorithmsAsStrings()}, "ldp_vc": {"proof_type_values_supported": proofTypeValuesSupported}, "ldp_vp": {"proof_type_values_supported": proofTypeValuesSupported}, } diff --git a/auth/oauth/types.go b/auth/oauth/types.go index 96a05b3713..72179bed17 100644 --- a/auth/oauth/types.go +++ b/auth/oauth/types.go @@ -243,6 +243,9 @@ type AuthorizationServerMetadata struct { // If omitted, the default value is `pre-registered` (referring to the client), which is currently not supported. ClientIdSchemesSupported []string `json:"client_id_schemes_supported,omitempty"` + // DPoPSigningAlgValuesSupported is a JSON array containing a list of the DPoP proof JWS signing algorithms ("alg" values) supported by the token endpoint. + DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"` + /* ******** JWT-Secured Authorization Request RFC9101 & OpenID Connect Core v1.0: §6. Passing Request Parameters as JWTs ******** */ // RequireSignedRequestObject specifies if the authorization server requires the use of signed request objects. diff --git a/crypto/dpop.go b/crypto/dpop.go new file mode 100644 index 0000000000..cb3cb4f797 --- /dev/null +++ b/crypto/dpop.go @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package crypto + +import ( + "context" + "github.com/nuts-foundation/nuts-node/crypto/dpop" +) + +func (client *Crypto) SignDPoP(ctx context.Context, token dpop.DPoP, kid string) (string, error) { + privateKey, kid, err := client.getPrivateKey(ctx, kid) + if err != nil { + return "", err + } + + keyAsJWK, err := jwkKey(privateKey) + if err != nil { + return "", err + } + + return token.Sign(keyAsJWK) +} diff --git a/crypto/dpop/dpop.go b/crypto/dpop/dpop.go new file mode 100644 index 0000000000..369a40462f --- /dev/null +++ b/crypto/dpop/dpop.go @@ -0,0 +1,259 @@ +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package dpop + +import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "errors" + "fmt" + "net/http" + "net/url" + "slices" + "strings" + "time" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/nuts-foundation/nuts-node/crypto/hash" + "github.com/nuts-foundation/nuts-node/crypto/jwx" +) + +const ( + // ATHKey is the claim key of the ath JWT claim for a DPoP token + ATHKey = "ath" + // DPopType is the value of the typ JWT header for a DPoP token + DPopType = "dpop+jwt" + HTMKey = "htm" + HTUKey = "htu" +) + +// maxJtiLength is the maximum length of the jti claim in a DPoP token. +// jti's are stored to prevent replay attacks and should be unique. +// Allowing too long jti's could lead to a memory exhaustion attack. +const maxJtiLength = 256 + +// DPoP represents a DPoP token used for internal processing +type DPoP struct { + raw string + Headers jws.Headers `json:"-"` + Token jwt.Token `json:"-"` +} + +// ErrInvalidDPoP is returned when a DPoP token is invalid +var ErrInvalidDPoP = errors.New("invalid DPoP token") + +// New creates a new DPoP token from the given http request +func New(request http.Request) *DPoP { + result := DPoP{} + result.Token = jwt.New() + // errors won't occur + _ = result.Token.Set(HTMKey, request.Method) + _ = result.Token.Set(HTUKey, request.URL.String()) + _ = result.Token.Set(jwt.JwtIDKey, generateID()) + _ = result.Token.Set(jwt.IssuedAtKey, time.Now()) + + result.Headers = jws.NewHeaders() + _ = result.Headers.Set(jws.TypeKey, DPopType) + + return &result +} + +// generateID generates a random id for the DPoP token +// can't use the method from crypto due to circular dependencies +func generateID() string { + buf := make([]byte, 256/8) + _, err := rand.Read(buf) + if err != nil { + panic(err) + } + return base64.RawURLEncoding.EncodeToString(buf) +} + +// Sign the DPoP token with the given key +// It also adds the jwk and alg header +func (t *DPoP) Sign(key jwk.Key) (string, error) { + if t.raw != "" { + return "", errors.New("already signed") + } + pkey, err := key.PublicKey() + if err != nil { + return "", err + } + _ = t.Headers.Set(jws.JWKKey, pkey) + + var sig []byte + sig, err = jwt.Sign(t.Token, jwt.WithKey(jwa.SignatureAlgorithm(key.Algorithm().String()), key, jws.WithProtectedHeaders(t.Headers))) + if err != nil { + return "", err + } + t.raw = string(sig) + + return t.raw, nil +} + +// GenerateProof generates the proof for the DPoP token +// It sets the ath claim to the base64 encoded SHA256 hash of the access token +func (t DPoP) GenerateProof(accessToken string) DPoP { + accessTokenHash := hash.SHA256Sum([]byte(accessToken)) + base64Hash := base64.RawURLEncoding.EncodeToString(accessTokenHash.Slice()) + _ = t.Token.Set(ATHKey, base64Hash) + return t +} + +// Parse parses a DPoP token from a string. +// The token is validated for the required claims and headers. +func Parse(s string) (*DPoP, error) { + message, err := jws.ParseString(s) + if err != nil { + return nil, errors.Join(ErrInvalidDPoP, err) + } + // we require exactly one signature + if len(message.Signatures()) != 1 { + return nil, fmt.Errorf("%w: invalid number of signatures", ErrInvalidDPoP) + } + headers := message.Signatures()[0].ProtectedHeaders() + if !slices.Contains(jwx.SupportedAlgorithms, headers.Algorithm()) { + return nil, fmt.Errorf("%w: invalid alg: %s", ErrInvalidDPoP, headers.Algorithm()) + } + if headers.Type() != "dpop+jwt" { + return nil, fmt.Errorf("%w: invalid type: %s", ErrInvalidDPoP, headers.Type()) + } + if headers.JWK() == nil { + return nil, fmt.Errorf("%w: missing jwk header", ErrInvalidDPoP) + } + if jwkIsPrivateKey(headers.JWK()) { + return nil, fmt.Errorf("%w: invalid jwk header", ErrInvalidDPoP) + } + token, err := jwt.ParseString(s, jwt.WithKey(headers.Algorithm(), headers.JWK())) + if err != nil { + return nil, errors.Join(ErrInvalidDPoP, err) + } + if token.IssuedAt().IsZero() { + return nil, fmt.Errorf("%w: missing iat claim", ErrInvalidDPoP) + } + if v, ok := token.Get(HTUKey); !ok || v == "" { + return nil, fmt.Errorf("%w: missing htu claim", ErrInvalidDPoP) + } + if v, ok := token.Get(HTMKey); !ok || v == "" { + return nil, fmt.Errorf("%w: missing htm claim", ErrInvalidDPoP) + } + if token.JwtID() == "" { + return nil, fmt.Errorf("%w: missing jti claim", ErrInvalidDPoP) + } + if len(token.JwtID()) > maxJtiLength { + return nil, fmt.Errorf("%w: jti claim too long", ErrInvalidDPoP) + } + + return &DPoP{raw: s, Token: token, Headers: headers}, nil +} + +func jwkIsPrivateKey(jwk jwk.Key) bool { + // we try to parse it as different private keys, if there's no error, it's a private key + var rsaPrivateKey rsa.PrivateKey + if err := jwk.Raw(&rsaPrivateKey); err == nil { + return true + } + var ecPrivateKey ecdsa.PrivateKey + if err := jwk.Raw(&ecPrivateKey); err == nil { + return true + } + var edPrivateKey ed25519.PrivateKey + if err := jwk.Raw(&edPrivateKey); err == nil { + return true + } + return false +} + +// HTU returns the htu claim of the DPoP token +func (t DPoP) HTU() string { + if v, ok := t.Token.Get(HTUKey); ok { + return v.(string) + } + return "" +} + +// HTM returns the htm claim of the DPoP token +func (t DPoP) HTM() string { + if v, ok := t.Token.Get(HTMKey); ok { + return v.(string) + } + return "" +} + +// Match checks if the JWK, http method, domain and path of the DPoP tokens match +// for the url, the port is stripped. +// If there is a mismatch, the reason is returned in an error. +func (t DPoP) Match(jkt string, method string, url string) (bool, error) { + tp, _ := t.Headers.JWK().Thumbprint(crypto.SHA256) + base64tp := base64.RawURLEncoding.EncodeToString(tp) + + if base64tp != jkt { + return false, errors.New("jkt mismatch") + } + + // check method and url + if method != t.HTM() { + return false, fmt.Errorf("method mismatch, token: %s, given: %s", t.HTM(), method) + } + urlLeft := strip(t.HTU()) + urlRight := strip(url) + if urlLeft != urlRight { + return false, fmt.Errorf("url mismatch, token: %s, given: %s", urlLeft, urlRight) + } + + return true, nil +} + +func strip(raw string) string { + url, _ := url.Parse(raw) + url.Scheme = "https" + url.Host = strings.Split(url.Host, ":")[0] + url.RawQuery = "" + url.Fragment = "" + return url.String() +} + +func (t DPoP) MarshalJSON() ([]byte, error) { + quoted := fmt.Sprintf("%q", t.raw) + return []byte(quoted), nil +} + +func (t *DPoP) UnmarshalJSON(bytes []byte) error { + if len(bytes) < 2 || bytes[0] != '"' || bytes[len(bytes)-1] != '"' { + return fmt.Errorf("invalid DPoP token: %s", string(bytes)) + } + unquoted := string(bytes[1 : len(bytes)-1]) + tmp, err := Parse(unquoted) + if err != nil { + return err + } + *t = *tmp + return nil +} + +func (t DPoP) String() string { + return t.raw +} diff --git a/crypto/dpop/dpop_test.go b/crypto/dpop/dpop_test.go new file mode 100644 index 0000000000..41a62eadaf --- /dev/null +++ b/crypto/dpop/dpop_test.go @@ -0,0 +1,343 @@ +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package dpop + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/base64" + "net/http" + "testing" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jws" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewDPoP(t *testing.T) { + t.Run("sets correct headers and claims", func(t *testing.T) { + request, _ := http.NewRequest("POST", "https://server.example.com/token", nil) + + token := New(*request) + + require.NotNil(t, token) + assert.Equal(t, DPopType, token.Headers.Type()) + assert.Equal(t, "POST", token.HTM()) + assert.Equal(t, "https://server.example.com/token", token.HTU()) + // check if jti is set + jti, ok := token.Token.Get(jwt.JwtIDKey) + require.True(t, ok) + assert.NotEmpty(t, jti) + }) +} + +func TestDPoP_Proof(t *testing.T) { + t.Run("adds ath claim to token", func(t *testing.T) { + request, _ := http.NewRequest("POST", "https://server.example.com/token", nil) + + token := New(*request) + token.GenerateProof("token") + + ath, ok := token.Token.Get(ATHKey) + require.True(t, ok) + assert.Equal(t, "PEaenWxYddN6Q_NT1PiOYfz4EsZu7jRXRlpAsNpBU-A", ath) + }) +} + +func TestDPoP_Sign(t *testing.T) { + keyPair, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + jwkKey, _ := jwk.FromRaw(keyPair) + _ = jwkKey.Set(jwk.AlgorithmKey, jwa.ES256) + publicKey, _ := jwkKey.PublicKey() + request, _ := http.NewRequest("POST", "https://server.example.com/token", nil) + + t.Run("ok", func(t *testing.T) { + token := New(*request) + token.GenerateProof("token") + + tokenString, err := token.Sign(jwkKey) + + require.NoError(t, err) + // check if jwk header is set and if the private part of the is omitted + message, err := jws.ParseString(tokenString) + require.NoError(t, err) + jwk, ok := message.Signatures()[0].ProtectedHeaders().Get(jws.JWKKey) + require.True(t, ok) + assert.Equal(t, publicKey, jwk) + }) + t.Run("already signed", func(t *testing.T) { + token := New(*request) + _, _ = token.Sign(jwkKey) + + _, err := token.Sign(nil) + + require.Error(t, err) + assert.EqualError(t, err, "already signed") + }) +} + +func TestParseDPoP(t *testing.T) { + keyPair, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + jwkKey, _ := jwk.FromRaw(keyPair) + _ = jwkKey.Set(jwk.AlgorithmKey, jwa.ES256) + pkey, _ := jwkKey.PublicKey() + request, _ := http.NewRequest("GET", "https://server.example.com/token", nil) + + t.Run("ok", func(t *testing.T) { + dpopToken := New(*request) + dpopString, err := dpopToken.Sign(jwkKey) + require.NoError(t, err) + + token, err := Parse(dpopString) + require.NoError(t, err) + + assert.Equal(t, pkey, token.Headers.JWK()) + assert.Equal(t, "GET", token.HTM()) + assert.Equal(t, "https://server.example.com/token", token.HTU()) + }) + t.Run("invalid jwt", func(t *testing.T) { + _, err := Parse("invalid") + + require.Error(t, err) + assert.EqualError(t, err, "invalid DPoP token\ninvalid compact serialization format: invalid number of segments") + }) + t.Run("unsupported algorithm", func(t *testing.T) { + customJwt := jwt.New() + sig, _ := jwt.Sign(customJwt, jwt.WithInsecureNoSignature()) + tokenString := string(sig) + + _, err := Parse(tokenString) + + require.Error(t, err) + assert.EqualError(t, err, "invalid DPoP token: invalid alg: none") + }) + t.Run("invalid type", func(t *testing.T) { + dpopToken := New(*request) + dpopToken.Headers.Set(jws.TypeKey, "JWT") + dpopString, _ := dpopToken.Sign(jwkKey) + + _, err := Parse(dpopString) + + require.Error(t, err) + assert.EqualError(t, err, "invalid DPoP token: invalid type: JWT") + }) + t.Run("missing jwk", func(t *testing.T) { + altToken := jwt.New() + altHeaders := jws.NewHeaders() + altHeaders.Set(jws.TypeKey, DPopType) + + tokenBytes, _ := jwt.Sign(altToken, jwt.WithKey(jwa.SignatureAlgorithm(jwkKey.Algorithm().String()), jwkKey, jws.WithProtectedHeaders(altHeaders))) + + _, err := Parse(string(tokenBytes)) + + require.Error(t, err) + assert.EqualError(t, err, "invalid DPoP token: missing jwk header") + }) + t.Run("private key included", func(t *testing.T) { + altToken := jwt.New() + altHeaders := jws.NewHeaders() + altHeaders.Set(jws.TypeKey, DPopType) + altHeaders.Set(jws.JWKKey, jwkKey) + + tokenBytes, _ := jwt.Sign(altToken, jwt.WithKey(jwa.SignatureAlgorithm(jwkKey.Algorithm().String()), jwkKey, jws.WithProtectedHeaders(altHeaders))) + + _, err := Parse(string(tokenBytes)) + + require.Error(t, err) + assert.EqualError(t, err, "invalid DPoP token: invalid jwk header") + }) + t.Run("jwt parsing failed due to wrong signature", func(t *testing.T) { + dpopToken := New(*request) + dpopString, _ := dpopToken.Sign(jwkKey) + + _, err := Parse(dpopString + "0") + + require.Error(t, err) + assert.EqualError(t, err, "invalid DPoP token\ncould not verify message using any of the signatures or keys") + }) + t.Run("missing iat claim", func(t *testing.T) { + dpopToken := New(*request) + _ = dpopToken.Token.Remove(jwt.IssuedAtKey) + dpopString, _ := dpopToken.Sign(jwkKey) + + _, err := Parse(dpopString) + + require.Error(t, err) + assert.EqualError(t, err, "invalid DPoP token: missing iat claim") + + }) + t.Run("missing htu claim", func(t *testing.T) { + dpopToken := New(*request) + _ = dpopToken.Token.Remove(HTUKey) + dpopString, _ := dpopToken.Sign(jwkKey) + + _, err := Parse(dpopString) + + require.Error(t, err) + assert.EqualError(t, err, "invalid DPoP token: missing htu claim") + }) + t.Run("missing htm claim", func(t *testing.T) { + dpopToken := New(*request) + _ = dpopToken.Token.Remove(HTMKey) + dpopString, _ := dpopToken.Sign(jwkKey) + + _, err := Parse(dpopString) + + require.Error(t, err) + assert.EqualError(t, err, "invalid DPoP token: missing htm claim") + }) + t.Run("missing jti claim", func(t *testing.T) { + dpopToken := New(*request) + _ = dpopToken.Token.Remove(jwt.JwtIDKey) + dpopString, _ := dpopToken.Sign(jwkKey) + + _, err := Parse(dpopString) + + require.Error(t, err) + assert.EqualError(t, err, "invalid DPoP token: missing jti claim") + }) + t.Run("jti claim too long", func(t *testing.T) { + dpopToken := New(*request) + bytes := make([]byte, maxJtiLength+1) + _, _ = rand.Reader.Read(bytes) + dpopToken.Token.Set(jwt.JwtIDKey, string(bytes)) + dpopString, _ := dpopToken.Sign(jwkKey) + + _, err := Parse(dpopString) + + require.Error(t, err) + assert.EqualError(t, err, "invalid DPoP token: jti claim too long") + }) +} + +func TestDPoP_Match(t *testing.T) { + accessToken := "token" + keyPair, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + jwkKey, _ := jwk.FromRaw(keyPair) + _ = jwkKey.Set(jwk.AlgorithmKey, jwa.ES256) + thumbprint, _ := jwkKey.Thumbprint(crypto.SHA256) + thumbprintString := base64.RawURLEncoding.EncodeToString(thumbprint) + request, _ := http.NewRequest("POST", "https://server.example.com/token", nil) + + t.Run("ok", func(t *testing.T) { + dpopToken := New(*request).GenerateProof(accessToken) + dpopString, _ := dpopToken.Sign(jwkKey) + parsedToken, _ := Parse(dpopString) + + ok, err := parsedToken.Match(thumbprintString, "POST", "https://server.example.com/token") + + require.NoError(t, err) + assert.True(t, ok) + }) + t.Run("ok with different port", func(t *testing.T) { + dpopToken := New(*request).GenerateProof(accessToken) + dpopString, _ := dpopToken.Sign(jwkKey) + parsedToken, _ := Parse(dpopString) + + ok, err := parsedToken.Match(thumbprintString, "POST", "https://server.example.com:443/token") + + require.NoError(t, err) + assert.True(t, ok) + }) + t.Run("ok with different scheme", func(t *testing.T) { + dpopToken := New(*request).GenerateProof(accessToken) + dpopString, _ := dpopToken.Sign(jwkKey) + parsedToken, _ := Parse(dpopString) + + ok, err := parsedToken.Match(thumbprintString, "POST", "http://server.example.com/token") + + require.NoError(t, err) + assert.True(t, ok) + }) + t.Run("ok with query/fragment", func(t *testing.T) { + dpopToken := New(*request).GenerateProof(accessToken) + dpopString, _ := dpopToken.Sign(jwkKey) + parsedToken, _ := Parse(dpopString) + + ok, err := parsedToken.Match(thumbprintString, "POST", "https://server.example.com/token?a=b#c") + + require.NoError(t, err) + assert.True(t, ok) + }) + t.Run("invalid thumbprint", func(t *testing.T) { + dpopToken := New(*request).GenerateProof(accessToken) + dpopString, _ := dpopToken.Sign(jwkKey) + parsedToken, _ := Parse(dpopString) + + ok, err := parsedToken.Match("jkt", "POST", "https://server.example.com/token") + + require.Error(t, err) + assert.False(t, ok) + assert.EqualError(t, err, "jkt mismatch") + }) + t.Run("invalid method", func(t *testing.T) { + dpopToken := New(*request).GenerateProof(accessToken) + dpopString, _ := dpopToken.Sign(jwkKey) + parsedToken, _ := Parse(dpopString) + + ok, err := parsedToken.Match(thumbprintString, "GET", "https://server.example.com/token") + + require.Error(t, err) + assert.False(t, ok) + assert.EqualError(t, err, "method mismatch, token: POST, given: GET") + }) + t.Run("invalid url", func(t *testing.T) { + dpopToken := New(*request).GenerateProof(accessToken) + dpopString, _ := dpopToken.Sign(jwkKey) + parsedToken, _ := Parse(dpopString) + + ok, err := parsedToken.Match(thumbprintString, "POST", "https://server.example.com/token2") + + require.Error(t, err) + assert.False(t, ok) + assert.EqualError(t, err, "url mismatch, token: https://server.example.com/token, given: https://server.example.com/token2") + }) +} + +func TestDPoP_marshalling(t *testing.T) { + keyPair, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + jwkKey, _ := jwk.FromRaw(keyPair) + _ = jwkKey.Set(jwk.AlgorithmKey, jwa.ES256) + request, _ := http.NewRequest("POST", "https://server.example.com/token", nil) + + t.Run("marshal", func(t *testing.T) { + dpopToken := New(*request).GenerateProof("token") + dpopString, _ := dpopToken.Sign(jwkKey) + + marshalled, err := dpopToken.MarshalJSON() + + require.NoError(t, err) + assert.Equal(t, []byte("\""+dpopString+"\""), marshalled) + }) + t.Run("unmarshal", func(t *testing.T) { + dpopToken := New(*request).GenerateProof("token") + dpopString, _ := dpopToken.Sign(jwkKey) + + var token DPoP + err := token.UnmarshalJSON([]byte("\"" + dpopString + "\"")) + + require.NoError(t, err) + assert.Equal(t, dpopToken.raw, token.raw) + }) +} diff --git a/crypto/dpop_test.go b/crypto/dpop_test.go new file mode 100644 index 0000000000..113fb40e27 --- /dev/null +++ b/crypto/dpop_test.go @@ -0,0 +1,71 @@ +/* + * Nuts node + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package crypto + +import ( + "encoding/base64" + "net/http" + "testing" + + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/nuts-foundation/nuts-node/audit" + "github.com/nuts-foundation/nuts-node/crypto/dpop" + "github.com/nuts-foundation/nuts-node/crypto/hash" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDPOP(t *testing.T) { + client := createCrypto(t) + privateKey, _ := client.New(audit.TestContext(), StringNamingFunc("kid")) + keyAsJWK, _ := jwk.FromRaw(privateKey.Public()) + _ = keyAsJWK.Set(jwk.AlgorithmKey, jwa.ES256) + request, _ := http.NewRequest("POST", "https://server.example.com/token", nil) + + t.Run("creates valid DPoP token", func(t *testing.T) { + token := dpop.New(*request) + tokenString, err := client.SignDPoP(audit.TestContext(), *token, privateKey.KID()) + require.NoError(t, err) + + token, err = dpop.Parse(tokenString) + require.NoError(t, err) + + assert.Equal(t, keyAsJWK, token.Headers.JWK()) + assert.Equal(t, "POST", token.HTM()) + assert.Equal(t, "https://server.example.com/token", token.HTU()) + }) + + t.Run("creates valid DPoP proof for access token", func(t *testing.T) { + accesstoken := "token" + hashBytes := hash.SHA256Sum([]byte(accesstoken)) + hashString := base64.RawURLEncoding.EncodeToString(hashBytes.Slice()) + token := dpop.New(*request) + token.GenerateProof(accesstoken) + proofString, err := client.SignDPoP(audit.TestContext(), *token, privateKey.KID()) + require.NoError(t, err) + + proof, err := dpop.Parse(proofString) + require.NoError(t, err) + + ath, ok := proof.Token.Get(dpop.ATHKey) + require.True(t, ok) + assert.Equal(t, hashString, ath) + }) +} diff --git a/crypto/interface.go b/crypto/interface.go index 63f641aab9..93434d7ffa 100644 --- a/crypto/interface.go +++ b/crypto/interface.go @@ -22,6 +22,7 @@ import ( "context" "crypto" "errors" + "github.com/nuts-foundation/nuts-node/crypto/dpop" ) // ErrPrivateKeyNotFound is returned when the private key doesn't exist @@ -84,6 +85,9 @@ type JWTSigner interface { // context is used to pass audit information. // Returns ErrPrivateKeyNotFound when the private key is not present. SignJWS(ctx context.Context, payload []byte, headers map[string]interface{}, key interface{}, detached bool) (string, error) + // SignDPoP signs a DPoP token for the given kid. + // It adds the requested key as jwk header to the DPoP token. + SignDPoP(ctx context.Context, token dpop.DPoP, kid string) (string, error) } // JsonWebEncryptor is the interface used to encrypt and decrypt JWE messages. diff --git a/crypto/jwx.go b/crypto/jwx.go index bc2ba04b7f..7590205096 100644 --- a/crypto/jwx.go +++ b/crypto/jwx.go @@ -34,33 +34,11 @@ import ( "github.com/lestrrat-go/jwx/v2/jwt" "github.com/mr-tron/base58" "github.com/nuts-foundation/nuts-node/audit" + "github.com/nuts-foundation/nuts-node/crypto/jwx" "github.com/nuts-foundation/nuts-node/crypto/log" "github.com/nuts-foundation/nuts-node/crypto/storage/spi" ) -// ErrUnsupportedSigningKey is returned when an unsupported private key is used to sign. Currently only ecdsa and rsa keys are supported -var ErrUnsupportedSigningKey = errors.New("signing key algorithm not supported") - -var supportedAlgorithms = []jwa.SignatureAlgorithm{jwa.PS256, jwa.PS384, jwa.PS512, jwa.ES256, jwa.EdDSA, jwa.ES384, jwa.ES512} - -const defaultRsaEncryptionAlgorithm = jwa.RSA_OAEP_256 -const defaultEcEncryptionAlgorithm = jwa.ECDH_ES_A256KW -const defaultContentEncryptionAlgorithm = jwa.A256GCM - -func isAlgorithmSupported(alg jwa.SignatureAlgorithm) bool { - for _, curr := range supportedAlgorithms { - if curr == alg { - return true - } - } - return false -} - -func AddSupportedAlgorithm(alg jwa.SignatureAlgorithm) bool { - supportedAlgorithms = append(supportedAlgorithms, alg) - return true -} - // SignJWT creates a JWT from the given claims and signs it with the given key. func (client *Crypto) SignJWT(ctx context.Context, claims map[string]interface{}, headers map[string]interface{}, key interface{}) (string, error) { privateKey, kid, err := client.getPrivateKey(ctx, key) @@ -203,7 +181,7 @@ func ParseJWT(tokenString string, f PublicKeyFunc, options ...jwt.ParseOption) ( return nil, err } - if !isAlgorithmSupported(alg) { + if !jwx.IsAlgorithmSupported(alg) { return nil, fmt.Errorf("token signing algorithm is not supported: %s", alg) } @@ -230,7 +208,7 @@ func ParseJWS(token []byte, f PublicKeyFunc) (payload []byte, err error) { signature := signatures[i] // Get and check the algorithm alg := signature.ProtectedHeaders().Algorithm() - if !isAlgorithmSupported(alg) { + if !jwx.IsAlgorithmSupported(alg) { return nil, fmt.Errorf("token signing algorithm is not supported: %s", alg) } // Get the verifier for the algorithm @@ -325,7 +303,7 @@ func EncryptJWE(payload []byte, protectedHeaders map[string]interface{}, publicK } // Figure out the KeyEncryptionAlgorithm, give prevalence to the headers - enc := defaultContentEncryptionAlgorithm + enc := jwx.DefaultContentEncryptionAlgorithm if len(headers.ContentEncryption().String()) > 0 { enc = headers.ContentEncryption() } @@ -388,7 +366,7 @@ func ecAlgUsingPublicKey(key ecdsa.PublicKey) (alg jwa.SignatureAlgorithm, err e case 521: alg = jwa.ES512 default: - err = ErrUnsupportedSigningKey + err = jwx.ErrUnsupportedSigningKey } return } @@ -432,11 +410,12 @@ func SignatureAlgorithm(key crypto.PublicKey) (jwa.SignatureAlgorithm, error) { } func encryptionAlgorithm(key crypto.PublicKey) (jwa.KeyEncryptionAlgorithm, error) { + switch key.(type) { case *rsa.PublicKey: - return defaultRsaEncryptionAlgorithm, nil + return jwx.DefaultRsaEncryptionAlgorithm, nil case *ecdsa.PublicKey: - return defaultEcEncryptionAlgorithm, nil + return jwx.DefaultEcEncryptionAlgorithm, nil default: return "", fmt.Errorf("could not determine encryption algorithm for key type '%T'", key) } diff --git a/crypto/jwx/algorithm.go b/crypto/jwx/algorithm.go new file mode 100644 index 0000000000..3e16efcb01 --- /dev/null +++ b/crypto/jwx/algorithm.go @@ -0,0 +1,38 @@ +package jwx + +import ( + "errors" + "github.com/lestrrat-go/jwx/v2/jwa" +) + +// ErrUnsupportedSigningKey is returned when an unsupported private key is used to sign. Currently only ecdsa and rsa keys are supported +var ErrUnsupportedSigningKey = errors.New("signing key algorithm not supported") + +var SupportedAlgorithms = []jwa.SignatureAlgorithm{jwa.PS256, jwa.PS384, jwa.PS512, jwa.ES256, jwa.EdDSA, jwa.ES384, jwa.ES512} + +const DefaultRsaEncryptionAlgorithm = jwa.RSA_OAEP_256 +const DefaultEcEncryptionAlgorithm = jwa.ECDH_ES_A256KW +const DefaultContentEncryptionAlgorithm = jwa.A256GCM + +func IsAlgorithmSupported(alg jwa.SignatureAlgorithm) bool { + for _, curr := range SupportedAlgorithms { + if curr == alg { + return true + } + } + return false +} + +func AddSupportedAlgorithm(alg jwa.SignatureAlgorithm) bool { + SupportedAlgorithms = append(SupportedAlgorithms, alg) + return true +} + +// SupportedAlgorithmsAsStrings returns the supported algorithms as a slice of strings +func SupportedAlgorithmsAsStrings() []string { + var result []string + for _, alg := range SupportedAlgorithms { + result = append(result, string(alg)) + } + return result +} diff --git a/crypto/jwx_test.go b/crypto/jwx_test.go index 0ca937de98..5586dea7cf 100644 --- a/crypto/jwx_test.go +++ b/crypto/jwx_test.go @@ -29,6 +29,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/nuts-foundation/nuts-node/crypto/jwx" "testing" "time" @@ -257,7 +258,7 @@ func TestCrypto_EncryptJWE(t *testing.T) { privateKey, _, err := client.getPrivateKey(context.Background(), key) require.NoError(t, err) - token, err := jwe.Decrypt([]byte(tokenString), jwe.WithKey(defaultEcEncryptionAlgorithm, privateKey)) + token, err := jwe.Decrypt([]byte(tokenString), jwe.WithKey(jwx.DefaultEcEncryptionAlgorithm, privateKey)) require.NoError(t, err) var body = make(map[string]interface{}) @@ -274,7 +275,7 @@ func TestCrypto_EncryptJWE(t *testing.T) { require.NoError(t, err) - token, err := jwe.Decrypt([]byte(tokenString), jwe.WithKey(defaultRsaEncryptionAlgorithm, keyPair)) + token, err := jwe.Decrypt([]byte(tokenString), jwe.WithKey(jwx.DefaultRsaEncryptionAlgorithm, keyPair)) require.NoError(t, err) var body = make(map[string]interface{}) @@ -314,7 +315,7 @@ func TestCrypto_EncryptJWE(t *testing.T) { privateKey, _, err := client.getPrivateKey(context.Background(), key) require.NoError(t, err) - token, err := jwe.Decrypt([]byte(tokenString), jwe.WithKey(defaultEcEncryptionAlgorithm, privateKey)) + token, err := jwe.Decrypt([]byte(tokenString), jwe.WithKey(jwx.DefaultEcEncryptionAlgorithm, privateKey)) require.NoError(t, err) var body = make(map[string]interface{}) @@ -468,7 +469,6 @@ func TestSignJWS(t *testing.T) { assert.Contains(t, signature, "..") }) }) - } func TestCrypto_convertHeaders(t *testing.T) { @@ -501,9 +501,9 @@ func TestCrypto_convertHeaders(t *testing.T) { } func Test_isAlgorithmSupported(t *testing.T) { - assert.True(t, isAlgorithmSupported(jwa.PS256)) - assert.False(t, isAlgorithmSupported(jwa.RS256)) - assert.False(t, isAlgorithmSupported("")) + assert.True(t, jwx.IsAlgorithmSupported(jwa.PS256)) + assert.False(t, jwx.IsAlgorithmSupported(jwa.RS256)) + assert.False(t, jwx.IsAlgorithmSupported("")) } func TestSignatureAlgorithm(t *testing.T) { @@ -523,7 +523,7 @@ func TestSignatureAlgorithm(t *testing.T) { ecKey224, _ := ecdsa.GenerateKey(elliptic.P224(), rand.Reader) _, err := SignatureAlgorithm(ecKey224) - assert.Equal(t, ErrUnsupportedSigningKey, err) + assert.Equal(t, jwx.ErrUnsupportedSigningKey, err) }) tests := []struct { diff --git a/crypto/memory.go b/crypto/memory.go index fce8be9014..05c9dcf72e 100644 --- a/crypto/memory.go +++ b/crypto/memory.go @@ -22,6 +22,7 @@ import ( "context" "errors" "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/nuts-foundation/nuts-node/crypto/dpop" ) var _ JWTSigner = &MemoryJWTSigner{} @@ -47,3 +48,7 @@ func (m MemoryJWTSigner) SignJWT(_ context.Context, claims map[string]interface{ func (m MemoryJWTSigner) SignJWS(_ context.Context, _ []byte, _ map[string]interface{}, _ interface{}, _ bool) (string, error) { return "", errNotSupportedForInMemoryKeyStore } + +func (m MemoryJWTSigner) SignDPoP(ctx context.Context, token dpop.DPoP, kid string) (string, error) { + return "", errNotSupportedForInMemoryKeyStore +} diff --git a/crypto/mock.go b/crypto/mock.go index 0d2bd28ed7..5ec55244d2 100644 --- a/crypto/mock.go +++ b/crypto/mock.go @@ -14,6 +14,7 @@ import ( crypto "crypto" reflect "reflect" + dpop "github.com/nuts-foundation/nuts-node/crypto/dpop" gomock "go.uber.org/mock/gomock" ) @@ -262,6 +263,21 @@ func (mr *MockKeyStoreMockRecorder) Resolve(ctx, kid any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resolve", reflect.TypeOf((*MockKeyStore)(nil).Resolve), ctx, kid) } +// SignDPoP mocks base method. +func (m *MockKeyStore) SignDPoP(ctx context.Context, token dpop.DPoP, kid string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SignDPoP", ctx, token, kid) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SignDPoP indicates an expected call of SignDPoP. +func (mr *MockKeyStoreMockRecorder) SignDPoP(ctx, token, kid any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SignDPoP", reflect.TypeOf((*MockKeyStore)(nil).SignDPoP), ctx, token, kid) +} + // SignJWS mocks base method. func (m *MockKeyStore) SignJWS(ctx context.Context, payload []byte, headers map[string]any, key any, detached bool) (string, error) { m.ctrl.T.Helper() @@ -353,6 +369,21 @@ func (m *MockJWTSigner) EXPECT() *MockJWTSignerMockRecorder { return m.recorder } +// SignDPoP mocks base method. +func (m *MockJWTSigner) SignDPoP(ctx context.Context, token dpop.DPoP, kid string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SignDPoP", ctx, token, kid) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SignDPoP indicates an expected call of SignDPoP. +func (mr *MockJWTSignerMockRecorder) SignDPoP(ctx, token, kid any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SignDPoP", reflect.TypeOf((*MockJWTSigner)(nil).SignDPoP), ctx, token, kid) +} + // SignJWS mocks base method. func (m *MockJWTSigner) SignJWS(ctx context.Context, payload []byte, headers map[string]any, key any, detached bool) (string, error) { m.ctrl.T.Helper() diff --git a/docs/_static/auth/iam.yaml b/docs/_static/auth/iam.yaml index 19971af3ff..6ed13ef835 100644 --- a/docs/_static/auth/iam.yaml +++ b/docs/_static/auth/iam.yaml @@ -53,7 +53,9 @@ paths: /oauth2/{did}/token: post: summary: Used by to request access- or refresh tokens. - description: Specified by https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-token-endpoint + 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 tags: - oauth2 @@ -442,54 +444,6 @@ paths: "$ref": "#/components/schemas/OAuthClientMetadata" default: $ref: '../common/error_response.yaml' -# /internal/auth/v2/{did}/request-presentation: -# post: -# operationId: requestPresentation -# summary: Requests a credential presentation using OAuth2 from a remote wallet through a user-agent. -# description: | -# Requests a credential presentation using OAuth2 from a remote wallet. -# It will redirect the user-agent to the wallet, so the user can give consent. -# -# error returns: -# * 400 - one of the parameters has the wrong format -# * 503 - the authorizer could not be reached or returned an error -# tags: -# - auth -# parameters: -# - name: did -# in: path -# required: true -# schema: -# type: string -# example: did:nuts:123 -# requestBody: -# required: true -# content: -# application/json: -# schema: -# required: -# - wallet -# - scope -# properties: -# wallet: -# type: string -# # TODO: how should this be specified? -# scope: -# type: string -# description: maps to the verifiable credentials to request -# responses: -# '200': -# description: Request initiated, the response will contain a redirect URL to which the user-agent -# content: -# application/json: -# schema: -# required: -# - redirect_uri -# properties: -# redirect_uri: -# type: string -# default: -# $ref: '../common/error_response.yaml' /internal/auth/v2/{did}/request-service-access-token: post: operationId: requestServiceAccessToken @@ -519,17 +473,7 @@ paths: content: application/json: schema: - required: - - verifier - - scope - properties: - verifier: - type: string - example: did:web:example.com - scope: - type: string - description: The scope that will be the service for which this access token can be used. - example: eOverdracht-sender + $ref: '#/components/schemas/ServiceAccessTokenRequest' responses: '200': description: Successful request. Responds with an access token as described in rfc6749 section 5.1. @@ -569,27 +513,7 @@ paths: content: application/json: schema: - required: - - redirect_uri - - scope - - verifier - properties: - verifier: - type: string - description: The DID of the verifier, the relying party for which this access token is requested. - example: did:web:example.com - scope: - type: string - description: The scope that will be the service for which this access token can be used. - example: eOverdracht-sender - redirect_uri: - type: string - description: | - The URL to which the user-agent will be redirected after the authorization request. - This is the URL of the calling application. - The OAuth2 flow will finish at the /callback URL of the node and the node will redirect the user to this redirect_uri. - preauthorized_user: - $ref: '#/components/schemas/UserDetails' + $ref: '#/components/schemas/UserAccessTokenRequest' responses: '200': description: | @@ -656,6 +580,68 @@ paths: description: | This is returned when an OAuth2 Client is unauthorized to talk to the introspection endpoint. Note: introspection of an invalid or malformed token returns a 200 where with field 'active'=false + /internal/auth/v2/{did}/dpop: + post: + operationId: createDPoPProof + summary: Create a DPoP proof as specified by RFC9449 for a given access token. It is to be used as HTTP header when accessing resources. + tags: + - oauth2 + parameters: + - name: did + in: path + required: true + description: The DID of the requester, a wallet owner at this node. + content: + plain/text: + schema: + type: string + example: did:web:example.com + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DPoPRequest" + responses: + '200': + description: A response containing the DPoP proof as specified by RFC9449 + content: + application/json: + schema: + $ref: "#/components/schemas/DPoPResponse" + '401': + description: This is returned when an OAuth2 Client is unauthorized to talk to the DPoP endpoint. + /internal/auth/v2/dpop_validate: + post: + operationId: validateDPoPProof + summary: Handle some of the validation of a DPoP proof as specified by RFC9449. + description: | + Handle some of the validation of a DPoP proof as specified by RFC9449. + Full validation as specified by RFC9449 is the responsibility of the resource server. + This is a convenience API where the the following is validated: + * The DPoP proof is a valid JWT + * The http method in the DPoP proof is the same as the http method in the request + * The URL in the DPoP proof is the same as the URL in the request (fragment and query parameters are ignored) + * The thumbprint given (returned from token introspection) is the same as the thumbprint of the public key used to sign the DPoP proof + * The ath field in the DPoP proof matches the hash of the access token + * The jti field in the DPoP proof is unique + tags: + - oauth2 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DPoPValidateRequest" + responses: + '200': + description: A response containing the validity of the DPoP proof header for the access token and HTTP request + content: + application/json: + schema: + $ref: "#/components/schemas/DPoPValidateResponse" + default: + $ref: '../common/error_response.yaml' /statuslist/{did}/{page}: parameters: - name: did @@ -772,7 +758,7 @@ paths: * 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 + 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 tags: @@ -815,8 +801,133 @@ paths: $ref: '../common/error_response.yaml' components: schemas: + cnf: + description: The 'confirmation' claim is used in JWTs to proof the possession of a key. + required: + - jkt + properties: + jkt: + type: string + description: JWK thumbprint DIDDocument: $ref: '../common/ssi_types.yaml#/components/schemas/DIDDocument' + DPoPRequest: + type: object + required: + - htm + - token + - htu + properties: + htm: + type: string + description: The HTTP method for which the DPoP proof is requested. + example: "POST" + token: + type: string + description: The access token for which the DPoP proof is requested. + example: "eyJhbGciOi" + htu: + type: string + description: The URL for which the DPoP proof is requested. Query params and fragments are ignored during validation. + example: "https://example.com/resource" + DPoPResponse: + type: object + required: + - dpop + properties: + dpop: + type: string + description: The DPoP proof as specified by https://datatracker.ietf.org/doc/html/rfc9449 for resource requests + DPoPValidateRequest: + type: object + required: + - dpop_proof + - method + - thumbprint + - token + - url + properties: + dpop_proof: + type: string + description: The DPoP Proof as specified by https://datatracker.ietf.org/doc/html/rfc9449 for resource requests + example: "eyJhbGciOi..lgtla" + method: + type: string + description: The HTTP method against which the DPoP proof is validated. + example: "POST" + thumbprint: + type: string + description: The thumbprint of the public key used to sign the DPoP proof. Base64url encoded, no padding. + example: "jlkhnp87453slfhansdhf" + token: + type: string + description: The access token against which the DPoP proof is validated. + example: "eyJhbGciOi" + url: + type: string + description: The URL against which the DPoP proof is validated. Query params and fragments are ignored during validation. + example: "https://example.com/resource" + DPoPValidateResponse: + type: object + required: + - valid + properties: + reason: + type: string + description: The reason why the DPoP Proof header is invalid. + valid: + type: boolean + description: True if the DPoP Proof header is valid for the access token and HTTP request, false if it is not. + ServiceAccessTokenRequest: + type: object + description: Request for an access token for a service. + required: + - verifier + - scope + properties: + verifier: + type: string + example: did:web:example.com + scope: + type: string + description: The scope that will be the service for which this access token can be used. + example: eOverdracht-sender + tokenType: + type: string + description: "The type of access token that is prefered, default: DPoP" + enum: + - Bearer + - DPoP + UserAccessTokenRequest: + type: object + description: Request for an access token for a user. + required: + - redirect_uri + - scope + - verifier + properties: + verifier: + type: string + description: The DID of the verifier, the relying party for which this access token is requested. + example: did:web:example.com + scope: + type: string + description: The scope that will be the service for which this access token can be used. + example: eOverdracht-sender + token_type: + type: string + description: "The type of access token that is prefered. Supported values: [Bearer, DPoP], default: DPoP" + enum: + - Bearer + - DPoP + redirect_uri: + type: string + description: | + The URL to which the user-agent will be redirected after the authorization request. + This is the URL of the calling application. + The OAuth2 flow will finish at the /callback URL of the node and the node will redirect the user to this redirect_uri. + preauthorized_user: + $ref: '#/components/schemas/UserDetails' VerifiablePresentation: $ref: '../common/ssi_types.yaml#/components/schemas/VerifiablePresentation' RedirectResponse: @@ -889,7 +1000,7 @@ components: type: string description: | The type of the token issued as described in [RFC6749]. - example: "bearer" + example: "Bearer" scope: type: string status: @@ -907,7 +1018,7 @@ components: example: { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6Ikp..sHQ", - "token_type": "bearer", + "token_type": "Bearer", "expires_in": 3600, } OAuthAuthorizationServerMetadata: @@ -953,8 +1064,7 @@ components: properties: token: type: string - example: - eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhaWQiOiJ1cm46b2lkOjIuMTYuODQwLjEuMTEzODgzLjIuNC42LjE6MDAwMDAwMDAiLCJleHAiOjE1ODE0MTI2NjcsImlhdCI6MTU4MTQxMTc2NywiaXNzIjoidXJuOm9pZDoyLjE2Ljg0MC4xLjExMzg4My4yLjQuNi4xOjAwMDAwMDAxIiwic2lkIjoidXJuOm9pZDoyLjE2Ljg0MC4xLjExMzg4My4yLjQuNi4zOjk5OTk5OTk5MCIsInN1YiI6IiJ9.OhniTJcPS45nhJVqXfxsngG5eYS_0BvqFg-96zaWFO90I_5_N9Eg_k7NmIF5eNZ9Xutl1aqSxlSp80EX07Gmk8uzZO9PEReo0YZxnNQV-Zeq1njCMmfdwusmiczFlwcBi5Bl1xYGmLrxP7NcAoljmDgMgmLH0xaKfP4VVim6snPkPHqBdSzAgSrrc-cgVDLl-9V2obPB1HiVsFMYfbHEIb4MPsnPRnSGavYHTxt34mHbRsS8BvoBy3v6VNYaewLr6yz-_Zstrnr4I_wxtYbSiPJUeVQHcD-a9Ck53BdjspnhVHZ4IFVvuNrpflVaB1A7P3A2xZ7G_a8gF_SHMynYSA + example: spnhVHZ4IFVvuNrpflVaB1A7P3A2xZ7G_a8gF_SHMynYSA TokenIntrospectionResponse: description: Token introspection response as described in RFC7662 section 2.2 required: @@ -964,6 +1074,8 @@ components: active: type: boolean description: True if the token is active, false if the token is expired, malformed etc. Required per RFC7662 + cnf: + $ref: '#/components/schemas/cnf' iss: type: string description: Contains the DID of the authorizer. Should be equal to 'sub' @@ -1020,7 +1132,7 @@ components: securitySchemes: jwtBearerAuth: type: http - scheme: bearer + scheme: Bearer security: - {} diff --git a/docs/index.rst b/docs/index.rst index 342b1fb476..642146f4bb 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -33,6 +33,7 @@ Nuts documentation pages/deployment/pex.rst pages/deployment/key-rotation.rst pages/deployment/audit-logging.rst + pages/deployment/oauth.rst .. toctree:: :maxdepth: 1 diff --git a/docs/pages/deployment/oauth.rst b/docs/pages/deployment/oauth.rst new file mode 100644 index 0000000000..afec837a68 --- /dev/null +++ b/docs/pages/deployment/oauth.rst @@ -0,0 +1,61 @@ +.. _oauth-profile: + +OAuth2 Profile +############## + +This page describes the OAuth2 related RFCs and where they are implemented. +The Nuts node implements (parts of) the following RFCs: + +- `RFC 6749 `_ - The OAuth 2.0 Authorization Framework +- `RFC 7636 `_ - Proof Key for Code Exchange by OAuth Public Clients +- `RFC 7662 `_ - OAuth 2.0 Token Introspection +- `RFC 8414 `_ - OAuth 2.0 Authorization Server Metadata +- `RFC 9101 `_ - The OAuth 2.0 Authorization Framework: JWT-Secured Authorization Request (JAR) +- `RFC 9449 `_ - OAuth 2.0 Demonstrating Proof of Possession (DPoP) +- `Nuts RFC021 `_ - RFC021 VP Token Grant Type +- `OpenID4VP `_ - OpenID for Verifiable Presentations - draft 20 +- `Presentation Exchange `_ - Presentation Exchange + + +There are two different flows implemented in the Nuts node to get an access token: Authorization Code Flow using OpenID4VP and the VP Token Grant type. + +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. +- 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. +- OpenID4VP for providing the VP token to the authorization server. + +Both JAR and PKCE are mandatory. DPoP is optional, usage is determined by the client. +The Nuts node will do this automatically as client and authorization server. + +VP Token Grant Type +******************* + +The VP Token Grant Type is a new grant type that allows a client to request an access token using a verifiable presentation token. +It is a custom grant type that bypasses user interaction and allows a client to request an access token directly from the Nuts node. + +The Nuts node implements the following: + +- RFC021 VP Token Grant Type for the token request. +- DPoP (Demonstrating Proof of Possession) for the token request. Each resources request will require a new DPoP Proof header. + The resources server is also required to check this header in an additional step after the token introspection. + +DPoP is optional, usage is determined by the client. + +DPoP +**** + +When the client wants to use DPoP, it must enable it in the access token request from client to Nuts node. +If enabled the client will also need to call the Nuts node to create a new DPop Proof header for each request to the resources server. + +A resources server must check the type of access token used to request data. If a DPoP token is used, the resource server must verify the DPoP Proof using the hash of the public key from the introspection result. +The Nuts node provides a convenience API to do this for you. +Some of the calls to the Nuts node are required because it handles key material for the DPoP Proof. The keys used for the DPoP headers are taken from the DID Document of a tenant. +More information can be found on the `API documentation `_ page. The relevant API's are: +- ``POST /internal/auth/v2/{did}/dpop`` +- ``POST /internal/auth/v2/dpop_validate`` \ No newline at end of file diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 15730d5867..f4e85e74f9 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -20,6 +20,63 @@ const ( JwtBearerAuthScopes = "jwtBearerAuth.Scopes" ) +// Defines values for ServiceAccessTokenRequestTokenType. +const ( + ServiceAccessTokenRequestTokenTypeBearer ServiceAccessTokenRequestTokenType = "Bearer" + ServiceAccessTokenRequestTokenTypeDPoP ServiceAccessTokenRequestTokenType = "DPoP" +) + +// Defines values for UserAccessTokenRequestTokenType. +const ( + UserAccessTokenRequestTokenTypeBearer UserAccessTokenRequestTokenType = "Bearer" + UserAccessTokenRequestTokenTypeDPoP UserAccessTokenRequestTokenType = "DPoP" +) + +// DPoPRequest defines model for DPoPRequest. +type DPoPRequest struct { + // Htm The HTTP method for which the DPoP proof is requested. + Htm string `json:"htm"` + + // Htu The URL for which the DPoP proof is requested. Query params and fragments are ignored during validation. + Htu string `json:"htu"` + + // Token The access token for which the DPoP proof is requested. + Token string `json:"token"` +} + +// DPoPResponse defines model for DPoPResponse. +type DPoPResponse struct { + // Dpop The DPoP proof as specified by https://datatracker.ietf.org/doc/html/rfc9449 for resource requests + Dpop string `json:"dpop"` +} + +// DPoPValidateRequest defines model for DPoPValidateRequest. +type DPoPValidateRequest struct { + // DpopProof The DPoP Proof as specified by https://datatracker.ietf.org/doc/html/rfc9449 for resource requests + DpopProof string `json:"dpop_proof"` + + // Method The HTTP method against which the DPoP proof is validated. + Method string `json:"method"` + + // Thumbprint The thumbprint of the public key used to sign the DPoP proof. Base64url encoded, no padding. + Thumbprint string `json:"thumbprint"` + + // Token The access token against which the DPoP proof is validated. + Token string `json:"token"` + + // Url The URL against which the DPoP proof is validated. Query params and fragments are ignored during validation. + Url string `json:"url"` +} + +// DPoPValidateResponse defines model for DPoPValidateResponse. +type DPoPValidateResponse struct { + // Reason The reason why the DPoP Proof header is invalid. + Reason *string `json:"reason,omitempty"` + + // Valid True if the DPoP Proof header is valid for the access token and HTTP request, false if it is not. + Valid bool `json:"valid"` +} + // RedirectResponseWithID defines model for RedirectResponseWithID. type RedirectResponseWithID struct { // RedirectUri The URL to which the user-agent will be redirected after the authorization request. @@ -29,6 +86,22 @@ type RedirectResponseWithID struct { SessionId string `json:"session_id"` } +// RequestObjectResponse A JSON Web Token (JWT) whose JWT Claims Set holds the JSON-encoded OAuth 2.0 authorization request parameters. +type RequestObjectResponse = string + +// ServiceAccessTokenRequest Request for an access token for a service. +type ServiceAccessTokenRequest struct { + // Scope The scope that will be the service for which this access token can be used. + Scope string `json:"scope"` + + // TokenType The type of access token that is prefered, default: DPoP + TokenType *ServiceAccessTokenRequestTokenType `json:"tokenType,omitempty"` + Verifier string `json:"verifier"` +} + +// ServiceAccessTokenRequestTokenType The type of access token that is prefered, default: DPoP +type ServiceAccessTokenRequestTokenType string + // TokenIntrospectionRequest Token introspection request as described in RFC7662 section 2.1 // Alongside the defined properties, it can return values (additionalProperties) from the Verifiable Credentials that resulted from the Presentation Exchange. type TokenIntrospectionRequest struct { @@ -46,6 +119,9 @@ type TokenIntrospectionResponse struct { // ClientId The client (DID) the access token was issued to ClientId *string `json:"client_id,omitempty"` + // Cnf The 'confirmation' claim is used in JWTs to proof the possession of a key. + Cnf *Cnf `json:"cnf,omitempty"` + // Exp Expiration date in seconds since UNIX epoch Exp *int `json:"exp,omitempty"` @@ -73,6 +149,29 @@ type TokenIntrospectionResponse struct { AdditionalProperties map[string]interface{} `json:"-"` } +// UserAccessTokenRequest Request for an access token for a user. +type UserAccessTokenRequest struct { + // PreauthorizedUser Claims about the authorized user. + PreauthorizedUser *UserDetails `json:"preauthorized_user,omitempty"` + + // RedirectUri The URL to which the user-agent will be redirected after the authorization request. + // This is the URL of the calling application. + // The OAuth2 flow will finish at the /callback URL of the node and the node will redirect the user to this redirect_uri. + RedirectUri string `json:"redirect_uri"` + + // Scope The scope that will be the service for which this access token can be used. + Scope string `json:"scope"` + + // TokenType The type of access token that is prefered. Supported values: [Bearer, DPoP], default: DPoP + TokenType *UserAccessTokenRequestTokenType `json:"token_type,omitempty"` + + // Verifier The DID of the verifier, the relying party for which this access token is requested. + Verifier string `json:"verifier"` +} + +// UserAccessTokenRequestTokenType The type of access token that is prefered. Supported values: [Bearer, DPoP], default: DPoP +type UserAccessTokenRequestTokenType string + // UserDetails Claims about the authorized user. type UserDetails struct { // Id Machine-readable identifier, uniquely identifying the user in the issuing system. @@ -85,6 +184,12 @@ type UserDetails struct { Role string `json:"role"` } +// Cnf The 'confirmation' claim is used in JWTs to proof the possession of a key. +type Cnf struct { + // Jkt JWK thumbprint + Jkt string `json:"jkt"` +} + // CallbackOid4vciCredentialIssuanceParams defines parameters for CallbackOid4vciCredentialIssuance. type CallbackOid4vciCredentialIssuanceParams struct { // Code The oauth2 code response. @@ -113,30 +218,6 @@ type RequestOid4vciCredentialIssuanceJSONBody struct { RedirectUri string `json:"redirect_uri"` } -// RequestServiceAccessTokenJSONBody defines parameters for RequestServiceAccessToken. -type RequestServiceAccessTokenJSONBody struct { - // Scope The scope that will be the service for which this access token can be used. - Scope string `json:"scope"` - Verifier string `json:"verifier"` -} - -// RequestUserAccessTokenJSONBody defines parameters for RequestUserAccessToken. -type RequestUserAccessTokenJSONBody struct { - // PreauthorizedUser Claims about the authorized user. - PreauthorizedUser *UserDetails `json:"preauthorized_user,omitempty"` - - // RedirectUri The URL to which the user-agent will be redirected after the authorization request. - // This is the URL of the calling application. - // The OAuth2 flow will finish at the /callback URL of the node and the node will redirect the user to this redirect_uri. - RedirectUri string `json:"redirect_uri"` - - // Scope The scope that will be the service for which this access token can be used. - Scope string `json:"scope"` - - // Verifier The DID of the verifier, the relying party for which this access token is requested. - Verifier string `json:"verifier"` -} - // HandleAuthorizeRequestParams defines parameters for HandleAuthorizeRequest. type HandleAuthorizeRequestParams struct { Params *map[string]string `form:"params,omitempty" json:"params,omitempty"` @@ -163,6 +244,17 @@ type PresentationDefinitionParams struct { WalletOwnerType *WalletOwnerType `form:"wallet_owner_type,omitempty" json:"wallet_owner_type,omitempty"` } +// PostRequestJWTFormdataBody defines parameters for PostRequestJWT. +type PostRequestJWTFormdataBody 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"` + + // WalletNonce A String value used to mitigate replay attacks of the Authorization Request. + // When received, the Verifier MUST use it as the wallet_nonce value in the signed authorization request object. + WalletNonce *string `form:"wallet_nonce,omitempty" json:"wallet_nonce,omitempty"` +} + // HandleAuthorizeResponseFormdataBody defines parameters for HandleAuthorizeResponse. type HandleAuthorizeResponseFormdataBody struct { // Error error code as defined by the OAuth2 specification @@ -193,14 +285,23 @@ type HandleTokenRequestFormdataBody struct { // IntrospectAccessTokenFormdataRequestBody defines body for IntrospectAccessToken for application/x-www-form-urlencoded ContentType. type IntrospectAccessTokenFormdataRequestBody = TokenIntrospectionRequest +// ValidateDPoPProofJSONRequestBody defines body for ValidateDPoPProof for application/json ContentType. +type ValidateDPoPProofJSONRequestBody = DPoPValidateRequest + +// CreateDPoPProofJSONRequestBody defines body for CreateDPoPProof for application/json ContentType. +type CreateDPoPProofJSONRequestBody = DPoPRequest + // RequestOid4vciCredentialIssuanceJSONRequestBody defines body for RequestOid4vciCredentialIssuance for application/json ContentType. type RequestOid4vciCredentialIssuanceJSONRequestBody RequestOid4vciCredentialIssuanceJSONBody // RequestServiceAccessTokenJSONRequestBody defines body for RequestServiceAccessToken for application/json ContentType. -type RequestServiceAccessTokenJSONRequestBody RequestServiceAccessTokenJSONBody +type RequestServiceAccessTokenJSONRequestBody = ServiceAccessTokenRequest // RequestUserAccessTokenJSONRequestBody defines body for RequestUserAccessToken for application/json ContentType. -type RequestUserAccessTokenJSONRequestBody RequestUserAccessTokenJSONBody +type RequestUserAccessTokenJSONRequestBody = UserAccessTokenRequest + +// PostRequestJWTFormdataRequestBody defines body for PostRequestJWT for application/x-www-form-urlencoded ContentType. +type PostRequestJWTFormdataRequestBody PostRequestJWTFormdataBody // HandleAuthorizeResponseFormdataRequestBody defines body for HandleAuthorizeResponse for application/x-www-form-urlencoded ContentType. type HandleAuthorizeResponseFormdataRequestBody HandleAuthorizeResponseFormdataBody @@ -257,6 +358,14 @@ func (a *TokenIntrospectionResponse) UnmarshalJSON(b []byte) error { delete(object, "client_id") } + if raw, found := object["cnf"]; found { + err = json.Unmarshal(raw, &a.Cnf) + if err != nil { + return fmt.Errorf("error reading 'cnf': %w", err) + } + delete(object, "cnf") + } + if raw, found := object["exp"]; found { err = json.Unmarshal(raw, &a.Exp) if err != nil { @@ -359,6 +468,13 @@ func (a TokenIntrospectionResponse) MarshalJSON() ([]byte, error) { } } + if a.Cnf != nil { + object["cnf"], err = json.Marshal(a.Cnf) + if err != nil { + return nil, fmt.Errorf("error marshaling 'cnf': %w", err) + } + } + if a.Exp != nil { object["exp"], err = json.Marshal(a.Exp) if err != nil { @@ -520,6 +636,16 @@ type ClientInterface interface { // RetrieveAccessToken request RetrieveAccessToken(ctx context.Context, sessionID string, reqEditors ...RequestEditorFn) (*http.Response, error) + // ValidateDPoPProofWithBody request with any body + ValidateDPoPProofWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + ValidateDPoPProof(ctx context.Context, body ValidateDPoPProofJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // CreateDPoPProofWithBody request with any body + CreateDPoPProofWithBody(ctx context.Context, did string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + CreateDPoPProof(ctx context.Context, did string, body CreateDPoPProofJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // RequestOid4vciCredentialIssuanceWithBody request with any body RequestOid4vciCredentialIssuanceWithBody(ctx context.Context, did string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -547,6 +673,14 @@ type ClientInterface interface { // PresentationDefinition request PresentationDefinition(ctx context.Context, did string, params *PresentationDefinitionParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetRequestJWT request + GetRequestJWT(ctx context.Context, did string, id string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // PostRequestJWTWithBody request with any body + PostRequestJWTWithBody(ctx context.Context, did string, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PostRequestJWTWithFormdataBody(ctx context.Context, did string, id string, body PostRequestJWTFormdataRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // HandleAuthorizeResponseWithBody request with any body HandleAuthorizeResponseWithBody(ctx context.Context, did string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -657,6 +791,54 @@ func (c *Client) RetrieveAccessToken(ctx context.Context, sessionID string, reqE return c.Client.Do(req) } +func (c *Client) ValidateDPoPProofWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewValidateDPoPProofRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ValidateDPoPProof(ctx context.Context, body ValidateDPoPProofJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewValidateDPoPProofRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreateDPoPProofWithBody(ctx context.Context, did string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreateDPoPProofRequestWithBody(c.Server, did, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) CreateDPoPProof(ctx context.Context, did string, body CreateDPoPProofJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCreateDPoPProofRequest(c.Server, did, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) RequestOid4vciCredentialIssuanceWithBody(ctx context.Context, did string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewRequestOid4vciCredentialIssuanceRequestWithBody(c.Server, did, contentType, body) if err != nil { @@ -777,6 +959,42 @@ func (c *Client) PresentationDefinition(ctx context.Context, did string, params return c.Client.Do(req) } +func (c *Client) GetRequestJWT(ctx context.Context, did string, id string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetRequestJWTRequest(c.Server, did, id) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostRequestJWTWithBody(ctx context.Context, did string, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostRequestJWTRequestWithBody(c.Server, did, id, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostRequestJWTWithFormdataBody(ctx context.Context, did string, id string, body PostRequestJWTFormdataRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostRequestJWTRequestWithFormdataBody(c.Server, did, id, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) HandleAuthorizeResponseWithBody(ctx context.Context, did string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewHandleAuthorizeResponseRequestWithBody(c.Server, did, contentType, body) if err != nil { @@ -1122,6 +1340,90 @@ func NewRetrieveAccessTokenRequest(server string, sessionID string) (*http.Reque return req, nil } +// NewValidateDPoPProofRequest calls the generic ValidateDPoPProof builder with application/json body +func NewValidateDPoPProofRequest(server string, body ValidateDPoPProofJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewValidateDPoPProofRequestWithBody(server, "application/json", bodyReader) +} + +// NewValidateDPoPProofRequestWithBody generates requests for ValidateDPoPProof with any type of body +func NewValidateDPoPProofRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/internal/auth/v2/dpop_validate") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewCreateDPoPProofRequest calls the generic CreateDPoPProof builder with application/json body +func NewCreateDPoPProofRequest(server string, did string, body CreateDPoPProofJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewCreateDPoPProofRequestWithBody(server, did, "application/json", bodyReader) +} + +// NewCreateDPoPProofRequestWithBody generates requests for CreateDPoPProof with any type of body +func NewCreateDPoPProofRequestWithBody(server string, did string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0 = did + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/internal/auth/v2/%s/dpop", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewRequestOid4vciCredentialIssuanceRequest calls the generic RequestOid4vciCredentialIssuance builder with application/json body func NewRequestOid4vciCredentialIssuanceRequest(server string, did string, body RequestOid4vciCredentialIssuanceJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader @@ -1504,31 +1806,27 @@ func NewPresentationDefinitionRequest(server string, did string, params *Present return req, nil } -// NewHandleAuthorizeResponseRequestWithFormdataBody calls the generic HandleAuthorizeResponse builder with application/x-www-form-urlencoded body -func NewHandleAuthorizeResponseRequestWithFormdataBody(server string, did string, body HandleAuthorizeResponseFormdataRequestBody) (*http.Request, error) { - var bodyReader io.Reader - bodyStr, err := runtime.MarshalForm(body, nil) - if err != nil { - return nil, err - } - bodyReader = strings.NewReader(bodyStr.Encode()) - return NewHandleAuthorizeResponseRequestWithBody(server, did, "application/x-www-form-urlencoded", bodyReader) -} - -// NewHandleAuthorizeResponseRequestWithBody generates requests for HandleAuthorizeResponse with any type of body -func NewHandleAuthorizeResponseRequestWithBody(server string, did string, contentType string, body io.Reader) (*http.Request, error) { +// NewGetRequestJWTRequest generates requests for GetRequestJWT +func NewGetRequestJWTRequest(server string, did string, id string) (*http.Request, error) { var err error var pathParam0 string pathParam0 = did + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id) + if err != nil { + return nil, err + } + serverURL, err := url.Parse(server) if err != nil { return nil, err } - operationPath := fmt.Sprintf("/oauth2/%s/response", pathParam0) + operationPath := fmt.Sprintf("/oauth2/%s/request.jwt/%s", pathParam0, pathParam1) if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -1538,13 +1836,106 @@ func NewHandleAuthorizeResponseRequestWithBody(server string, did string, conten return nil, err } - req, err := http.NewRequest("POST", queryURL.String(), body) + req, err := http.NewRequest("GET", queryURL.String(), nil) if err != nil { return nil, err } - req.Header.Add("Content-Type", contentType) - + return req, nil +} + +// NewPostRequestJWTRequestWithFormdataBody calls the generic PostRequestJWT builder with application/x-www-form-urlencoded body +func NewPostRequestJWTRequestWithFormdataBody(server string, did string, id string, body PostRequestJWTFormdataRequestBody) (*http.Request, error) { + var bodyReader io.Reader + bodyStr, err := runtime.MarshalForm(body, nil) + if err != nil { + return nil, err + } + bodyReader = strings.NewReader(bodyStr.Encode()) + return NewPostRequestJWTRequestWithBody(server, did, id, "application/x-www-form-urlencoded", bodyReader) +} + +// NewPostRequestJWTRequestWithBody generates requests for PostRequestJWT with any type of body +func NewPostRequestJWTRequestWithBody(server string, did string, id string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0 = did + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/oauth2/%s/request.jwt/%s", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewHandleAuthorizeResponseRequestWithFormdataBody calls the generic HandleAuthorizeResponse builder with application/x-www-form-urlencoded body +func NewHandleAuthorizeResponseRequestWithFormdataBody(server string, did string, body HandleAuthorizeResponseFormdataRequestBody) (*http.Request, error) { + var bodyReader io.Reader + bodyStr, err := runtime.MarshalForm(body, nil) + if err != nil { + return nil, err + } + bodyReader = strings.NewReader(bodyStr.Encode()) + return NewHandleAuthorizeResponseRequestWithBody(server, did, "application/x-www-form-urlencoded", bodyReader) +} + +// NewHandleAuthorizeResponseRequestWithBody generates requests for HandleAuthorizeResponse with any type of body +func NewHandleAuthorizeResponseRequestWithBody(server string, did string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0 = did + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/oauth2/%s/response", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + return req, nil } @@ -1696,6 +2087,16 @@ type ClientWithResponsesInterface interface { // RetrieveAccessTokenWithResponse request RetrieveAccessTokenWithResponse(ctx context.Context, sessionID string, reqEditors ...RequestEditorFn) (*RetrieveAccessTokenResponse, error) + // ValidateDPoPProofWithBodyWithResponse request with any body + ValidateDPoPProofWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ValidateDPoPProofResponse, error) + + ValidateDPoPProofWithResponse(ctx context.Context, body ValidateDPoPProofJSONRequestBody, reqEditors ...RequestEditorFn) (*ValidateDPoPProofResponse, error) + + // CreateDPoPProofWithBodyWithResponse request with any body + CreateDPoPProofWithBodyWithResponse(ctx context.Context, did string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateDPoPProofResponse, error) + + CreateDPoPProofWithResponse(ctx context.Context, did string, body CreateDPoPProofJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateDPoPProofResponse, error) + // RequestOid4vciCredentialIssuanceWithBodyWithResponse request with any body RequestOid4vciCredentialIssuanceWithBodyWithResponse(ctx context.Context, did string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RequestOid4vciCredentialIssuanceResponse, error) @@ -1723,6 +2124,14 @@ type ClientWithResponsesInterface interface { // PresentationDefinitionWithResponse request PresentationDefinitionWithResponse(ctx context.Context, did string, params *PresentationDefinitionParams, reqEditors ...RequestEditorFn) (*PresentationDefinitionResponse, error) + // GetRequestJWTWithResponse request + GetRequestJWTWithResponse(ctx context.Context, did string, id string, reqEditors ...RequestEditorFn) (*GetRequestJWTResponse, error) + + // PostRequestJWTWithBodyWithResponse request with any body + PostRequestJWTWithBodyWithResponse(ctx context.Context, did string, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostRequestJWTResponse, error) + + PostRequestJWTWithFormdataBodyWithResponse(ctx context.Context, did string, id string, body PostRequestJWTFormdataRequestBody, reqEditors ...RequestEditorFn) (*PostRequestJWTResponse, error) + // HandleAuthorizeResponseWithBodyWithResponse request with any body HandleAuthorizeResponseWithBodyWithResponse(ctx context.Context, did string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*HandleAuthorizeResponseResponse, error) @@ -1930,6 +2339,60 @@ func (r RetrieveAccessTokenResponse) StatusCode() int { return 0 } +type ValidateDPoPProofResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DPoPValidateResponse + ApplicationproblemJSONDefault *struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } +} + +// Status returns HTTPResponse.Status +func (r ValidateDPoPProofResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ValidateDPoPProofResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type CreateDPoPProofResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DPoPResponse +} + +// Status returns HTTPResponse.Status +func (r CreateDPoPProofResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r CreateDPoPProofResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type RequestOid4vciCredentialIssuanceResponse struct { Body []byte HTTPResponse *http.Response @@ -2142,6 +2605,68 @@ func (r PresentationDefinitionResponse) StatusCode() int { return 0 } +type GetRequestJWTResponse struct { + Body []byte + HTTPResponse *http.Response + ApplicationproblemJSONDefault *struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } +} + +// Status returns HTTPResponse.Status +func (r GetRequestJWTResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetRequestJWTResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type PostRequestJWTResponse struct { + Body []byte + HTTPResponse *http.Response + ApplicationproblemJSONDefault *struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } +} + +// Status returns HTTPResponse.Status +func (r PostRequestJWTResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PostRequestJWTResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type HandleAuthorizeResponseResponse struct { Body []byte HTTPResponse *http.Response @@ -2290,6 +2815,40 @@ func (c *ClientWithResponses) RetrieveAccessTokenWithResponse(ctx context.Contex return ParseRetrieveAccessTokenResponse(rsp) } +// ValidateDPoPProofWithBodyWithResponse request with arbitrary body returning *ValidateDPoPProofResponse +func (c *ClientWithResponses) ValidateDPoPProofWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ValidateDPoPProofResponse, error) { + rsp, err := c.ValidateDPoPProofWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseValidateDPoPProofResponse(rsp) +} + +func (c *ClientWithResponses) ValidateDPoPProofWithResponse(ctx context.Context, body ValidateDPoPProofJSONRequestBody, reqEditors ...RequestEditorFn) (*ValidateDPoPProofResponse, error) { + rsp, err := c.ValidateDPoPProof(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseValidateDPoPProofResponse(rsp) +} + +// CreateDPoPProofWithBodyWithResponse request with arbitrary body returning *CreateDPoPProofResponse +func (c *ClientWithResponses) CreateDPoPProofWithBodyWithResponse(ctx context.Context, did string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateDPoPProofResponse, error) { + rsp, err := c.CreateDPoPProofWithBody(ctx, did, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateDPoPProofResponse(rsp) +} + +func (c *ClientWithResponses) CreateDPoPProofWithResponse(ctx context.Context, did string, body CreateDPoPProofJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateDPoPProofResponse, error) { + rsp, err := c.CreateDPoPProof(ctx, did, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateDPoPProofResponse(rsp) +} + // RequestOid4vciCredentialIssuanceWithBodyWithResponse request with arbitrary body returning *RequestOid4vciCredentialIssuanceResponse func (c *ClientWithResponses) RequestOid4vciCredentialIssuanceWithBodyWithResponse(ctx context.Context, did string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RequestOid4vciCredentialIssuanceResponse, error) { rsp, err := c.RequestOid4vciCredentialIssuanceWithBody(ctx, did, contentType, body, reqEditors...) @@ -2377,6 +2936,32 @@ func (c *ClientWithResponses) PresentationDefinitionWithResponse(ctx context.Con return ParsePresentationDefinitionResponse(rsp) } +// GetRequestJWTWithResponse request returning *GetRequestJWTResponse +func (c *ClientWithResponses) GetRequestJWTWithResponse(ctx context.Context, did string, id string, reqEditors ...RequestEditorFn) (*GetRequestJWTResponse, error) { + rsp, err := c.GetRequestJWT(ctx, did, id, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetRequestJWTResponse(rsp) +} + +// PostRequestJWTWithBodyWithResponse request with arbitrary body returning *PostRequestJWTResponse +func (c *ClientWithResponses) PostRequestJWTWithBodyWithResponse(ctx context.Context, did string, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostRequestJWTResponse, error) { + rsp, err := c.PostRequestJWTWithBody(ctx, did, id, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostRequestJWTResponse(rsp) +} + +func (c *ClientWithResponses) PostRequestJWTWithFormdataBodyWithResponse(ctx context.Context, did string, id string, body PostRequestJWTFormdataRequestBody, reqEditors ...RequestEditorFn) (*PostRequestJWTResponse, error) { + rsp, err := c.PostRequestJWTWithFormdataBody(ctx, did, id, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostRequestJWTResponse(rsp) +} + // HandleAuthorizeResponseWithBodyWithResponse request with arbitrary body returning *HandleAuthorizeResponseResponse func (c *ClientWithResponses) HandleAuthorizeResponseWithBodyWithResponse(ctx context.Context, did string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*HandleAuthorizeResponseResponse, error) { rsp, err := c.HandleAuthorizeResponseWithBody(ctx, did, contentType, body, reqEditors...) @@ -2659,6 +3244,74 @@ func ParseRetrieveAccessTokenResponse(rsp *http.Response) (*RetrieveAccessTokenR return response, nil } +// ParseValidateDPoPProofResponse parses an HTTP response from a ValidateDPoPProofWithResponse call +func ParseValidateDPoPProofResponse(rsp *http.Response) (*ValidateDPoPProofResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ValidateDPoPProofResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DPoPValidateResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParseCreateDPoPProofResponse parses an HTTP response from a CreateDPoPProofWithResponse call +func ParseCreateDPoPProofResponse(rsp *http.Response) (*CreateDPoPProofResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &CreateDPoPProofResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DPoPResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + } + + return response, nil +} + // ParseRequestOid4vciCredentialIssuanceResponse parses an HTTP response from a RequestOid4vciCredentialIssuanceWithResponse call func ParseRequestOid4vciCredentialIssuanceResponse(rsp *http.Response) (*RequestOid4vciCredentialIssuanceResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -2920,6 +3573,76 @@ func ParsePresentationDefinitionResponse(rsp *http.Response) (*PresentationDefin return response, nil } +// ParseGetRequestJWTResponse parses an HTTP response from a GetRequestJWTWithResponse call +func ParseGetRequestJWTResponse(rsp *http.Response) (*GetRequestJWTResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetRequestJWTResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + +// ParsePostRequestJWTResponse parses an HTTP response from a PostRequestJWTWithResponse call +func ParsePostRequestJWTResponse(rsp *http.Response) (*PostRequestJWTResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PostRequestJWTResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest struct { + // Detail A human-readable explanation specific to this occurrence of the problem. + Detail string `json:"detail"` + + // Status HTTP statuscode + Status float32 `json:"status"` + + // Title A short, human-readable summary of the problem type. + Title string `json:"title"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.ApplicationproblemJSONDefault = &dest + + } + + return response, nil +} + // ParseHandleAuthorizeResponseResponse parses an HTTP response from a HandleAuthorizeResponseWithResponse call func ParseHandleAuthorizeResponseResponse(rsp *http.Response) (*HandleAuthorizeResponseResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/e2e-tests/oauth-flow/openid4vp/do-test.sh b/e2e-tests/oauth-flow/openid4vp/do-test.sh index 51a2b369ea..6e0f26f495 100755 --- a/e2e-tests/oauth-flow/openid4vp/do-test.sh +++ b/e2e-tests/oauth-flow/openid4vp/do-test.sh @@ -132,11 +132,28 @@ else exitWithDockerLogs 1 fi +ACCESS_TOKEN=$(cat ./node-B/accesstoken.txt) + +echo "------------------------------------" +echo "Create DPoP header..." +echo "------------------------------------" +REQUEST="{\"htm\":\"GET\",\"htu\":\"https://resource:80/resource\", \"token\":\"$ACCESS_TOKEN\"}" +RESPONSE=$(echo $REQUEST | curl -X POST -s --data-binary @- http://localhost:28081/internal/auth/v2/$PARTY_B_DID/dpop -H "Content-Type: application/json" -v) +if echo $RESPONSE | grep -q "dpop"; then + echo $RESPONSE | sed -E 's/.*"dpop":"([^"]*).*/\1/' > ./node-B/dpop.txt + echo "dpop token stored in ./node-B/dpop.txt" +else + echo "FAILED: Could not get dpop token from node-B" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi + +DPOP=$(cat ./node-B/dpop.txt) echo "------------------------------------" echo "Retrieving data..." echo "------------------------------------" -RESPONSE=$(docker compose exec nodeB-backend curl http://resource:80/resource -H "Authorization: bearer $(cat ./node-B/accesstoken.txt)") +RESPONSE=$(docker compose exec nodeB-backend curl http://resource:80/resource -H "Authorization: DPoP $ACCESS_TOKEN" -H "DPoP: $DPOP" -v) if echo $RESPONSE | grep -q "OK"; then echo "success!" else @@ -149,4 +166,4 @@ echo "------------------------------------" echo "Stopping Docker containers..." echo "------------------------------------" docker compose stop -rm ./node-B/*.txt +rm node-*/*.txt \ No newline at end of file diff --git a/e2e-tests/oauth-flow/openid4vp/resource/nginx.conf b/e2e-tests/oauth-flow/openid4vp/resource/nginx.conf index f1c2ce9d7f..8f765cca97 100644 --- a/e2e-tests/oauth-flow/openid4vp/resource/nginx.conf +++ b/e2e-tests/oauth-flow/openid4vp/resource/nginx.conf @@ -11,9 +11,9 @@ events { } http { - js_import oauth2.js; - include /etc/nginx/mime.types; - default_type application/octet-stream; + js_import oauth2.js; + include /etc/nginx/mime.types; + default_type application/octet-stream; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' @@ -39,13 +39,20 @@ http { js_content oauth2.introspectAccessToken; } + # Location in javascript subrequest. # this is needed to set headers and method - location /_oauth2_send_request { - internal; - proxy_method POST; - proxy_set_header Content-Type "application/x-www-form-urlencoded"; - proxy_pass http://nodeA-backend/internal/auth/v2/accesstoken/introspect; - } + location /_oauth2_send_request { + internal; + proxy_method POST; + proxy_set_header Content-Type "application/x-www-form-urlencoded"; + proxy_pass http://nodeA-backend/internal/auth/v2/accesstoken/introspect; + } + location /_dpop_send_request { + internal; + proxy_method POST; + proxy_set_header Content-Type "application/json"; + proxy_pass http://nodeA-backend/internal/auth/v2/dpop_validate; + } } } diff --git a/e2e-tests/oauth-flow/rfc021/do-test.sh b/e2e-tests/oauth-flow/rfc021/do-test.sh index 38fad807d6..10447d905b 100755 --- a/e2e-tests/oauth-flow/rfc021/do-test.sh +++ b/e2e-tests/oauth-flow/rfc021/do-test.sh @@ -64,7 +64,7 @@ echo "Perform OAuth 2.0 rfc021 flow..." echo "---------------------------------------" # Request access token REQUEST="{\"verifier\":\"${VENDOR_A_DID}\",\"scope\":\"test\"}" -RESPONSE=$(echo $REQUEST | curl -X POST -s --data-binary @- http://localhost:28081/internal/auth/v2/$VENDOR_B_DID/request-service-access-token -H "Content-Type:application/json" -v) +RESPONSE=$(echo $REQUEST | curl -X POST -s --data-binary @- http://localhost:28081/internal/auth/v2/$VENDOR_B_DID/request-service-access-token -H "Content-Type: application/json" -v) if echo $RESPONSE | grep -q "access_token"; then echo $RESPONSE | sed -E 's/.*"access_token":"([^"]*).*/\1/' > ./node-B/accesstoken.txt echo "access token stored in ./node-B/accesstoken.txt" @@ -74,10 +74,28 @@ else exitWithDockerLogs 1 fi +ACCESS_TOKEN=$(cat ./node-B/accesstoken.txt) + +echo "------------------------------------" +echo "Create DPoP header..." +echo "------------------------------------" +REQUEST="{\"htm\":\"GET\",\"htu\":\"https://nodeA:443/resource\", \"token\":\"$ACCESS_TOKEN\"}" +RESPONSE=$(echo $REQUEST | curl -X POST -s --data-binary @- http://localhost:28081/internal/auth/v2/$VENDOR_B_DID/dpop -H "Content-Type: application/json" -v) +if echo $RESPONSE | grep -q "dpop"; then + echo $RESPONSE | sed -E 's/.*"dpop":"([^"]*).*/\1/' > ./node-B/dpop.txt + echo "dpop token stored in ./node-B/dpop.txt" +else + echo "FAILED: Could not get dpop token from node-B" 1>&2 + echo $RESPONSE + exitWithDockerLogs 1 +fi + +DPOP=$(cat ./node-B/dpop.txt) + echo "------------------------------------" echo "Retrieving data..." echo "------------------------------------" -RESPONSE=$($db_dc exec nodeB curl --http1.1 --insecure --cert /etc/nginx/ssl/server.pem --key /etc/nginx/ssl/key.pem https://nodeA:443/resource -H "Authorization: bearer $(cat ./node-B/accesstoken.txt)" -v) +RESPONSE=$($db_dc exec nodeB curl --http1.1 --insecure --cert /etc/nginx/ssl/server.pem --key /etc/nginx/ssl/key.pem https://nodeA:443/resource -H "Authorization: DPoP $ACCESS_TOKEN" -H "DPoP: $DPOP" -v) if echo $RESPONSE | grep -q "OK"; then echo "success!" else @@ -89,5 +107,5 @@ fi echo "------------------------------------" echo "Stopping Docker containers..." echo "------------------------------------" -$db_dc stop -rm node-*/accesstoken.txt +$db_dc down +rm node-*/*.txt \ No newline at end of file diff --git a/e2e-tests/oauth-flow/rfc021/node-A/nginx.conf b/e2e-tests/oauth-flow/rfc021/node-A/nginx.conf index 5bb8c7b0d5..4ac2047991 100644 --- a/e2e-tests/oauth-flow/rfc021/node-A/nginx.conf +++ b/e2e-tests/oauth-flow/rfc021/node-A/nginx.conf @@ -61,5 +61,11 @@ http { proxy_set_header Content-Type "application/x-www-form-urlencoded"; proxy_pass http://nodeA-internal/internal/auth/v2/accesstoken/introspect; } + location /_dpop_send_request { + internal; + proxy_method POST; + proxy_set_header Content-Type "application/json"; + proxy_pass http://nodeA-internal/internal/auth/v2/dpop_validate; + } } } diff --git a/e2e-tests/oauth-flow/rfc021/node-A/nuts.yaml b/e2e-tests/oauth-flow/rfc021/node-A/nuts.yaml index 2e421ad651..c35ba581f2 100644 --- a/e2e-tests/oauth-flow/rfc021/node-A/nuts.yaml +++ b/e2e-tests/oauth-flow/rfc021/node-A/nuts.yaml @@ -3,6 +3,7 @@ verbosity: debug strictmode: false internalratelimiter: false http: + log: metadata-and-body internal: address: :8081 auth: diff --git a/e2e-tests/oauth-flow/rfc021/node-B/nuts.yaml b/e2e-tests/oauth-flow/rfc021/node-B/nuts.yaml index 069170a1bb..f683b03c01 100644 --- a/e2e-tests/oauth-flow/rfc021/node-B/nuts.yaml +++ b/e2e-tests/oauth-flow/rfc021/node-B/nuts.yaml @@ -3,6 +3,7 @@ verbosity: debug strictmode: false internalratelimiter: false http: + log: metadata-and-body internal: address: :8081 auth: diff --git a/e2e-tests/oauth-flow/scripts/oauth2.js b/e2e-tests/oauth-flow/scripts/oauth2.js index c963d03fdf..197b014d74 100644 --- a/e2e-tests/oauth-flow/scripts/oauth2.js +++ b/e2e-tests/oauth-flow/scripts/oauth2.js @@ -1,14 +1,42 @@ // # check access via token introspection as described by https://www.nginx.com/blog/validating-oauth-2-0-access-tokens-nginx/ function introspectAccessToken(r) { - // strip the first 8 chars - var token = "token=" + r.headersIn['Authorization'].substring(7); + // strip the first 5 chars + var token = "token=" + r.headersIn['Authorization'].substring(5); // make a subrequest to the introspection endpoint r.subrequest("/_oauth2_send_request", - { method: "POST", body: token }, + { method: "POST", body: token}, function(reply) { if (reply.status == 200) { var introspection = JSON.parse(reply.responseBody); if (introspection.active) { + dpop(r, introspection.cnf) + } else { + r.return(403, "Unauthorized"); + } + } else { + r.return(500, "Internal Server Error"); + } + } + ); +} + +function dpop(r, cnf) { + + // create JSON payload + const payload = { + dpop_proof: r.headersIn['DPoP'], + method: r.method, + thumbprint: cnf.jkt, + token: r.headersIn['Authorization'].substring(5), + url: r.headersIn.host + r.uri + } + // make a subrequest to the dpop endpoint + r.subrequest("/_dpop_send_request", + { method: "POST", body: JSON.stringify(payload)}, + function(reply) { + if (reply.status == 200) { + var introspection = JSON.parse(reply.responseBody); + if (introspection.valid) { r.return(200, "OK"); } else { r.return(403, "Unauthorized"); diff --git a/e2e-tests/util.sh b/e2e-tests/util.sh index e122d25ae6..dd50d74c5e 100644 --- a/e2e-tests/util.sh +++ b/e2e-tests/util.sh @@ -60,7 +60,7 @@ function waitForDiagnostic { function exitWithDockerLogs { EXIT_CODE=$1 docker compose logs - docker compose stop + docker compose down exit $EXIT_CODE } diff --git a/http/cmd/cmd.go b/http/cmd/cmd.go index 84481ae2f7..37667697da 100644 --- a/http/cmd/cmd.go +++ b/http/cmd/cmd.go @@ -42,7 +42,7 @@ func FlagSet() *pflag.FlagSet { flags.String("http.internal.auth.type", string(defs.Internal.Auth.Type), fmt.Sprintf("Whether to enable authentication for /internal endpoints, specify '%s' for bearer token mode or '%s' for legacy bearer token mode.", http.BearerTokenAuthV2, http.BearerTokenAuth)) flags.String("http.internal.auth.audience", defs.Internal.Auth.Audience, "Expected audience for JWT tokens (default: hostname)") flags.String("http.internal.auth.authorizedkeyspath", defs.Internal.Auth.AuthorizedKeysPath, "Path to an authorized_keys file for trusted JWT signers") - flags.String("http.log", string(defs.Log), fmt.Sprintf("What to log about HTTP requests. Options are '%s', '%s' (log request method, URI, IP and response code), and '%s' (log the request and response body, in addition to the metadata).", http.LogNothingLevel, http.LogMetadataLevel, http.LogMetadataAndBodyLevel)) + flags.String("http.log", string(defs.Log), fmt.Sprintf("What to log about HTTP requests. Options are '%s', '%s' (log request method, URI, IP and response code), and '%s' (log the request and response body, in addition to the metadata). When debug vebosity is set the authorization headers are also logged when the request is fully logged.", http.LogNothingLevel, http.LogMetadataLevel, http.LogMetadataAndBodyLevel)) return flags } diff --git a/http/requestlogger.go b/http/requestlogger.go index f1f55276df..9a30299b27 100644 --- a/http/requestlogger.go +++ b/http/requestlogger.go @@ -31,6 +31,7 @@ import ( func requestLoggerMiddleware(skipper middleware.Skipper, logger *logrus.Entry) echo.MiddlewareFunc { return middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{ Skipper: skipper, + LogHeaders: []string{"Authorization", "DPoP"}, LogURI: true, LogStatus: true, LogMethod: true, @@ -42,12 +43,16 @@ func requestLoggerMiddleware(skipper middleware.Skipper, logger *logrus.Entry) e status = core.GetHTTPStatusCode(values.Error, c) } - logger.WithFields(logrus.Fields{ + fields := logrus.Fields{ "remote_ip": values.RemoteIP, "method": values.Method, "uri": values.URI, "status": status, - }).Info("HTTP request") + } + if logger.Level >= logrus.DebugLevel { + fields["headers"] = values.Headers + } + logger.WithFields(fields).Info("HTTP request") return nil },