Skip to content

Commit

Permalink
fix: Revise well-known metadata retrieval for OID4VCI, OAuth 2.0 and …
Browse files Browse the repository at this point in the history
…OIDC. fixes #62
  • Loading branch information
nklomp committed Aug 19, 2023
1 parent 14d6461 commit a750cc7
Show file tree
Hide file tree
Showing 14 changed files with 411 additions and 333 deletions.
165 changes: 106 additions & 59 deletions packages/client/lib/MetadataClient.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import {
AuthorizationServerMetadata,
AuthorizationServerType,
CredentialIssuerMetadata,
CredentialOfferPayload,
CredentialOfferRequestWithBaseUrl,
EndpointMetadata,
EndpointMetadataResult,
getIssuerFromCredentialOfferPayload,
OAuth2ASMetadata,
Oauth2ASWithOID4VCIMetadata,
OpenIDResponse,
WellKnownEndpoints,
} from '@sphereon/oid4vci-common';
Expand All @@ -21,17 +21,18 @@ export class MetadataClient {
*
* @param credentialOffer
*/
public static async retrieveAllMetadataFromCredentialOffer(credentialOffer: CredentialOfferRequestWithBaseUrl): Promise<EndpointMetadata> {
public static async retrieveAllMetadataFromCredentialOffer(credentialOffer: CredentialOfferRequestWithBaseUrl): Promise<EndpointMetadataResult> {
return MetadataClient.retrieveAllMetadataFromCredentialOfferRequest(credentialOffer.credential_offer);
}

/**
* Retrieve the metada using the initiation request obtained from a previous step
* @param request
*/
public static async retrieveAllMetadataFromCredentialOfferRequest(request: CredentialOfferPayload): Promise<EndpointMetadata> {
if (getIssuerFromCredentialOfferPayload(request)) {
return MetadataClient.retrieveAllMetadata(getIssuerFromCredentialOfferPayload(request) as string);
public static async retrieveAllMetadataFromCredentialOfferRequest(request: CredentialOfferPayload): Promise<EndpointMetadataResult> {
const issuer = getIssuerFromCredentialOfferPayload(request);
if (issuer) {
return MetadataClient.retrieveAllMetadata(issuer);
}
throw new Error("can't retrieve metadata from CredentialOfferRequest. No issuer field is present");
}
Expand All @@ -41,75 +42,115 @@ export class MetadataClient {
* @param issuer The issuer URL
* @param opts
*/
public static async retrieveAllMetadata(issuer: string, opts?: { errorOnNotFound: boolean }): Promise<EndpointMetadata> {
let token_endpoint;
let credential_endpoint;
const response = await MetadataClient.retrieveOpenID4VCIServerMetadata(issuer);
let issuerMetadata = response?.successBody;
if (issuerMetadata) {
debug(`Issuer ${issuer} OID4VCI well-known server metadata\r\n${issuerMetadata}`);
credential_endpoint = issuerMetadata.credential_endpoint;
token_endpoint = issuerMetadata.token_endpoint;
if (!token_endpoint && issuerMetadata.authorization_server) {
debug(
`Issuer ${issuer} OID4VCI metadata has separate authorization_server ${issuerMetadata.authorization_server} that contains the token endpoint`,
);
// Crossword uses this to separate the AS metadata. We fail when not found, since we now have no way of getting the token endpoint
const response: OpenIDResponse<OAuth2ASMetadata> = await this.retrieveWellknown(
issuerMetadata.authorization_server,
WellKnownEndpoints.OAUTH_AS,
{
errorOnNotFound: true,
},
);
token_endpoint = response.successBody?.token_endpoint;
public static async retrieveAllMetadata(issuer: string, opts?: { errorOnNotFound: boolean }): Promise<EndpointMetadataResult> {
let token_endpoint: string | undefined;
let credential_endpoint: string | undefined;
let authorization_endpoint: string | undefined;
let authorizationServerType: AuthorizationServerType = 'OID4VCI';
let authorization_server: string = issuer;
const oid4vciResponse = await MetadataClient.retrieveOpenID4VCIServerMetadata(issuer, { errorOnNotFound: false }); // We will handle errors later, given we will also try other metadata locations
let credentialIssuerMetadata = oid4vciResponse?.successBody;
if (credentialIssuerMetadata) {
debug(`Issuer ${issuer} OID4VCI well-known server metadata\r\n${JSON.stringify(credentialIssuerMetadata)}`);
credential_endpoint = credentialIssuerMetadata.credential_endpoint;
if (credentialIssuerMetadata.token_endpoint) {
token_endpoint = credentialIssuerMetadata.token_endpoint;
}
if (credentialIssuerMetadata.authorization_server) {
authorization_server = credentialIssuerMetadata.authorization_server;
}
if (credentialIssuerMetadata.authorization_endpoint) {
authorization_endpoint = credentialIssuerMetadata.authorization_endpoint;
}
}
// No specific OID4VCI endpoint. Either can be an OAuth2 AS or an OIDC IDP. Let's start with OIDC first
let response: OpenIDResponse<AuthorizationServerMetadata> = await MetadataClient.retrieveWellknown(
authorization_server,
WellKnownEndpoints.OPENID_CONFIGURATION,
{
errorOnNotFound: false,
},
);
let authMetadata = response.successBody;
if (authMetadata) {
debug(`Issuer ${issuer} has OpenID Connect Server metadata in well-known location`);
authorizationServerType = 'OIDC';
} else {
// No specific OID4VCI endpoint. Either can be an OAuth2 AS or an OpenID IDP. Let's start with OIDC first
let response: OpenIDResponse<Oauth2ASWithOID4VCIMetadata> = await MetadataClient.retrieveWellknown(
issuer,
WellKnownEndpoints.OPENID_CONFIGURATION,
{
errorOnNotFound: false,
},
);
let asConfig = response.successBody;
if (asConfig) {
debug(`Issuer ${issuer} has OpenID Connect Server metadata in well-known location`);
} else {
// Now oAuth2
response = await MetadataClient.retrieveWellknown(issuer, WellKnownEndpoints.OAUTH_AS, { errorOnNotFound: false });
asConfig = response.successBody;
// Now let's do OAuth2
response = await MetadataClient.retrieveWellknown(authorization_server, WellKnownEndpoints.OAUTH_AS, { errorOnNotFound: false });
authMetadata = response.successBody;
}
if (!authMetadata) {
// We will always throw an error, no matter whether the user provided the option not to, because this is bad.
if (issuer !== authorization_server) {
throw Error(`Issuer ${issuer} provided a separate authorization server ${authorization_server}, but that server did not provide metadata`);
}
} else {
if (!authorizationServerType) {
authorizationServerType = 'OAuth 2.0';
}
if (asConfig) {
debug(`Issuer ${issuer} has oAuth2 Server metadata in well-known location`);
issuerMetadata = asConfig;
credential_endpoint = issuerMetadata.credential_endpoint;
token_endpoint = issuerMetadata.token_endpoint;
debug(`Issuer ${issuer} has ${authorizationServerType} Server metadata in well-known location`);
if (!authMetadata.authorization_endpoint) {
throw Error(`Authorization Sever ${authorization_server} did not provide an authorization_endpoint`);
} else if (authorization_endpoint && authMetadata.authorization_endpoint !== authorization_endpoint) {
throw Error(
`Credential issuer has a different authorization_endpoint (${authorization_endpoint}) from the Authorization Server (${authMetadata.authorization_endpoint})`,
);
}
authorization_endpoint = authMetadata.authorization_endpoint;
if (!authMetadata.token_endpoint) {
throw Error(`Authorization Sever ${authorization_server} did not provide a token_endpoint`);
} else if (token_endpoint && authMetadata.token_endpoint !== token_endpoint) {
throw Error(
`Credential issuer has a different token_endpoint (${token_endpoint}) from the Authorization Server (${authMetadata.token_endpoint})`,
);
}
token_endpoint = authMetadata.token_endpoint;
if (authMetadata.credential_endpoint) {
if (credential_endpoint && authMetadata.credential_endpoint !== credential_endpoint) {
debug(
`Credential issuer has a different credential_endpoint (${credential_endpoint}) from the Authorization Server (${authMetadata.token_endpoint}). Will use the issuer value`,
);
} else {
credential_endpoint = authMetadata.credential_endpoint;
}
}
}

if (!authorization_endpoint) {
debug(`Issuer ${issuer} does not expose authorization_endpoint, so only pre-auth will be supported`);
}
if (!token_endpoint) {
debug(`Issuer ${issuer} does not have a token_endpoint listed in well-known locations!`);
if (opts?.errorOnNotFound) {
throw new Error(`Could not deduce the token endpoint for ${issuer}`);
throw Error(`Could not deduce the token_endpoint for ${issuer}`);
} else {
token_endpoint = `${issuer}${issuer.endsWith('/') ? '' : '/'}token`;
token_endpoint = `${issuer}${issuer.endsWith('/') ? 'token' : '/token'}`;
}
}
if (!credential_endpoint) {
debug(`Issuer ${issuer} does not have a credential_endpoint listed in well-known locations!`);
if (opts?.errorOnNotFound) {
throw new Error(`Could not deduce the credential endpoint for ${issuer}`);
throw Error(`Could not deduce the credential endpoint for ${issuer}`);
} else {
credential_endpoint = `${issuer}${issuer.endsWith('/') ? '' : '/'}credential`;
credential_endpoint = `${issuer}${issuer.endsWith('/') ? 'credential' : '/credential'}`;
}
}

if (!credentialIssuerMetadata && authMetadata) {
// Apparently everything worked out and the issuer is exposing everything in oAuth2/OIDC well-knowns. Spec is vague about this situation, but we can support it
credentialIssuerMetadata = authMetadata as CredentialIssuerMetadata;
}
debug(`Issuer ${issuer} token endpoint ${token_endpoint}, credential endpoint ${credential_endpoint}`);
return {
issuer,
token_endpoint,
credential_endpoint,
issuerMetadata,
authorization_server,
authorization_endpoint,
authorizationServerType,
credentialIssuerMetadata: credentialIssuerMetadata,
authorizationServerMetadata: authMetadata,
};
}

Expand All @@ -118,9 +159,15 @@ export class MetadataClient {
*
* @param issuerHost The issuer hostname
*/
public static async retrieveOpenID4VCIServerMetadata(issuerHost: string): Promise<OpenIDResponse<CredentialIssuerMetadata> | undefined> {
// Since the server metadata endpoint is optional we are not going to throw an error.
return MetadataClient.retrieveWellknown(issuerHost, WellKnownEndpoints.OPENID4VCI_ISSUER, { errorOnNotFound: false });
public static async retrieveOpenID4VCIServerMetadata(
issuerHost: string,
opts?: {
errorOnNotFound?: boolean;
},
): Promise<OpenIDResponse<CredentialIssuerMetadata> | undefined> {
return MetadataClient.retrieveWellknown(issuerHost, WellKnownEndpoints.OPENID4VCI_ISSUER, {
errorOnNotFound: opts?.errorOnNotFound === undefined ? true : opts.errorOnNotFound,
});
}

/**
Expand All @@ -138,9 +185,9 @@ export class MetadataClient {
const result: OpenIDResponse<T> = await getJson(`${host.endsWith('/') ? host.slice(0, -1) : host}${endpointType}`, {
exceptionOnHttpErrorStatus: opts?.errorOnNotFound,
});
if (result.origResponse.status === 404) {
if (result.origResponse.status >= 400) {
// We only get here when error on not found is false
debug(`host ${host} with endpoint type ${endpointType} was not found (404)`);
debug(`host ${host} with endpoint type ${endpointType} status: ${result.origResponse.status}, ${result.origResponse.statusText}`);
}
return result;
}
Expand Down
35 changes: 22 additions & 13 deletions packages/client/lib/OpenID4VCIClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
CredentialResponse,
CredentialSupported,
EndpointMetadata,
EndpointMetadataResult,
OID4VCICredentialFormat,
OpenId4VCIVersion,
OpenIDResponse,
Expand Down Expand Up @@ -53,7 +54,7 @@ export class OpenID4VCIClient {
private _clientId?: string;
private _kid: string | undefined;
private _alg: Alg | string | undefined;
private _endpointMetadata: EndpointMetadata | undefined;
private _endpointMetadata: EndpointMetadataResult | undefined;
private _accessTokenResponse: AccessTokenResponse | undefined;

private constructor(
Expand Down Expand Up @@ -119,9 +120,14 @@ export class OpenID4VCIClient {
if (!scope && !authorizationDetails) {
throw Error('Please provide a scope or authorization_details');
}
// todo: handling this because of the support for v1_0-08
if (this._endpointMetadata && this._endpointMetadata.issuerMetadata && 'authorization_endpoint' in this._endpointMetadata.issuerMetadata) {
this._endpointMetadata.authorization_endpoint = this._endpointMetadata.issuerMetadata.authorization_endpoint as string;
// todo: Probably can go with current logic in MetadataClient who will always set the authorization_endpoint when found
// handling this because of the support for v1_0-08
if (
this._endpointMetadata &&
this._endpointMetadata.credentialIssuerMetadata &&
'authorization_endpoint' in this._endpointMetadata.credentialIssuerMetadata
) {
this._endpointMetadata.authorization_endpoint = this._endpointMetadata.credentialIssuerMetadata.authorization_endpoint as string;
}
if (!this._endpointMetadata?.authorization_endpoint) {
throw Error('Server metadata does not contain authorization endpoint');
Expand Down Expand Up @@ -169,13 +175,13 @@ export class OpenID4VCIClient {
// What happens if it doesn't ???
// let parEndpoint: string
if (
!this._endpointMetadata?.issuerMetadata ||
!('pushed_authorization_request_endpoint' in this._endpointMetadata.issuerMetadata) ||
typeof this._endpointMetadata.issuerMetadata.pushed_authorization_request_endpoint !== 'string'
!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.issuerMetadata.pushed_authorization_request_endpoint;
const parEndpoint: string = this._endpointMetadata.credentialIssuerMetadata.pushed_authorization_request_endpoint;

// add 'openid' scope if not present
if (scope && !scope.includes('openid')) {
Expand Down Expand Up @@ -207,7 +213,10 @@ export class OpenID4VCIClient {
}

private handleLocations(authorizationDetails: AuthDetails) {
if (authorizationDetails && (this.endpointMetadata.issuerMetadata?.authorization_server || this.endpointMetadata.authorization_endpoint)) {
if (
authorizationDetails &&
(this.endpointMetadata.credentialIssuerMetadata?.authorization_server || this.endpointMetadata.authorization_endpoint)
) {
if (authorizationDetails.locations) {
if (Array.isArray(authorizationDetails.locations)) {
(authorizationDetails.locations as string[]).push(this.endpointMetadata.issuer);
Expand Down Expand Up @@ -293,8 +302,8 @@ export class OpenID4VCIClient {
metadata: this.endpointMetadata,
});
requestBuilder.withTokenFromResponse(this.accessTokenResponse);
if (this.endpointMetadata?.issuerMetadata) {
const metadata = this.endpointMetadata.issuerMetadata;
if (this.endpointMetadata?.credentialIssuerMetadata) {
const metadata = this.endpointMetadata.credentialIssuerMetadata;
const types = Array.isArray(credentialTypes) ? credentialTypes : [credentialTypes];
if (metadata.credentials_supported && Array.isArray(metadata.credentials_supported)) {
for (const type of types) {
Expand Down Expand Up @@ -359,7 +368,7 @@ export class OpenID4VCIClient {

getCredentialsSupported(restrictToInitiationTypes: boolean, supportedType?: string): CredentialSupported[] {
return getSupportedCredentials({
issuerMetadata: this.endpointMetadata.issuerMetadata,
issuerMetadata: this.endpointMetadata.credentialIssuerMetadata,
version: this.version(),
supportedType,
credentialTypes: restrictToInitiationTypes ? this.getCredentialTypes() : undefined,
Expand Down Expand Up @@ -449,7 +458,7 @@ export class OpenID4VCIClient {
return this.credentialOffer.version;
}

public get endpointMetadata(): EndpointMetadata {
public get endpointMetadata(): EndpointMetadataResult {
this.assertServerMetadata();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return this._endpointMetadata!;
Expand Down
11 changes: 10 additions & 1 deletion packages/client/lib/__tests__/AccessTokenClient.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { AccessTokenRequest, AccessTokenRequestOpts, AccessTokenResponse, GrantTypes, OpenIDResponse } from '@sphereon/oid4vci-common';
import {
AccessTokenRequest,
AccessTokenRequestOpts,
AccessTokenResponse,
GrantTypes,
OpenIDResponse,
WellKnownEndpoints
} from '@sphereon/oid4vci-common'
import nock from 'nock';

import { AccessTokenClient } from '../AccessTokenClient';
Expand All @@ -11,6 +18,8 @@ const MOCK_URL = 'https://sphereonjunit20221013.com/';
describe('AccessTokenClient should', () => {
beforeEach(() => {
nock.cleanAll();
nock(MOCK_URL).get(WellKnownEndpoints.OAUTH_AS).reply(404, {});
nock(MOCK_URL).get(WellKnownEndpoints.OPENID_CONFIGURATION).reply(404, {});
});

afterEach(() => {
Expand Down
5 changes: 3 additions & 2 deletions packages/client/lib/__tests__/IT.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import {
CredentialOfferRequestWithBaseUrl,
Jwt,
OpenId4VCIVersion,
ProofOfPossession,
} from '@sphereon/oid4vci-common';
ProofOfPossession, WellKnownEndpoints
} from '@sphereon/oid4vci-common'
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import nock from 'nock';
Expand Down Expand Up @@ -55,6 +55,7 @@ describe('OID4VCI-Client should', () => {
function succeedWithAFullFlowWithClientSetup() {
nock(IDENTIPROOF_ISSUER_URL).get('/.well-known/openid-credential-issuer').reply(200, JSON.stringify(IDENTIPROOF_OID4VCI_METADATA));
nock(IDENTIPROOF_AS_URL).get('/.well-known/oauth-authorization-server').reply(200, JSON.stringify(IDENTIPROOF_AS_METADATA));
nock(IDENTIPROOF_AS_URL).get(WellKnownEndpoints.OPENID_CONFIGURATION).reply(404, {});
nock(IDENTIPROOF_AS_URL)
.post(/oauth2\/token.*/)
.reply(200, JSON.stringify(mockedAccessTokenResponse));
Expand Down
Loading

0 comments on commit a750cc7

Please sign in to comment.