Skip to content

Commit

Permalink
IAM: Handle /token requests with vp_token bearer grant type
Browse files Browse the repository at this point in the history
  • Loading branch information
reinkrul committed Sep 28, 2023
1 parent 722e7a1 commit 96c8c85
Show file tree
Hide file tree
Showing 11 changed files with 711 additions and 110 deletions.
58 changes: 45 additions & 13 deletions auth/api/iam/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"github.com/nuts-foundation/nuts-node/auth"
"github.com/nuts-foundation/nuts-node/auth/log"
"github.com/nuts-foundation/nuts-node/core"
"github.com/nuts-foundation/nuts-node/crypto"
"github.com/nuts-foundation/nuts-node/vcr"
"github.com/nuts-foundation/nuts-node/vcr/openid4vci"
"github.com/nuts-foundation/nuts-node/vdr"
Expand All @@ -48,25 +49,27 @@ var assets embed.FS

// Wrapper handles OAuth2 flows.
type Wrapper struct {
vcr vcr.VCR
vdr vdr.VDR
auth auth.AuthenticationServices
sessions *SessionManager
templates *template.Template
vcr vcr.VCR
vdr vdr.VDR
auth auth.AuthenticationServices
privateKeyStore crypto.KeyStore
sessions *SessionManager
templates *template.Template
}

func New(authInstance auth.AuthenticationServices, vcrInstance vcr.VCR, vdrInstance vdr.VDR) *Wrapper {
func New(authInstance auth.AuthenticationServices, vcrInstance vcr.VCR, vdrInstance vdr.VDR, privateKeyStore crypto.KeyStore) *Wrapper {
templates := template.New("oauth2 templates")
_, err := templates.ParseFS(assets, "assets/*.html")
if err != nil {
panic(err)
}
return &Wrapper{
sessions: &SessionManager{sessions: new(sync.Map)},
auth: authInstance,
vcr: vcrInstance,
vdr: vdrInstance,
templates: templates,
sessions: &SessionManager{sessions: new(sync.Map)},
auth: authInstance,
vcr: vcrInstance,
vdr: vdrInstance,
privateKeyStore: privateKeyStore,
templates: templates,
}
}

Expand Down Expand Up @@ -113,6 +116,11 @@ func (r Wrapper) Routes(router core.EchoRouter) {

// HandleTokenRequest handles calls to the token endpoint for exchanging a grant (e.g authorization code or pre-authorized code) for an access token.
func (r Wrapper) HandleTokenRequest(ctx context.Context, request HandleTokenRequestRequestObject) (HandleTokenRequestResponseObject, error) {
ownDID, err := r.idToOwnedDID(ctx, request.Id)
if err != nil {
return nil, err
}

switch request.Body.GrantType {
case "authorization_code":
// Options:
Expand All @@ -124,6 +132,9 @@ func (r Wrapper) HandleTokenRequest(ctx context.Context, request HandleTokenRequ
case "urn:ietf:params:oauth:grant-type:pre-authorized_code":
// Options:
// - OpenID4VCI
case "vp_token-bearer":
// Nuts RFC021 vp_token bearer flow
return r.handleS2SAccessTokenRequest(ctx, *ownDID, request.Body.AdditionalProperties)
default:
// TODO: Don't use openid4vci package for errors
return nil, openid4vci.Error{
Expand All @@ -146,7 +157,10 @@ func (r Wrapper) HandleTokenRequest(ctx context.Context, request HandleTokenRequ

// HandleAuthorizeRequest handles calls to the authorization endpoint for starting an authorization code flow.
func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAuthorizeRequestRequestObject) (HandleAuthorizeRequestResponseObject, error) {
ownDID := idToDID(request.Id)
ownDID, err := r.idToOwnedDID(ctx, request.Id)
if err != nil {
return nil, err
}
// Create session object to be passed to handler

// Workaround: deepmap codegen doesn't support dynamic query parameters.
Expand All @@ -156,7 +170,7 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho
for key, value := range httpRequest.URL.Query() {
params[key] = value[0]
}
session := createSession(params, ownDID)
session := createSession(params, *ownDID)
if session.RedirectURI == "" {
// TODO: Spec says that the redirect URI is optional, but it's not clear what to do if it's not provided.
// Threat models say it's unsafe to omit redirect_uri.
Expand Down Expand Up @@ -253,6 +267,24 @@ func (r Wrapper) OAuthClientMetadata(ctx context.Context, request OAuthClientMet
return OAuthClientMetadata200JSONResponse(clientMetadata(*identity)), nil
}

func (r Wrapper) idToOwnedDID(ctx context.Context, id string) (*did.DID, error) {
ownDID := idToDID(id)
owned, err := r.vdr.IsOwner(ctx, ownDID)
if err != nil {
if resolver.IsFunctionalResolveError(err) {
// TODO: OAuth2 error 404 (waiting for https://github.com/nuts-foundation/nuts-node/pull/2515)
return nil, core.NotFoundError("DID unresolvable: %w", err)
}
// TODO: OAuth2 error 500 (waiting for https://github.com/nuts-foundation/nuts-node/pull/2515)
return nil, core.Error(500, "DID resolution failed: %w", err)
}
if !owned {
// TODO: OAuth2 error 404 (waiting for https://github.com/nuts-foundation/nuts-node/pull/2515)
return nil, core.NotFoundError("DID not owned")
}
return &ownDID, nil
}

func createSession(params map[string]string, ownDID did.DID) *Session {
session := &Session{
// TODO: Validate client ID
Expand Down
26 changes: 24 additions & 2 deletions auth/api/iam/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ import (
"github.com/nuts-foundation/nuts-node/audit"
"github.com/nuts-foundation/nuts-node/auth"
"github.com/nuts-foundation/nuts-node/core"
"github.com/nuts-foundation/nuts-node/crypto"
"github.com/nuts-foundation/nuts-node/vcr"
"github.com/nuts-foundation/nuts-node/vcr/pe"
"github.com/nuts-foundation/nuts-node/vcr/verifier"
"github.com/nuts-foundation/nuts-node/vdr"
"github.com/nuts-foundation/nuts-node/vdr/resolver"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -169,25 +173,43 @@ type testCtx struct {
client *Wrapper
authnServices *auth.MockAuthenticationServices
vdr *vdr.MockVDR
keyStore *crypto.MockKeyStore
resolver *resolver.MockDIDResolver
verifier *verifier.MockVerifier
vcr *vcr.MockVCR
}

func newTestClient(t testing.TB) *testCtx {
publicURL, err := url.Parse("https://example.com")
require.NoError(t, err)
ctrl := gomock.NewController(t)
keyStore := crypto.NewMockKeyStore(ctrl)
mockVerifier := verifier.NewMockVerifier(ctrl)
mockVCR := vcr.NewMockVCR(ctrl)
mockVCR.EXPECT().Verifier().Return(mockVerifier).AnyTimes()

authnServices := auth.NewMockAuthenticationServices(ctrl)
authnServices.EXPECT().PublicURL().Return(publicURL).AnyTimes()
presentationDefinitionResolver := pe.DefinitionResolver{}
err = presentationDefinitionResolver.LoadFromFile("../../../vcr/pe/test/definition_mapping.json")
require.NoError(t, err)
authnServices.EXPECT().PresentationDefinitions().Return(&presentationDefinitionResolver).AnyTimes()

resolver := resolver.NewMockDIDResolver(ctrl)
vdr := vdr.NewMockVDR(ctrl)
vdr.EXPECT().Resolver().Return(resolver).AnyTimes()
return &testCtx{
authnServices: authnServices,
resolver: resolver,
vdr: vdr,
keyStore: keyStore,
verifier: mockVerifier,
vcr: mockVCR,
client: &Wrapper{
auth: authnServices,
vdr: vdr,
auth: authnServices,
vdr: vdr,
vcr: mockVCR,
privateKeyStore: keyStore,
},
}
}
4 changes: 2 additions & 2 deletions auth/api/iam/openid4vp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ var holderDID = did.MustParseDID("did:web:example.com:holder")
var issuerDID = did.MustParseDID("did:web:example.com:issuer")

func TestWrapper_sendPresentationRequest(t *testing.T) {
instance := New(nil, nil, nil)
instance := New(nil, nil, nil, nil)

redirectURI, _ := url.Parse("https://example.com/redirect")
verifierID, _ := url.Parse("https://example.com/verifier")
Expand Down Expand Up @@ -101,7 +101,7 @@ func TestWrapper_handlePresentationRequest(t *testing.T) {
mockAuth.EXPECT().PresentationDefinitions().Return(peStore)
mockWallet.EXPECT().List(gomock.Any(), holderDID).Return(walletCredentials, nil)
mockVDR.EXPECT().IsOwner(gomock.Any(), holderDID).Return(true, nil)
instance := New(mockAuth, mockVCR, mockVDR)
instance := New(mockAuth, mockVCR, mockVDR, nil)

params := map[string]string{
"scope": "eOverdracht-overdrachtsbericht",
Expand Down
162 changes: 130 additions & 32 deletions auth/api/iam/s2s_vptoken.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,49 +19,147 @@
package iam

import (
"bytes"
"context"
"encoding/json"
"errors"
"github.com/labstack/echo/v4"
"fmt"
"github.com/lestrrat-go/jwx/jws"
"github.com/lestrrat-go/jwx/jwt"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/core"
"github.com/nuts-foundation/nuts-node/vcr/pe"
"github.com/nuts-foundation/nuts-node/vdr/resolver"
"net/http"
"net/url"
"strings"
"time"
)

// serviceToService adds support for service-to-service OAuth2 flows,
// which uses a custom vp_token grant to authenticate calls to the token endpoint.
// Clients first call the presentation definition endpoint to get a presentation definition for the desired scope,
// then create a presentation submission given the definition which is posted to the token endpoint as vp_token.
// The AS then returns an access token with the requested scope.
// Requires:
// - GET /presentation_definition?scope=... (returns a presentation definition)
// - POST /token (with vp_token grant)
type serviceToService struct {
}
// accessTokenValidity defines how long access tokens are valid.
// TODO: Do we need to make this configurable?
const accessTokenValidity = 15 * time.Minute

func (s serviceToService) Routes(router core.EchoRouter) {
router.Add("GET", "/public/oauth2/:did/presentation_definition", func(echoCtx echo.Context) error {
// TODO: Read scope, map to presentation definition, return
return echoCtx.JSON(http.StatusOK, map[string]string{})
})
}
// scopeJWTClaimKey defines the JWT claim name for the OAuth2 scope in created tokens.
const scopeJWTClaimKey = "scope"

// scopeCredentialsClaimKey defines the JWT claim name for the Verifiable Credentials that were presented by the holder
// when the token was created.
const scopeCredentialsClaimKey = "vcs"

// handleS2SAccessTokenRequest handles the /token request with vp_token bearer grant type, intended for service-to-service exchanges.
// It performs cheap checks first (parameter presence and validity), then the more expensive ones (checking signatures and matching VCs to the presentation definition).
func (s Wrapper) handleS2SAccessTokenRequest(ctx context.Context, issuer did.DID, params map[string]string) (HandleTokenRequestResponseObject, error) {
submissionEncoded := params["presentation_submission"]
scope := params[scopeParam]
assertionEncoded := params["assertion"]
if submissionEncoded == "" || scope == "" || assertionEncoded == "" {
// TODO: OAuth2 error
return nil, errors.New("missing required parameters")
}

// Unmarshal VP, which can be in URL-encoded JSON or JWT format
if strings.HasPrefix(assertionEncoded, "ey") {
// (VP in JWT format)
return nil, errors.New("TODO: VPs in JWT format not yet supported")
}
// (VP as URL-encoded JSON)
assertionDecoded, err := url.QueryUnescape(assertionEncoded)
if err != nil {
// TODO: OAuth2 error
return nil, fmt.Errorf("assertion parameter is invalid: %w", err)
}
var vp vc.VerifiablePresentation
err = json.Unmarshal([]byte(assertionDecoded), &vp)
if err != nil {
// TODO: OAuth2 error
return nil, fmt.Errorf("assertion parameter is invalid: %w", err)
}

// Unmarshal presentation submission
var submission pe.PresentationSubmission
submissionDecoded, err := url.QueryUnescape(submissionEncoded)
if err != nil {
// TODO: OAuth2 error
return nil, fmt.Errorf("presentation_submission parameter is invalid: %w", err)
}
if err = json.Unmarshal([]byte(submissionDecoded), &submission); err != nil {
// TODO: OAuth2 error
return nil, fmt.Errorf("presentation_submission parameter is invalid: %w", err)
}

// Check signatures and VC issuer trust
issueTime := time.Now()
_, err = s.vcr.Verifier().VerifyVP(vp, true, false, &issueTime)
if err != nil {
// TODO: OAuth2 error
return nil, fmt.Errorf("verifiable presentation verification failed: %w", err)
}

// Validate the presentation submission:
// 1. Resolve presentation definition for the requested scope
// 2. Take VCs mapped by the presentation submission
// 3. Match the VCs from (2) to the definition from (1). This should yield the same list of credentials.
// This actually creates a new submission.
presentationDefinition := s.auth.PresentationDefinitions().ByScope(scope)
if presentationDefinition == nil {
return nil, fmt.Errorf("unsupported scope for presentation exchange: %s", scope)
}
submissionCredentials, err := submission.Credentials(vp)
if err != nil {
return nil, err
}
// Match the credentials from the presentation submission with the presentation definition we got through the scope
_, matchedCredentials, err := presentationDefinition.Match(submissionCredentials)
if err != nil {
return nil, fmt.Errorf("presentation_submission parameter is invalid: re-evaluation of the credentials against the presentation definition fails: %w", err)
}
submissionCredentialsJSON, _ := json.Marshal(submissionCredentials)
matchedCredentialsJSON, _ := json.Marshal(matchedCredentials)
if !bytes.Equal(submissionCredentialsJSON, matchedCredentialsJSON) {
return nil, errors.New("presentation_submission parameter is invalid: re-evaluation of the credentials against the presentation definition yields different credentials (bug or attempted hack)")
}

func (s serviceToService) validateVPToken(params map[string]string) (string, error) {
submission := params["presentation_submission"]
scope := params["scope"]
vp_token := params["vp_token"]
if submission == "" || scope == "" || vp_token == "" {
// TODO: right error response
return "", errors.New("missing required parameters")
}
// TODO: https://github.com/nuts-foundation/nuts-node/issues/2418
// TODO: verify parameters
return scope, nil
// TODO: Check that the returned credentials fulfill the presentation definition of the scope.
// TODO: Only include credentials from the presentation submission
response, err := s.createAccessToken(ctx, issuer, issueTime, matchedCredentials, scope)
if err != nil {
return nil, fmt.Errorf("creating access token: %w", err)
}
return HandleTokenRequest200JSONResponse(*response), nil
}

func (s serviceToService) handleAuthzRequest(_ map[string]string, _ *Session) (*authzResponse, error) {
// Protocol does not support authorization code flow
return nil, nil
func (s Wrapper) createAccessToken(ctx context.Context, issuer did.DID, issueTime time.Time, credentials []vc.VerifiableCredential, scope string) (*TokenResponse, error) {
// Sign with the private key of the issuer
keyResolver := resolver.DIDKeyResolver{Resolver: s.vdr.Resolver()}
signingKeyID, _, err := keyResolver.ResolveKey(issuer, &issueTime, resolver.NutsSigningKeyType)
if err != nil {
return nil, fmt.Errorf("resolve issuer signing key: %w", err)
}
// TODO: The JWT becomes quite large due to inclusion of the complete VCs;
// do we need to have an opaque token with backend storage of tokens?
claims := map[string]interface{}{
jwt.IssuedAtKey: issueTime.Unix(),
jwt.IssuerKey: issuer.String(),
jwt.ExpirationKey: issueTime.Add(accessTokenValidity).Unix(),
jwt.NotBeforeKey: issueTime.Unix(),
scopeJWTClaimKey: scope,
scopeCredentialsClaimKey: credentials,
}
headers := map[string]interface{}{
jws.KeyIDKey: signingKeyID.String(),
}
token, err := s.privateKeyStore.SignJWT(ctx, claims, headers, signingKeyID.String())
if err != nil {
return nil, fmt.Errorf("sign token: %w", err)
}
expiredIn := int(accessTokenValidity.Seconds())
return &TokenResponse{
AccessToken: token,
ExpiresIn: &expiredIn,
Scope: &scope,
TokenType: "bearer",
}, nil
}

func (r Wrapper) RequestAccessToken(ctx context.Context, request RequestAccessTokenRequestObject) (RequestAccessTokenResponseObject, error) {
Expand Down
Loading

0 comments on commit 96c8c85

Please sign in to comment.