From a2f74fb11f4ddd151748b3512ce909390263e239 Mon Sep 17 00:00:00 2001 From: Karim Stekelenburg Date: Mon, 30 Jan 2023 22:31:48 +0100 Subject: [PATCH 01/16] feat: add AuthorizationRequestClientBuilder Added a new builder for the authorization request and added the authorization_endpoint to the EndpointMetadata interface. Signed-off-by: Karim Stekelenburg --- lib/AuthorizationRequestClientBuilder.ts | 52 ++++++++++++++++++++++++ lib/types/Generic.types.ts | 1 + 2 files changed, 53 insertions(+) create mode 100644 lib/AuthorizationRequestClientBuilder.ts diff --git a/lib/AuthorizationRequestClientBuilder.ts b/lib/AuthorizationRequestClientBuilder.ts new file mode 100644 index 00000000..0ccc4023 --- /dev/null +++ b/lib/AuthorizationRequestClientBuilder.ts @@ -0,0 +1,52 @@ +import { EndpointMetadata } from "./types"; + + +export type CodeChallengeMethod = 'text' | 'S256'; + + +export class AuthorizationRequestClientBuilder { + + authorizationEndpoint: string; + clientId: string; + codeChallange: string; + codeChallengeMethod: CodeChallengeMethod; + authorizationDetails: Record // TODO: add typing for this + redirectUri?: string; + + public static fromMetadata(metadata: EndpointMetadata) { + const builder = new AuthorizationRequestClientBuilder(); + builder.withAuthorizationEndpointFromMetadata(metadata); + + } + + public withAuthorizationEndpointFromMetadata(metadata: EndpointMetadata): AuthorizationRequestClientBuilder { + this.authorizationEndpoint = metadata.authorization_endpoint; + return this; + } + + public withClientId(clientId: string): AuthorizationRequestClientBuilder { + this.clientId = clientId; + return this; + } + + public withCodeChallenge(codeChallenge: string): AuthorizationRequestClientBuilder { + this.codeChallange = codeChallenge; + return this; + } + + public withCodeChallengeMethod(codeChallengeMethod: CodeChallengeMethod): AuthorizationRequestClientBuilder { + this.codeChallengeMethod = codeChallengeMethod; + return this; + } + + public withAuthorizationDetails(authorizationDetails: Record): AuthorizationRequestClientBuilder { + this.authorizationDetails = authorizationDetails; + return this; + } + + public withRedirectUri(redirectUri: string): AuthorizationRequestClientBuilder { + this.redirectUri = redirectUri; + return this; + } + +} diff --git a/lib/types/Generic.types.ts b/lib/types/Generic.types.ts index 71ddbf17..d9bf87e1 100644 --- a/lib/types/Generic.types.ts +++ b/lib/types/Generic.types.ts @@ -19,5 +19,6 @@ export interface EndpointMetadata { issuer: string; token_endpoint: string; credential_endpoint: string; + authorization_endpoint: string; openid4vci_metadata?: OpenID4VCIServerMetadata; } From 57e08a2155e602e58e8a6f06ed9e858dd82bb762 Mon Sep 17 00:00:00 2001 From: Karim Stekelenburg Date: Fri, 10 Mar 2023 02:36:16 +0100 Subject: [PATCH 02/16] feat: update authorization types for auth flow Signed-off-by: Karim Stekelenburg --- lib/types/Authorization.types.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/types/Authorization.types.ts b/lib/types/Authorization.types.ts index 7385a2b0..d155241d 100644 --- a/lib/types/Authorization.types.ts +++ b/lib/types/Authorization.types.ts @@ -16,6 +16,11 @@ export enum ResponseType { AUTH_CODE = 'code', } +export enum CodeChallengeMethod { + TEXT = 'text', + SHA256 = 'S256', +} + export interface AuthorizationServerOpts { allowInsecureEndpoints?: boolean; as?: string; // If not provided the issuer hostname will be used! @@ -33,14 +38,27 @@ export interface AccessTokenRequestOpts { issuanceInitiation: IssuanceInitiationWithBaseUrl; asOpts?: AuthorizationServerOpts; metadata?: EndpointMetadata; + codeVerifier?: string; // only required for authorization flow + code?: string; // only required for authorization flow + redirectUri?: string; // only required for authorization flow pin?: string; // Pin-number. Only used when required } export interface AuthorizationRequest { response_type: ResponseType.AUTH_CODE; client_id: string; + code_challenge: string; + code_challenge_method: CodeChallengeMethod; redirect_uri: string; - scope?: string; + scope?: string[]; +} + +export interface AuthorizationRequestOpts { + clientId: string; + codeChallenge: string; + codeChallengeMethod: CodeChallengeMethod; + redirectUri: string; + scope?: string[]; } export interface AuthorizationGrantResponse { @@ -52,6 +70,7 @@ export interface AuthorizationGrantResponse { export interface AccessTokenRequest { client_id?: string; + code?: string; code_verifier?: string; grant_type: GrantTypes; 'pre-authorized_code': string; From f080c96d3393ed490aabdaf3e8c02846add94e1e Mon Sep 17 00:00:00 2001 From: Karim Stekelenburg Date: Fri, 10 Mar 2023 02:36:37 +0100 Subject: [PATCH 03/16] fix: make auth endpoint optional --- lib/types/Generic.types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/types/Generic.types.ts b/lib/types/Generic.types.ts index d9bf87e1..a34d0ce9 100644 --- a/lib/types/Generic.types.ts +++ b/lib/types/Generic.types.ts @@ -19,6 +19,6 @@ export interface EndpointMetadata { issuer: string; token_endpoint: string; credential_endpoint: string; - authorization_endpoint: string; + authorization_endpoint?: string; openid4vci_metadata?: OpenID4VCIServerMetadata; } From ff6079f1252cdbabadd9577890088bf719b053a9 Mon Sep 17 00:00:00 2001 From: Karim Stekelenburg Date: Fri, 10 Mar 2023 02:37:29 +0100 Subject: [PATCH 04/16] feat: add auth endpoint to server metadata Signed-off-by: Karim Stekelenburg --- lib/types/OpenID4VCIServerMetadata.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/types/OpenID4VCIServerMetadata.ts b/lib/types/OpenID4VCIServerMetadata.ts index 4b1b7699..7eafa9ac 100644 --- a/lib/types/OpenID4VCIServerMetadata.ts +++ b/lib/types/OpenID4VCIServerMetadata.ts @@ -9,6 +9,7 @@ export interface OpenID4VCIServerMetadata { credential_issuer?: CredentialIssuer; // A JSON object containing display properties for the Credential issuer. token_endpoint?: string; //NON-SPEC compliant, but used by several issuers. URL of the OP's Token Endpoint. This URL MUST use the https scheme and MAY contain port, path and query parameter components. authorization_server?: string; //NON-SPEC compliant, but used by some issuers. URL of the AS. This URL MUST use the https scheme and MAY contain port, path and query parameter components. + authorization_endpoint?: string; } export type Oauth2ASWithOID4VCIMetadata = OAuth2ASMetadata & OpenID4VCIServerMetadata; From b6d66aaaf1b85db0400a5952cedb70a7fdc30a0d Mon Sep 17 00:00:00 2001 From: Karim Stekelenburg Date: Fri, 10 Mar 2023 02:37:50 +0100 Subject: [PATCH 05/16] fix: remove auth builder --- lib/AuthorizationRequestClientBuilder.ts | 52 ------------------------ 1 file changed, 52 deletions(-) delete mode 100644 lib/AuthorizationRequestClientBuilder.ts diff --git a/lib/AuthorizationRequestClientBuilder.ts b/lib/AuthorizationRequestClientBuilder.ts deleted file mode 100644 index 0ccc4023..00000000 --- a/lib/AuthorizationRequestClientBuilder.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { EndpointMetadata } from "./types"; - - -export type CodeChallengeMethod = 'text' | 'S256'; - - -export class AuthorizationRequestClientBuilder { - - authorizationEndpoint: string; - clientId: string; - codeChallange: string; - codeChallengeMethod: CodeChallengeMethod; - authorizationDetails: Record // TODO: add typing for this - redirectUri?: string; - - public static fromMetadata(metadata: EndpointMetadata) { - const builder = new AuthorizationRequestClientBuilder(); - builder.withAuthorizationEndpointFromMetadata(metadata); - - } - - public withAuthorizationEndpointFromMetadata(metadata: EndpointMetadata): AuthorizationRequestClientBuilder { - this.authorizationEndpoint = metadata.authorization_endpoint; - return this; - } - - public withClientId(clientId: string): AuthorizationRequestClientBuilder { - this.clientId = clientId; - return this; - } - - public withCodeChallenge(codeChallenge: string): AuthorizationRequestClientBuilder { - this.codeChallange = codeChallenge; - return this; - } - - public withCodeChallengeMethod(codeChallengeMethod: CodeChallengeMethod): AuthorizationRequestClientBuilder { - this.codeChallengeMethod = codeChallengeMethod; - return this; - } - - public withAuthorizationDetails(authorizationDetails: Record): AuthorizationRequestClientBuilder { - this.authorizationDetails = authorizationDetails; - return this; - } - - public withRedirectUri(redirectUri: string): AuthorizationRequestClientBuilder { - this.redirectUri = redirectUri; - return this; - } - -} From 43cbb75659fe76a3eb48e7b8fcc2cf1413fdf24c Mon Sep 17 00:00:00 2001 From: Karim Stekelenburg Date: Fri, 10 Mar 2023 02:39:57 +0100 Subject: [PATCH 06/16] feat: implement auth request --- lib/AccessTokenClient.ts | 68 ++++++++++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 9 deletions(-) diff --git a/lib/AccessTokenClient.ts b/lib/AccessTokenClient.ts index c8b8236d..05fcff3b 100644 --- a/lib/AccessTokenClient.ts +++ b/lib/AccessTokenClient.ts @@ -23,6 +23,9 @@ export class AccessTokenClient { issuanceInitiation, asOpts, pin, + codeVerifier, + code, + redirectUri, metadata, }: AccessTokenRequestOpts): Promise> { const { issuanceInitiationRequest } = issuanceInitiation; @@ -34,6 +37,9 @@ export class AccessTokenClient { accessTokenRequest: await this.createAccessTokenRequest({ issuanceInitiation, asOpts, + codeVerifier, + code, + redirectUri, pin, }), isPinRequired, @@ -69,8 +75,16 @@ export class AccessTokenClient { return this.sendAuthCode(requestTokenURL, accessTokenRequest); } - public async createAccessTokenRequest({ issuanceInitiation, asOpts, pin }: AccessTokenRequestOpts): Promise { + public async createAccessTokenRequest({ + issuanceInitiation, + asOpts, + pin, + codeVerifier, + code, + redirectUri, + }: AccessTokenRequestOpts): Promise { const issuanceInitiationRequest = issuanceInitiation.issuanceInitiationRequest; + issuanceInitiationRequest; const request: Partial = {}; if (asOpts?.clientId) { request.client_id = asOpts.clientId; @@ -80,18 +94,24 @@ export class AccessTokenClient { request.user_pin = pin; if (issuanceInitiationRequest[PRE_AUTH_CODE_LITERAL]) { + if (codeVerifier) { + throw new Error('Cannot pass a code_verifier when flow type is pre-authorized'); + } request.grant_type = GrantTypes.PRE_AUTHORIZED_CODE; request[PRE_AUTH_CODE_LITERAL] = issuanceInitiationRequest[PRE_AUTH_CODE_LITERAL]; } if (issuanceInitiationRequest.op_state) { this.throwNotSupportedFlow(); - /** - * Code is here for when we start to support this flow - */ - // if (issuanceInitiationRequest[PRE_AUTH_CODE_LITERAL]) { - // throw new Error('Cannot have both a pre_authorized_code and a op_state in the same initiation request'); - // } - // request.grant_type = GrantTypes.AUTHORIZATION_CODE; + if (issuanceInitiationRequest[PRE_AUTH_CODE_LITERAL]) { + throw new Error('Cannot have both a pre_authorized_code and a op_state in the same initiation request'); + } + request.grant_type = GrantTypes.AUTHORIZATION_CODE; + } + if (codeVerifier) { + request.code_verifier = codeVerifier; + request.code = code; + request.redirect_uri = redirectUri; + request.grant_type = GrantTypes.AUTHORIZATION_CODE; } return request as AccessTokenRequest; @@ -103,6 +123,12 @@ export class AccessTokenClient { } } + private assertAuthorizationGrantType(grantType: GrantTypes): void { + if (GrantTypes.AUTHORIZATION_CODE !== grantType) { + throw new Error("grant type must be 'authorization_code'"); + } + } + private isPinRequiredValue(issuanceInitiationRequest: IssuanceInitiationRequestPayload): boolean { let isPinRequired = false; if (issuanceInitiationRequest !== undefined) { @@ -135,13 +161,37 @@ export class AccessTokenClient { } } + private assertNonEmptyCodeVerifier(accessTokenRequest: AccessTokenRequest): void { + if (!accessTokenRequest.code_verifier) { + debug('No code_verifier present, whilst it is required'); + throw new Error('Authorization flow requires the code_verifier to be present'); + } + } + + private assertNonEmptyCode(accessTokenRequest: AccessTokenRequest): void { + if (!accessTokenRequest.code) { + debug('No code present, whilst it is required'); + throw new Error('Authorization flow requires the code to be present'); + } + } + + private assertNonEmptyRedirectUri(accessTokenRequest: AccessTokenRequest): void { + if (!accessTokenRequest.redirect_uri) { + debug('No redirect_uri present, whilst it is required'); + throw new Error('Authorization flow requires the redirect_uri to be present'); + } + } + private validate(accessTokenRequest: AccessTokenRequest, isPinRequired?: boolean): void { if (accessTokenRequest.grant_type === GrantTypes.PRE_AUTHORIZED_CODE) { this.assertPreAuthorizedGrantType(accessTokenRequest.grant_type); this.assertNonEmptyPreAuthorizedCode(accessTokenRequest); this.assertNumericPin(isPinRequired, accessTokenRequest.user_pin); } else { - this.throwNotSupportedFlow(); + this.assertAuthorizationGrantType(accessTokenRequest.grant_type); + this.assertNonEmptyCodeVerifier(accessTokenRequest); + this.assertNonEmptyCode(accessTokenRequest); + this.assertNonEmptyRedirectUri(accessTokenRequest); } } From 064db2fb870b723b753afd9363dd4293532716cf Mon Sep 17 00:00:00 2001 From: Karim Stekelenburg Date: Fri, 10 Mar 2023 02:40:30 +0100 Subject: [PATCH 07/16] test: add tests for auth flow --- tests/AccessTokenClient.spec.ts | 47 ++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/tests/AccessTokenClient.spec.ts b/tests/AccessTokenClient.spec.ts index 4404c8a2..3ce0a7a2 100644 --- a/tests/AccessTokenClient.spec.ts +++ b/tests/AccessTokenClient.spec.ts @@ -1,6 +1,6 @@ import nock from 'nock'; -import { AccessTokenClient, AccessTokenRequest, AccessTokenResponse, GrantTypes, OpenIDResponse } from '../lib'; +import { AccessTokenClient, AccessTokenRequest, AccessTokenRequestOpts, AccessTokenResponse, GrantTypes, OpenIDResponse } from '../lib'; import { UNIT_TEST_TIMEOUT } from './IT.spec'; import { INITIATION_TEST } from './MetadataMocks'; @@ -11,8 +11,9 @@ describe('AccessTokenClient should', () => { beforeEach(() => { nock.cleanAll(); }); + it( - 'get Access Token without resulting in errors', + 'get Access Token for with pre-authorized code without resulting in errors', async () => { const accessTokenClient: AccessTokenClient = new AccessTokenClient(); @@ -43,22 +44,32 @@ describe('AccessTokenClient should', () => { ); it( - 'get error', + 'get Access Token for authorization code without resulting in errors', async () => { const accessTokenClient: AccessTokenClient = new AccessTokenClient(); const accessTokenRequest: AccessTokenRequest = { + client_id: 'test-client', + code_verifier: 'F0Y2OGARX2ppIERYdSVuLCV3Zi95Ci5yWzAYNU8QQC0', + code: '9mq3kwIuNZ88czRjJ2-UDxtaNXulOfxHSXo-kM01MLV', + redirect_uri: 'http://test.com/cb', grant_type: GrantTypes.AUTHORIZATION_CODE, } as AccessTokenRequest; - nock(MOCK_URL).post(/.*/).reply(200, ''); + const body: AccessTokenResponse = { + access_token: '6W-kZopGNBq8e-5KvnGf2u0p0iGSxWZ7jIGV86nO1Dn', + expires_in: 3600, + scope: 'TestCredential', + token_type: 'Bearer', + }; + nock(MOCK_URL).post(/.*/).reply(200, JSON.stringify(body)); - await expect( - accessTokenClient.acquireAccessTokenUsingRequest({ - accessTokenRequest, - asOpts: { as: MOCK_URL }, - }) - ).rejects.toThrow('Only pre-authorized-code flow is supported'); + const accessTokenResponse: OpenIDResponse = await accessTokenClient.acquireAccessTokenUsingRequest({ + accessTokenRequest, + asOpts: { as: MOCK_URL }, + }); + + expect(accessTokenResponse.successBody).toEqual(body); }, UNIT_TEST_TIMEOUT ); @@ -180,11 +191,21 @@ describe('AccessTokenClient should', () => { ).rejects.toThrow(Error('Cannot set a pin, when the pin is not required.')); }); - it('get error for unsupported flow type', async () => { + it('get error if code_verifier is present when flow type is pre-authorized', async () => { const accessTokenClient: AccessTokenClient = new AccessTokenClient(); - await expect(accessTokenClient.acquireAccessTokenUsingRequest({ accessTokenRequest: {} as never })).rejects.toThrow( - Error('Only pre-authorized-code flow is supported') + nock(MOCK_URL).post(/.*/).reply(200, {}); + + const requestOpts: AccessTokenRequestOpts = { + issuanceInitiation: INITIATION_TEST, + pin: undefined, + codeVerifier: 'RylyWGQ-dzpObnEcoMBDIH9cTAwZXk1wYzktKxsOFgA', + code: 'LWCt225yj7gzT2cWeMP4hXj4B4oIYkEiGs4T6pfez91', + redirectUri: 'http://example.com/cb', + }; + + await expect(() => accessTokenClient.acquireAccessTokenUsingIssuanceInitiation(requestOpts)).rejects.toThrow( + Error('Cannot pass a code_verifier when flow type is pre-authorized') ); }); From e104dbc608d9bb36983cf86f7918397b176c4928 Mon Sep 17 00:00:00 2001 From: Karim Stekelenburg Date: Fri, 10 Mar 2023 02:42:09 +0100 Subject: [PATCH 08/16] feat: update acquireAccessToken for auth flow --- lib/OpenID4VCIClient.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/lib/OpenID4VCIClient.ts b/lib/OpenID4VCIClient.ts index 8c7a7db9..5d642171 100644 --- a/lib/OpenID4VCIClient.ts +++ b/lib/OpenID4VCIClient.ts @@ -70,17 +70,33 @@ export class OpenID4VCIClient { return this._serverMetadata; } - public async acquireAccessToken({ pin, clientId }: { pin?: string; clientId?: string }): Promise { + public async acquireAccessToken({ + pin, + clientId, + codeVerifier, + code, + redirectUri, + }: { + pin?: string; + clientId?: string; + codeVerifier?: string; + code?: string; + redirectUri?: string; + }): Promise { this.assertInitiation(); if (clientId) { this._clientId = clientId; } if (!this._accessTokenResponse) { const accessTokenClient = new AccessTokenClient(); + const response = await accessTokenClient.acquireAccessTokenUsingIssuanceInitiation({ issuanceInitiation: this._initiation, metadata: this._serverMetadata, pin, + codeVerifier, + code, + redirectUri, asOpts: { clientId: this.clientId }, }); if (response.errorBody) { @@ -91,6 +107,7 @@ export class OpenID4VCIClient { } this._accessTokenResponse = response.successBody; } + return this._accessTokenResponse; } From 267b897fb88fea042de4d110d829f7dca97c0888 Mon Sep 17 00:00:00 2001 From: Karim Stekelenburg Date: Fri, 10 Mar 2023 02:43:13 +0100 Subject: [PATCH 09/16] feat: add method to encode the initiation url --- lib/OpenID4VCIClient.ts | 54 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/lib/OpenID4VCIClient.ts b/lib/OpenID4VCIClient.ts index 5d642171..876e7f8d 100644 --- a/lib/OpenID4VCIClient.ts +++ b/lib/OpenID4VCIClient.ts @@ -6,9 +6,12 @@ import { CredentialRequestClientBuilder } from './CredentialRequestClientBuilder import { IssuanceInitiation } from './IssuanceInitiation'; import { MetadataClient } from './MetadataClient'; import { ProofOfPossessionBuilder } from './ProofOfPossessionBuilder'; +import { convertJsonToURI } from './functions'; import { AccessTokenResponse, Alg, + AuthorizationRequest, + AuthorizationRequestOpts, AuthzFlowType, CredentialMetadata, CredentialResponse, @@ -16,6 +19,7 @@ import { EndpointMetadata, IssuanceInitiationWithBaseUrl, ProofOfPossessionCallbacks, + ResponseType, } from './types'; const debug = Debug('sphereon:openid4vci:flow'); @@ -30,9 +34,6 @@ export class OpenID4VCIClient { private _accessTokenResponse: AccessTokenResponse; private constructor(initiation: IssuanceInitiationWithBaseUrl, flowType: AuthzFlowType, kid?: string, alg?: Alg | string, clientId?: string) { - if (flowType !== AuthzFlowType.PRE_AUTHORIZED_CODE_FLOW) { - throw new Error(`Only pre-authorized code flow is support at present`); - } this._flowType = flowType; this._initiation = initiation; this._kid = kid; @@ -70,6 +71,53 @@ export class OpenID4VCIClient { return this._serverMetadata; } + public createAuthorizationRequestUrl({ clientId, codeChallengeMethod, codeChallenge, redirectUri, scope }: AuthorizationRequestOpts): string { + if (!scope) { + throw Error('Please provide a scope. authorization_details based requests are not supported at this time'); + } + + if (!this._serverMetadata.openid4vci_metadata.authorization_endpoint) { + throw Error('Server metadata does not contain authorization endpoint'); + } + + // make sure the first scope is 'openid' + if (scope.includes('openid')) { + // if the 'openid' scope is present, but isn't the first element, + // remove it and add it as the first element. + if (scope[0] !== 'openid') { + const index = scope.indexOf('openid'); + scope.splice(index, 1); + scope.unshift('openid'); + } + } else { + // if the 'openid' scope isn't present at all, add it + scope.unshift('openid'); + } + if (scope.length < 2) { + throw Error("Scope array only contains the 'openid' scope. Please also provide a credential type"); + } + console.log(scope); + + const queryObj: AuthorizationRequest = { + response_type: ResponseType.AUTH_CODE, + client_id: clientId, + code_challenge_method: codeChallengeMethod, + code_challenge: codeChallenge, + redirect_uri: redirectUri, + scope: scope, + }; + + const authRequestUrl = convertJsonToURI( + { ...queryObj, scope: scope.join(' ') }, + { + baseUrl: this._serverMetadata.openid4vci_metadata.authorization_endpoint, + uriTypeProperties: ['redirect_uri', 'scope'], + } + ); + + return authRequestUrl; + } + public async acquireAccessToken({ pin, clientId, From 0fee406ae17c2442ca441f026f3fc30d54dc0a6b Mon Sep 17 00:00:00 2001 From: Karim Stekelenburg Date: Fri, 10 Mar 2023 02:43:56 +0100 Subject: [PATCH 10/16] test: add tests for createAuthorizationRequestUrl --- tests/OpenID4VCIClient.spec.ts | 117 +++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 tests/OpenID4VCIClient.spec.ts diff --git a/tests/OpenID4VCIClient.spec.ts b/tests/OpenID4VCIClient.spec.ts new file mode 100644 index 00000000..d8f4bab8 --- /dev/null +++ b/tests/OpenID4VCIClient.spec.ts @@ -0,0 +1,117 @@ +import nock from 'nock'; + +import { AuthzFlowType, CodeChallengeMethod, OpenID4VCIClient } from '../lib'; + +const MOCK_URL = 'https://server.example.com/'; + +describe('OpenID4VCIClient should', () => { + let client; + + beforeEach(async () => { + nock(MOCK_URL).get(/.*/).reply(200, {}); + client = await OpenID4VCIClient.initiateFromURI({ + issuanceInitiationURI: 'openid-initiate-issuance://?issuer=https://server.example.com&credential_type=TestCredential', + flowType: AuthzFlowType.AUTHORIZATION_CODE_FLOW, + }); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + it('should create successfully construct an authorization request url', async () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + client._serverMetadata.openid4vci_metadata.authorization_endpoint = `${MOCK_URL}v1/auth/authorize`; + const url = client.createAuthorizationRequestUrl({ + clientId: 'test-client', + codeChallengeMethod: CodeChallengeMethod.SHA256, + codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + scope: ['openid', 'TestCredential'], + redirectUri: 'http://localhost:8881/cb', + }); + + const urlSearchParams = new URLSearchParams(url.split('?')[1]); + const scope = urlSearchParams.get('scope')?.split(' '); + + expect(scope[0]).toBe('openid'); + }); + it('throw an error if authorization endpoint is not set in server metadata', async () => { + expect(() => { + client.createAuthorizationRequestUrl({ + clientId: 'test-client', + codeChallengeMethod: CodeChallengeMethod.SHA256, + codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + scope: ['openid', 'TestCredential'], + redirectUri: 'http://localhost:8881/cb', + }); + }).toThrow(Error('Server metadata does not contain authorization endpoint')); + }); + it('throw an error if only the openid scope is provided', async () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + client._serverMetadata.openid4vci_metadata.authorization_endpoint = `${MOCK_URL}v1/auth/authorize`; + + expect(() => { + client.createAuthorizationRequestUrl({ + clientId: 'test-client', + codeChallengeMethod: CodeChallengeMethod.SHA256, + codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + scope: ['openid'], + redirectUri: 'http://localhost:8881/cb', + }); + }).toThrow(Error("Scope array only contains the 'openid' scope. Please also provide a credential type")); + }); + it('set the openid scope as the first scope if provided at different array index', async () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + client._serverMetadata.openid4vci_metadata.authorization_endpoint = `${MOCK_URL}v1/auth/authorize`; + console.log('set'); + + const url = client.createAuthorizationRequestUrl({ + clientId: 'test-client', + codeChallengeMethod: CodeChallengeMethod.SHA256, + codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + scope: ['TestCredential', 'openid'], + redirectUri: 'http://localhost:8881/cb', + }); + + const urlSearchParams = new URLSearchParams(url.split('?')[1]); + const scope = urlSearchParams.get('scope')?.split(' '); + + expect(scope[0]).toBe('openid'); + }); + it("injects 'openid' as the first scope if not provided", async () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + client._serverMetadata.openid4vci_metadata.authorization_endpoint = `${MOCK_URL}v1/auth/authorize`; + console.log('set'); + + const url = client.createAuthorizationRequestUrl({ + clientId: 'test-client', + codeChallengeMethod: CodeChallengeMethod.SHA256, + codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + scope: ['TestCredential'], + redirectUri: 'http://localhost:8881/cb', + }); + + const urlSearchParams = new URLSearchParams(url.split('?')[1]); + const scope = urlSearchParams.get('scope')?.split(' '); + + expect(scope[0]).toBe('openid'); + }); + it('throw an error if no scope is provided', async () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + client._serverMetadata.openid4vci_metadata.authorization_endpoint = `${MOCK_URL}v1/auth/authorize`; + + expect(() => { + client.createAuthorizationRequestUrl({ + clientId: 'test-client', + codeChallengeMethod: CodeChallengeMethod.SHA256, + codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + redirectUri: 'http://localhost:8881/cb', + }); + }).toThrow(Error('Please provide a scope. authorization_details based requests are not supported at this time')); + }); +}); From 4a0460772aa8f0dcc4eed3fb794e41ab033459a5 Mon Sep 17 00:00:00 2001 From: Karim Stekelenburg Date: Fri, 10 Mar 2023 11:58:09 +0100 Subject: [PATCH 11/16] fix: add comment to authorization_endpoint Co-authored-by: Niels Klomp --- lib/types/OpenID4VCIServerMetadata.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/types/OpenID4VCIServerMetadata.ts b/lib/types/OpenID4VCIServerMetadata.ts index 7eafa9ac..f4062039 100644 --- a/lib/types/OpenID4VCIServerMetadata.ts +++ b/lib/types/OpenID4VCIServerMetadata.ts @@ -9,6 +9,7 @@ export interface OpenID4VCIServerMetadata { credential_issuer?: CredentialIssuer; // A JSON object containing display properties for the Credential issuer. token_endpoint?: string; //NON-SPEC compliant, but used by several issuers. URL of the OP's Token Endpoint. This URL MUST use the https scheme and MAY contain port, path and query parameter components. authorization_server?: string; //NON-SPEC compliant, but used by some issuers. URL of the AS. This URL MUST use the https scheme and MAY contain port, path and query parameter components. + // TODO: The above authorization_server being used in the wild, serves roughly the same purpose as the below spec compliant endpoint. Look at how to use authorization_server as authorization_endpoint in case it is present authorization_endpoint?: string; } From f63de0ab77088ee0c537f48a79bdaedd69543131 Mon Sep 17 00:00:00 2001 From: Karim Stekelenburg Date: Fri, 10 Mar 2023 11:57:15 +0100 Subject: [PATCH 12/16] fix: throw error for unknown flow types Signed-off-by: Karim Stekelenburg --- lib/AccessTokenClient.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/AccessTokenClient.ts b/lib/AccessTokenClient.ts index 05fcff3b..51c4b3cd 100644 --- a/lib/AccessTokenClient.ts +++ b/lib/AccessTokenClient.ts @@ -187,11 +187,13 @@ export class AccessTokenClient { this.assertPreAuthorizedGrantType(accessTokenRequest.grant_type); this.assertNonEmptyPreAuthorizedCode(accessTokenRequest); this.assertNumericPin(isPinRequired, accessTokenRequest.user_pin); - } else { + } else if (accessTokenRequest.grant_type === GrantTypes.AUTHORIZATION_CODE) { this.assertAuthorizationGrantType(accessTokenRequest.grant_type); this.assertNonEmptyCodeVerifier(accessTokenRequest); this.assertNonEmptyCode(accessTokenRequest); this.assertNonEmptyRedirectUri(accessTokenRequest); + } else { + this.throwNotSupportedFlow; } } From b06bff39eb0253ca961e3a8cb62f020938a5ff98 Mon Sep 17 00:00:00 2001 From: Karim Stekelenburg Date: Fri, 10 Mar 2023 12:47:02 +0100 Subject: [PATCH 13/16] fix: remove console.log statements Signed-off-by: Karim Stekelenburg --- lib/OpenID4VCIClient.ts | 1 - tests/OpenID4VCIClient.spec.ts | 2 -- 2 files changed, 3 deletions(-) diff --git a/lib/OpenID4VCIClient.ts b/lib/OpenID4VCIClient.ts index 876e7f8d..49ceeda5 100644 --- a/lib/OpenID4VCIClient.ts +++ b/lib/OpenID4VCIClient.ts @@ -96,7 +96,6 @@ export class OpenID4VCIClient { if (scope.length < 2) { throw Error("Scope array only contains the 'openid' scope. Please also provide a credential type"); } - console.log(scope); const queryObj: AuthorizationRequest = { response_type: ResponseType.AUTH_CODE, diff --git a/tests/OpenID4VCIClient.spec.ts b/tests/OpenID4VCIClient.spec.ts index d8f4bab8..b8864f84 100644 --- a/tests/OpenID4VCIClient.spec.ts +++ b/tests/OpenID4VCIClient.spec.ts @@ -66,7 +66,6 @@ describe('OpenID4VCIClient should', () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore client._serverMetadata.openid4vci_metadata.authorization_endpoint = `${MOCK_URL}v1/auth/authorize`; - console.log('set'); const url = client.createAuthorizationRequestUrl({ clientId: 'test-client', @@ -85,7 +84,6 @@ describe('OpenID4VCIClient should', () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore client._serverMetadata.openid4vci_metadata.authorization_endpoint = `${MOCK_URL}v1/auth/authorize`; - console.log('set'); const url = client.createAuthorizationRequestUrl({ clientId: 'test-client', From 6ff15914925e9a96d621112075bc47dadc9a538b Mon Sep 17 00:00:00 2001 From: Karim Stekelenburg Date: Mon, 13 Mar 2023 13:53:05 +0100 Subject: [PATCH 14/16] fix: throw error if both flowtypes are indicated Add a clause that will throw an error if the request.grand_type is set to authorization code and the pre_auth code literal is present on the issuance initiation request. --- lib/AccessTokenClient.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/AccessTokenClient.ts b/lib/AccessTokenClient.ts index 51c4b3cd..1a1b701d 100644 --- a/lib/AccessTokenClient.ts +++ b/lib/AccessTokenClient.ts @@ -113,6 +113,9 @@ export class AccessTokenClient { request.redirect_uri = redirectUri; request.grant_type = GrantTypes.AUTHORIZATION_CODE; } + if (request.grant_type === GrantTypes.AUTHORIZATION_CODE && issuanceInitiationRequest[PRE_AUTH_CODE_LITERAL]) { + throw Error('A pre_authorized_code flow cannot have an op_state in the initiation request'); + } return request as AccessTokenRequest; } From 9460b9d0fd345276a9ec6f15d53e4515015be350 Mon Sep 17 00:00:00 2001 From: Karim Stekelenburg Date: Mon, 13 Mar 2023 14:35:36 +0100 Subject: [PATCH 15/16] refactor: remove redundant tests and simplify scope parameter The tests for throwing an error when only the 'openid' scope is provided and for setting the 'openid' scope as the first scope if provided at a different array index are redundant and have been removed. The scope parameter has been simplified to a string instead of an array of strings for better readability and consistency with the OpenID Connect specification. --- lib/OpenID4VCIClient.ts | 29 ++++++------------------ lib/types/Authorization.types.ts | 4 ++-- tests/OpenID4VCIClient.spec.ts | 39 +++----------------------------- 3 files changed, 12 insertions(+), 60 deletions(-) diff --git a/lib/OpenID4VCIClient.ts b/lib/OpenID4VCIClient.ts index 49ceeda5..098b2cd1 100644 --- a/lib/OpenID4VCIClient.ts +++ b/lib/OpenID4VCIClient.ts @@ -80,21 +80,9 @@ export class OpenID4VCIClient { throw Error('Server metadata does not contain authorization endpoint'); } - // make sure the first scope is 'openid' - if (scope.includes('openid')) { - // if the 'openid' scope is present, but isn't the first element, - // remove it and add it as the first element. - if (scope[0] !== 'openid') { - const index = scope.indexOf('openid'); - scope.splice(index, 1); - scope.unshift('openid'); - } - } else { - // if the 'openid' scope isn't present at all, add it - scope.unshift('openid'); - } - if (scope.length < 2) { - throw Error("Scope array only contains the 'openid' scope. Please also provide a credential type"); + // add 'openid' scope if not present + if (!scope.includes('openid')) { + scope = `openid ${scope}`; } const queryObj: AuthorizationRequest = { @@ -106,13 +94,10 @@ export class OpenID4VCIClient { scope: scope, }; - const authRequestUrl = convertJsonToURI( - { ...queryObj, scope: scope.join(' ') }, - { - baseUrl: this._serverMetadata.openid4vci_metadata.authorization_endpoint, - uriTypeProperties: ['redirect_uri', 'scope'], - } - ); + const authRequestUrl = convertJsonToURI(queryObj, { + baseUrl: this._serverMetadata.openid4vci_metadata.authorization_endpoint, + uriTypeProperties: ['redirect_uri', 'scope'], + }); return authRequestUrl; } diff --git a/lib/types/Authorization.types.ts b/lib/types/Authorization.types.ts index d155241d..772e52ee 100644 --- a/lib/types/Authorization.types.ts +++ b/lib/types/Authorization.types.ts @@ -50,7 +50,7 @@ export interface AuthorizationRequest { code_challenge: string; code_challenge_method: CodeChallengeMethod; redirect_uri: string; - scope?: string[]; + scope?: string; } export interface AuthorizationRequestOpts { @@ -58,7 +58,7 @@ export interface AuthorizationRequestOpts { codeChallenge: string; codeChallengeMethod: CodeChallengeMethod; redirectUri: string; - scope?: string[]; + scope?: string; } export interface AuthorizationGrantResponse { diff --git a/tests/OpenID4VCIClient.spec.ts b/tests/OpenID4VCIClient.spec.ts index b8864f84..4b74a4ce 100644 --- a/tests/OpenID4VCIClient.spec.ts +++ b/tests/OpenID4VCIClient.spec.ts @@ -27,7 +27,7 @@ describe('OpenID4VCIClient should', () => { clientId: 'test-client', codeChallengeMethod: CodeChallengeMethod.SHA256, codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', - scope: ['openid', 'TestCredential'], + scope: 'openid TestCredential', redirectUri: 'http://localhost:8881/cb', }); @@ -42,44 +42,11 @@ describe('OpenID4VCIClient should', () => { clientId: 'test-client', codeChallengeMethod: CodeChallengeMethod.SHA256, codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', - scope: ['openid', 'TestCredential'], + scope: 'openid TestCredential', redirectUri: 'http://localhost:8881/cb', }); }).toThrow(Error('Server metadata does not contain authorization endpoint')); }); - it('throw an error if only the openid scope is provided', async () => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - client._serverMetadata.openid4vci_metadata.authorization_endpoint = `${MOCK_URL}v1/auth/authorize`; - - expect(() => { - client.createAuthorizationRequestUrl({ - clientId: 'test-client', - codeChallengeMethod: CodeChallengeMethod.SHA256, - codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', - scope: ['openid'], - redirectUri: 'http://localhost:8881/cb', - }); - }).toThrow(Error("Scope array only contains the 'openid' scope. Please also provide a credential type")); - }); - it('set the openid scope as the first scope if provided at different array index', async () => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - client._serverMetadata.openid4vci_metadata.authorization_endpoint = `${MOCK_URL}v1/auth/authorize`; - - const url = client.createAuthorizationRequestUrl({ - clientId: 'test-client', - codeChallengeMethod: CodeChallengeMethod.SHA256, - codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', - scope: ['TestCredential', 'openid'], - redirectUri: 'http://localhost:8881/cb', - }); - - const urlSearchParams = new URLSearchParams(url.split('?')[1]); - const scope = urlSearchParams.get('scope')?.split(' '); - - expect(scope[0]).toBe('openid'); - }); it("injects 'openid' as the first scope if not provided", async () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -89,7 +56,7 @@ describe('OpenID4VCIClient should', () => { clientId: 'test-client', codeChallengeMethod: CodeChallengeMethod.SHA256, codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', - scope: ['TestCredential'], + scope: 'TestCredential', redirectUri: 'http://localhost:8881/cb', }); From dbd2ce521b7a5cec0892b4bf4b00adb9fe15bacb Mon Sep 17 00:00:00 2001 From: Niels Klomp Date: Fri, 17 Mar 2023 01:14:38 +0100 Subject: [PATCH 16/16] Remove check that is already done later --- lib/AccessTokenClient.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/AccessTokenClient.ts b/lib/AccessTokenClient.ts index 1a1b701d..ad840e17 100644 --- a/lib/AccessTokenClient.ts +++ b/lib/AccessTokenClient.ts @@ -102,9 +102,6 @@ export class AccessTokenClient { } if (issuanceInitiationRequest.op_state) { this.throwNotSupportedFlow(); - if (issuanceInitiationRequest[PRE_AUTH_CODE_LITERAL]) { - throw new Error('Cannot have both a pre_authorized_code and a op_state in the same initiation request'); - } request.grant_type = GrantTypes.AUTHORIZATION_CODE; } if (codeVerifier) {