diff --git a/packages/client/lib/MetadataClient.ts b/packages/client/lib/MetadataClient.ts index e76f0c0b..75c49b06 100644 --- a/packages/client/lib/MetadataClient.ts +++ b/packages/client/lib/MetadataClient.ts @@ -70,6 +70,7 @@ export class MetadataClient { let credential_endpoint: string | undefined; let deferred_credential_endpoint: string | undefined; let authorization_endpoint: string | undefined; + let authorization_challenge_endpoint: string | undefined; let authorizationServerType: AuthorizationServerType = 'OID4VCI'; let authorization_servers: string[] | undefined = [issuer]; let authorization_server: string | undefined = undefined; @@ -130,6 +131,14 @@ export class MetadataClient { ); } authorization_endpoint = authMetadata.authorization_endpoint; + if (!authMetadata.authorization_challenge_endpoint) { + throw Error(`Authorization Sever ${authorization_challenge_endpoint} did not provide a authorization_challenge_endpoint`); + } else if (authorization_challenge_endpoint && authMetadata.authorization_challenge_endpoint !== authorization_challenge_endpoint) { + throw Error( + `Credential issuer has a different authorization_challenge_endpoint (${authorization_challenge_endpoint}) from the Authorization Server (${authMetadata.authorization_challenge_endpoint})`, + ); + } + authorization_challenge_endpoint = authMetadata.authorization_challenge_endpoint; if (!authMetadata.token_endpoint) { throw Error(`Authorization Sever ${authorization_servers} did not provide a token_endpoint`); } else if (token_endpoint && authMetadata.token_endpoint !== token_endpoint) { @@ -193,6 +202,7 @@ export class MetadataClient { deferred_credential_endpoint, ...(authorization_server ? { authorization_server } : { authorization_servers: authorization_servers }), authorization_endpoint, + authorization_challenge_endpoint, authorizationServerType, credentialIssuerMetadata: authorization_server ? (credentialIssuerMetadata as IssuerMetadataV1_0_08 & Partial) diff --git a/packages/client/lib/MetadataClientV1_0_11.ts b/packages/client/lib/MetadataClientV1_0_11.ts index ebe9a78e..c0aad97b 100644 --- a/packages/client/lib/MetadataClientV1_0_11.ts +++ b/packages/client/lib/MetadataClientV1_0_11.ts @@ -50,6 +50,7 @@ export class MetadataClientV1_0_11 { let credential_endpoint: string | undefined; let deferred_credential_endpoint: string | undefined; let authorization_endpoint: string | undefined; + let authorization_challenge_endpoint: string | undefined; let authorizationServerType: AuthorizationServerType = 'OID4VCI'; let authorization_server: string = issuer; const oid4vciResponse = await MetadataClientV1_0_11.retrieveOpenID4VCIServerMetadata(issuer, { errorOnNotFound: false }); // We will handle errors later, given we will also try other metadata locations @@ -105,6 +106,14 @@ export class MetadataClientV1_0_11 { ); } authorization_endpoint = authMetadata.authorization_endpoint; + if (!authMetadata.authorization_challenge_endpoint) { + throw Error(`Authorization Sever ${authorization_challenge_endpoint} did not provide a authorization_challenge_endpoint`); + } else if (authorization_challenge_endpoint && authMetadata.authorization_challenge_endpoint !== authorization_challenge_endpoint) { + throw Error( + `Credential issuer has a different authorization_challenge_endpoint (${authorization_challenge_endpoint}) from the Authorization Server (${authMetadata.authorization_challenge_endpoint})`, + ); + } + authorization_challenge_endpoint = authMetadata.authorization_challenge_endpoint; if (!authMetadata.token_endpoint) { throw Error(`Authorization Sever ${authorization_server} did not provide a token_endpoint`); } else if (token_endpoint && authMetadata.token_endpoint !== token_endpoint) { @@ -165,6 +174,7 @@ export class MetadataClientV1_0_11 { deferred_credential_endpoint, authorization_server, authorization_endpoint, + authorization_challenge_endpoint, authorizationServerType, credentialIssuerMetadata: credentialIssuerMetadata as unknown as Partial & IssuerMetadataV1_0_08, authorizationServerMetadata: authMetadata, diff --git a/packages/client/lib/MetadataClientV1_0_13.ts b/packages/client/lib/MetadataClientV1_0_13.ts index 6318e6ec..849c572b 100644 --- a/packages/client/lib/MetadataClientV1_0_13.ts +++ b/packages/client/lib/MetadataClientV1_0_13.ts @@ -50,6 +50,7 @@ export class MetadataClientV1_0_13 { let credential_endpoint: string | undefined; let deferred_credential_endpoint: string | undefined; let authorization_endpoint: string | undefined; + let authorization_challenge_endpoint: string | undefined; let authorizationServerType: AuthorizationServerType = 'OID4VCI'; let authorization_servers: string[] = [issuer]; const oid4vciResponse = await MetadataClientV1_0_13.retrieveOpenID4VCIServerMetadata(issuer, { errorOnNotFound: false }); // We will handle errors later, given we will also try other metadata locations @@ -104,6 +105,14 @@ export class MetadataClientV1_0_13 { ); } authorization_endpoint = authMetadata.authorization_endpoint; + if (!authMetadata.authorization_challenge_endpoint) { + throw Error(`Authorization Sever ${authorization_challenge_endpoint} did not provide a authorization_challenge_endpoint`); + } else if (authorization_challenge_endpoint && authMetadata.authorization_challenge_endpoint !== authorization_challenge_endpoint) { + throw Error( + `Credential issuer has a different authorization_challenge_endpoint (${authorization_challenge_endpoint}) from the Authorization Server (${authMetadata.authorization_challenge_endpoint})`, + ); + } + authorization_challenge_endpoint = authMetadata.authorization_challenge_endpoint; if (!authMetadata.token_endpoint) { throw Error(`Authorization Sever ${authorization_servers} did not provide a token_endpoint`); } else if (token_endpoint && authMetadata.token_endpoint !== token_endpoint) { @@ -164,6 +173,7 @@ export class MetadataClientV1_0_13 { deferred_credential_endpoint, authorization_server: authorization_servers[0], authorization_endpoint, + authorization_challenge_endpoint, authorizationServerType, credentialIssuerMetadata: credentialIssuerMetadata, authorizationServerMetadata: authMetadata, diff --git a/packages/issuer-rest/lib/__tests__/ClientIssuerIT.spec.ts b/packages/issuer-rest/lib/__tests__/ClientIssuerIT.spec.ts index b209e27b..48c92440 100644 --- a/packages/issuer-rest/lib/__tests__/ClientIssuerIT.spec.ts +++ b/packages/issuer-rest/lib/__tests__/ClientIssuerIT.spec.ts @@ -60,6 +60,7 @@ describe('VcIssuer', () => { .withCredentialEndpoint('http://localhost:3456/test/credential-endpoint') .withTokenEndpoint('http://localhost:3456/test/token') .withAuthorizationEndpoint('https://token-endpoint.example.com/authorize') + .withAuthorizationChallengeEndpoint('http://localhost:3456/test/authorize-challenge') .withTokenEndpointAuthMethodsSupported(['none', 'client_secret_basic', 'client_secret_jwt', 'client_secret_post']) .withResponseTypesSupported(['code', 'token', 'id_token']) .withScopesSupported(['openid', 'abcdef']) @@ -266,6 +267,7 @@ describe('VcIssuer', () => { it('should retrieve server metadata', async () => { await expect(client.retrieveServerMetadata()).resolves.toEqual({ authorizationServerMetadata: { + authorization_challenge_endpoint: 'http://localhost:3456/test/authorize-challenge', authorization_endpoint: 'https://token-endpoint.example.com/authorize', credential_endpoint: 'http://localhost:3456/test/credential-endpoint', issuer: 'http://localhost:3456/test', @@ -275,6 +277,7 @@ describe('VcIssuer', () => { token_endpoint_auth_methods_supported: ['none', 'client_secret_basic', 'client_secret_jwt', 'client_secret_post'], }, authorizationServerType: 'OID4VCI', + authorization_challenge_endpoint: 'http://localhost:3456/test/authorize-challenge', authorization_endpoint: 'https://token-endpoint.example.com/authorize', deferred_credential_endpoint: undefined, authorization_server: 'http://localhost:3456/test', @@ -316,6 +319,7 @@ describe('VcIssuer', () => { token_endpoint: 'http://localhost:3456/test/token', }) }) + it('should get state on server side', async () => { const preAuthCode = client.credentialOffer!.credential_offer.grants?.[PRE_AUTH_GRANT_LITERAL]?.[PRE_AUTH_CODE_LITERAL] expect(preAuthCode).toBeDefined() @@ -382,4 +386,5 @@ describe('VcIssuer', () => { format: 'jwt_vc_json', }) }) + }) diff --git a/packages/issuer-rest/lib/__tests__/authorizationChallengeCodeServer.spec.ts b/packages/issuer-rest/lib/__tests__/authorizationChallengeCodeServer.spec.ts new file mode 100644 index 00000000..f3b39d0b --- /dev/null +++ b/packages/issuer-rest/lib/__tests__/authorizationChallengeCodeServer.spec.ts @@ -0,0 +1,193 @@ +import { uuidv4 } from '@sphereon/oid4vc-common' +import { + AuthorizationChallengeError, + CNonceState, + CredentialIssuerMetadataOptsV1_0_13, + CredentialOfferSession, + IssueStatus +} from '@sphereon/oid4vci-common' +import { VcIssuer } from '@sphereon/oid4vci-issuer' +import { AuthorizationServerMetadataBuilder } from '@sphereon/oid4vci-issuer' +import { MemoryStates } from '@sphereon/oid4vci-issuer/dist/state-manager' +import { ExpressBuilder, ExpressSupport } from '@sphereon/ssi-express-support' +import { DIDDocument } from 'did-resolver' +import { Express } from 'express' +import requests from 'supertest' + +import { OID4VCIServer } from '../OID4VCIServer' + +const authorizationServerMetadata = new AuthorizationServerMetadataBuilder() + .withIssuer('test-issuer') + .withAuthorizationChallengeEndpoint('http://localhost:3456/test/authorize-challenge') + .withResponseTypesSupported(['code', 'token', 'id_token']) + .build() + +describe('OID4VCIServer', () => { + let app: Express + let expressSupport: ExpressSupport + const sessionId = 'c1413695-8744-4369-845b-c2bd0ee8d5e4' + + beforeAll(async () => { + const credentialOfferState1: CredentialOfferSession = { + // preAuthorizedCode: preAuthorizedCode1, + txCode: '493536', + notification_id: uuidv4(), + createdAt: +new Date(), + lastUpdatedAt: +new Date(), + status: IssueStatus.OFFER_CREATED, + credentialOffer: { + credential_offer: { + credential_issuer: 'test_issuer', + credentials: [ + { + format: 'ldp_vc', + credential_definition: { + '@context': ['test_context'], + types: ['VerifiableCredential'], + credentialSubject: {}, + }, + }, + ], + + // grants: { + // 'urn:ietf:params:oauth:grant-type:pre-authorized_code': { + // tx_code: { + // length: 6, + // input_mode: 'numeric', + // description: 'Please enter the 6 digit code you received on your phone', + // }, + // //'pre-authorized_code': preAuthorizedCode1, + // }, + // }, + }, + }, + } + const credentialOfferSessions = new MemoryStates() + await credentialOfferSessions.set(sessionId, credentialOfferState1) + + const vcIssuer: VcIssuer = new VcIssuer( + { + credential_endpoint: 'http://localhost:9000', + authorization_challenge_endpoint: 'http://localhost:9000/authorize-challenge', + } as CredentialIssuerMetadataOptsV1_0_13, + authorizationServerMetadata, + { + cNonceExpiresIn: 300, + credentialOfferSessions, + cNonces: new MemoryStates(), + }, + ) + + expressSupport = ExpressBuilder.fromServerOpts({ + startListening: false, + port: 9000, + hostname: '0.0.0.0', + }).build({ startListening: false }) + const vcIssuerServer = new OID4VCIServer(expressSupport, { + issuer: vcIssuer, + baseUrl: 'http://localhost:9000', + endpointOpts: { + tokenEndpointOpts: { + tokenEndpointDisabled: true + }, + authorizationChallengeOpts: { + enabled: true, + verifyAuthResponseCallback: async () => true, + createAuthRequestUriCallback: async () => '/authorize?client_id=..&request_uri=https://rp.example.com/oidc/request/1234' + } + }, + }) + expressSupport.start() + app = vcIssuerServer.app + }) + + afterAll(async () => { + if (expressSupport) { + await expressSupport.stop() + } + await new Promise((resolve) => setTimeout((v: void) => resolve(v), 500)) + }) + + it('should return http code 400 with error invalid_request', async () => { + const res = await requests(app) + .post('/authorize-challenge') + .send(`client_id=${uuidv4()}`) + expect(res.statusCode).toEqual(400) + const actual = JSON.parse(res.text) + expect(actual).toEqual({ + error: AuthorizationChallengeError.invalid_request + }) + }) + + it('should return http code 400 with message No client id or auth session present', async () => { + const res = await requests(app) + .post('/authorize-challenge') + .send() + expect(res.statusCode).toEqual(400) + const actual = JSON.parse(res.text) + expect(actual).toEqual({ + error: AuthorizationChallengeError.invalid_request, + error_description: 'No client id or auth session present' + }) + }) + + it('should return http code 400 with message Session is invalid with invalid issuer_state', async () => { + const res = await requests(app) + .post('/authorize-challenge') + .send(`client_id=${uuidv4()}&issuer_state=${uuidv4()}`) + expect(res.statusCode).toEqual(400) + const actual = JSON.parse(res.text) + expect(actual).toEqual({ + error: AuthorizationChallengeError.invalid_session, + error_description: 'Session is invalid' + }) + }) + + it('should return http code 400 with message No definition id present', async () => { + const res = await requests(app) + .post('/authorize-challenge') + .send(`client_id=${uuidv4()}&issuer_state=${sessionId}`) + expect(res.statusCode).toEqual(400) + const actual = JSON.parse(res.text) + expect(actual).toEqual({ + error: AuthorizationChallengeError.invalid_request, + error_description: 'No definition id present' + }) + }) + + it('should return http code 400 with error insufficient_authorization', async () => { + const res = await requests(app) + .post('/authorize-challenge') + .send(`client_id=${uuidv4()}&issuer_state=${sessionId}&definition_id=${'testValue'}`) + expect(res.statusCode).toEqual(400) + const actual = JSON.parse(res.text) + expect(actual).toEqual({ + error: AuthorizationChallengeError.insufficient_authorization, + auth_session: "c1413695-8744-4369-845b-c2bd0ee8d5e4", + presentation: "/authorize?client_id=..&request_uri=https://rp.example.com/oidc/request/1234" + }) + }) + + it('should return http code 400 with message Session is invalid with invalid auth_session', async () => { + const res = await requests(app) + .post('/authorize-challenge') + .send(`auth_session=${uuidv4()}&presentation_during_issuance_session=${uuidv4()}&definition_id=testDefinitionId`) + expect(res.statusCode).toEqual(400) + const actual = JSON.parse(res.text) + expect(actual).toEqual({ + error: AuthorizationChallengeError.invalid_session, + error_description: 'Session is invalid' + }) + }) + + it('should return http code 200 with authorization_code', async () => { + const res = await requests(app) + .post('/authorize-challenge') + .send(`auth_session=${sessionId}&presentation_during_issuance_session=${uuidv4()}&definition_id=testDefinitionId`) + expect(res.statusCode).toEqual(200) + const actual = JSON.parse(res.text) + expect(actual).toBeDefined() + expect(actual.authorization_code).toBeDefined() + }) + +}) diff --git a/packages/issuer-rest/lib/oid4vci-api-functions.ts b/packages/issuer-rest/lib/oid4vci-api-functions.ts index f99da1c0..1a9b757f 100644 --- a/packages/issuer-rest/lib/oid4vci-api-functions.ts +++ b/packages/issuer-rest/lib/oid4vci-api-functions.ts @@ -123,25 +123,28 @@ export function authorizationChallengeEndpoint( try { if (!client_id && !auth_session) { const authorizationChallengeErrorResponse: AuthorizationChallengeErrorResponse = { - error: AuthorizationChallengeError.invalid_request - } - return Promise.reject(authorizationChallengeErrorResponse) + error: AuthorizationChallengeError.invalid_request, + error_description: 'No client id or auth session present' + } as AuthorizationChallengeErrorResponse + throw authorizationChallengeErrorResponse } if (!auth_session && issuer_state) { const session = await issuer.credentialOfferSessions.get(issuer_state) if (!session) { const authorizationChallengeErrorResponse: AuthorizationChallengeErrorResponse = { - error: AuthorizationChallengeError.invalid_session + error: AuthorizationChallengeError.invalid_session, + error_description: 'Session is invalid' } - return Promise.reject(authorizationChallengeErrorResponse) + throw authorizationChallengeErrorResponse } if (!definition_id) { const authorizationChallengeErrorResponse: AuthorizationChallengeErrorResponse = { - error: AuthorizationChallengeError.invalid_request + error: AuthorizationChallengeError.invalid_request, + error_description: 'No definition id present' } - return Promise.reject(authorizationChallengeErrorResponse) + throw authorizationChallengeErrorResponse } const authRequestURI = await opts.createAuthRequestUriCallback(definition_id, issuer_state) @@ -150,16 +153,17 @@ export function authorizationChallengeEndpoint( auth_session: issuer_state, presentation: authRequestURI } - return Promise.reject(authorizationChallengeErrorResponse) + throw authorizationChallengeErrorResponse } if (auth_session && presentation_during_issuance_session && definition_id) { const session = await issuer.credentialOfferSessions.get(auth_session) if (!session) { const authorizationChallengeErrorResponse: AuthorizationChallengeErrorResponse = { - error: AuthorizationChallengeError.invalid_session + error: AuthorizationChallengeError.invalid_session, + error_description: 'Session is invalid' } - return Promise.reject(authorizationChallengeErrorResponse) + throw authorizationChallengeErrorResponse } const verifiedResponse = await opts.verifyAuthResponseCallback(definition_id, presentation_during_issuance_session) @@ -177,7 +181,7 @@ export function authorizationChallengeEndpoint( const authorizationChallengeErrorResponse: AuthorizationChallengeErrorResponse = { error: AuthorizationChallengeError.invalid_request } - return Promise.reject(authorizationChallengeErrorResponse) + throw authorizationChallengeErrorResponse } catch (e) { return sendErrorResponse( response, diff --git a/packages/oid4vci-common/lib/types/ServerMetadata.ts b/packages/oid4vci-common/lib/types/ServerMetadata.ts index 637ac50d..38d8cdaa 100644 --- a/packages/oid4vci-common/lib/types/ServerMetadata.ts +++ b/packages/oid4vci-common/lib/types/ServerMetadata.ts @@ -110,6 +110,7 @@ export interface AuthorizationServerMetadata extends DynamicRegistrationClientMe export const authorizationServerMetadataFieldNames: Array = [ 'issuer', 'authorization_endpoint', + 'authorization_challenge_endpoint', 'token_endpoint', 'jwks_uri', 'registration_endpoint',