Skip to content

Commit

Permalink
Add DPoP to access token request and introspection (#3033)
Browse files Browse the repository at this point in the history
  • Loading branch information
woutslakhorst authored May 7, 2024
1 parent 476cb42 commit b8f5a3b
Show file tree
Hide file tree
Showing 49 changed files with 3,095 additions and 431 deletions.
104 changes: 104 additions & 0 deletions auth/api/iam/access_token.go
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*
*/

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
}
54 changes: 39 additions & 15 deletions auth/api/iam/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ package iam
import (
"bytes"
"context"
"crypto"
"embed"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
Expand All @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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{
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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())
}
Expand Down Expand Up @@ -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
Expand All @@ -859,15 +877,15 @@ 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
// left here for completeness, node 2 node interaction always uses JAR since the AS metadata has it hardcoded
// TODO: in the user flow we have no AS metadata, meaning that we add all params to the query.
// This is most likely going to fail on mobile devices due to request url length.
modifier(params)
redirectURL := httpNuts.AddQueryParams(*endpoint, params)
redirectURL := nutsHttp.AddQueryParams(*endpoint, params)
return &redirectURL, nil
}

Expand Down Expand Up @@ -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...)
Expand Down
Loading

0 comments on commit b8f5a3b

Please sign in to comment.