Skip to content

Commit b8717ff

Browse files
authored
Merge pull request #198 from Sphereon-Opensource/feature/SSISDK-58_host-nonce-endpoint
feature/SSISDK-58_host-nonce-endpoint
2 parents 5a451f0 + 70c9306 commit b8717ff

6 files changed

Lines changed: 338 additions & 75 deletions

File tree

packages/issuer-rest/lib/OID4VCIServer.ts

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
getCredentialOfferEndpoint,
2828
getCredentialOfferReferenceEndpoint,
2929
getIssueStatusEndpoint,
30-
getMetadataEndpoints,
30+
getMetadataEndpoints, nonceEndpoint,
3131
pushedAuthorizationEndpoint
3232
} from './oid4vci-api-functions'
3333

@@ -38,7 +38,7 @@ function buildVCIFromEnvironment() {
3838
.withFormat(process.env.credential_supported_format as unknown as OID4VCICredentialFormat)
3939
.withCredentialName(process.env.credential_supported_name_1 as string)
4040
.withCredentialDefinition({
41-
type: [process.env.credential_supported_1_definition_type_1 as string, process.env.credential_supported_1_definition_type_2 as string],
41+
type: [process.env.credential_supported_1_definition_type_1 as string, process.env.credential_supported_1_definition_type_2 as string]
4242
// TODO: setup credentialSubject here from env
4343
// credentialSubject
4444
})
@@ -47,20 +47,24 @@ function buildVCIFromEnvironment() {
4747
locale: process.env.credential_display_locale as string,
4848
logo: {
4949
url: process.env.credential_display_logo_url as string,
50-
alt_text: process.env.credential_display_logo_alt_text as string,
50+
alt_text: process.env.credential_display_logo_alt_text as string
5151
},
5252
background_color: process.env.credential_display_background_color as string,
53-
text_color: process.env.credential_display_text_color as string,
53+
text_color: process.env.credential_display_text_color as string
5454
})
5555
.build()
5656
const issuerBuilder = new VcIssuerBuilder()
57-
.withTXCode({ length: process.env.user_pin_length as unknown as number, input_mode: process.env.user_pin_input_mode as 'numeric' | 'text' })
57+
.withTXCode({
58+
length: process.env.user_pin_length as unknown as number,
59+
input_mode: process.env.user_pin_input_mode as 'numeric' | 'text'
60+
})
5861
.withAuthorizationServers(process.env.authorization_server as string)
5962
.withCredentialEndpoint(process.env.credential_endpoint as string)
63+
.withNonceEndpoint(process.env.nonce_endpoint as string)
6064
.withCredentialIssuer(process.env.credential_issuer as string)
6165
.withIssuerDisplay({
6266
name: process.env.issuer_name as string,
63-
locale: process.env.issuer_locale as string,
67+
locale: process.env.issuer_locale as string
6468
})
6569
.withCredentialConfigurationsSupported(credentialsSupported)
6670
.withInMemoryCredentialOfferState()
@@ -73,7 +77,7 @@ function buildVCIFromEnvironment() {
7377
issuerBuilder.withASClientMetadataParams({
7478
client_id: process.env.authorization_server_client_id,
7579
client_secret: process.env.authorization_server_client_secret,
76-
redirect_uris: [process.env.authorization_server_redirect_uri],
80+
redirect_uris: [process.env.authorization_server_redirect_uri]
7781
})
7882
}
7983

@@ -133,6 +137,11 @@ export interface IOID4VCIEndpointOpts {
133137
getIssuePayloadOpts?: IGetIssuePayloadEndpointOpts
134138
parOpts?: ISingleEndpointOpts
135139
authorizationChallengeOpts?: IAuthorizationChallengeEndpointOpts
140+
nonceOpts?: INonceEndpointOpts
141+
}
142+
143+
export interface INonceEndpointOpts extends ISingleEndpointOpts {
144+
baseUrl: string | URL
136145
}
137146

138147
export interface IOID4VCIServerOpts extends HasEndpointOpts {
@@ -153,7 +162,9 @@ export class OID4VCIServer {
153162

154163
constructor(
155164
expressSupport: ExpressSupport,
156-
opts: IOID4VCIServerOpts & { issuer?: VcIssuer } /*If not supplied as argument, it will be fully configured from environment variables*/,
165+
opts: IOID4VCIServerOpts & {
166+
issuer?: VcIssuer
167+
} /*If not supplied as argument, it will be fully configured from environment variables*/
157168
) {
158169
this._baseUrl = new URL(opts?.baseUrl ?? process.env.BASE_URL ?? opts?.issuer?.issuerMetadata?.credential_issuer ?? 'http://localhost')
159170
this._expressSupport = expressSupport
@@ -169,7 +180,7 @@ export class OID4VCIServer {
169180
if (this.isGetIssuePayloadEndpointEnabled(opts?.endpointOpts?.getIssuePayloadOpts)) {
170181
issuerPayloadPath = getCredentialOfferReferenceEndpoint(this.router, this.issuer, {
171182
...opts?.endpointOpts?.getIssuePayloadOpts,
172-
baseUrl: this.baseUrl,
183+
baseUrl: this.baseUrl
173184
})
174185
}
175186

@@ -185,11 +196,11 @@ export class OID4VCIServer {
185196
opts.endpointOpts?.tokenEndpointOpts?.accessTokenVerificationCallback ??
186197
(this._asClientOpts
187198
? oidcAccessTokenVerifyCallback({
188-
clientMetadata: this._asClientOpts,
189-
credentialIssuer: this._issuer.issuerMetadata.credential_issuer,
190-
authorizationServer: this._issuer.issuerMetadata.authorization_servers![0],
191-
})
192-
: undefined),
199+
clientMetadata: this._asClientOpts,
200+
credentialIssuer: this._issuer.issuerMetadata.credential_issuer,
201+
authorizationServer: this._issuer.issuerMetadata.authorization_servers![0]
202+
})
203+
: undefined)
193204
})
194205
this.assertAccessTokenHandling()
195206
if (!this.isTokenEndpointDisabled(opts?.endpointOpts?.tokenEndpointOpts, opts?.asClientOpts)) {
@@ -204,7 +215,17 @@ export class OID4VCIServer {
204215
} else if (!opts?.endpointOpts?.authorizationChallengeOpts?.verifyAuthResponseCallback) {
205216
throw Error(`Unable to enable authorization challenge endpoint. No verifyAuthResponseCallback present in authorization challenge options`)
206217
}
207-
authorizationChallengeEndpoint(this.router, this.issuer, { ...opts?.endpointOpts?.authorizationChallengeOpts, baseUrl: this.baseUrl })
218+
authorizationChallengeEndpoint(this.router, this.issuer, {
219+
...opts?.endpointOpts?.authorizationChallengeOpts,
220+
baseUrl: this.baseUrl
221+
})
222+
}
223+
224+
if (this.isNonceEndpointEnabled(opts?.endpointOpts?.nonceOpts)) {
225+
nonceEndpoint(this.router, this.issuer, {
226+
...opts?.endpointOpts?.nonceOpts,
227+
baseUrl: this.baseUrl,
228+
})
208229
}
209230
this._app.use(getBasePath(this.baseUrl), this._router)
210231
}
@@ -253,7 +274,7 @@ export class OID4VCIServer {
253274
if (this.isTokenEndpointDisabled(tokenEndpointOpts, this.issuer.asClientOpts)) {
254275
if (!authServer || authServer.length === 0) {
255276
throw Error(
256-
`No Authorization Server (AS) is defined in the issuer metadata and the token endpoint is disabled. An AS or token endpoints needs to be present`,
277+
`No Authorization Server (AS) is defined in the issuer metadata and the token endpoint is disabled. An AS or token endpoints needs to be present`
257278
)
258279
}
259280
if (this.issuer.asClientOpts) {
@@ -264,13 +285,18 @@ export class OID4VCIServer {
264285
} else {
265286
if (authServer && authServer.some((as) => as !== this.issuer.issuerMetadata.credential_issuer)) {
266287
throw Error(
267-
`An external Authorization Server (AS) was already enabled in the issuer metadata (${authServer}). Cannot both have an AS and enable the token endpoint at the same time `,
288+
`An external Authorization Server (AS) was already enabled in the issuer metadata (${authServer}). Cannot both have an AS and enable the token endpoint at the same time `
268289
)
269290
} else if (this._asClientOpts) {
270291
throw Error(`OIDC Client metadata is set, but the token endpoint is not disabled. This is not supported.`)
271292
}
272293
}
273294
}
295+
296+
private isNonceEndpointEnabled(nonceEndpointOpts?: INonceEndpointOpts) {
297+
return nonceEndpointOpts?.enabled !== false || process.env.NONCE_ENDPOINT_ENABLED !== 'false'
298+
}
299+
274300
get baseUrl(): URL {
275301
return this._baseUrl
276302
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { CNonceState, CredentialIssuerMetadataOptsV1_0_15 } from '@sphereon/oid4vci-common'
2+
import { AuthorizationServerMetadataBuilder, MemoryStates, VcIssuer } from '@sphereon/oid4vci-issuer'
3+
import { ExpressBuilder, ExpressSupport } from '@sphereon/ssi-express-support'
4+
import { Express } from 'express'
5+
import requests from 'supertest'
6+
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
7+
8+
import { OID4VCIServer } from '../OID4VCIServer'
9+
10+
const authorizationServerMetadata = new AuthorizationServerMetadataBuilder()
11+
.withIssuer('test-issuer')
12+
.withNonceEndpoint('http://localhost:9002/nonce')
13+
.withCredentialEndpoint('http://localhost:9002/credential-endpoint')
14+
.withTokenEndpoint('http://localhost:9002/token')
15+
.withResponseTypesSupported(['code'])
16+
.build()
17+
18+
describe('Nonce Endpoint', () => {
19+
let app: Express
20+
let expressSupport: ExpressSupport
21+
let vcIssuer: VcIssuer
22+
23+
beforeAll(async () => {
24+
vcIssuer = new VcIssuer(
25+
{
26+
credential_endpoint: 'http://localhost:9002/credential-endpoint',
27+
nonce_endpoint: 'http://localhost:9002/nonce',
28+
credential_issuer: 'test_issuer',
29+
credential_configurations_supported: {
30+
TestCredential: {
31+
format: 'jwt_vc_json',
32+
credential_definition: {
33+
type: ['VerifiableCredential']
34+
},
35+
cryptographic_binding_methods_supported: ['did'],
36+
credential_signing_alg_values_supported: ['ES256K']
37+
}
38+
}
39+
} as CredentialIssuerMetadataOptsV1_0_15,
40+
authorizationServerMetadata,
41+
{
42+
cNonceExpiresIn: 300,
43+
credentialOfferSessions: new MemoryStates(),
44+
cNonces: new MemoryStates<CNonceState>()
45+
}
46+
)
47+
48+
expressSupport = ExpressBuilder.fromServerOpts({
49+
startListening: false,
50+
port: 9002,
51+
hostname: '0.0.0.0'
52+
}).build({ startListening: false })
53+
54+
const vcIssuerServer = new OID4VCIServer(expressSupport, {
55+
issuer: vcIssuer,
56+
baseUrl: 'http://localhost:9002',
57+
endpointOpts: {
58+
tokenEndpointOpts: {
59+
tokenEndpointDisabled: true
60+
},
61+
nonceOpts: {
62+
enabled: true,
63+
baseUrl: 'http://localhost:9002'
64+
}
65+
}
66+
})
67+
68+
expressSupport.start()
69+
app = vcIssuerServer.app
70+
})
71+
72+
afterAll(async () => {
73+
if (expressSupport) {
74+
await expressSupport.stop()
75+
}
76+
await new Promise((resolve) => setTimeout((v: void) => resolve(v), 500))
77+
})
78+
79+
it('should return fresh c_nonce without authorization', async () => {
80+
const res = await requests(app).post('/nonce').send()
81+
82+
expect(res.statusCode).toEqual(200)
83+
const actual = JSON.parse(res.text)
84+
expect(actual).toEqual({
85+
c_nonce: expect.any(String),
86+
c_nonce_expires_in: 300
87+
})
88+
expect(actual.c_nonce).toMatch(/^[a-f0-9-]{36}$/) // UUID format
89+
})
90+
91+
it('should store nonce in issuer state', async () => {
92+
const res = await requests(app).post('/nonce').send()
93+
94+
expect(res.statusCode).toEqual(200)
95+
const { c_nonce } = JSON.parse(res.text)
96+
97+
const storedNonce = await vcIssuer.cNonces.get(c_nonce)
98+
expect(storedNonce).toBeDefined()
99+
expect(storedNonce?.cNonce).toEqual(c_nonce)
100+
expect(storedNonce?.createdAt).toBeTypeOf('number')
101+
expect(storedNonce?.expiresAt).toBeGreaterThan(Math.floor(Date.now() / 1000))
102+
})
103+
104+
it('should return error with invalid access token', async () => {
105+
const res = await requests(app)
106+
.post('/nonce')
107+
.set('Authorization', 'Bearer invalid-token')
108+
.send()
109+
110+
expect(res.statusCode).toEqual(400)
111+
const actual = JSON.parse(res.text)
112+
expect(actual).toEqual({
113+
error: 'invalid_token'
114+
})
115+
})
116+
117+
it('should work when nonce endpoint is disabled', async () => {
118+
const disabledVcIssuer = new VcIssuer(
119+
{
120+
credential_endpoint: 'http://localhost:9003/credential-endpoint',
121+
credential_issuer: 'test_issuer',
122+
credential_configurations_supported: {}
123+
} as CredentialIssuerMetadataOptsV1_0_15,
124+
new AuthorizationServerMetadataBuilder()
125+
.withIssuer('test')
126+
.withResponseTypesSupported(['code'])
127+
.build(),
128+
{
129+
credentialOfferSessions: new MemoryStates(),
130+
cNonces: new MemoryStates<CNonceState>()
131+
}
132+
)
133+
134+
const disabledExpressSupport = ExpressBuilder.fromServerOpts({
135+
startListening: false,
136+
port: 9003
137+
}).build({ startListening: false })
138+
139+
new OID4VCIServer(disabledExpressSupport, {
140+
issuer: disabledVcIssuer,
141+
baseUrl: 'http://localhost:9003',
142+
endpointOpts: {
143+
tokenEndpointOpts: {
144+
tokenEndpointDisabled: true
145+
},
146+
nonceOpts: {
147+
enabled: false,
148+
baseUrl: 'http://localhost:9003'
149+
}
150+
}
151+
})
152+
153+
const res = await requests(disabledExpressSupport.express).post('/nonce').send()
154+
expect(res.statusCode).toEqual(404)
155+
156+
await disabledExpressSupport.stop()
157+
})
158+
})

0 commit comments

Comments
 (0)