Skip to content

Commit

Permalink
feat: add sd-jwt issuer support and e2e test
Browse files Browse the repository at this point in the history
Signed-off-by: Timo Glastra <[email protected]>
  • Loading branch information
TimoGlastra committed Nov 17, 2023
1 parent a37ef06 commit 951bf2c
Show file tree
Hide file tree
Showing 5 changed files with 203 additions and 12 deletions.
26 changes: 19 additions & 7 deletions packages/client/lib/OpenID4VCIClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
CredentialSupported,
EndpointMetadataResult,
JsonURIMode,
JWK,
KID_JWK_X5C_ERROR,
OID4VCICredentialFormat,
OpenId4VCIVersion,
ProofOfPossessionCallbacks,
Expand Down Expand Up @@ -49,6 +51,7 @@ export class OpenID4VCIClient {
private readonly _credentialOffer: CredentialOfferRequestWithBaseUrl;
private _clientId?: string;
private _kid: string | undefined;
private _jwk: JWK | undefined;
private _alg: Alg | string | undefined;
private _endpointMetadata: EndpointMetadataResult | undefined;
private _accessTokenResponse: AccessTokenResponse | undefined;
Expand Down Expand Up @@ -281,23 +284,26 @@ export class OpenID4VCIClient {
proofCallbacks,
format,
kid,
jwk,
alg,
jti,
}: {
credentialTypes: string | string[];
proofCallbacks: ProofOfPossessionCallbacks<any>;
format?: CredentialFormat | OID4VCICredentialFormat;
kid?: string;
jwk?: JWK;
alg?: Alg | string;
jti?: string;
}): Promise<CredentialResponse> {
if (alg) {
this._alg = alg;
}
if (kid) {
this._kid = kid;
if ([jwk, kid].filter((v) => v !== undefined).length > 1) {
throw new Error(KID_JWK_X5C_ERROR + `. jwk: ${jwk !== undefined}, kid: ${kid !== undefined}`);
}

if (alg) this._alg = alg;
if (jwk) this._jwk = jwk;
if (kid) this._kid = kid;

const requestBuilder = CredentialRequestClientBuilder.fromCredentialOffer({
credentialOffer: this.credentialOffer,
metadata: this.endpointMetadata,
Expand Down Expand Up @@ -339,8 +345,14 @@ export class OpenID4VCIClient {
version: this.version(),
})
.withIssuer(this.getIssuer())
.withAlg(this.alg)
.withKid(this.kid);
.withAlg(this.alg);

if (this._jwk) {
proofBuilder.withJWK(this._jwk);
}
if (this._kid) {
proofBuilder.withKid(this._kid);
}

if (this.clientId) {
proofBuilder.withClientId(this.clientId);
Expand Down
8 changes: 8 additions & 0 deletions packages/client/lib/ProofOfPossessionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
AccessTokenResponse,
Alg,
EndpointMetadata,
JWK,
Jwt,
NO_JWT_PROVIDED,
OpenId4VCIVersion,
Expand All @@ -19,6 +20,7 @@ export class ProofOfPossessionBuilder<DIDDoc> {
private readonly version: OpenId4VCIVersion;

private kid?: string;
private jwk?: JWK;
private clientId?: string;
private issuer?: string;
private jwt?: Jwt;
Expand Down Expand Up @@ -91,6 +93,11 @@ export class ProofOfPossessionBuilder<DIDDoc> {
return this;
}

withJWK(jwk: JWK): this {
this.jwk = jwk;
return this;
}

withIssuer(issuer: string): this {
this.issuer = issuer;
return this;
Expand Down Expand Up @@ -182,6 +189,7 @@ export class ProofOfPossessionBuilder<DIDDoc> {
{
typ: this.typ ?? (this.version < OpenId4VCIVersion.VER_1_0_11 ? 'jwt' : 'openid4vci-proof+jwt'),
kid: this.kid,
jwk: this.jwk,
jti: this.jti,
alg: this.alg,
issuer: this.issuer,
Expand Down
157 changes: 157 additions & 0 deletions packages/client/lib/__tests__/SdJwt.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { AccessTokenRequest, CredentialRequestV1_0_11, CredentialSupportedSdJwtVc } from '@sphereon/oid4vci-common';
import nock from 'nock';

import { OpenID4VCIClient } from '..';
import { createAccessTokenResponse, IssuerMetadataBuilderV1_11, VcIssuerBuilder } from '../../../issuer';

export const UNIT_TEST_TIMEOUT = 30000;

const alg = 'ES256';
const jwk = { kty: 'EC', crv: 'P-256', x: 'zQOowIC1gWJtdddB5GAt4lau6Lt8Ihy771iAfam-1pc', y: 'cjD_7o3gdQ1vgiQy3_sMGs7WrwCMU9FQYimA3HxnMlw' };

const issuerMetadata = new IssuerMetadataBuilderV1_11()
.withCredentialIssuer('https://example.com')
.withCredentialEndpoint('https://credenital-endpoint.example.com')
.withTokenEndpoint('https://token-endpoint.example.com')
.addSupportedCredential({
format: 'vc+sd-jwt',
credential_definition: {
vct: 'SdJwtCredential',
},
id: 'SdJwtCredentialId',
})
.build();

const vcIssuer = new VcIssuerBuilder()
.withIssuerMetadata(issuerMetadata)
.withInMemoryCNonceState()
.withInMemoryCredentialOfferState()
.withInMemoryCredentialOfferURIState()
// TODO: see if we can construct an sd-jwt vc based on the input
.withCredentialSignerCallback(async () => {
return 'sd-jwt';
})
.withJWTVerifyCallback(() =>
Promise.resolve({
alg,
jwk,
jwt: {
header: {
typ: 'openid4vci-proof+jwt',
alg,
jwk,
},
payload: {
aud: issuerMetadata.credential_issuer,
iat: +new Date(),
nonce: 'a-c-nonce',
},
},
}),
)
.build();

describe('sd-jwt vc', () => {
beforeEach(() => {
nock.cleanAll();
});
afterEach(() => {
nock.cleanAll();
});

it(
'succeed with a full flow',
async () => {
const offerUri = await vcIssuer.createCredentialOfferURI({
grants: {
'urn:ietf:params:oauth:grant-type:pre-authorized_code': {
'pre-authorized_code': '123',
user_pin_required: false,
},
},
credentials: ['SdJwtCredentialId'],
});

nock(vcIssuer.issuerMetadata.credential_issuer).get('/.well-known/openid-credential-issuer').reply(200, JSON.stringify(issuerMetadata));
nock(vcIssuer.issuerMetadata.credential_issuer).get('/.well-known/openid-configuration').reply(404);
nock(vcIssuer.issuerMetadata.credential_issuer).get('/.well-known/oauth-authorization-server').reply(404);

expect(offerUri.uri).toEqual(
'openid-credential-offer://?credential_offer=%7B%22grants%22%3A%7B%22urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Apre-authorized_code%22%3A%7B%22pre-authorized_code%22%3A%22123%22%2C%22user_pin_required%22%3Afalse%7D%7D%2C%22credentials%22%3A%5B%22SdJwtCredentialId%22%5D%2C%22credential_issuer%22%3A%22https%3A%2F%2Fexample.com%22%7D',
);

const client = await OpenID4VCIClient.fromURI({
uri: offerUri.uri,
});

expect(client.credentialOffer.credential_offer).toEqual({
credential_issuer: 'https://example.com',
credentials: ['SdJwtCredentialId'],
grants: {
'urn:ietf:params:oauth:grant-type:pre-authorized_code': {
'pre-authorized_code': '123',
user_pin_required: false,
},
},
});

const supported = client.getCredentialsSupported(true, 'vc+sd-jwt');
expect(supported).toEqual([
{
credential_definition: {
vct: 'SdJwtCredential',
},
format: 'vc+sd-jwt',
id: 'SdJwtCredentialId',
},
]);

const offered = supported[0] as CredentialSupportedSdJwtVc;

nock(issuerMetadata.token_endpoint as string)
.post('/')
.reply(200, async (_, body: string) => {
const parsedBody = Object.fromEntries(body.split('&').map((x) => x.split('=')));
return createAccessTokenResponse(parsedBody as AccessTokenRequest, {
credentialOfferSessions: vcIssuer.credentialOfferSessions,
accessTokenIssuer: 'https://issuer.example.com',
cNonces: vcIssuer.cNonces,
cNonce: 'a-c-nonce',
accessTokenSignerCallback: async () => 'ey.val.ue',
tokenExpiresIn: 500,
});
});

await client.acquireAccessToken({});

nock(issuerMetadata.credential_endpoint as string)
.post('/')
.reply(200, async (_, body) =>
vcIssuer.issueCredential({
credentialRequest: body as CredentialRequestV1_0_11,
credential: {} as any, // TODO: define the interface for credential when using sd-jwt
newCNonce: 'new-c-nonce',
}),
);

const credentials = await client.acquireCredentials({
credentialTypes: [offered.credential_definition.vct],
format: 'vc+sd-jwt',
alg,
jwk,
proofCallbacks: {
// When using sd-jwt for real, this jwt should include a jwk
signCallback: async () => 'ey.ja.ja',
},
});

expect(credentials).toEqual({
c_nonce: 'new-c-nonce',
c_nonce_expires_in: 300000,
credential: 'sd-jwt', // TODO: make this a real sd-jwt vc
format: 'vc+sd-jwt',
});
},
UNIT_TEST_TIMEOUT,
);
});
22 changes: 18 additions & 4 deletions packages/client/lib/functions/ProofUtil.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
import { BAD_PARAMS, JWS_NOT_VALID, Jwt, JWTHeader, JWTPayload, ProofOfPossession, ProofOfPossessionCallbacks, Typ } from '@sphereon/oid4vci-common';
import {
BAD_PARAMS,
BaseJWK,
JWK,
JWS_NOT_VALID,
Jwt,
JWTHeader,
JWTPayload,
ProofOfPossession,
ProofOfPossessionCallbacks,
Typ,
} from '@sphereon/oid4vci-common';
import Debug from 'debug';

const debug = Debug('sphereon:openid4vci:token');
Expand Down Expand Up @@ -61,6 +72,7 @@ const partiallyValidateJWS = (jws: string): void => {
export interface JwtProps {
typ?: Typ;
kid?: string;
jwk?: JWK;
issuer?: string;
clientId?: string;
alg?: string;
Expand All @@ -76,7 +88,8 @@ const createJWT = (jwtProps?: JwtProps, existingJwt?: Jwt): Jwt => {
const nonce = getJwtProperty<string>('nonce', false, jwtProps?.nonce, existingJwt?.payload?.nonce); // Officially this is required, but some implementations don't have it
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const alg = getJwtProperty<string>('alg', false, jwtProps?.alg, existingJwt?.header?.alg, 'ES256')!;
const kid = getJwtProperty<string>('kid', true, jwtProps?.kid, existingJwt?.header?.kid);
const kid = getJwtProperty<string>('kid', false, jwtProps?.kid, existingJwt?.header?.kid);
const jwk = getJwtProperty<BaseJWK>('jwk', false, jwtProps?.jwk, existingJwt?.header?.jwk);
const jwt: Partial<Jwt> = existingJwt ? existingJwt : {};
const now = +new Date();
const jwtPayload: Partial<JWTPayload> = {
Expand All @@ -92,15 +105,16 @@ const createJWT = (jwtProps?: JwtProps, existingJwt?: Jwt): Jwt => {
typ,
alg,
kid,
jwk,
};
return {
payload: { ...jwt.payload, ...jwtPayload },
header: { ...jwt.header, ...jwtHeader },
};
};

const getJwtProperty = <T>(propertyName: string, required: boolean, option?: string, jwtProperty?: T, defaultValue?: T): T | undefined => {
if (option && jwtProperty && option !== jwtProperty) {
const getJwtProperty = <T>(propertyName: string, required: boolean, option?: string | JWK, jwtProperty?: T, defaultValue?: T): T | undefined => {
if (typeof option === 'string' && option && jwtProperty && option !== jwtProperty) {
throw Error(`Cannot have a property '${propertyName}' with value '${option}' and different JWT value '${jwtProperty}' at the same time`);
}
let result = (jwtProperty ? jwtProperty : option) as T | undefined;
Expand Down
2 changes: 1 addition & 1 deletion packages/issuer/lib/VcIssuer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ export class VcIssuer<DIDDoc extends object> {
let preAuthorizedCode: string | undefined
let issuerState: string | undefined
try {
if (credentialRequest.format !== 'jwt_vc_json' && credentialRequest.format !== 'jwt_vc_json-ld') {
if (credentialRequest.format !== 'jwt_vc_json' && credentialRequest.format !== 'jwt_vc_json-ld' && credentialRequest.format !== 'vc+sd-jwt') {
throw Error(`Format ${credentialRequest.format} not supported yet`)
} else if (typeof this._jwtVerifyCallback !== 'function' && typeof jwtVerifyCallback !== 'function') {
throw new Error(JWT_VERIFY_CONFIG_ERROR)
Expand Down

0 comments on commit 951bf2c

Please sign in to comment.