Skip to content

Commit

Permalink
feat: PAR improvements
Browse files Browse the repository at this point in the history
The createAuthorizationRequestUrl now automatically handles PAR, instead of having a separate method. There is a new Param to determine whether PAR should be used automatically, never be used, or whether it is required. The latter is also set when the AS has a PAR required metadata value. As a result the createAuthorizationUrl method now is asynchronous
  • Loading branch information
nklomp committed Jan 23, 2024
1 parent 5d5cb06 commit 99f55c2
Show file tree
Hide file tree
Showing 12 changed files with 187 additions and 142 deletions.
114 changes: 29 additions & 85 deletions packages/client/lib/OpenID4VCIClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string> {
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) {
Expand Down Expand Up @@ -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,
Expand All @@ -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<PushedAuthorizationResponse>(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<string> {
// 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<PushedAuthorizationResponse>(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)) {
Expand Down Expand Up @@ -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`);
Expand Down
2 changes: 2 additions & 0 deletions packages/client/lib/__tests__/AccessTokenClient.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
12 changes: 10 additions & 2 deletions packages/client/lib/__tests__/CredentialRequestClient.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,15 +157,15 @@ describe('Credential Request Client ', () => {
});

describe('Credential Request Client with Walt.id ', () => {
beforeAll(() => {
beforeEach(() => {
nock.cleanAll();
});

afterEach(() => {
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);
Expand All @@ -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';
Expand All @@ -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 (
Expand Down
6 changes: 2 additions & 4 deletions packages/client/lib/__tests__/EBSIE2E.spec.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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());
Expand Down
58 changes: 32 additions & 26 deletions packages/client/lib/__tests__/OpenID4VCIClient.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
Expand All @@ -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',
});
Expand All @@ -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
Expand All @@ -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',
Expand All @@ -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',
);
});
Expand All @@ -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',
Expand All @@ -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',
);
});
Expand All @@ -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',
Expand All @@ -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',
);
});
Expand Down
Loading

0 comments on commit 99f55c2

Please sign in to comment.