Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/enable authorization code flow helpers #70

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ import { OpenID4VCIClient } from '@sphereon/oid4vci-client';
// The client is initiated from a URI. This URI is provided by the Issuer, typically as a URL or QR code.
const client = await OpenID4VCIClient.fromURI({
uri: 'openid-initiate-issuance://?issuer=https%3A%2F%2Fissuer.research.identiproof.io&credential_type=OpenBadgeCredentialUrl&pre-authorized_code=4jLs9xZHEfqcoow0kHE7d1a8hUk6Sy-5bVSV2MqBUGUgiFFQi-ImL62T-FmLIo8hKA1UdMPH0lM1xAgcFkJfxIw9L-lI3mVs0hRT8YVwsEM1ma6N3wzuCdwtMU4bcwKp&user_pin_required=true',
flowType: AuthzFlowType.PRE_AUTHORIZED_CODE_FLOW, // The flow to use
kid: 'did:example:ebfeb1f712ebc6f1c276e12ec21#key-1', // Our DID. You can defer this also to when the acquireCredential method is called
alg: Alg.ES256, // The signing Algorithm we will use. You can defer this also to when the acquireCredential method is called
clientId: 'test-clientId', // The clientId if the Authrozation Service requires it. If a clientId is needed you can defer this also to when the acquireAccessToken method is called
Expand Down
36 changes: 18 additions & 18 deletions packages/client/lib/AccessTokenClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import {
AccessTokenResponse,
assertedUniformCredentialOffer,
AuthorizationServerOpts,
AuthzFlowType,
EndpointMetadata,
getIssuerFromCredentialOfferPayload,
GrantTypes,
isPreAuthCode,
IssuerOpts,
OpenIDResponse,
PRE_AUTH_CODE_LITERAL,
Expand Down Expand Up @@ -67,6 +67,7 @@ export class AccessTokenClient {
issuerOpts?: IssuerOpts;
}): Promise<OpenIDResponse<AccessTokenResponse>> {
this.validate(accessTokenRequest, isPinRequired);

const requestTokenURL = AccessTokenClient.determineTokenURL({
asOpts,
issuerOpts,
Expand All @@ -76,45 +77,44 @@ export class AccessTokenClient {
? await MetadataClient.retrieveAllMetadata(issuerOpts.issuer, { errorOnNotFound: false })
: undefined,
});

return this.sendAuthCode(requestTokenURL, accessTokenRequest);
}

public async createAccessTokenRequest(opts: AccessTokenRequestOpts): Promise<AccessTokenRequest> {
const { asOpts, pin, codeVerifier, code, redirectUri } = opts;
const credentialOfferRequest = await toUniformCredentialOfferRequest(opts.credentialOffer);
const request: Partial<AccessTokenRequest> = {};

if (asOpts?.clientId) {
request.client_id = asOpts.clientId;
}

this.assertNumericPin(this.isPinRequiredValue(credentialOfferRequest.credential_offer), pin);
request.user_pin = pin;
if (credentialOfferRequest.supportedFlows.includes(AuthzFlowType.PRE_AUTHORIZED_CODE_FLOW)) {
this.assertNumericPin(this.isPinRequiredValue(credentialOfferRequest.credential_offer), pin);
request.user_pin = pin;

const isPreAuth = isPreAuthCode(credentialOfferRequest);
if (isPreAuth) {
if (codeVerifier) {
throw new Error('Cannot pass a code_verifier when flow type is pre-authorized');
}
request.grant_type = GrantTypes.PRE_AUTHORIZED_CODE;
// we actually know it is there because of the isPreAuthCode call
request[PRE_AUTH_CODE_LITERAL] =
credentialOfferRequest?.credential_offer.grants?.['urn:ietf:params:oauth:grant-type:pre-authorized_code']?.[PRE_AUTH_CODE_LITERAL];

return request as AccessTokenRequest;
}
if (!isPreAuth && credentialOfferRequest.credential_offer.grants?.authorization_code?.issuer_state) {
this.throwNotSupportedFlow(); // not supported yet

if (credentialOfferRequest.supportedFlows.includes(AuthzFlowType.AUTHORIZATION_CODE_FLOW)) {
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 && isPreAuth) {
throw Error('A pre_authorized_code flow cannot have an issuer state in the credential offer');

if (codeVerifier) {
request.code_verifier = codeVerifier;
}

return request as AccessTokenRequest;
}

return request as AccessTokenRequest;
throw new Error('Credential offer request does not follow neither pre-authorized code nor authorization code flow requirements.');
}

private assertPreAuthorizedGrantType(grantType: GrantTypes): void {
Expand Down
117 changes: 58 additions & 59 deletions packages/client/lib/OpenID4VCIClient.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
AccessTokenResponse,
Alg,
AuthorizationRequestV1_0_09,
AuthzFlowType,
CodeChallengeMethod,
CredentialOfferPayloadV1_0_08,
Expand All @@ -11,7 +10,6 @@ import {
EndpointMetadataResult,
OID4VCICredentialFormat,
OpenId4VCIVersion,
OpenIDResponse,
ProofOfPossessionCallbacks,
PushedAuthorizationResponse,
ResponseType,
Expand Down Expand Up @@ -39,7 +37,6 @@ interface AuthDetails {
}

interface AuthRequestOpts {
clientId: string;
codeChallenge: string;
codeChallengeMethod: CodeChallengeMethod;
authorizationDetails?: AuthDetails | AuthDetails[];
Expand All @@ -48,25 +45,14 @@ interface AuthRequestOpts {
}

export class OpenID4VCIClient {
private readonly _flowType: AuthzFlowType;
private readonly _credentialOffer: CredentialOfferRequestWithBaseUrl;
private _clientId?: string;
private _kid: string | undefined;
private _alg: Alg | string | undefined;
private _endpointMetadata: EndpointMetadataResult | undefined;
private _accessTokenResponse: AccessTokenResponse | undefined;

private constructor(
credentialOffer: CredentialOfferRequestWithBaseUrl,
flowType: AuthzFlowType,
kid?: string,
alg?: Alg | string,
clientId?: string,
) {
if (!credentialOffer.supportedFlows.includes(flowType)) {
throw Error(`Flows ${flowType} is not supported by issuer ${credentialOffer.credential_offer_uri}`);
}
this._flowType = flowType;
private constructor(credentialOffer: CredentialOfferRequestWithBaseUrl, kid?: string, alg?: Alg | string, clientId?: string) {
this._credentialOffer = credentialOffer;
this._kid = kid;
this._alg = alg;
Expand All @@ -75,22 +61,20 @@ export class OpenID4VCIClient {

public static async fromURI({
uri,
flowType,
kid,
alg,
retrieveServerMetadata,
clientId,
resolveOfferUri,
}: {
uri: string;
flowType: AuthzFlowType;
kid?: string;
alg?: Alg | string;
retrieveServerMetadata?: boolean;
resolveOfferUri?: boolean;
clientId?: string;
}): Promise<OpenID4VCIClient> {
const client = new OpenID4VCIClient(await CredentialOfferClient.fromURI(uri, { resolve: resolveOfferUri }), flowType, kid, alg, clientId);
const client = new OpenID4VCIClient(await CredentialOfferClient.fromURI(uri, { resolve: resolveOfferUri }), kid, alg, clientId);

if (retrieveServerMetadata === undefined || retrieveServerMetadata) {
await client.retrieveServerMetadata();
Expand All @@ -106,14 +90,7 @@ export class OpenID4VCIClient {
return this.endpointMetadata;
}

public createAuthorizationRequestUrl({
clientId,
codeChallengeMethod,
codeChallenge,
authorizationDetails,
redirectUri,
scope,
}: AuthRequestOpts): string {
public createAuthorizationRequestUrl({ codeChallengeMethod, codeChallenge, authorizationDetails, redirectUri, scope }: AuthRequestOpts): 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) {
Expand All @@ -133,36 +110,40 @@ export class OpenID4VCIClient {
}

// add 'openid' scope if not present
if (scope && !scope.includes('openid')) {
scope = `openid ${scope}`;
if (!scope?.includes('openid')) {
scope = ['openid', scope].filter((s) => !!s).join(' ');
}

//fixme: handle this for v11
const queryObj = {
const queryObj: { [key: string]: string } = {
response_type: ResponseType.AUTH_CODE,
client_id: clientId,
code_challenge_method: codeChallengeMethod,
code_challenge: codeChallenge,
authorization_details: JSON.stringify(this.handleAuthorizationDetails(authorizationDetails)),
redirect_uri: redirectUri,
scope: scope,
} as AuthorizationRequestV1_0_09;
};

if (this.clientId) {
queryObj['client_id'] = this.clientId;
}

if (this.credentialOffer.issuerState) {
queryObj['issuer_state'] = this.credentialOffer.issuerState;
}

return convertJsonToURI(queryObj, {
baseUrl: this._endpointMetadata.authorization_endpoint,
uriTypeProperties: ['redirect_uri', 'scope', 'authorization_details'],
version: this.version(),
uriTypeProperties: ['redirect_uri', 'scope', 'authorization_details', 'issuer_state'],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will look later into whether leaving the version out doesn't have any side effects

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on the current convertJsonToURI implementation, excluding version param will do exactly what is needed in this case. Provided attributes have be added as query params. else statement does exactly that.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the problem however is that the convertJsonToURI is called from many places and it for sure needs the logic to distinguish between V11 and higher in other areas. What I will do is add an additional param that takes precedence over the version. From the first look of it you need it from the formPost method. So will use that param there

});
}

public async acquirePushedAuthorizationRequestURI({
clientId,
codeChallengeMethod,
codeChallenge,
authorizationDetails,
redirectUri,
scope,
}: AuthRequestOpts): Promise<OpenIDResponse<PushedAuthorizationResponse>> {
}: 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) {
Expand All @@ -183,21 +164,36 @@ export class OpenID4VCIClient {
const parEndpoint: string = this._endpointMetadata.credentialIssuerMetadata.pushed_authorization_request_endpoint;

// add 'openid' scope if not present
if (scope && !scope.includes('openid')) {
scope = `openid ${scope}`;
if (!scope?.includes('openid')) {
scope = ['openid', scope].filter((s) => !!s).join(' ');
}

//fixme: handle this for v11
const queryObj: AuthorizationRequestV1_0_09 = {
const queryObj: { [key: string]: string } = {
response_type: ResponseType.AUTH_CODE,
client_id: clientId,
code_challenge_method: codeChallengeMethod,
code_challenge: codeChallenge,
authorization_details: JSON.stringify(this.handleAuthorizationDetails(authorizationDetails)),
redirect_uri: redirectUri,
scope: scope,
};
return await formPost(parEndpoint, JSON.stringify(queryObj));

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'],
},
);
}

public handleAuthorizationDetails(authorizationDetails?: AuthDetails | AuthDetails[]): AuthDetails | AuthDetails[] | undefined {
Expand Down Expand Up @@ -237,7 +233,9 @@ export class OpenID4VCIClient {
redirectUri?: string;
}): Promise<AccessTokenResponse> {
const { pin, clientId, codeVerifier, code, redirectUri } = opts ?? {};

this.assertIssuerData();

if (clientId) {
this._clientId = clientId;
}
Expand Down Expand Up @@ -300,24 +298,29 @@ export class OpenID4VCIClient {
credentialOffer: this.credentialOffer,
metadata: this.endpointMetadata,
});

requestBuilder.withTokenFromResponse(this.accessTokenResponse);
if (this.endpointMetadata?.credentialIssuerMetadata) {
const metadata = this.endpointMetadata.credentialIssuerMetadata;
const types = Array.isArray(credentialTypes) ? credentialTypes : [credentialTypes];
const types = Array.isArray(credentialTypes) ? credentialTypes.sort() : [credentialTypes];

if (metadata.credentials_supported && Array.isArray(metadata.credentials_supported)) {
for (const type of types) {
let typeSupported = false;
for (const credentialSupported of metadata.credentials_supported) {
if (!credentialSupported.types || credentialSupported.types.length === 0) {
throw Error('types is required in the credentials supported');
}
if (credentialSupported.types.indexOf(type) != -1) {
typeSupported = true;
}
let typeSupported = false;

metadata.credentials_supported.forEach((supportedCredential) => {
if (!supportedCredential.types || supportedCredential.types.length === 0) {
throw Error('types is required in the credentials supported');
}
if (!typeSupported) {
throw Error(`Not all credential types ${JSON.stringify(credentialTypes)} are supported by issuer ${this.getIssuer()}`);
if (
supportedCredential.types.sort().every((t, i) => types[i] === t) ||
(types.length === 1 && (types[0] === supportedCredential.id || supportedCredential.types.includes(types[0])))
) {
typeSupported = true;
}
});

if (!typeSupported) {
throw Error(`Not all credential types ${JSON.stringify(credentialTypes)} are supported by issuer ${this.getIssuer()}`);
}
} else if (metadata.credentials_supported && !Array.isArray(metadata.credentials_supported)) {
const credentialsSupported = metadata.credentials_supported as CredentialSupportedTypeV1_0_08;
Expand Down Expand Up @@ -389,16 +392,12 @@ export class OpenID4VCIClient {
result[0] = types;
return result;
} else {
return this.credentialOffer.credential_offer.credentials.map((c, index) => {
return this.credentialOffer.credential_offer.credentials.map((c) => {
return typeof c === 'string' ? [c] : c.types;
});
}
}

get flowType(): AuthzFlowType {
return this._flowType;
}

issuerSupportedFlowTypes(): AuthzFlowType[] {
return this.credentialOffer.supportedFlows;
}
Expand Down
27 changes: 1 addition & 26 deletions packages/client/lib/__tests__/AccessTokenClient.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,4 @@
import {
AccessTokenRequest,
AccessTokenRequestOpts,
AccessTokenResponse,
GrantTypes,
OpenIDResponse,
WellKnownEndpoints,
} from '@sphereon/oid4vci-common';
import { AccessTokenRequest, AccessTokenResponse, GrantTypes, OpenIDResponse, WellKnownEndpoints } from '@sphereon/oid4vci-common';
import nock from 'nock';

import { AccessTokenClient } from '../AccessTokenClient';
Expand Down Expand Up @@ -204,24 +197,6 @@ describe('AccessTokenClient should', () => {
).rejects.toThrow(Error('Cannot set a pin, when the pin is not required.'));
});

it('get error if code_verifier is present when flow type is pre-authorized', async () => {
const accessTokenClient: AccessTokenClient = new AccessTokenClient();

nock(MOCK_URL).post(/.*/).reply(200, {});

const requestOpts: AccessTokenRequestOpts = {
credentialOffer: INITIATION_TEST,
pin: undefined,
codeVerifier: 'RylyWGQ-dzpObnEcoMBDIH9cTAwZXk1wYzktKxsOFgA',
code: 'LWCt225yj7gzT2cWeMP4hXj4B4oIYkEiGs4T6pfez91',
redirectUri: 'http://example.com/cb',
};

await expect(() => accessTokenClient.acquireAccessToken(requestOpts)).rejects.toThrow(
Error('Cannot pass a code_verifier when flow type is pre-authorized'),
);
});

it('get error if no as, issuer and metadata values are present', async () => {
await expect(() =>
AccessTokenClient.determineTokenURL({
Expand Down
Loading