diff --git a/packages/client/lib/OpenID4VCIClient.ts b/packages/client/lib/OpenID4VCIClient.ts index b94de01f..5b789f67 100644 --- a/packages/client/lib/OpenID4VCIClient.ts +++ b/packages/client/lib/OpenID4VCIClient.ts @@ -29,34 +29,21 @@ import { CredentialRequestClientBuilder } from './CredentialRequestClientBuilder import { MetadataClient } from './MetadataClient'; import { ProofOfPossessionBuilder } from './ProofOfPossessionBuilder'; import { convertJsonToURI, formPost } from './functions'; +import { createPKCEOpts } from './functions/AuthorizationUtil'; +import { AuthDetails, AuthRequestOpts, PARMode, PKCEOpts } from './types'; const debug = Debug('sphereon:oid4vci'); -interface AuthDetails { - type: 'openid_credential' | string; - locations?: string | string[]; - format: CredentialFormat | CredentialFormat[]; - - [s: string]: unknown; -} - -interface AuthRequestOpts { - codeChallenge: string; - codeChallengeMethod?: CodeChallengeMethod; - authorizationDetails?: AuthDetails | AuthDetails[]; - redirectUri: string; - scope?: string; -} - export class OpenID4VCIClient { private readonly _credentialOffer?: CredentialOfferRequestWithBaseUrl; - private _credentialIssuer: string; + private readonly _credentialIssuer: string; private _clientId?: string; private _kid: string | undefined; private _jwk: JWK | undefined; private _alg: Alg | string | undefined; private _endpointMetadata: EndpointMetadataResult | undefined; private _accessTokenResponse: AccessTokenResponse | undefined; + private _pkce: PKCEOpts = { disabled: false, codeChallengeMethod: CodeChallengeMethod.S256 }; private constructor({ credentialOffer, @@ -144,9 +131,13 @@ export class OpenID4VCIClient { return this.endpointMetadata; } - // todo: Unify this method with the par method - - public createAuthorizationRequestUrl({ codeChallengeMethod, codeChallenge, authorizationDetails, redirectUri, scope }: AuthRequestOpts): string { + public async createAuthorizationRequestUrl(opts: AuthRequestOpts): Promise { + const { redirectUri } = opts; + let { scope, authorizationDetails } = opts; + const parMode = this._endpointMetadata?.credentialIssuerMetadata?.require_pushed_authorization_requests + ? PARMode.REQUIRE + : opts.parMode ?? PARMode.AUTO; + this._pkce = createPKCEOpts({ ...this._pkce, ...opts.pkce }); // Scope and authorization_details can be used in the same authorization request // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rar-23#name-relationship-to-scope-param if (!scope && !authorizationDetails) { @@ -181,16 +172,19 @@ export class OpenID4VCIClient { if (!this._endpointMetadata?.authorization_endpoint) { throw Error('Server metadata does not contain authorization endpoint'); } + const parEndpoint = this._endpointMetadata.credentialIssuerMetadata?.pushed_authorization_request_endpoint; // add 'openid' scope if not present if (!scope?.includes('openid')) { scope = ['openid', scope].filter((s) => !!s).join(' '); } - const queryObj: { [key: string]: string } = { + let queryObj: { [key: string]: string } | PushedAuthorizationResponse = { response_type: ResponseType.AUTH_CODE, - code_challenge_method: codeChallengeMethod ?? CodeChallengeMethod.SHA256, - code_challenge: codeChallenge, + ...(!this._pkce.disabled && { + code_challenge_method: this._pkce.codeChallengeMethod ?? CodeChallengeMethod.S256, + code_challenge: this._pkce.codeChallenge, + }), authorization_details: JSON.stringify(this.handleAuthorizationDetails(authorizationDetails)), redirect_uri: redirectUri, scope: scope, @@ -203,76 +197,24 @@ export class OpenID4VCIClient { if (this.credentialOffer?.issuerState) { queryObj['issuer_state'] = this.credentialOffer.issuerState; } + if (!parEndpoint && parMode === PARMode.REQUIRE) { + throw Error(`PAR mode is set to required by Authorization Server does not support PAR!`); + } else if (parEndpoint && parMode !== PARMode.NEVER) { + const parResponse = await formPost(parEndpoint, new URLSearchParams(queryObj)); + if (parResponse.errorBody || !parResponse.successBody) { + throw Error(`PAR error`); + } + queryObj = { request_uri: parResponse.successBody.request_uri }; + } return convertJsonToURI(queryObj, { baseUrl: this._endpointMetadata.authorization_endpoint, - uriTypeProperties: ['redirect_uri', 'scope', 'authorization_details', 'issuer_state'], + uriTypeProperties: ['request_uri', 'redirect_uri', 'scope', 'authorization_details', 'issuer_state'], mode: JsonURIMode.X_FORM_WWW_URLENCODED, // We do not add the version here, as this always needs to be form encoded }); } - // todo: Unify this method with the create auth request url method - public async acquirePushedAuthorizationRequestURI({ - codeChallengeMethod, - codeChallenge, - authorizationDetails, - redirectUri, - scope, - }: AuthRequestOpts): Promise { - // Scope and authorization_details can be used in the same authorization request - // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rar-23#name-relationship-to-scope-param - if (!scope && !authorizationDetails) { - throw Error('Please provide a scope or authorization_details'); - } - - // Authorization servers supporting PAR SHOULD include the URL of their pushed authorization request endpoint in their authorization server metadata document - // Note that the presence of pushed_authorization_request_endpoint is sufficient for a client to determine that it may use the PAR flow. - // What happens if it doesn't ??? - // let parEndpoint: string - if ( - !this._endpointMetadata?.credentialIssuerMetadata || - !('pushed_authorization_request_endpoint' in this._endpointMetadata.credentialIssuerMetadata) || - typeof this._endpointMetadata.credentialIssuerMetadata.pushed_authorization_request_endpoint !== 'string' - ) { - throw Error('Server metadata does not contain pushed authorization request endpoint'); - } - const parEndpoint: string = this._endpointMetadata.credentialIssuerMetadata.pushed_authorization_request_endpoint; - - // add 'openid' scope if not present - if (!scope?.includes('openid')) { - scope = ['openid', scope].filter((s) => !!s).join(' '); - } - - const queryObj: { [key: string]: string } = { - response_type: ResponseType.AUTH_CODE, - code_challenge_method: codeChallengeMethod ?? CodeChallengeMethod.SHA256, - code_challenge: codeChallenge, - authorization_details: JSON.stringify(this.handleAuthorizationDetails(authorizationDetails)), - redirect_uri: redirectUri, - scope: scope, - }; - - if (this.clientId) { - queryObj['client_id'] = this.clientId; - } - - if (this.credentialOffer?.issuerState) { - queryObj['issuer_state'] = this.credentialOffer.issuerState; - } - - const response = await formPost(parEndpoint, new URLSearchParams(queryObj)); - - return convertJsonToURI( - { request_uri: response.successBody?.request_uri }, - { - baseUrl: this._endpointMetadata.credentialIssuerMetadata.authorization_endpoint, - uriTypeProperties: ['request_uri'], - mode: JsonURIMode.X_FORM_WWW_URLENCODED, - }, - ); - } - public handleAuthorizationDetails(authorizationDetails?: AuthDetails | AuthDetails[]): AuthDetails | AuthDetails[] | undefined { if (authorizationDetails) { if (Array.isArray(authorizationDetails)) { @@ -570,10 +512,12 @@ export class OpenID4VCIClient { public hasDeferredCredentialEndpoint(): boolean { return !!this.getAccessTokenEndpoint(); } + public getDeferredCredentialEndpoint(): string { this.assertIssuerData(); return this.endpointMetadata ? this.endpointMetadata.credential_endpoint : `${this.getIssuer()}/credential`; } + private assertIssuerData(): void { if (!this._credentialOffer && this.issuerSupportedFlowTypes().includes(AuthzFlowType.PRE_AUTHORIZED_CODE_FLOW)) { throw Error(`No issuance initiation or credential offer present`); diff --git a/packages/client/lib/__tests__/AccessTokenClient.spec.ts b/packages/client/lib/__tests__/AccessTokenClient.spec.ts index 045b2148..d0080667 100644 --- a/packages/client/lib/__tests__/AccessTokenClient.spec.ts +++ b/packages/client/lib/__tests__/AccessTokenClient.spec.ts @@ -1,4 +1,6 @@ import { AccessTokenRequest, AccessTokenResponse, GrantTypes, OpenIDResponse, WellKnownEndpoints } from '@sphereon/oid4vci-common'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore import nock from 'nock'; import { AccessTokenClient } from '../AccessTokenClient'; diff --git a/packages/client/lib/__tests__/CredentialRequestClient.spec.ts b/packages/client/lib/__tests__/CredentialRequestClient.spec.ts index de8d05e5..84dc9689 100644 --- a/packages/client/lib/__tests__/CredentialRequestClient.spec.ts +++ b/packages/client/lib/__tests__/CredentialRequestClient.spec.ts @@ -157,7 +157,7 @@ describe('Credential Request Client ', () => { }); describe('Credential Request Client with Walt.id ', () => { - beforeAll(() => { + beforeEach(() => { nock.cleanAll(); }); @@ -165,7 +165,7 @@ describe('Credential Request Client with Walt.id ', () => { nock.cleanAll(); }); it('should have correct metadata endpoints', async function () { - // nock.cleanAll(); + nock.cleanAll(); const WALT_IRR_URI = 'openid-initiate-issuance://?issuer=https%3A%2F%2Fjff.walt.id%2Fissuer-api%2Foidc%2F&credential_type=OpenBadgeCredential&pre-authorized_code=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhOTUyZjUxNi1jYWVmLTQ4YjMtODIxYy00OTRkYzgyNjljZjAiLCJwcmUtYXV0aG9yaXplZCI6dHJ1ZX0.YE5DlalcLC2ChGEg47CQDaN1gTxbaQqSclIVqsSAUHE&user_pin_required=false'; const credentialOffer = await CredentialOfferClient.fromURI(WALT_IRR_URI); @@ -184,6 +184,13 @@ describe('Credential Request Client with Walt.id ', () => { }); describe('Credential Request Client with different issuers ', () => { + beforeEach(() => { + nock.cleanAll(); + }); + + afterEach(() => { + nock.cleanAll(); + }); it('should create correct CredentialRequest for Spruce', async () => { const IRR_URI = 'openid-initiate-issuance://?issuer=https%3A%2F%2Fngi%2Doidc4vci%2Dtest%2Espruceid%2Exyz&credential_type=OpenBadgeCredential&pre-authorized_code=eyJhbGciOiJFUzI1NiJ9.eyJjcmVkZW50aWFsX3R5cGUiOlsiT3BlbkJhZGdlQ3JlZGVudGlhbCJdLCJleHAiOiIyMDIzLTA0LTIwVDA5OjA0OjM2WiIsIm5vbmNlIjoibWFibmVpT0VSZVB3V3BuRFFweEt3UnRsVVRFRlhGUEwifQ.qOZRPN8sTv_knhp7WaWte2-aDULaPZX--2i9unF6QDQNUllqDhvxgIHMDCYHCV8O2_Gj-T2x1J84fDMajE3asg&user_pin_required=false'; @@ -208,6 +215,7 @@ describe('Credential Request Client with different issuers ', () => { }); it('should create correct CredentialRequest for Walt', async () => { + nock.cleanAll(); const IRR_URI = 'openid-initiate-issuance://?issuer=https%3A%2F%2Fjff.walt.id%2Fissuer-api%2Fdefault%2Foidc%2F&credential_type=OpenBadgeCredential&pre-authorized_code=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIwMTc4OTNjYy04ZTY3LTQxNzItYWZlOS1lODcyYmYxNDBlNWMiLCJwcmUtYXV0aG9yaXplZCI6dHJ1ZX0.ODfq2AIhOcB61dAb3zMrXBJjPJaf53zkeHh_AssYyYA&user_pin_required=false'; const credentialOffer = await ( diff --git a/packages/client/lib/__tests__/EBSIE2E.spec.test.ts b/packages/client/lib/__tests__/EBSIE2E.spec.test.ts index 1d32a9dd..ca436d84 100644 --- a/packages/client/lib/__tests__/EBSIE2E.spec.test.ts +++ b/packages/client/lib/__tests__/EBSIE2E.spec.test.ts @@ -1,4 +1,4 @@ -import { Alg, CodeChallengeMethod, Jwt } from '@sphereon/oid4vci-common'; +import { Alg, Jwt } from '@sphereon/oid4vci-common'; import { toJwk } from '@sphereon/ssi-sdk-ext.key-utils'; import { CredentialMapper } from '@sphereon/ssi-types'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -68,10 +68,8 @@ describe('OID4VCI-Client using Sphereon issuer should', () => { expect(client.getAccessTokenEndpoint()).toEqual(`${AUTH_URL}/token`); if (credentialType !== 'CTWalletCrossPreAuthorisedInTime') { - const url = client.createAuthorizationRequestUrl({ + const url = await client.createAuthorizationRequestUrl({ redirectUri: 'openid4vc%3A', - codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', - codeChallengeMethod: CodeChallengeMethod.SHA256, }); const result = await fetch(url); console.log(result.text()); diff --git a/packages/client/lib/__tests__/OpenID4VCIClient.spec.ts b/packages/client/lib/__tests__/OpenID4VCIClient.spec.ts index 97a575db..7c5a6f89 100644 --- a/packages/client/lib/__tests__/OpenID4VCIClient.spec.ts +++ b/packages/client/lib/__tests__/OpenID4VCIClient.spec.ts @@ -24,13 +24,11 @@ describe('OpenID4VCIClient should', () => { nock.cleanAll(); }); - it('should create successfully construct an authorization request url', async () => { + it('should successfully construct an authorization request url', async () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore client._endpointMetadata?.credentialIssuerMetadata.authorization_endpoint = `${MOCK_URL}v1/auth/authorize`; - const url = client.createAuthorizationRequestUrl({ - codeChallengeMethod: CodeChallengeMethod.SHA256, - codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + const url = await client.createAuthorizationRequestUrl({ scope: 'openid TestCredential', redirectUri: 'http://localhost:8881/cb', }); @@ -41,23 +39,23 @@ describe('OpenID4VCIClient should', () => { expect(scope?.[0]).toBe('openid'); }); it('throw an error if authorization endpoint is not set in server metadata', async () => { - expect(() => { + await expect( client.createAuthorizationRequestUrl({ - codeChallengeMethod: CodeChallengeMethod.SHA256, - codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', scope: 'openid TestCredential', redirectUri: 'http://localhost:8881/cb', - }); - }).toThrow(Error('Server metadata does not contain authorization endpoint')); + }), + ).rejects.toThrow(Error('Server metadata does not contain authorization endpoint')); }); it("injects 'openid' as the first scope if not provided", async () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore client._endpointMetadata?.credentialIssuerMetadata.authorization_endpoint = `${MOCK_URL}v1/auth/authorize`; - const url = client.createAuthorizationRequestUrl({ - codeChallengeMethod: CodeChallengeMethod.SHA256, - codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + const url = await client.createAuthorizationRequestUrl({ + pkce: { + codeChallengeMethod: CodeChallengeMethod.S256, + codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + }, scope: 'TestCredential', redirectUri: 'http://localhost:8881/cb', }); @@ -77,13 +75,15 @@ describe('OpenID4VCIClient should', () => { // @ts-ignore client._endpointMetadata?.credentialIssuerMetadata.authorization_endpoint = `${MOCK_URL}v1/auth/authorize`; - expect(() => { + await expect( client.createAuthorizationRequestUrl({ - codeChallengeMethod: CodeChallengeMethod.SHA256, - codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + pkce: { + codeChallengeMethod: CodeChallengeMethod.S256, + codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + }, redirectUri: 'http://localhost:8881/cb', - }); - }).toThrow(Error('Please provide a scope or authorization_details')); + }), + ).rejects.toThrow(Error('Please provide a scope or authorization_details')); }); it('create an authorization request url with authorization_details array property', async () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -92,8 +92,10 @@ describe('OpenID4VCIClient should', () => { expect( client.createAuthorizationRequestUrl({ - codeChallengeMethod: CodeChallengeMethod.SHA256, - codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + pkce: { + codeChallengeMethod: CodeChallengeMethod.S256, + codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + }, authorizationDetails: [ { type: 'openid_credential', @@ -111,7 +113,7 @@ describe('OpenID4VCIClient should', () => { ], redirectUri: 'http://localhost:8881/cb', }), - ).toEqual( + ).resolves.toEqual( 'https://server.example.com/v1/auth/authorize?response_type=code&code_challenge_method=S256&code_challenge=mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs&authorization_details=%5B%7B%22type%22%3A%22openid_credential%22%2C%22format%22%3A%22ldp_vc%22%2C%22credential_definition%22%3A%7B%22%40context%22%3A%5B%22https%3A%2F%2Fwww%2Ew3%2Eorg%2F2018%2Fcredentials%2Fv1%22%2C%22https%3A%2F%2Fwww%2Ew3%2Eorg%2F2018%2Fcredentials%2Fexamples%2Fv1%22%5D%2C%22types%22%3A%5B%22VerifiableCredential%22%2C%22UniversityDegreeCredential%22%5D%7D%2C%22locations%22%3A%22https%3A%2F%2Fserver%2Eexample%2Ecom%22%7D%2C%7B%22type%22%3A%22openid_credential%22%2C%22format%22%3A%22mso_mdoc%22%2C%22doctype%22%3A%22org%2Eiso%2E18013%2E5%2E1%2EmDL%22%2C%22locations%22%3A%22https%3A%2F%2Fserver%2Eexample%2Ecom%22%7D%5D&redirect_uri=http%3A%2F%2Flocalhost%3A8881%2Fcb&scope=openid&client_id=test-client', ); }); @@ -122,8 +124,10 @@ describe('OpenID4VCIClient should', () => { expect( client.createAuthorizationRequestUrl({ - codeChallengeMethod: CodeChallengeMethod.SHA256, - codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + pkce: { + codeChallengeMethod: CodeChallengeMethod.S256, + codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + }, authorizationDetails: { type: 'openid_credential', format: 'ldp_vc', @@ -134,7 +138,7 @@ describe('OpenID4VCIClient should', () => { }, redirectUri: 'http://localhost:8881/cb', }), - ).toEqual( + ).resolves.toEqual( 'https://server.example.com/v1/auth/authorize?response_type=code&code_challenge_method=S256&code_challenge=mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs&authorization_details=%7B%22type%22%3A%22openid_credential%22%2C%22format%22%3A%22ldp_vc%22%2C%22credential_definition%22%3A%7B%22%40context%22%3A%5B%22https%3A%2F%2Fwww%2Ew3%2Eorg%2F2018%2Fcredentials%2Fv1%22%2C%22https%3A%2F%2Fwww%2Ew3%2Eorg%2F2018%2Fcredentials%2Fexamples%2Fv1%22%5D%2C%22types%22%3A%5B%22VerifiableCredential%22%2C%22UniversityDegreeCredential%22%5D%7D%2C%22locations%22%3A%22https%3A%2F%2Fserver%2Eexample%2Ecom%22%7D&redirect_uri=http%3A%2F%2Flocalhost%3A8881%2Fcb&scope=openid&client_id=test-client', ); }); @@ -145,8 +149,10 @@ describe('OpenID4VCIClient should', () => { expect( client.createAuthorizationRequestUrl({ - codeChallengeMethod: CodeChallengeMethod.SHA256, - codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + pkce: { + codeChallengeMethod: CodeChallengeMethod.S256, + codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + }, authorizationDetails: { type: 'openid_credential', format: 'ldp_vc', @@ -159,7 +165,7 @@ describe('OpenID4VCIClient should', () => { scope: 'openid', redirectUri: 'http://localhost:8881/cb', }), - ).toEqual( + ).resolves.toEqual( 'https://server.example.com/v1/auth/authorize?response_type=code&code_challenge_method=S256&code_challenge=mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs&authorization_details=%7B%22type%22%3A%22openid_credential%22%2C%22format%22%3A%22ldp_vc%22%2C%22locations%22%3A%5B%22https%3A%2F%2Ftest%2Ecom%22%2C%22https%3A%2F%2Fserver%2Eexample%2Ecom%22%5D%2C%22credential_definition%22%3A%7B%22%40context%22%3A%5B%22https%3A%2F%2Fwww%2Ew3%2Eorg%2F2018%2Fcredentials%2Fv1%22%2C%22https%3A%2F%2Fwww%2Ew3%2Eorg%2F2018%2Fcredentials%2Fexamples%2Fv1%22%5D%2C%22types%22%3A%5B%22VerifiableCredential%22%2C%22UniversityDegreeCredential%22%5D%7D%7D&redirect_uri=http%3A%2F%2Flocalhost%3A8881%2Fcb&scope=openid&client_id=test-client', ); }); diff --git a/packages/client/lib/__tests__/OpenID4VCIClientPAR.spec.ts b/packages/client/lib/__tests__/OpenID4VCIClientPAR.spec.ts index bad3da3c..68ad66fc 100644 --- a/packages/client/lib/__tests__/OpenID4VCIClientPAR.spec.ts +++ b/packages/client/lib/__tests__/OpenID4VCIClientPAR.spec.ts @@ -1,7 +1,10 @@ -import { CodeChallengeMethod, WellKnownEndpoints } from '@sphereon/oid4vci-common'; +import { WellKnownEndpoints } from '@sphereon/oid4vci-common'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore import nock from 'nock'; import { OpenID4VCIClient } from '../OpenID4VCIClient'; +import { PARMode } from '../types'; const MOCK_URL = 'https://server.example.com/'; describe('OpenID4VCIClient', () => { @@ -25,9 +28,8 @@ describe('OpenID4VCIClient', () => { it('should successfully retrieve the authorization code using PAR', async () => { client.endpointMetadata.credentialIssuerMetadata!.pushed_authorization_request_endpoint = `${MOCK_URL}v1/auth/par`; client.endpointMetadata.credentialIssuerMetadata!.authorization_endpoint = `${MOCK_URL}v1/auth/authorize`; - const actual = await client.acquirePushedAuthorizationRequestURI({ - codeChallengeMethod: CodeChallengeMethod.SHA256, - codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + const actual = await client.createAuthorizationRequestUrl({ + parMode: PARMode.REQUIRE, scope: 'openid TestCredential', redirectUri: 'http://localhost:8881/cb', }); @@ -35,32 +37,30 @@ describe('OpenID4VCIClient', () => { }); it('should fail when pushed_authorization_request_endpoint is not present', async () => { + client.endpointMetadata.credentialIssuerMetadata!.authorization_endpoint = `${MOCK_URL}v1/auth/authorize`; await expect(() => - client.acquirePushedAuthorizationRequestURI({ - codeChallengeMethod: CodeChallengeMethod.SHA256, - codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + client.createAuthorizationRequestUrl({ + parMode: PARMode.REQUIRE, scope: 'openid TestCredential', redirectUri: 'http://localhost:8881/cb', }), - ).rejects.toThrow(Error('Server metadata does not contain pushed authorization request endpoint')); + ).rejects.toThrow(Error('PAR mode is set to required by Authorization Server does not support PAR!')); }); it('should fail when authorization_details and scope are not present', async () => { await expect(() => - client.acquirePushedAuthorizationRequestURI({ - codeChallengeMethod: CodeChallengeMethod.SHA256, - codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + client.createAuthorizationRequestUrl({ + parMode: PARMode.REQUIRE, redirectUri: 'http://localhost:8881/cb', }), - ).rejects.toThrow(Error('Please provide a scope or authorization_details')); + ).rejects.toThrow(Error('Could not create authorization details from credential offer. Please pass in explicit details')); }); it('should not fail when only authorization_details is present', async () => { client.endpointMetadata.credentialIssuerMetadata!.pushed_authorization_request_endpoint = `${MOCK_URL}v1/auth/par`; client.endpointMetadata.credentialIssuerMetadata!.authorization_endpoint = `${MOCK_URL}v1/auth/authorize`; - const actual = await client.acquirePushedAuthorizationRequestURI({ - codeChallengeMethod: CodeChallengeMethod.SHA256, - codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + const actual = await client.createAuthorizationRequestUrl({ + parMode: PARMode.REQUIRE, authorizationDetails: [ { type: 'openid_credential', @@ -79,9 +79,8 @@ describe('OpenID4VCIClient', () => { it('should not fail when only scope is present', async () => { client.endpointMetadata.credentialIssuerMetadata!.pushed_authorization_request_endpoint = `${MOCK_URL}v1/auth/par`; client.endpointMetadata.credentialIssuerMetadata!.authorization_endpoint = `${MOCK_URL}v1/auth/authorize`; - const actual = await client.acquirePushedAuthorizationRequestURI({ - codeChallengeMethod: CodeChallengeMethod.SHA256, - codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + const actual = await client.createAuthorizationRequestUrl({ + parMode: PARMode.REQUIRE, scope: 'openid TestCredential', redirectUri: 'http://localhost:8881/cb', }); @@ -91,9 +90,8 @@ describe('OpenID4VCIClient', () => { it('should not fail when both authorization_details and scope are present', async () => { client.endpointMetadata.credentialIssuerMetadata!.pushed_authorization_request_endpoint = `${MOCK_URL}v1/auth/par`; client.endpointMetadata.credentialIssuerMetadata!.authorization_endpoint = `${MOCK_URL}v1/auth/authorize`; - const actual = await client.acquirePushedAuthorizationRequestURI({ - codeChallengeMethod: CodeChallengeMethod.SHA256, - codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + const actual = await client.createAuthorizationRequestUrl({ + parMode: PARMode.REQUIRE, authorizationDetails: [ { type: 'openid_credential', diff --git a/packages/client/lib/__tests__/SdJwt.spec.ts b/packages/client/lib/__tests__/SdJwt.spec.ts index 061532e9..bb02c364 100644 --- a/packages/client/lib/__tests__/SdJwt.spec.ts +++ b/packages/client/lib/__tests__/SdJwt.spec.ts @@ -1,4 +1,6 @@ import { AccessTokenRequest, CredentialRequestV1_0_11, CredentialSupportedSdJwtVc } from '@sphereon/oid4vci-common'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore import nock from 'nock'; import { OpenID4VCIClient } from '..'; diff --git a/packages/client/lib/types/index.ts b/packages/client/lib/types/index.ts new file mode 100644 index 00000000..f057ca15 --- /dev/null +++ b/packages/client/lib/types/index.ts @@ -0,0 +1,56 @@ +import { CodeChallengeMethod } from '@sphereon/oid4vci-common'; +import { CredentialFormat } from '@sphereon/ssi-types'; + +export interface AuthDetails { + type: 'openid_credential' | string; + locations?: string | string[]; + format: CredentialFormat | CredentialFormat[]; + + [s: string]: unknown; +} + +/** + * Determinse whether PAR should be used when supported + * + * REQUIRE: Require PAR, if AS does not support it throw an error + * AUTO: Use PAR is the AS supports it, otherwise construct a reqular URI, + * NEVER: Do not use PAR even if the AS supports it (not recommended) + */ +export enum PARMode { + REQUIRE, + AUTO, + NEVER, +} + +export interface AuthRequestOpts { + pkce?: PKCEOpts; + parMode?: PARMode; + authorizationDetails?: AuthDetails | AuthDetails[]; + redirectUri: string; + scope?: string; +} + +/** + * Optional options to provide PKCE params like code verifier and challenge yourself, or to disable PKCE altogether. If not provide PKCE will still be used! If individual params are not provide, they will be generated/calculated + */ +export interface PKCEOpts { + /** + * PKCE is enabled by default even if you do not provide these options. Set this to true to disable PKCE + */ + disabled?: boolean; + + /** + * Provide a code_challenge, otherwise it will be calculated using the code_verifier and method + */ + codeChallenge?: string; + + /** + * The code_challenge_method, should always by S256 + */ + codeChallengeMethod?: CodeChallengeMethod; + + /** + * Provide a code_verifier, otherwise it will be generated + */ + codeVerifier?: string; +} diff --git a/packages/common/lib/functions/index.ts b/packages/common/lib/functions/index.ts index 92b097fa..5d96c6b1 100644 --- a/packages/common/lib/functions/index.ts +++ b/packages/common/lib/functions/index.ts @@ -1,3 +1,4 @@ +import { randomBytes } from './randomBytes.js'; export * from './CredentialRequestUtil'; export * from './CredentialResponseUtil'; export * from './CredentialOfferUtil'; @@ -5,3 +6,5 @@ export * from './Encoding'; export * from './TypeConversionUtils'; export * from './IssuerMetadataUtils'; export * from './FormatUtils'; +export { randomBytes }; +export * from './RandomUtils'; diff --git a/packages/common/lib/types/Authorization.types.ts b/packages/common/lib/types/Authorization.types.ts index 7f5ed8a1..15640236 100644 --- a/packages/common/lib/types/Authorization.types.ts +++ b/packages/common/lib/types/Authorization.types.ts @@ -159,8 +159,8 @@ export enum ResponseType { } export enum CodeChallengeMethod { - TEXT = 'text', - SHA256 = 'S256', + plain = 'plain', + S256 = 'S256', } export interface AuthorizationServerOpts { diff --git a/packages/common/package.json b/packages/common/package.json index 5c988635..0f5df258 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -12,12 +12,23 @@ "dependencies": { "@sphereon/ssi-types": "^0.18.1", "cross-fetch": "^3.1.8", - "jwt-decode": "^3.1.2" + "jwt-decode": "^3.1.2", + "uint8arrays": "3.1.1", + "sha.js": "^2.4.11" }, "devDependencies": { "@types/jest": "^29.5.3", + "@types/sha.js": "^2.4.4", "typescript": "5.3.3" }, + "peerDependencies": { + "msrcrypto": "^1.5.8" + }, + "peerDependenciesMeta": { + "msrcrypto": { + "optional": true + } + }, "engines": { "node": ">=18" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fc41718..cee9f3b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -214,10 +214,22 @@ importers: jwt-decode: specifier: ^3.1.2 version: 3.1.2 + msrcrypto: + specifier: ^1.5.8 + version: 1.5.8 + sha.js: + specifier: ^2.4.11 + version: 2.4.11 + uint8arrays: + specifier: 3.1.1 + version: 3.1.1 devDependencies: '@types/jest': specifier: ^29.5.3 version: 29.5.5 + '@types/sha.js': + specifier: ^2.4.4 + version: 2.4.4 typescript: specifier: 5.3.3 version: 5.3.3 @@ -4085,6 +4097,12 @@ packages: '@types/node': 18.18.0 dev: true + /@types/sha.js@2.4.4: + resolution: {integrity: sha512-Qukd+D6S2Hm0wLVt2Vh+/eWBIoUt+wF8jWjBsG4F8EFQRwKtYvtXCPcNl2OEUQ1R+eTr3xuSaBYUyM3WD1x/Qw==} + dependencies: + '@types/node': 18.18.0 + dev: true + /@types/stack-utils@2.0.1: resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==} @@ -12165,7 +12183,6 @@ packages: dependencies: inherits: 2.0.4 safe-buffer: 5.2.1 - dev: true /shallow-clone@3.0.1: resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==}