Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion auth/api/iam/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,17 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS
if err != nil {
return nil, err
}
// service_provider_subject_id is optional. When absent the single-VP flow runs; when present it
// selects the two-VP jwt-bearer flow. An explicit empty string is ambiguous, reject it here so
// the flow doesn't route on an unset value.
if request.Body.ServiceProviderSubjectId != nil {
if *request.Body.ServiceProviderSubjectId == "" {
return nil, core.InvalidInputError("service_provider_subject_id is optional and cannot be empty: omit the field or set a non-empty value")
}
if err := r.subjectExists(ctx, *request.Body.ServiceProviderSubjectId); err != nil {
return nil, err
}
}

tokenCache := r.accessTokenCache()
cacheKey := accessTokenRequestCacheKey(request)
Expand Down Expand Up @@ -781,7 +792,7 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS
}

clientID := r.subjectToBaseURL(request.SubjectID)
tokenResult, err := r.auth.IAMClient().RequestServiceAccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, useDPoP, credentials, credentialSelection, nil)
tokenResult, err := r.auth.IAMClient().RequestServiceAccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, useDPoP, credentials, credentialSelection, request.Body.ServiceProviderSubjectId)
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
58 changes: 58 additions & 0 deletions auth/api/iam/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,64 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) {

assert.NotEqual(t, token1, token2)
})
t.Run("service_provider_subject_id", func(t *testing.T) {
t.Run("ok - threads through to IAM client", func(t *testing.T) {
// Verifies the wire-up of the new OpenAPI field. The dispatch and feature gating live in
// the IAM client (under #4227); the handler's job is to validate and forward the value.
ctx := newTestClient(t)
spSubject := verifierSubject // re-use the global fixture's existing Exists(true) mock
bodyWithSP := &RequestServiceAccessTokenJSONRequestBody{
AuthorizationServer: verifierURL.String(),
Scope: "first second",
ServiceProviderSubjectId: &spSubject,
}
response := &oauth.TokenResponse{AccessToken: "sp-token", TokenType: "Bearer", ExpiresIn: to.Ptr(900)}
ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, &spSubject).Return(response, nil)

tokenResponse, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: bodyWithSP})

require.NoError(t, err)
// Assert the response actually round-trips, not just that the call didn't error.
assert.Equal(t, "sp-token", tokenResponse.(RequestServiceAccessToken200JSONResponse).AccessToken)
})
t.Run("empty string is rejected up front", func(t *testing.T) {
// service_provider_subject_id is optional; an explicit "" must not silently dispatch into
// the two-VP flow with a meaningless subject.
ctx := newTestClient(t)
emptySP := ""
bodyWithEmptySP := &RequestServiceAccessTokenJSONRequestBody{
AuthorizationServer: verifierURL.String(),
Scope: "first second",
ServiceProviderSubjectId: &emptySP,
}

_, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: bodyWithEmptySP})

require.Error(t, err)
assert.ErrorContains(t, err, "service_provider_subject_id")
assert.ErrorContains(t, err, "cannot be empty")
})
t.Run("unknown subject returns 400", func(t *testing.T) {
// Mirrors the existing path-param check: a non-existent service-provider subject yields a
// clear OAuth invalid_request, not the misleading 412 "did method mismatch" that would come
// from a downstream ListDIDs("") result.
ctx := newTestClient(t)
unknownSP := unknownSubjectID
bodyWithUnknownSP := &RequestServiceAccessTokenJSONRequestBody{
AuthorizationServer: verifierURL.String(),
Scope: "first second",
ServiceProviderSubjectId: &unknownSP,
}

_, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: bodyWithUnknownSP})

require.Error(t, err)
var oauthErr oauth.OAuth2Error
require.ErrorAs(t, err, &oauthErr)
assert.Equal(t, oauth.InvalidRequest, oauthErr.Code)
assert.Contains(t, oauthErr.Description, "subject not found")
})
})
t.Run("self-asserted credentials", func(t *testing.T) {
response := &oauth.TokenResponse{
AccessToken: "token",
Expand Down
22 changes: 19 additions & 3 deletions auth/api/iam/generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,17 @@ func (auth *Auth) RelyingParty() oauth.RelyingParty {

func (auth *Auth) IAMClient() iam.Client {
keyResolver := resolver.DIDKeyResolver{Resolver: auth.vdrInstance.Resolver()}
return iam.NewClient(auth.vcr.Wallet(), keyResolver, auth.subjectManager, auth.keyStore, auth.jsonldManager.DocumentLoader(), auth.policyBackend, auth.strictMode, auth.httpClientTimeout, auth.config.Experimental.JwtBearerClient)
return iam.NewClient(iam.ClientConfig{
Wallet: auth.vcr.Wallet(),
KeyResolver: keyResolver,
SubjectManager: auth.subjectManager,
JWTSigner: auth.keyStore,
LDDocumentLoader: auth.jsonldManager.DocumentLoader(),
PolicyBackend: auth.policyBackend,
StrictMode: auth.strictMode,
HTTPClientTimeout: auth.httpClientTimeout,
ExperimentalJwtBearerClient: auth.config.Experimental.JwtBearerClient,
})
}

// Configure the Auth struct by creating a validator and create an Irma server
Expand Down
65 changes: 41 additions & 24 deletions auth/client/iam/openid4vp.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,31 +70,48 @@ type OpenID4VPClient struct {
experimentalJwtBearerClient bool
}

// NewClient returns an implementation of Holder
func NewClient(wallet holder.Wallet, keyResolver resolver.KeyResolver, subjectManager didsubject.Manager, jwtSigner nutsCrypto.JWTSigner,
ldDocumentLoader ld.DocumentLoader, policyBackend policy.PDPBackend, strictMode bool, httpClientTimeout time.Duration,
experimentalJwtBearerClient bool) *OpenID4VPClient {
// ClientConfig groups the dependencies and toggles needed to construct an OpenID4VPClient.
// The interface fields (Wallet, KeyResolver, SubjectManager, JWTSigner, LDDocumentLoader,
// PolicyBackend) are required; NewClient does not validate them and missing fields will surface as
// nil-pointer panics on first use. Scalar fields default to their zero value (StrictMode=false,
// HTTPClientTimeout=0, ExperimentalJwtBearerClient=false) and the zero values are valid.
type ClientConfig struct {
Wallet holder.Wallet
KeyResolver resolver.KeyResolver
SubjectManager didsubject.Manager
JWTSigner nutsCrypto.JWTSigner
LDDocumentLoader ld.DocumentLoader
PolicyBackend policy.PDPBackend
StrictMode bool
HTTPClientTimeout time.Duration
// ExperimentalJwtBearerClient gates the RFC 7523 jwt-bearer two-VP token request flow.
// Tracked for replacement by a list-style grant-types config under issue #4231.
ExperimentalJwtBearerClient bool
}

// NewClient returns an OpenID4VPClient configured with the given dependencies.
func NewClient(cfg ClientConfig) *OpenID4VPClient {
httpClient := HTTPClient{
strictMode: strictMode,
httpClient: client.NewWithCache(httpClientTimeout),
keyResolver: keyResolver,
strictMode: cfg.StrictMode,
httpClient: client.NewWithCache(cfg.HTTPClientTimeout),
keyResolver: cfg.KeyResolver,
}
client := &OpenID4VPClient{
c := &OpenID4VPClient{
httpClient: httpClient,
keyResolver: keyResolver,
jwtSigner: jwtSigner,
ldDocumentLoader: ldDocumentLoader,
subjectManager: subjectManager,
strictMode: strictMode,
wallet: wallet,
policyBackend: policyBackend,
experimentalJwtBearerClient: experimentalJwtBearerClient,
}
client.pdResolver = PresentationDefinitionResolver{
pdFetcher: client,
policyBackend: policyBackend,
}
return client
keyResolver: cfg.KeyResolver,
jwtSigner: cfg.JWTSigner,
ldDocumentLoader: cfg.LDDocumentLoader,
subjectManager: cfg.SubjectManager,
strictMode: cfg.StrictMode,
wallet: cfg.Wallet,
policyBackend: cfg.PolicyBackend,
experimentalJwtBearerClient: cfg.ExperimentalJwtBearerClient,
}
c.pdResolver = PresentationDefinitionResolver{
pdFetcher: c,
policyBackend: cfg.PolicyBackend,
}
return c
}

func (c *OpenID4VPClient) ClientMetadata(ctx context.Context, endpoint string) (*oauth.OAuthClientMetadata, error) {
Expand Down Expand Up @@ -345,7 +362,7 @@ func (c *OpenID4VPClient) requestVPTokenAccessToken(ctx context.Context, clientI
// parameter is sent on this path.
// Both PDs are resolved from the local policy backend; the AS's remote presentation_definition endpoint
// is not consulted (no standardised mechanism exists today for the AS to advertise a service_provider PD).
func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subjectID string, serviceProviderSubjectID string,
func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, organizationSubjectID string, serviceProviderSubjectID string,
authServerURL string, scopes string, useDPoP bool, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string,
metadata *oauth.AuthorizationServerMetadata) (*oauth.TokenResponse, error) {
profile, resolvedScope, err := loadAndValidateProfile(ctx, c.policyBackend, scopes)
Expand All @@ -368,7 +385,7 @@ func (c *OpenID4VPClient) requestJwtBearerAccessToken(ctx context.Context, subje
Format: metadata.VPFormatsSupported,
Nonce: nutsCrypto.GenerateNonce(),
}
organizationVP, organizationSubmission, err := c.buildSubmissionForSubject(ctx, subjectID, orgPD, additionalCredentials, credentialSelection, params)
organizationVP, organizationSubmission, err := c.buildSubmissionForSubject(ctx, organizationSubjectID, orgPD, additionalCredentials, credentialSelection, params)
if err != nil {
return nil, err
}
Expand Down
73 changes: 70 additions & 3 deletions auth/client/iam/openid4vp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -423,11 +423,13 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) {
scopes := "first second"

t.Run("rejects the request when the experimental jwt-bearer feature is disabled", func(t *testing.T) {
// The default zero value of experimentalJwtBearerClient is false; we leave it alone so the gate fires.
// The gate fires synchronously before any HTTP call, so the lightweight test context (no TLS
// server, no metadata fixtures) is sufficient. The default zero value of
// experimentalJwtBearerClient is false; we leave it alone so the gate fires.
sp := spSubjectID
ctx := createClientServerTestContext(t)
ctx := createClientTestContext(t, nil)

_, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp)
_, err := ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, "https://example.com/oauth2/verifier", scopes, false, nil, nil, &sp)

require.Error(t, err)
assert.ErrorContains(t, err, "jwt-bearer")
Expand Down Expand Up @@ -470,6 +472,71 @@ func TestRelyingParty_RequestServiceAccessToken_TwoVP(t *testing.T) {
// Scope-policy behaviour (profile-only rejection, passthrough forwarding, missing organization PD)
// is covered by TestLoadAndValidateProfile, which exercises the same helper this path delegates to.

t.Run("rejects the request when the SP wallet has no DIDs matching the AS's supported methods", func(t *testing.T) {
// VP1 succeeds; the second buildSubmissionForSubject for the SP wallet then runs ListDIDs
// followed by filterDIDsByMethods, which returns ErrPreconditionFailed when no DID's method
// is in the AS's DIDMethodsSupported list. The test pins DIDMethodsSupported explicitly so
// the assertion does not silently flip when the fixture default changes.
sp := spSubjectID
hcpDID := did.MustParseDID("did:test:hcp")
spDIDWrongMethod := did.MustParseDID("did:web:example.com:sp")
organizationVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`)
require.NoError(t, err)

ctx := createClientServerTestContext(t)
enableJwtBearerClient(t, ctx)
ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType}
ctx.authzServerMetadata.DIDMethodsSupported = []string{"test"}
ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{
CredentialProfileScope: "first",
WalletOwnerMapping: pe.WalletOwnerMapping{
pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "org_pd"},
pe.WalletOwnerServiceProvider: pe.PresentationDefinition{Id: "sp_pd"},
},
}, nil)
ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{hcpDID}, nil)
ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDIDWrongMethod}, nil)
ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{hcpDID}, gomock.Any(),
pe.PresentationDefinition{Id: "org_pd"}, gomock.Any(), gomock.Any()).Return(organizationVP, &pe.PresentationSubmission{}, nil)

_, err = ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp)

require.ErrorIs(t, err, ErrPreconditionFailed)
assert.ErrorContains(t, err, "did method mismatch")
})

t.Run("rejects the request when the SP wallet lacks credentials matching the service_provider PD", func(t *testing.T) {
// VP1 succeeds; the SP wallet has DIDs but its BuildSubmission cannot satisfy sp_pd, so it
// returns pe.ErrNoCredentials. The error must propagate to the caller unchanged so the API
// layer can map it to a 412 Precondition Failed.
sp := spSubjectID
hcpDID := did.MustParseDID("did:test:hcp")
spDID := did.MustParseDID("did:test:sp")
organizationVP, err := vc.ParseVerifiablePresentation(`{"holder":"did:test:hcp","proof":[{"verificationMethod":"did:test:hcp#1"}]}`)
require.NoError(t, err)

ctx := createClientServerTestContext(t)
enableJwtBearerClient(t, ctx)
ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JwtBearerGrantType}
ctx.policyBackend.EXPECT().FindCredentialProfile(gomock.Any(), scopes).Return(&policy.CredentialProfileMatch{
CredentialProfileScope: "first",
WalletOwnerMapping: pe.WalletOwnerMapping{
pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "org_pd"},
pe.WalletOwnerServiceProvider: pe.PresentationDefinition{Id: "sp_pd"},
},
}, nil)
ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{hcpDID}, nil)
ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), spSubjectID).Return([]did.DID{spDID}, nil)
ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{hcpDID}, gomock.Any(),
pe.PresentationDefinition{Id: "org_pd"}, gomock.Any(), gomock.Any()).Return(organizationVP, &pe.PresentationSubmission{}, nil)
ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{spDID}, gomock.Any(),
pe.PresentationDefinition{Id: "sp_pd"}, gomock.Any(), gomock.Any()).Return(nil, nil, pe.ErrNoCredentials)

_, err = ctx.client.RequestServiceAccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil, &sp)

assert.ErrorIs(t, err, pe.ErrNoCredentials)
})

t.Run("posts a jwt-bearer form body on the happy path", func(t *testing.T) {
sp := spSubjectID
hcpDID := did.MustParseDID("did:test:hcp")
Expand Down
Loading