diff --git a/packages/client/lib/CredentialOfferClient.ts b/packages/client/lib/CredentialOfferClient.ts index 0ad4e294..7d3ec9dd 100644 --- a/packages/client/lib/CredentialOfferClient.ts +++ b/packages/client/lib/CredentialOfferClient.ts @@ -5,6 +5,7 @@ import { CredentialOfferRequestWithBaseUrl, CredentialOfferV1_0_11, determineSpecVersionFromURI, + getClientIdFromCredentialOfferPayload, OpenId4VCIVersion, toUniformCredentialOfferRequest, } from '@sphereon/oid4vci-common'; @@ -43,6 +44,7 @@ export class CredentialOfferClient { throw Error('Either a credential_offer or credential_offer_uri should be present in ' + uri); } } + const clientId = getClientIdFromCredentialOfferPayload(credentialOffer?.credential_offer); const request = await toUniformCredentialOfferRequest(credentialOffer, { ...opts, version, @@ -53,6 +55,7 @@ export class CredentialOfferClient { return { scheme, baseUrl, + clientId, ...request, ...(grants?.authorization_code?.issuer_state && { issuerState: grants.authorization_code.issuer_state }), ...(grants?.['urn:ietf:params:oauth:grant-type:pre-authorized_code']?.['pre-authorized_code'] && { diff --git a/packages/client/lib/OpenID4VCIClient.ts b/packages/client/lib/OpenID4VCIClient.ts index f51fea2c..3fefc136 100644 --- a/packages/client/lib/OpenID4VCIClient.ts +++ b/packages/client/lib/OpenID4VCIClient.ts @@ -139,7 +139,7 @@ export class OpenID4VCIClient { credentialOffer: credentialOfferClient, kid, alg, - clientId: clientId ?? authorizationRequest?.clientId, + clientId: clientId ?? authorizationRequest?.clientId ?? credentialOfferClient.clientId, pkce, authorizationRequest, }); diff --git a/packages/common/lib/__tests__/CredentialOfferUtil.spec.ts b/packages/common/lib/__tests__/CredentialOfferUtil.spec.ts index a8278299..bf2c0181 100644 --- a/packages/common/lib/__tests__/CredentialOfferUtil.spec.ts +++ b/packages/common/lib/__tests__/CredentialOfferUtil.spec.ts @@ -1,5 +1,5 @@ -import { determineSpecVersionFromURI } from '../functions'; -import { OpenId4VCIVersion } from '../types'; +import { determineSpecVersionFromURI, getClientIdFromCredentialOfferPayload } from '../functions'; +import { CredentialOfferPayload, OpenId4VCIVersion } from '../types'; export const UNIT_TEST_TIMEOUT = 30000; @@ -52,4 +52,53 @@ describe('CredentialOfferUtil should', () => { }, UNIT_TEST_TIMEOUT, ); + + it('get client_id from JWT pre-auth code offer', () => { + const offer = { + credential_issuer: 'https://conformance-test.ebsi.eu/conformance/v3/issuer-mock', + credentials: [ + { + format: 'jwt_vc', + types: ['VerifiableCredential', 'VerifiableAttestation', 'CTWalletCrossPreAuthorisedInTime'], + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + trust_framework: { name: 'ebsi', type: 'Accreditation', uri: 'TIR link towards accreditation' }, + }, + ], + grants: { + 'urn:ietf:params:oauth:grant-type:pre-authorized_code': { + 'pre-authorized_code': + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6IkJFTmRqRGZhdGxLai11UW92WUpsT184U2pPY1ZIdmk2SHJYS0xLRUI3UG8ifQ.eyJjbGllbnRfaWQiOiJkaWQ6a2V5OnoyZG16RDgxY2dQeDhWa2k3SmJ1dU1tRllyV1BnWW95dHlrVVozZXlxaHQxajlLYnFTWlpGakc0dFZnS2hFd0twcm9qcUxCM0MyWXBqNEg3M1N0Z2pNa1NYZzJtUXh1V0xmenVSMTJRc052Z1FXenJ6S1NmN1lSQk5yUlhLNzF2ZnExMkJieXhUTEZFWkJXZm5IcWV6QlZHUWlOTGZxZXV5d1pIZ3N0TUNjUzQ0VFhmYjIiLCJhdXRob3JpemF0aW9uX2RldGFpbHMiOlt7InR5cGUiOiJvcGVuaWRfY3JlZGVudGlhbCIsImZvcm1hdCI6Imp3dF92YyIsImxvY2F0aW9ucyI6WyJodHRwczovL2NvbmZvcm1hbmNlLXRlc3QuZWJzaS5ldS9jb25mb3JtYW5jZS92My9pc3N1ZXItbW9jayJdLCJ0eXBlcyI6WyJWZXJpZmlhYmxlQ3JlZGVudGlhbCIsIlZlcmlmaWFibGVBdHRlc3RhdGlvbiIsIkNUV2FsbGV0Q3Jvc3NQcmVBdXRob3Jpc2VkSW5UaW1lIl19XSwiaWF0IjoxNzA2MTI2NDI5LCJleHAiOjE3MDYxMjY3MjksImlzcyI6Imh0dHBzOi8vY29uZm9ybWFuY2UtdGVzdC5lYnNpLmV1L2NvbmZvcm1hbmNlL3YzL2lzc3Vlci1tb2NrIiwiYXVkIjoiaHR0cHM6Ly9jb25mb3JtYW5jZS10ZXN0LmVic2kuZXUvY29uZm9ybWFuY2UvdjMvYXV0aC1tb2NrIiwic3ViIjoiZGlkOmtleTp6MmRtekQ4MWNnUHg4VmtpN0pidXVNbUZZcldQZ1lveXR5a1VaM2V5cWh0MWo5S2JxU1paRmpHNHRWZ0toRXdLcHJvanFMQjNDMllwajRINzNTdGdqTWtTWGcybVF4dVdMZnp1UjEyUXNOdmdRV3pyektTZjdZUkJOclJYSzcxdmZxMTJCYnl4VExGRVpCV2ZuSHFlekJWR1FpTkxmcWV1eXdaSGdzdE1DY1M0NFRYZmIyIn0.Zq2o33CU4wlRNtWOIITI5qbJcuNc2c9hLwIio7OlsHBa5ZAyQR8UUU_r5EufSChe4ji15Ihrr20m5-oUiZW80A', + user_pin_required: true, + }, + }, + } as CredentialOfferPayload; + expect(getClientIdFromCredentialOfferPayload(offer)).toEqual( + 'did:key:z2dmzD81cgPx8Vki7JbuuMmFYrWPgYoytykUZ3eyqht1j9KbqSZZFjG4tVgKhEwKprojqLB3C2Ypj4H73StgjMkSXg2mQxuWLfzuR12QsNvgQWzrzKSf7YRBNrRXK71vfq12BbyxTLFEZBWfnHqezBVGQiNLfqeuywZHgstMCcS44TXfb2', + ); + }); + + it('get client_id from JWT authorization code offer', () => { + const offer = { + credential_issuer: 'https://conformance-test.ebsi.eu/conformance/v3/issuer-mock', + credentials: [ + { + format: 'jwt_vc', + types: ['VerifiableCredential', 'VerifiableAttestation', 'CTWalletCrossAuthorisedInTime'], + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + trust_framework: { name: 'ebsi', type: 'Accreditation', uri: 'TIR link towards accreditation' }, + }, + ], + grants: { + authorization_code: { + issuer_state: + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6IkJFTmRqRGZhdGxLai11UW92WUpsT184U2pPY1ZIdmk2SHJYS0xLRUI3UG8ifQ.eyJjbGllbnRfaWQiOiJkaWQ6a2V5OnoyZG16RDgxY2dQeDhWa2k3SmJ1dU1tRllyV1BnWW95dHlrVVozZXlxaHQxajlLYnFTWlpGakc0dFZnS2hFd0twcm9qcUxCM0MyWXBqNEg3M1N0Z2pNa1NYZzJtUXh1V0xmenVSMTJRc052Z1FXenJ6S1NmN1lSQk5yUlhLNzF2ZnExMkJieXhUTEZFWkJXZm5IcWV6QlZHUWlOTGZxZXV5d1pIZ3N0TUNjUzQ0VFhmYjIiLCJjcmVkZW50aWFsX3R5cGVzIjpbIlZlcmlmaWFibGVDcmVkZW50aWFsIiwiVmVyaWZpYWJsZUF0dGVzdGF0aW9uIiwiQ1RXYWxsZXRDcm9zc0F1dGhvcmlzZWRJblRpbWUiXSwiaWF0IjoxNzA2MTI1ODUwLCJleHAiOjE3MDYxMjYxNTAsImlzcyI6Imh0dHBzOi8vY29uZm9ybWFuY2UtdGVzdC5lYnNpLmV1L2NvbmZvcm1hbmNlL3YzL2lzc3Vlci1tb2NrIiwiYXVkIjoiaHR0cHM6Ly9jb25mb3JtYW5jZS10ZXN0LmVic2kuZXUvY29uZm9ybWFuY2UvdjMvYXV0aC1tb2NrIiwic3ViIjoiZGlkOmtleTp6MmRtekQ4MWNnUHg4VmtpN0pidXVNbUZZcldQZ1lveXR5a1VaM2V5cWh0MWo5S2JxU1paRmpHNHRWZ0toRXdLcHJvanFMQjNDMllwajRINzNTdGdqTWtTWGcybVF4dVdMZnp1UjEyUXNOdmdRV3pyektTZjdZUkJOclJYSzcxdmZxMTJCYnl4VExGRVpCV2ZuSHFlekJWR1FpTkxmcWV1eXdaSGdzdE1DY1M0NFRYZmIyIn0.jxzbE6OdqnJfLzSfwYcgRZQURI5UcAtuYU9gPOZYyUwjWMDtVo1k4PCYH4mnjok7pfj47ik8FnaHWE7d99u-_w', + }, + }, + } as CredentialOfferPayload; + expect(getClientIdFromCredentialOfferPayload(offer)).toEqual( + 'did:key:z2dmzD81cgPx8Vki7JbuuMmFYrWPgYoytykUZ3eyqht1j9KbqSZZFjG4tVgKhEwKprojqLB3C2Ypj4H73StgjMkSXg2mQxuWLfzuR12QsNvgQWzrzKSf7YRBNrRXK71vfq12BbyxTLFEZBWfnHqezBVGQiNLfqeuywZHgstMCcS44TXfb2', + ); + }); }); diff --git a/packages/common/lib/functions/CredentialOfferUtil.ts b/packages/common/lib/functions/CredentialOfferUtil.ts index c8df735d..5eb60dbb 100644 --- a/packages/common/lib/functions/CredentialOfferUtil.ts +++ b/packages/common/lib/functions/CredentialOfferUtil.ts @@ -1,4 +1,5 @@ import Debug from 'debug'; +import jwtDecode, { JwtPayload } from 'jwt-decode'; import { AssertedUniformCredentialOffer, @@ -68,6 +69,49 @@ export function getIssuerFromCredentialOfferPayload(request: CredentialOfferPayl return 'issuer' in request ? request.issuer : request['credential_issuer']; } +export const getClientIdFromCredentialOfferPayload = (credentialOffer?: CredentialOfferPayload): string | undefined => { + if (!credentialOffer) { + return; + } + if ('client_id' in credentialOffer) { + return credentialOffer.client_id; + } + + const state: string | undefined = getStateFromCredentialOfferPayload(credentialOffer); + if (state && isJWT(state)) { + const decoded = jwtDecode(state, { header: false }); + if ('client_id' in decoded && typeof decoded.client_id === 'string') { + return decoded.client_id; + } + } + return; +}; + +const isJWT = (input?: string) => { + if (!input) { + return false; + } + const noParts = input?.split('.').length; + return input?.startsWith('ey') && noParts === 3; +}; +export const getStateFromCredentialOfferPayload = (credentialOffer: CredentialOfferPayload): string | undefined => { + if ('grants' in credentialOffer) { + if (credentialOffer.grants?.authorization_code) { + return credentialOffer.grants.authorization_code.issuer_state; + } else if (credentialOffer.grants?.['urn:ietf:params:oauth:grant-type:pre-authorized_code']) { + return credentialOffer.grants?.['urn:ietf:params:oauth:grant-type:pre-authorized_code']?.['pre-authorized_code']; + } + } + if ('op_state' in credentialOffer) { + // older spec versions + return credentialOffer.op_state; + } else if ('pre-authorized_code' in credentialOffer) { + return credentialOffer['pre-authorized_code']; + } + + return; +}; + export function determineSpecVersionFromOffer(offer: CredentialOfferPayload | CredentialOffer): OpenId4VCIVersion { if (isCredentialOfferV1_0_11(offer)) { return OpenId4VCIVersion.VER_1_0_11; diff --git a/packages/common/lib/types/CredentialIssuance.types.ts b/packages/common/lib/types/CredentialIssuance.types.ts index 97bc51f6..934e8db2 100644 --- a/packages/common/lib/types/CredentialIssuance.types.ts +++ b/packages/common/lib/types/CredentialIssuance.types.ts @@ -18,6 +18,7 @@ export interface CredentialResponse { export interface CredentialOfferRequestWithBaseUrl extends UniformCredentialOfferRequest { scheme: string; + clientId?: string; baseUrl: string; userPinRequired: boolean; issuerState?: string; @@ -26,7 +27,9 @@ export interface CredentialOfferRequestWithBaseUrl extends UniformCredentialOffe export type CredentialOffer = CredentialOfferV1_0_09 | CredentialOfferV1_0_11; -export type CredentialOfferPayload = CredentialOfferPayloadV1_0_08 | CredentialOfferPayloadV1_0_09 | CredentialOfferPayloadV1_0_11; +export type CredentialOfferPayload = (CredentialOfferPayloadV1_0_08 | CredentialOfferPayloadV1_0_09 | CredentialOfferPayloadV1_0_11) & { + [x: string]: any; +}; export interface AssertedUniformCredentialOffer extends UniformCredentialOffer { credential_offer: UniformCredentialOfferPayload; @@ -107,6 +110,7 @@ export interface JWK extends BaseJWK { x5t?: string; 'x5t#S256'?: string; x5u?: string; + [propName: string]: unknown; } @@ -147,6 +151,7 @@ export interface JWSHeaderParameters extends JoseHeaderParameters { alg?: Alg | string; // REQUIRED by the JWT signer b64?: boolean; crit?: string[]; + [propName: string]: unknown; } @@ -172,6 +177,7 @@ export interface JWTPayload { export type JWTSignerCallback = (jwt: Jwt, kid?: string) => Promise; export type JWTVerifyCallback = (args: { jwt: string; kid?: string }) => Promise>; + export interface JwtVerifyResult { jwt: Jwt; kid?: string; diff --git a/packages/common/lib/types/v1_0_11.types.ts b/packages/common/lib/types/v1_0_11.types.ts index 43f8cc32..86528483 100644 --- a/packages/common/lib/types/v1_0_11.types.ts +++ b/packages/common/lib/types/v1_0_11.types.ts @@ -48,6 +48,11 @@ export interface CredentialOfferPayloadV1_0_11 { * using the respective metadata. When multiple grants are present, it's at the Wallet's discretion which one to use. */ grants?: Grant; + + /** + * Some implementations might include a client_id in the offer. For instance EBSI in a same-device flow. (Cross-device tucks it in the state JWT) + */ + client_id?: string; } export type CredentialRequestV1_0_11 = CommonCredentialRequest &