Skip to content

Commit

Permalink
feat: add sd-jwt support
Browse files Browse the repository at this point in the history
Signed-off-by: Timo Glastra <timo@animo.id>
  • Loading branch information
TimoGlastra committed Nov 16, 2023
1 parent 9f4eca6 commit a37ef06
Show file tree
Hide file tree
Showing 21 changed files with 377 additions and 144 deletions.
18 changes: 11 additions & 7 deletions packages/callback-example/lib/__tests__/issuerCallback.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { CredentialRequestClient, CredentialRequestClientBuilder, ProofOfPossess
import {
Alg,
CNonceState,
CredentialOfferLdpVcV1_0_11,
CredentialSupported,
IssuerCredentialSubjectDisplay,
IssueStatus,
Expand Down Expand Up @@ -118,19 +117,24 @@ describe('issuerCallback', () => {
credentialOffer: {
credential_offer: {
credential_issuer: 'did:key:test',
credential_definition: {
types: ['VerifiableCredential'],
'@context': ['https://www.w3.org/2018/credentials/v1'],
credentialSubject: {},
},
credentials: [
{
format: 'ldp_vc',
credential_definition: {
types: ['VerifiableCredential'],
'@context': ['https://www.w3.org/2018/credentials/v1'],
credentialSubject: {},
},
},
],
grants: {
authorization_code: { issuer_state: 'test_code' },
'urn:ietf:params:oauth:grant-type:pre-authorized_code': {
'pre-authorized_code': 'test_code',
user_pin_required: true,
},
},
} as CredentialOfferLdpVcV1_0_11,
},
},
})

Expand Down
4 changes: 2 additions & 2 deletions packages/client/lib/AuthorizationDetailsBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { AuthorizationDetailsJwtVcJson, OID4VCICredentialFormat } from '@sphereon/oid4vci-common';
import { AuthorizationDetails, AuthorizationDetailsJwtVcJson, OID4VCICredentialFormat } from '@sphereon/oid4vci-common';

//todo: refactor this builder to be able to create ldp details as well
export class AuthorizationDetailsBuilder {
private readonly authorizationDetails: Partial<AuthorizationDetailsJwtVcJson>;
private readonly authorizationDetails: Partial<Exclude<AuthorizationDetails, string>>;

constructor() {
this.authorizationDetails = {};
Expand Down
79 changes: 42 additions & 37 deletions packages/client/lib/CredentialRequestClient.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
CredentialRequestV1_0_08,
CredentialResponse,
getCredentialRequestForVersion,
getUniformFormat,
OID4VCICredentialFormat,
OpenId4VCIVersion,
OpenIDResponse,
Expand Down Expand Up @@ -53,24 +54,7 @@ export class CredentialRequestClient {
}

public async acquireCredentialsUsingRequest(uniformRequest: UniformCredentialRequest): Promise<OpenIDResponse<CredentialResponse>> {
let request: CredentialRequestV1_0_08 | UniformCredentialRequest = uniformRequest;
if (!this.isV11OrHigher()) {
let format: string = uniformRequest.format;
if (format === 'jwt_vc_json') {
format = 'jwt_vc';
} else if (format === 'jwt_vc_json-ld') {
format = 'ldp_vc';
}

request = {
format,
proof: uniformRequest.proof,
type:
'types' in uniformRequest
? uniformRequest.types.filter((t) => t !== 'VerifiableCredential')[0]
: uniformRequest.credential_definition.types[0],
} as CredentialRequestV1_0_08;
}
const request = getCredentialRequestForVersion(uniformRequest, this.version());
const credentialEndpoint: string = this.credentialRequestOpts.credentialEndpoint;
if (!isValidURL(credentialEndpoint)) {
debug(`Invalid credential endpoint: ${credentialEndpoint}`);
Expand All @@ -92,45 +76,66 @@ export class CredentialRequestClient {
const { proofInput } = opts;
const formatSelection = opts.format ?? this.credentialRequestOpts.format;

let format: OID4VCICredentialFormat = formatSelection as OID4VCICredentialFormat;
if (opts.version < OpenId4VCIVersion.VER_1_0_11) {
if (formatSelection === 'jwt_vc' || formatSelection === 'jwt') {
format = 'jwt_vc_json';
} else if (formatSelection === 'ldp_vc' || formatSelection === 'ldp') {
format = 'jwt_vc_json-ld';
}
}

if (!format) {
if (!formatSelection) {
throw Error(`Format of credential to be issued is missing`);
} else if (format !== 'jwt_vc_json-ld' && format !== 'jwt_vc_json' && format !== 'ldp_vc') {
throw Error(`Invalid format of credential to be issued: ${format}`);
}
const format = getUniformFormat(formatSelection);
const typesSelection =
opts?.credentialTypes && (typeof opts.credentialTypes === 'string' || opts.credentialTypes.length > 0)
? opts.credentialTypes
: this.credentialRequestOpts.credentialTypes;
const types = Array.isArray(typesSelection) ? typesSelection : [typesSelection];
if (types.length === 0) {
throw Error(`Credential type(s) need to be provided`);
} else if (!this.isV11OrHigher() && types.length !== 1) {
}
// FIXME: this is mixing up the type (as id) from v8/v9 and the types (from the vc.type) from v11
else if (!this.isV11OrHigher() && types.length !== 1) {
throw Error('Only a single credential type is supported for V8/V9');
}

const proof =
'proof_type' in proofInput
? await ProofOfPossessionBuilder.fromProof(proofInput as ProofOfPossession, opts.version).build()
: await proofInput.build();
return {
types,
format,
proof,
} as UniformCredentialRequest;

// TODO: we should move format specific logic
if (format === 'jwt_vc_json') {
return {
types,
format,
proof,
};
} else if (format === 'jwt_vc_json-ld' || format === 'ldp_vc') {
return {
format,
proof,
credential_definition: {
types,
// FIXME: this was not included in the original code, but it is required
'@context': [],
},
};
} else if (format === 'vc+sd-jwt') {
if (types.length > 1) {
throw Error(`Only a single credential type is supported for ${format}`);
}

return {
format,
proof,
credential_definition: {
vct: types[0],
},
};
}

throw new Error(`Unsupported format: ${format}`);
}

private version(): OpenId4VCIVersion {
return this.credentialRequestOpts?.version ?? OpenId4VCIVersion.VER_1_0_11;
}

private isV11OrHigher(): boolean {
return this.version() >= OpenId4VCIVersion.VER_1_0_11;
}
Expand Down
3 changes: 2 additions & 1 deletion packages/client/lib/CredentialRequestClientBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
determineSpecVersionFromOffer,
EndpointMetadata,
getIssuerFromCredentialOfferPayload,
getTypesFromOffer,
OID4VCICredentialFormat,
OpenId4VCIVersion,
UniformCredentialOfferRequest,
Expand Down Expand Up @@ -46,7 +47,7 @@ export class CredentialRequestClientBuilder {
builder.withCredentialType((request.original_credential_offer as CredentialOfferPayloadV1_0_08).credential_type);
} else {
// todo: look whether this is correct
builder.withCredentialType(request.credential_offer.credentials.flatMap((c) => (typeof c === 'string' ? c : c.types)));
builder.withCredentialType(getTypesFromOffer(request.credential_offer));
}

return builder;
Expand Down
20 changes: 13 additions & 7 deletions packages/client/lib/OpenID4VCIClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
PushedAuthorizationResponse,
ResponseType,
} from '@sphereon/oid4vci-common';
import { getSupportedCredentials } from '@sphereon/oid4vci-common/dist/functions/IssuerMetadataUtils';
import { getSupportedCredentials, getTypesFromCredentialSupported } from '@sphereon/oid4vci-common/dist/functions/IssuerMetadataUtils';
import { CredentialSupportedTypeV1_0_08 } from '@sphereon/oid4vci-common/dist/types/v1_0_08.types';
import { CredentialFormat } from '@sphereon/ssi-types';
import Debug from 'debug';
Expand Down Expand Up @@ -312,12 +312,10 @@ export class OpenID4VCIClient {
let typeSupported = false;

metadata.credentials_supported.forEach((supportedCredential) => {
if (!supportedCredential.types || supportedCredential.types.length === 0) {
throw Error('types is required in the credentials supported');
}
const subTypes = getTypesFromCredentialSupported(supportedCredential);
if (
supportedCredential.types.sort().every((t, i) => types[i] === t) ||
(types.length === 1 && (types[0] === supportedCredential.id || supportedCredential.types.includes(types[0])))
subTypes.sort().every((t, i) => types[i] === t) ||
(types.length === 1 && (types[0] === supportedCredential.id || subTypes.includes(types[0])))
) {
typeSupported = true;
}
Expand Down Expand Up @@ -397,7 +395,15 @@ export class OpenID4VCIClient {
return result;
} else {
return this.credentialOffer.credential_offer.credentials.map((c) => {
return typeof c === 'string' ? [c] : c.types;
if (typeof c === 'string') {
return [c];
} else if ('types' in c) {
return c.types;
} else if ('vct' in c.credential_definition) {
return [c.credential_definition.vct];
} else {
return c.credential_definition.types;
}
});
}
}
Expand Down
19 changes: 12 additions & 7 deletions packages/client/lib/__tests__/CredentialRequestClient.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { KeyObject } from 'crypto';
import {
Alg,
EndpointMetadata,
getCredentialRequestForVersion,
getIssuerFromCredentialOfferPayload,
Jwt,
OpenId4VCIVersion,
Expand Down Expand Up @@ -149,9 +150,7 @@ describe('Credential Request Client ', () => {
.withKid(kid)
.withClientId('sphereon:wallet')
.build();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
await expect(credReqClient.acquireCredentialsUsingRequest({ format: 'jwt_vc_json-ld', types: ['random'], proof })).rejects.toThrow(
await expect(credReqClient.acquireCredentialsUsingRequest({ format: 'jwt_vc_json', types: ['random'], proof })).rejects.toThrow(
Error(URL_NOT_VALID),
);
});
Expand Down Expand Up @@ -194,10 +193,11 @@ describe('Credential Request Client with different issuers ', () => {
jwt: getMockData('spruce')?.credential.request.proof.jwt as string,
},
credentialTypes: ['OpenBadgeCredential'],
format: 'jwt_vc_json-ld',
format: 'jwt_vc',
version: OpenId4VCIVersion.VER_1_0_08,
});
expect(credentialRequest).toEqual(getMockData('spruce')?.credential.request);
const draft8CredentialRequest = getCredentialRequestForVersion(credentialRequest, OpenId4VCIVersion.VER_1_0_08);
expect(draft8CredentialRequest).toEqual(getMockData('spruce')?.credential.request);
});

it('should create correct CredentialRequest for Walt', async () => {
Expand Down Expand Up @@ -264,7 +264,8 @@ describe('Credential Request Client with different issuers ', () => {
format: 'ldp_vc',
version: OpenId4VCIVersion.VER_1_0_08,
});
expect(credentialOffer).toEqual(getMockData('mattr')?.credential.request);
const credentialRequest = getCredentialRequestForVersion(credentialOffer, OpenId4VCIVersion.VER_1_0_08);
expect(credentialRequest).toEqual(getMockData('mattr')?.credential.request);
});

it('should create correct CredentialRequest for diwala', async () => {
Expand All @@ -286,6 +287,10 @@ describe('Credential Request Client with different issuers ', () => {
format: 'ldp_vc',
version: OpenId4VCIVersion.VER_1_0_08,
});
expect(credentialOffer).toEqual(getMockData('diwala')?.credential.request);

// createCredentialRequest returns uniform format in draft 11
const credentialRequest = getCredentialRequestForVersion(credentialOffer, OpenId4VCIVersion.VER_1_0_08);

expect(credentialRequest).toEqual(getMockData('diwala')?.credential.request);
});
});
25 changes: 13 additions & 12 deletions packages/client/lib/__tests__/data/VciDataFixtures.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CredentialSupportedBrief, IssuerCredentialSubjectDisplay, IssuerMetadataV1_0_08 } from '@sphereon/oid4vci-common';
import { CredentialSupportedFormatV1_0_08, IssuerCredentialSubjectDisplay, IssuerMetadataV1_0_08 } from '@sphereon/oid4vci-common';
import { ICredentialStatus, W3CVerifiableCredential } from '@sphereon/ssi-types';

export function getMockData(issuerName: string): IssuerMockData | null {
Expand Down Expand Up @@ -42,7 +42,8 @@ export interface IssuerMockData {
url: string;
deeplink: string;
request: {
types: [string];
types?: [string];
type?: string;
format: 'jwt_vc' | 'ldp_vc' | 'jwt_vc_json-ld' | string;
proof: {
proof_type: 'jwt' | string;
Expand Down Expand Up @@ -110,8 +111,8 @@ const mockData: VciMockDataStructure = {
deeplink:
'openid-initiate-issuance://?issuer=https%3A%2F%2Fngi%2Doidc4vci%2Dtest%2Espruceid%2Exyz&credential_type=OpenBadgeCredential&pre-authorized_code=eyJhbGciOiJFUzI1NiJ9.eyJjcmVkZW50aWFsX3R5cGUiOlsiT3BlbkJhZGdlQ3JlZGVudGlhbCJdLCJleHAiOiIyMDIzLTA0LTIwVDA5OjA0OjM2WiIsIm5vbmNlIjoibWFibmVpT0VSZVB3V3BuRFFweEt3UnRsVVRFRlhGUEwifQ.qOZRPN8sTv_knhp7WaWte2-aDULaPZX--2i9unF6QDQNUllqDhvxgIHMDCYHCV8O2_Gj-T2x1J84fDMajE3asg&user_pin_required=false',
request: {
types: ['OpenBadgeCredential'],
format: 'jwt_vc_json-ld',
type: 'OpenBadgeCredential',
format: 'jwt_vc',
proof: {
proof_type: 'jwt',
jwt: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NksiLCJraWQiOiJkaWQ6andrOmV5SmhiR2NpT2lKRlV6STFOa3NpTENKMWMyVWlPaUp6YVdjaUxDSnJkSGtpT2lKRlF5SXNJbU55ZGlJNkluTmxZM0F5TlRack1TSXNJbmdpT2lKclpuVmpTa0V0VEhKck9VWjBPRmx5TFVkMlQzSmpia3N3YjNkc2RqUlhNblUwU3pJeFNHZHZTVlIzSWl3aWVTSTZJalozY0ZCUE1rOUNRVXBTU0ZFMVRXdEtXVlJaV0dsQlJFUXdOMU5OTlV0amVXcDNYMkUzVUUxWmVGa2lmUSMwIn0.eyJhdWQiOiJodHRwczovL25naS1vaWRjNHZjaS10ZXN0LnNwcnVjZWlkLnh5eiIsImlhdCI6MTY4MTkxMTA2MC45NDIsImV4cCI6MTY4MTkxMTcyMC45NDIsImlzcyI6InNwaGVyZW9uOnNzaS13YWxsZXQiLCJqdGkiOiJhNjA4MzMxZi02ZmE0LTQ0ZjAtYWNkZWY5NmFjMjdmNmQ3MCJ9.NwF3_41gwnlIdd_6Uk9CczeQHzIQt6UcvTT5Cxv72j9S1vNwiY9annA2kLsjsTiR5-WMBdUhJCO7wYCtZ15mxw',
Expand Down Expand Up @@ -514,7 +515,7 @@ const mockData: VciMockDataStructure = {
types: ['PermanentResidentCard'],
binding_methods_supported: ['did'],
cryptographic_suites_supported: ['Ed25519Signature2018'],
} as CredentialSupportedBrief,
} as CredentialSupportedFormatV1_0_08,
},
},
AcademicAward: {
Expand All @@ -525,7 +526,7 @@ const mockData: VciMockDataStructure = {
types: ['AcademicAward'],
binding_methods_supported: ['did'],
cryptographic_suites_supported: ['Ed25519Signature2018'],
} as CredentialSupportedBrief,
} as CredentialSupportedFormatV1_0_08,
},
},
LearnerProfile: {
Expand All @@ -536,7 +537,7 @@ const mockData: VciMockDataStructure = {
types: ['LearnerProfile'],
binding_methods_supported: ['did'],
cryptographic_suites_supported: ['Ed25519Signature2018'],
} as CredentialSupportedBrief,
} as CredentialSupportedFormatV1_0_08,
},
},
OpenBadgeCredential: {
Expand All @@ -547,7 +548,7 @@ const mockData: VciMockDataStructure = {
types: ['OpenBadgeCredential'],
binding_methods_supported: ['did'],
cryptographic_suites_supported: ['Ed25519Signature2018'],
} as CredentialSupportedBrief,
} as CredentialSupportedFormatV1_0_08,
},
},
},
Expand All @@ -573,8 +574,8 @@ const mockData: VciMockDataStructure = {
'openid-initiate-issuance://?issuer=https://launchpad.mattrlabs.com&credential_type=OpenBadgeCredential&pre-authorized_code=g0UCOj6RAN5AwHU6gczm_GzB4_lH6GW39Z0Dl2DOOiO',
url: 'https://launchpad.vii.electron.mattrlabs.io/oidc/v1/auth/credential',
request: {
types: ['OpenBadgeCredential'],
format: 'jwt_vc_json-ld',
type: 'OpenBadgeCredential',
format: 'ldp_vc',
proof: {
proof_type: 'jwt',
jwt: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa3AxM3N6QUFMVFN0cDV1OGtMcnl5YW5vYWtrVWtFUGZXazdvOHY3dms0RW1KI3o2TWtwMTNzekFBTFRTdHA1dThrTHJ5eWFub2Fra1VrRVBmV2s3bzh2N3ZrNEVtSiJ9.eyJhdWQiOiJodHRwczovL2xhdW5jaHBhZC5tYXR0cmxhYnMuY29tIiwiaWF0IjoxNjgxOTE0NDgyLjUxOSwiZXhwIjoxNjgxOTE1MTQyLjUxOSwiaXNzIjoic3BoZXJlb246c3NpLXdhbGxldCIsImp0aSI6ImI5NDY1ZGE5LTY4OGYtNDdjNi04MjUwNDA0ZGNiOWI5Y2E5In0.uQ8ewOfIjy_1p_Gk6PjeEWccBJnjOca1pwbTWiCAFMQX9wlIsfeUdGtXUoHjH5_PQtpwytodx7WU456_CT9iBQ',
Expand Down Expand Up @@ -687,8 +688,8 @@ const mockData: VciMockDataStructure = {
'openid-initiate-issuance://?issuer=https://oidc4vc.diwala.io&amp;credential_type=OpenBadgeCredential&amp;pre-authorized_code=eyJhbGciOiJIUzI1NiJ9.eyJjcmVkZW50aWFsX3R5cGUiOiJPcGVuQmFkZ2VDcmVkZW50aWFsIiwiZXhwIjoxNjgxOTg0NDY3fQ.fEAHKz2nuWfiYHw406iNxr-81pWkNkbi31bWsYSf6Ng',
url: 'https://oidc4vc.diwala.io/credential',
request: {
types: ['OpenBadgeCredential'],
format: 'jwt_vc_json-ld',
type: 'OpenBadgeCredential',
format: 'ldp_vc',
proof: {
proof_type: 'jwt',
jwt: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa3AxM3N6QUFMVFN0cDV1OGtMcnl5YW5vYWtrVWtFUGZXazdvOHY3dms0RW1KI3o2TWtwMTNzekFBTFRTdHA1dThrTHJ5eWFub2Fra1VrRVBmV2s3bzh2N3ZrNEVtSiJ9.eyJhdWQiOiJodHRwczovL29pZGM0dmMuZGl3YWxhLmlvIiwiaWF0IjoxNjgxOTE1MDk1LjIwMiwiZXhwIjoxNjgxOTE1NzU1LjIwMiwiaXNzIjoic3BoZXJlb246c3NpLXdhbGxldCIsImp0aSI6IjYxN2MwM2EzLTM3MTUtNGJlMy1hYjkxNzM4MTlmYzYxNTYzIn0.KA-cHjecaYp9FSaWHkz5cqtNyhBIVT_0I7cJnpHn03T4UWFvdhjhn8Hpe-BU247enFyWOWJ6v3NQZyZgle7xBA',
Expand Down
27 changes: 27 additions & 0 deletions packages/common/lib/functions/CredentialOfferUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,3 +340,30 @@ function recordVersion(currentVersion: OpenId4VCIVersion, matchingVersion: OpenI
`Invalid param. Some keys have been used from version: ${currentVersion} version while '${key}' is used from version: ${matchingVersion}`,
);
}

export function getTypesFromOffer(credentialOffer: UniformCredentialOfferPayload, opts?: { filterVerifiableCredential: boolean }) {
const types = credentialOffer.credentials.reduce<string[]>((prev, curr) => {
// FIXME returning the string value is wrong (as it's an id), but just matching the current behavior of this library
// The credential_type (from draft 8) and the actual 'type' value in a VC (from draft 11) are mixed up
// Fix for this here: https://github.com/Sphereon-Opensource/OID4VCI/pull/54
if (typeof curr === 'string') {
return [...prev, curr];
} else if (curr.format === 'jwt_vc_json-ld' || curr.format === 'ldp_vc') {
return [...prev, ...curr.credential_definition.types];
} else if (curr.format === 'jwt_vc_json') {
return [...prev, ...curr.types];
} else if (curr.format === 'vc+sd-jwt') {
return [...prev, curr.credential_definition.vct];
}

return prev;
}, []);

if (!types || types.length === 0) {
throw Error('Could not deduce types from credential offer');
}
if (opts?.filterVerifiableCredential) {
return types.filter((type) => type !== 'VerifiableCredential');
}
return types;
}
Loading

0 comments on commit a37ef06

Please sign in to comment.