Skip to content

Commit

Permalink
IAM: match user-targeted OpenID4VP PDs against session-wallet (#3009)
Browse files Browse the repository at this point in the history
  • Loading branch information
reinkrul authored May 7, 2024
1 parent e92d854 commit 734f544
Show file tree
Hide file tree
Showing 43 changed files with 4,121 additions and 145 deletions.
1 change: 1 addition & 0 deletions .codeclimate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ exclude_patterns:
- "**/*_mock.go"
- "docs/**/*.go"
- "**/*.pb.go"
- "e2e-tests/**/*.go"
plugins:
gofmt:
enabled: true
Expand Down
22 changes: 17 additions & 5 deletions auth/api/iam/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import (
"github.com/nuts-foundation/nuts-node/core"
cryptoNuts "github.com/nuts-foundation/nuts-node/crypto"
httpNuts "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"
"github.com/nuts-foundation/nuts-node/vcr"
Expand Down Expand Up @@ -88,14 +89,17 @@ type Wrapper struct {
auth auth.AuthenticationServices
policyBackend policy.PDPBackend
storageEngine storage.Engine
JSONLDManager jsonld.JSONLD
vcr vcr.VCR
vdr vdr.VDR
jwtSigner cryptoNuts.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) *Wrapper {
func New(
authInstance auth.AuthenticationServices, vcrInstance vcr.VCR, vdrInstance vdr.VDR, storageEngine storage.Engine,
policyBackend policy.PDPBackend, jwtSigner cryptoNuts.JWTSigner, jsonldManager jsonld.JSONLD) *Wrapper {
templates := template.New("oauth2 templates")
_, err := templates.ParseFS(assetsFS, "assets/*.html")
if err != nil {
Expand All @@ -107,6 +111,7 @@ func New(authInstance auth.AuthenticationServices, vcrInstance vcr.VCR, vdrInsta
storageEngine: storageEngine,
vcr: vcrInstance,
vdr: vdrInstance,
JSONLDManager: jsonldManager,
jwtSigner: jwtSigner,
keyResolver: resolver.DIDKeyResolver{Resolver: vdrInstance.Resolver()},
jar: &jar{
Expand Down Expand Up @@ -283,8 +288,8 @@ func (r Wrapper) IntrospectAccessToken(_ context.Context, request IntrospectAcce

if token.InputDescriptorConstraintIdMap != nil {
for _, reserved := range []string{"iss", "sub", "exp", "iat", "active", "client_id", "scope"} {
if _, exists := token.InputDescriptorConstraintIdMap[reserved]; exists {
return nil, fmt.Errorf("IntrospectAccessToken: InputDescriptorConstraintIdMap contains reserved claim name '%s'", reserved)
if _, isReserved := token.InputDescriptorConstraintIdMap[reserved]; isReserved {
return nil, fmt.Errorf("IntrospectAccessToken: InputDescriptorConstraintIdMap contains reserved claim name: %s", reserved)
}
}
response.AdditionalProperties = token.InputDescriptorConstraintIdMap
Expand Down Expand Up @@ -339,7 +344,14 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho
case responseTypeVPToken:
// Options:
// - OpenID4VP flow, vp_token is sent in Authorization Response
return r.handleAuthorizeRequestFromVerifier(ctx, *ownDID, authzParams)
// non-spec: if the scheme is openid4vp (URL starts with openid4vp:), the OpenID4VP request should be handled by a user wallet, rather than an organization wallet.
// Requests to user wallets can then be rendered as QR-code (or use a cloud wallet).
// Note that it can't be called from the outside, but only by internal dispatch (since Echo doesn't handle openid4vp:, obviously).
walletOwnerType := pe.WalletOwnerOrganization
if strings.HasPrefix(httpRequest.URL.String(), "openid4vp:") {
walletOwnerType = pe.WalletOwnerUser
}
return r.handleAuthorizeRequestFromVerifier(ctx, *ownDID, authzParams, walletOwnerType)
default:
// TODO: This should be a redirect?
redirectURI, _ := url.Parse(session.RedirectURI)
Expand Down Expand Up @@ -558,7 +570,7 @@ func (r Wrapper) RequestUserAccessToken(ctx context.Context, request RequestUser
return nil, err
}

// TODO: When we support authentication at an external IdP,
// Note: When we support authentication at an external IdP,
// the properties below become conditionally required.
if request.Body.PreauthorizedUser == nil {
return nil, core.InvalidInputError("missing preauthorized_user")
Expand Down
35 changes: 29 additions & 6 deletions auth/api/iam/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,10 +322,10 @@ func TestWrapper_HandleAuthorizeRequest(t *testing.T) {
oauth.CodeChallengeMethodParam: "S256",
}
ctx.vdr.EXPECT().IsOwner(gomock.Any(), verifierDID).Return(true, nil)
ctx.jar.EXPECT().Parse(gomock.Any(), verifierDID, url.Values{"key":[]string{"test_value"}}).Return(requestParams, nil)
ctx.jar.EXPECT().Parse(gomock.Any(), verifierDID, url.Values{"key": []string{"test_value"}}).Return(requestParams, nil)

// handleAuthorizeRequestFromHolder
expectedURL := "https://example.com/authorize?client_id=did%3Aweb%3Aexample.com%3Aiam%3Averifier&request_uri=https://example.com/oauth2/"+verifierDID.String()+"/request.jwt/&request_uri_method=get"
expectedURL := "https://example.com/authorize?client_id=did%3Aweb%3Aexample.com%3Aiam%3Averifier&request_uri=https://example.com/oauth2/" + verifierDID.String() + "/request.jwt/&request_uri_method=get"
serverMetadata := oauth.AuthorizationServerMetadata{
AuthorizationEndpoint: "https://example.com/authorize",
ClientIdSchemesSupported: []string{didScheme},
Expand Down Expand Up @@ -357,6 +357,16 @@ func TestWrapper_HandleAuthorizeRequest(t *testing.T) {
})
t.Run("ok - response_type=vp_token ", func(t *testing.T) {
ctx := newTestClient(t)
vmId := did.DIDURL{
DID: verifierDID,
Fragment: "key",
DecodedFragment: "key",
}
kid := vmId.String()
key := cryptoNuts.NewTestKey(kid)
didDocument := did.Document{ID: verifierDID}
vm, _ := did.NewVerificationMethod(vmId, ssi.JsonWebKey2020, did.DID{}, key.Public())
didDocument.AddAssertionMethod(vm)

// HandleAuthorizeRequest
requestParams := oauthParameters{
Expand Down Expand Up @@ -384,15 +394,25 @@ func TestWrapper_HandleAuthorizeRequest(t *testing.T) {
RedirectURI: "https://example.com/iam/holder/cb",
ResponseType: "code",
})
_ = ctx.client.userSessionStore().Put("session-id", UserSession{
TenantDID: holderDID,
Wallet: UserWallet{
DID: holderDID,
},
})
clientMetadata := oauth.OAuthClientMetadata{VPFormats: oauth.DefaultOpenIDSupportedFormats()}
ctx.iamClient.EXPECT().ClientMetadata(gomock.Any(), "https://example.com/.well-known/authorization-server/iam/verifier").Return(&clientMetadata, nil)
pdEndpoint := "https://example.com/oauth2/did:web:example.com:iam:verifier/presentation_definition?scope=test"
ctx.iamClient.EXPECT().PresentationDefinition(gomock.Any(), pdEndpoint).Return(&pe.PresentationDefinition{}, nil)
ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), holderDID, pe.PresentationDefinition{}, clientMetadata.VPFormats, gomock.Any()).Return(&vc.VerifiablePresentation{}, &pe.PresentationSubmission{}, nil)
ctx.iamClient.EXPECT().PostAuthorizationResponse(gomock.Any(), vc.VerifiablePresentation{}, pe.PresentationSubmission{}, "https://example.com/oauth2/did:web:example.com:iam:verifier/response", "state").Return("https://example.com/iam/holder/redirect", nil)

res, err := ctx.client.HandleAuthorizeRequest(requestContext(map[string]interface{}{}),
HandleAuthorizeRequestRequestObject{Did: holderDID.String()})
res, err := ctx.client.HandleAuthorizeRequest(requestContext(map[string]interface{}{}, func(request *http.Request) {
request.Header = make(http.Header)
request.AddCookie(createUserSessionCookie("session-id", "/"))
}), HandleAuthorizeRequestRequestObject{
Did: holderDID.String(),
})

require.NoError(t, err)
assert.IsType(t, HandleAuthorizeRequest302Response{}, res)
Expand Down Expand Up @@ -595,7 +615,7 @@ func TestWrapper_IntrospectAccessToken(t *testing.T) {

res, err := ctx.client.IntrospectAccessToken(context.Background(), IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "token"}})

require.EqualError(t, err, "IntrospectAccessToken: InputDescriptorConstraintIdMap contains reserved claim name 'iss'")
require.EqualError(t, err, "IntrospectAccessToken: InputDescriptorConstraintIdMap contains reserved claim name: iss")
require.Nil(t, res)
})

Expand Down Expand Up @@ -1393,7 +1413,7 @@ func requireOAuthError(t *testing.T, err error, errorCode oauth.ErrorCode, error
assert.Equal(t, errorDescription, oauthErr.Description)
}

func requestContext(queryParams map[string]interface{}) context.Context {
func requestContext(queryParams map[string]interface{}, httpRequestFn ...func(header *http.Request)) context.Context {
vals := url.Values{}
for key, value := range queryParams {
switch t := value.(type) {
Expand All @@ -1412,6 +1432,9 @@ func requestContext(queryParams map[string]interface{}) context.Context {
RawQuery: vals.Encode(),
},
}
for _, fn := range httpRequestFn {
fn(httpRequest)
}
return context.WithValue(audit.TestContext(), httpRequestContextKey{}, httpRequest)
}

Expand Down
27 changes: 27 additions & 0 deletions auth/api/iam/codegen_sillyness.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* 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 "encoding/json"

var _ json.Marshaler = IntrospectAccessToken200JSONResponse{}

func (r IntrospectAccessToken200JSONResponse) MarshalJSON() ([]byte, error) {
return json.Marshal(TokenIntrospectionResponse(r))
}
39 changes: 39 additions & 0 deletions auth/api/iam/codegen_sillyness_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* 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 (
"encoding/json"
"github.com/stretchr/testify/assert"
"testing"
)

func TestIntrospectAccessToken200JSONResponse_MarshalJSON(t *testing.T) {
// deepmap/oapi-codegen generates TokenIntrospectionResponse.MarshalJSON() function to support additionalProperties.
// But, the type being marshalled (due to the Strict Server Interface) is IntrospectAccessToken200JSONResponse
// which is a type definition for the TokenIntrospectionResponse type, which causes the custom MarshalJSON function to be ignored.
// This, in turn, causes additionalProperties not to be marshalled.
// The only way to circumvent this is to have IntrospectAccessToken200JSONResponse implement the json.Marshaler interface,
// and have it call the TokenIntrospectionResponse.MarshalJSON() function.
response := TokenIntrospectionResponse{AdditionalProperties: map[string]interface{}{
"message": "hello",
}}
asJSON, _ := json.Marshal(IntrospectAccessToken200JSONResponse(response))
assert.JSONEq(t, `{"active":false, "message":"hello"}`, string(asJSON))
}
68 changes: 62 additions & 6 deletions auth/api/iam/openid4vp.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +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/nuts-foundation/go-did/did"
Expand Down Expand Up @@ -213,6 +218,13 @@ func (r Wrapper) nextOpenID4VPFlow(ctx context.Context, state string, session OA
RedirectURI: session.redirectURI(),
}
}
if *walletOwnerType == pe.WalletOwnerUser {
// User wallet, make an openid4vp:// request URL
var newRequestURL url.URL
newRequestURL.Scheme = "openid4vp"
newRequestURL.RawQuery = authServerURL.RawQuery
authServerURL = &newRequestURL
}

// use nonce and state to store authorization request in session store
if err = r.oauthNonceStore().Put(nonce, state); err != nil {
Expand Down Expand Up @@ -240,7 +252,7 @@ func (r Wrapper) nextOpenID4VPFlow(ctx context.Context, state string, session OA
// there are way more error conditions that listed at: https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#name-error-response
// missing or invalid parameters are all mapped to invalid_request
// any operation that fails is mapped to server_error, this includes unreachable or broken backends.
func (r Wrapper) handleAuthorizeRequestFromVerifier(ctx context.Context, walletDID did.DID, params oauthParameters) (HandleAuthorizeRequestResponseObject, error) {
func (r Wrapper) handleAuthorizeRequestFromVerifier(ctx context.Context, tenantDID did.DID, params oauthParameters, walletOwnerType WalletOwnerType) (HandleAuthorizeRequestResponseObject, error) {
responseMode := params.get(responseModeParam)
if responseMode != responseModeDirectPost {
return nil, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "invalid response_mode parameter"}
Expand Down Expand Up @@ -272,6 +284,14 @@ func (r Wrapper) handleAuthorizeRequestFromVerifier(ctx context.Context, walletD
if nonce == "" {
return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "missing nonce parameter"}, responseURI, state)
}

// TODO: Create session if it does not exist (use client state to get original Authorization Code request)?
// Although it would be quite weird (maybe it expired).
userSession, err := r.loadUserSession(ctx.Value(httpRequestContextKey{}).(*http.Request), tenantDID, nil)
if userSession == nil {
return nil, oauth.OAuth2Error{Code: oauth.InvalidRequest, InternalError: err, Description: "no user session found"}
}

// get verifier metadata
metadata, err := r.auth.IAMClient().ClientMetadata(ctx, params.get(clientMetadataURIParam))
if err != nil {
Expand All @@ -293,25 +313,61 @@ func (r Wrapper) handleAuthorizeRequestFromVerifier(ctx context.Context, walletD
Expires: time.Now().Add(15 * time.Minute),
Nonce: nonce,
}
vp, submission, err := r.vcr.Wallet().BuildSubmission(ctx, walletDID, *presentationDefinition, metadata.VPFormats, buildParams)

targetWallet := r.vcr.Wallet()
walletDID := tenantDID
if walletOwnerType == pe.WalletOwnerUser {
// User wallet
var privateKey jwk.Key
privateKey, err = userSession.Wallet.Key()
walletDID = userSession.Wallet.DID
targetWallet = holder.NewMemoryWallet(
r.JSONLDManager.DocumentLoader(),
resolver.DIDKeyResolver{Resolver: didjwk.NewResolver()},
crypto.MemoryJWTSigner{Key: privateKey},
map[did.DID][]vc.VerifiableCredential{userSession.Wallet.DID: userSession.Wallet.Credentials},
)
}
vp, submission, err := targetWallet.BuildSubmission(ctx, walletDID, *presentationDefinition, metadata.VPFormats, buildParams)
if err != nil {
if errors.Is(err, holder.ErrNoCredentials) {
return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: "no credentials available"}, responseURI, state)
return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.InvalidRequest, Description: fmt.Sprintf("no credentials available (PD ID: %s, wallet: %s)", presentationDefinition.Id, walletDID)}, responseURI, state)
}
return r.sendAndHandleDirectPostError(ctx, oauth.OAuth2Error{Code: oauth.ServerError, Description: err.Error()}, responseURI, state)
}

// any error here is a server error, might need a fixup to prevent exposing to a user
return r.sendAndHandleDirectPost(ctx, *vp, *submission, responseURI, state)
return r.sendAndHandleDirectPost(ctx, tenantDID, *vp, *submission, responseURI, state)
}

// sendAndHandleDirectPost sends OpenID4VP direct_post to the verifier. The verifier responds with a redirect to the client (including error fields if needed).
// If the direct post fails, the user-agent will be redirected back to the client with an error. (Original redirect_uri).
func (r Wrapper) sendAndHandleDirectPost(ctx context.Context, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI string, state string) (HandleAuthorizeRequestResponseObject, error) {
func (r Wrapper) sendAndHandleDirectPost(ctx context.Context, walletDID did.DID, vp vc.VerifiablePresentation, presentationSubmission pe.PresentationSubmission, verifierResponseURI string, state string) (HandleAuthorizeRequestResponseObject, error) {
redirectURI, err := r.auth.IAMClient().PostAuthorizationResponse(ctx, vp, presentationSubmission, verifierResponseURI, state)
if err != nil {
return nil, err
}
// Redirect URI starting with openid4vp: is a signal from the OpenID4VP verifier
// that it requires another Verifiable Presentation, but this time from a user wallet.
if strings.HasPrefix(redirectURI, "openid4vp:") {
parsedRedirectURI, err := url.Parse(redirectURI)
if err != nil {
return nil, fmt.Errorf("verifier returned an invalid redirect URI: %w", err)
}
// Dispatch a new HTTP request to the local OpenID4VP wallet's authorization endpoint that includes request parameters,
// but with openid4vp: as scheme.
originalRequest := ctx.Value(httpRequestContextKey{}).(*http.Request)
dispatchHttpRequest := *originalRequest
dispatchHttpRequest.URL = parsedRedirectURI
ctx = context.WithValue(ctx, httpRequestContextKey{}, &dispatchHttpRequest)
response, err := r.HandleAuthorizeRequest(ctx, HandleAuthorizeRequestRequestObject{
Did: walletDID.String(),
})
if err != nil {
return nil, err
}
redirectURI = response.(HandleAuthorizeRequest302Response).Headers.Location
}
return HandleAuthorizeRequest302Response{
HandleAuthorizeRequest302ResponseHeaders{
Location: redirectURI,
Expand Down Expand Up @@ -609,7 +665,7 @@ func (r Wrapper) handleAccessTokenRequest(ctx context.Context, request HandleTok
if !validatePKCEParams(oauthSession.PKCEParams) {
return nil, oauthError(oauth.InvalidGrant, "invalid code_verifier")
}

// All done, issue access token
walletDID, err := did.ParseDID(oauthSession.ClientID)
if err != nil {
return nil, err
Expand Down
Loading

0 comments on commit 734f544

Please sign in to comment.