Skip to content

Commit

Permalink
Merge pull request #80 from TimoGlastra/feat/sd-jwt-issuance-and-test
Browse files Browse the repository at this point in the history
feat: add sd-jwt issuer support and e2e test
  • Loading branch information
nklomp authored Jan 10, 2024
2 parents 1595df2 + 40c2908 commit 6966e48
Show file tree
Hide file tree
Showing 22 changed files with 287 additions and 80 deletions.
7 changes: 4 additions & 3 deletions packages/callback-example/lib/IssuerCallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { Ed25519VerificationKey2020 } from '@digitalcredentials/ed25519-verifica
import { securityLoader } from '@digitalcredentials/security-document-loader'
import vc from '@digitalcredentials/vc'
import { CredentialRequestV1_0_11 } from '@sphereon/oid4vci-common'
import { ICredential, W3CVerifiableCredential } from '@sphereon/ssi-types'
import { CredentialIssuanceInput } from '@sphereon/oid4vci-issuer'
import { W3CVerifiableCredential } from '@sphereon/ssi-types'

// Example on how to generate a did:key to issue a verifiable credential
export const generateDid = async () => {
Expand All @@ -14,12 +15,12 @@ export const generateDid = async () => {
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const getIssuerCallback = (credential: ICredential, keyPair: any, verificationMethod: string) => {
export const getIssuerCallback = (credential: CredentialIssuanceInput, keyPair: any, verificationMethod: string) => {
if (!credential) {
throw new Error('A credential needs to be provided')
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
return async (_opts: { credentialRequest?: CredentialRequestV1_0_11; credential?: ICredential }): Promise<W3CVerifiableCredential> => {
return async (_opts: { credentialRequest?: CredentialRequestV1_0_11; credential?: CredentialIssuanceInput }): Promise<W3CVerifiableCredential> => {
const documentLoader = securityLoader().build()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const verificationKey: any = Array.from(keyPair.values())[0]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ describe('issuerCallback', () => {
)
.withCredentialSignerCallback((opts) =>
Promise.resolve({
...opts.credential,
...(opts.credential as ICredential),
proof: {
type: IProofType.JwtProof2020,
jwt: 'ye.ye.ye',
Expand Down
2 changes: 1 addition & 1 deletion packages/callback-example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"@sphereon/oid4vci-client": "workspace:*",
"@sphereon/oid4vci-common": "workspace:*",
"@sphereon/oid4vci-issuer": "workspace:*",
"@sphereon/ssi-types": "0.17.2",
"@sphereon/ssi-types": "0.17.6-unstable.69",
"jose": "^4.10.0"
},
"devDependencies": {
Expand Down
4 changes: 1 addition & 3 deletions packages/client/lib/CredentialRequestClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,7 @@ export class CredentialRequestClient {
return {
format,
proof,
credential_definition: {
vct: types[0],
},
vct: types[0],
};
}

Expand Down
30 changes: 21 additions & 9 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 Expand Up @@ -399,8 +411,8 @@ export class OpenID4VCIClient {
return [c];
} else if ('types' in c) {
return c.types;
} else if ('vct' in c.credential_definition) {
return [c.credential_definition.vct];
} else if ('vct' in c) {
return [c.vct];
} else {
return c.credential_definition.types;
}
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
161 changes: 161 additions & 0 deletions packages/client/lib/__tests__/SdJwt.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
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',
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([
{
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: {
vct: 'Hello',
iss: 'did:example:123',
iat: 123,
// Defines what can be disclosed (optional)
__disclosureFrame: {
name: true,
},
},
newCNonce: 'new-c-nonce',
}),
);

const credentials = await client.acquireCredentials({
credentialTypes: [offered.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',
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/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
},
"dependencies": {
"@sphereon/oid4vci-common": "workspace:*",
"@sphereon/ssi-types": "0.17.2",
"@sphereon/ssi-types": "0.17.6-unstable.69",
"cross-fetch": "^3.1.8",
"debug": "^4.3.4"
},
Expand Down
Loading

0 comments on commit 6966e48

Please sign in to comment.