diff --git a/lib/AccessTokenClient.ts b/lib/AccessTokenClient.ts index c8b8236d..ad840e17 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; + 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; + } + 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; @@ -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,39 @@ 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 if (accessTokenRequest.grant_type === GrantTypes.AUTHORIZATION_CODE) { + this.assertAuthorizationGrantType(accessTokenRequest.grant_type); + this.assertNonEmptyCodeVerifier(accessTokenRequest); + this.assertNonEmptyCode(accessTokenRequest); + this.assertNonEmptyRedirectUri(accessTokenRequest); } else { - this.throwNotSupportedFlow(); + this.throwNotSupportedFlow; } } diff --git a/lib/OpenID4VCIClient.ts b/lib/OpenID4VCIClient.ts index 8c7a7db9..098b2cd1 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,17 +71,64 @@ export class OpenID4VCIClient { return this._serverMetadata; } - public async acquireAccessToken({ pin, clientId }: { pin?: string; clientId?: string }): Promise { + 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'); + } + + // add 'openid' scope if not present + if (!scope.includes('openid')) { + scope = `openid ${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, { + baseUrl: this._serverMetadata.openid4vci_metadata.authorization_endpoint, + uriTypeProperties: ['redirect_uri', 'scope'], + }); + + return authRequestUrl; + } + + 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 +139,7 @@ export class OpenID4VCIClient { } this._accessTokenResponse = response.successBody; } + return this._accessTokenResponse; } diff --git a/lib/types/Authorization.types.ts b/lib/types/Authorization.types.ts index 7385a2b0..772e52ee 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,16 +38,29 @@ 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; } +export interface AuthorizationRequestOpts { + clientId: string; + codeChallenge: string; + codeChallengeMethod: CodeChallengeMethod; + redirectUri: string; + scope?: string; +} + export interface AuthorizationGrantResponse { grant_type: string; code: string; @@ -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; diff --git a/lib/types/Generic.types.ts b/lib/types/Generic.types.ts index 71ddbf17..a34d0ce9 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; } diff --git a/lib/types/OpenID4VCIServerMetadata.ts b/lib/types/OpenID4VCIServerMetadata.ts index 4b1b7699..f4062039 100644 --- a/lib/types/OpenID4VCIServerMetadata.ts +++ b/lib/types/OpenID4VCIServerMetadata.ts @@ -9,6 +9,8 @@ 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; } export type Oauth2ASWithOID4VCIMetadata = OAuth2ASMetadata & OpenID4VCIServerMetadata; 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') ); }); diff --git a/tests/OpenID4VCIClient.spec.ts b/tests/OpenID4VCIClient.spec.ts new file mode 100644 index 00000000..4b74a4ce --- /dev/null +++ b/tests/OpenID4VCIClient.spec.ts @@ -0,0 +1,82 @@ +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("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`; + + 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')); + }); +});