Skip to content

Commit

Permalink
feat: Add wallet signing support to VCI and notification support
Browse files Browse the repository at this point in the history
  • Loading branch information
nklomp committed Jun 9, 2024
1 parent e25e94c commit c4d3483
Show file tree
Hide file tree
Showing 38 changed files with 3,290 additions and 2,987 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { CredentialDataSupplierResult } from '@sphereon/oid4vci-issuer/dist/type
import { ICredential, IProofPurpose, IProofType, W3CVerifiableCredential } from '@sphereon/ssi-types'
import { DIDDocument } from 'did-resolver'
import * as jose from 'jose'
import { v4 } from 'uuid'

import { generateDid, getIssuerCallback, verifyCredential } from '../IssuerCallback'

Expand All @@ -37,7 +38,7 @@ async function proofOfPossessionCallbackFunction(args: Jwt, kid?: string): Promi
}
return await new jose.SignJWT({ ...args.payload })
.setProtectedHeader({ ...args.header })
.setIssuedAt(args.payload.iat ?? Math.round(+new Date()/1000))
.setIssuedAt(args.payload.iat ?? Math.round(+new Date() / 1000))
.setIssuer(kid)
.setAudience(args.payload.aud)
.setExpirationTime('2h')
Expand Down Expand Up @@ -113,6 +114,7 @@ describe('issuerCallback', () => {
createdAt: +new Date(),
lastUpdatedAt: +new Date(),
status: IssueStatus.OFFER_CREATED,
notification_id: v4(),
userPin: '123456',
credentialOffer: {
credential_offer: {
Expand Down Expand Up @@ -267,6 +269,7 @@ describe('issuerCallback', () => {

expect(credentialResponse).toEqual({
c_nonce: expect.any(String),
notification_id: expect.any(String),
c_nonce_expires_in: 300,
credential: {
'@context': ['https://www.w3.org/2018/credentials/v1', 'https://w3id.org/security/suites/ed25519-2020/v1'],
Expand Down
25 changes: 12 additions & 13 deletions packages/client/lib/AccessTokenClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {
assertedUniformCredentialOffer,
AuthorizationServerOpts,
AuthzFlowType,
convertJsonToURI,
EndpointMetadata,
formPost,
getIssuerFromCredentialOfferPayload,
GrantTypes,
IssuerOpts,
Expand All @@ -17,12 +19,9 @@ import {
UniformCredentialOfferPayload,
} from '@sphereon/oid4vci-common';
import { ObjectUtils } from '@sphereon/ssi-types';
import Debug from 'debug';

import { MetadataClient } from './MetadataClient';
import { convertJsonToURI, formPost } from './functions';

const debug = Debug('sphereon:oid4vci:token');
import { LOG } from './types';

export class AccessTokenClient {
public async acquireAccessToken(opts: AccessTokenRequestOpts): Promise<OpenIDResponse<AccessTokenResponse>> {
Expand Down Expand Up @@ -117,7 +116,7 @@ export class AccessTokenClient {
return request as AccessTokenRequest;
}

throw new Error('Credential offer request does not follow neither pre-authorized code nor authorization code flow requirements.');
throw new Error('Credential offer request follows neither pre-authorized code nor authorization code flow requirements.');
}

private assertPreAuthorizedGrantType(grantType: GrantTypes): void {
Expand All @@ -141,39 +140,39 @@ export class AccessTokenClient {
if (requestPayload.grants?.['urn:ietf:params:oauth:grant-type:pre-authorized_code']) {
isPinRequired = requestPayload.grants['urn:ietf:params:oauth:grant-type:pre-authorized_code']?.user_pin_required ?? false;
}
debug(`Pin required for issuer ${issuer}: ${isPinRequired}`);
LOG.warning(`Pin required for issuer ${issuer}: ${isPinRequired}`);
return isPinRequired;
}

private assertNumericPin(isPinRequired?: boolean, pin?: string): void {
if (isPinRequired) {
if (!pin || !/^\d{1,8}$/.test(pin)) {
debug(`Pin is not 1 to 8 digits long`);
LOG.warning(`Pin is not 1 to 8 digits long`);
throw new Error('A valid pin consisting of maximal 8 numeric characters must be present.');
}
} else if (pin) {
debug(`Pin set, whilst not required`);
LOG.warning(`Pin set, whilst not required`);
throw new Error('Cannot set a pin, when the pin is not required.');
}
}

private assertNonEmptyPreAuthorizedCode(accessTokenRequest: AccessTokenRequest): void {
if (!accessTokenRequest[PRE_AUTH_CODE_LITERAL]) {
debug(`No pre-authorized code present, whilst it is required`);
LOG.warning(`No pre-authorized code present, whilst it is required`, accessTokenRequest);
throw new Error('Pre-authorization must be proven by presenting the pre-authorized code. Code must be present.');
}
}

private assertNonEmptyCodeVerifier(accessTokenRequest: AccessTokenRequest): void {
if (!accessTokenRequest.code_verifier) {
debug('No code_verifier present, whilst it is required');
LOG.warning('No code_verifier present, whilst it is required', accessTokenRequest);
throw new Error('Authorization flow requires the code_verifier to be present');
}
}

private assertNonEmptyCode(accessTokenRequest: AccessTokenRequest): void {
if (!accessTokenRequest.code) {
debug('No code present, whilst it is required');
LOG.warning('No code present, whilst it is required');
throw new Error('Authorization flow requires the code to be present');
}
}
Expand Down Expand Up @@ -222,7 +221,7 @@ export class AccessTokenClient {
if (!url || !ObjectUtils.isString(url)) {
throw new Error('No authorization server token URL present. Cannot acquire access token');
}
debug(`Token endpoint determined to be ${url}`);
LOG.debug(`Token endpoint determined to be ${url}`);
return url;
}

Expand All @@ -239,7 +238,7 @@ export class AccessTokenClient {
}

private throwNotSupportedFlow(): void {
debug(`Only pre-authorized or authorization code flows supported.`);
LOG.warning(`Only pre-authorized or authorization code flows supported.`);
throw new Error('Only pre-authorized-code or authorization code flows are supported');
}
}
4 changes: 2 additions & 2 deletions packages/client/lib/CredentialOfferClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {
convertJsonToURI,
convertURIToJsonObject,
CredentialOffer,
CredentialOfferPayload,
CredentialOfferPayloadV1_0_09,
Expand All @@ -11,8 +13,6 @@ import {
} from '@sphereon/oid4vci-common';
import Debug from 'debug';

import { convertJsonToURI, convertURIToJsonObject } from './functions';

const debug = Debug('sphereon:oid4vci:offer');

export class CredentialOfferClient {
Expand Down
46 changes: 43 additions & 3 deletions packages/client/lib/CredentialRequestClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,33 @@ import {
getCredentialRequestForVersion,
getUniformFormat,
isDeferredCredentialResponse,
isValidURL,
NotificationErrorResponse,
NotificationRequest,
NotificationResult,
OID4VCICredentialFormat,
OpenId4VCIVersion,
OpenIDResponse,
post,
ProofOfPossession,
UniformCredentialRequest,
URL_NOT_VALID,
} from '@sphereon/oid4vci-common';
import { ExperimentalSubjectIssuance } from '@sphereon/oid4vci-common/dist/experimental/holder-vci';
import { CredentialFormat } from '@sphereon/ssi-types';
import Debug from 'debug';

import { CredentialRequestClientBuilder } from './CredentialRequestClientBuilder';
import { ProofOfPossessionBuilder } from './ProofOfPossessionBuilder';
import { isValidURL, post } from './functions';
import { LOG } from './types';

const debug = Debug('sphereon:oid4vci:credential');

export interface CredentialRequestOpts {
deferredCredentialAwait?: boolean;
deferredCredentialIntervalInMS?: number;
credentialEndpoint: string;
notificationEndpoint?: string;
deferredCredentialEndpoint?: string;
credentialTypes: string[];
format?: CredentialFormat | OID4VCICredentialFormat;
Expand Down Expand Up @@ -80,10 +87,11 @@ export class CredentialRequestClient {
credentialTypes?: string | string[];
context?: string[];
format?: CredentialFormat | OID4VCICredentialFormat;
subjectIssuance?: ExperimentalSubjectIssuance;
}): Promise<OpenIDResponse<CredentialResponse>> {
const { credentialTypes, proofInput, format, context } = opts;
const { credentialTypes, proofInput, format, context, subjectIssuance } = opts;

const request = await this.createCredentialRequest({ proofInput, credentialTypes, context, format, version: this.version() });
const request = await this.createCredentialRequest({ proofInput, credentialTypes, context, format, version: this.version(), subjectIssuance });
return await this.acquireCredentialsUsingRequest(request);
}

Expand All @@ -103,6 +111,11 @@ export class CredentialRequestClient {
response = await this.acquireDeferredCredential(response.successBody, { bearerToken: this.credentialRequestOpts.token });
}

if ((uniformRequest.credential_subject_issuance && response.successBody) || response.successBody?.credential_subject_issuance) {
if (uniformRequest.credential_subject_issuance !== response.successBody?.credential_subject_issuance) {
throw Error('Subject signing was requested, but issuer did not provide the options in its response');
}
}
debug(`Credential endpoint ${credentialEndpoint} response:\r\n${JSON.stringify(response, null, 2)}`);
return response;
}
Expand Down Expand Up @@ -136,6 +149,7 @@ export class CredentialRequestClient {
credentialTypes?: string | string[];
context?: string[];
format?: CredentialFormat | OID4VCICredentialFormat;
subjectIssuance?: ExperimentalSubjectIssuance;
version: OpenId4VCIVersion;
}): Promise<UniformCredentialRequest> {
const { proofInput } = opts;
Expand Down Expand Up @@ -165,6 +179,7 @@ export class CredentialRequestClient {
types,
format,
proof,
...opts.subjectIssuance,
};
} else if (format === 'jwt_vc_json-ld' || format === 'ldp_vc') {
if (this.version() >= OpenId4VCIVersion.VER_1_0_12 && !opts.context) {
Expand All @@ -174,6 +189,7 @@ export class CredentialRequestClient {
return {
format,
proof,
...opts.subjectIssuance,

// Ignored because v11 does not have the context value, but it is required in v12
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
Expand All @@ -192,12 +208,36 @@ export class CredentialRequestClient {
format,
proof,
vct: types[0],
...opts.subjectIssuance,
};
}

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

public async sendNotification(request: NotificationRequest, accessToken: string): Promise<NotificationResult> {
LOG.info(`Sending status notification event '${request.event}' for id ${request.notification_id}`);
if (!this.credentialRequestOpts.notificationEndpoint) {
throw Error(`Cannot send notification when no notification endpoint is provided`);
}
const response = await post<NotificationErrorResponse>(this.credentialRequestOpts.notificationEndpoint, JSON.stringify(request), {
bearerToken: accessToken,
});
const error = response.errorBody?.error !== undefined;
const result = {
error,
response: error ? await response.errorBody?.json() : undefined,
};
if (error) {
LOG.warning(
`Notification endpoint returned an error for event '${request.event}' and id ${request.notification_id}: ${await response.errorBody?.json()}`,
);
} else {
LOG.debug(`Notification endpoint returned success for event '${request.event}' and id ${request.notification_id}`);
}
return result;
}

private version(): OpenId4VCIVersion {
return this.credentialRequestOpts?.version ?? OpenId4VCIVersion.VER_1_0_11;
}
Expand Down
3 changes: 1 addition & 2 deletions packages/client/lib/MetadataClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,12 @@ import {
CredentialOfferRequestWithBaseUrl,
EndpointMetadataResult,
getIssuerFromCredentialOfferPayload,
getJson,
OpenIDResponse,
WellKnownEndpoints,
} from '@sphereon/oid4vci-common';
import Debug from 'debug';

import { getJson } from './functions';

const debug = Debug('sphereon:oid4vci:metadata');

export class MetadataClient {
Expand Down
3 changes: 1 addition & 2 deletions packages/client/lib/ProofOfPossessionBuilder.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
AccessTokenResponse,
Alg,
createProofOfPossession,
EndpointMetadata,
JWK,
Jwt,
Expand All @@ -12,8 +13,6 @@ import {
Typ,
} from '@sphereon/oid4vci-common';

import { createProofOfPossession } from './functions';

export class ProofOfPossessionBuilder<DIDDoc> {
private readonly proof?: ProofOfPossession;
private readonly callbacks?: ProofOfPossessionCallbacks<DIDDoc>;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// Walt uses a self signed cert
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';

import { KeyObject } from 'crypto';

import {
Expand Down Expand Up @@ -164,7 +167,8 @@ describe('Credential Request Client with Walt.id ', () => {
afterEach(() => {
nock.cleanAll();
});
it('should have correct metadata endpoints', async function () {
// Walt id has cert issue
it.skip('should have correct metadata endpoints', async function () {
nock.cleanAll();
const WALT_IRR_URI =
'openid-initiate-issuance://?issuer=https%3A%2F%2Fjff.walt.id%2Fissuer-api%2Foidc%2F&credential_type=OpenBadgeCredential&pre-authorized_code=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhOTUyZjUxNi1jYWVmLTQ4YjMtODIxYy00OTRkYzgyNjljZjAiLCJwcmUtYXV0aG9yaXplZCI6dHJ1ZX0.YE5DlalcLC2ChGEg47CQDaN1gTxbaQqSclIVqsSAUHE&user_pin_required=false';
Expand Down
2 changes: 1 addition & 1 deletion packages/client/lib/__tests__/HttpUtils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isValidURL } from '../functions';
import { isValidURL } from '@sphereon/oid4vci-common';

describe('httputils.isValidURL', () => {
it('Should return true for http://localhost', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { IDENTIPROOF_ISSUER_URL } from './MetadataMocks';

const jwt: Jwt = {
header: { alg: Alg.ES256, kid: 'did:example:ebfeb1f712ebc6f1c276e12ec21/keys/1', typ: 'jwt' },
payload: { iss: 'sphereon:wallet', nonce: 'tZignsnFbp', jti: 'tZignsnFbp223', aud: IDENTIPROOF_ISSUER_URL, iat: Date.now()/1000 },
payload: { iss: 'sphereon:wallet', nonce: 'tZignsnFbp', jti: 'tZignsnFbp223', aud: IDENTIPROOF_ISSUER_URL, iat: Date.now() / 1000 },
};

const kid = 'did:example:ebfeb1f712ebc6f1c276e12ec21/keys/1';
Expand Down
3 changes: 2 additions & 1 deletion packages/client/lib/__tests__/SdJwt.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const vcIssuer = new VcIssuerBuilder()
},
payload: {
aud: issuerMetadata.credential_issuer,
iat: +new Date()/1000,
iat: +new Date() / 1000,
nonce: 'a-c-nonce',
},
},
Expand Down Expand Up @@ -152,6 +152,7 @@ describe('sd-jwt vc', () => {
});

expect(credentials).toEqual({
notification_id: expect.any(String),
c_nonce: 'new-c-nonce',
c_nonce_expires_in: 300,
credential: 'sd-jwt',
Expand Down
3 changes: 2 additions & 1 deletion packages/client/lib/__tests__/SphereonE2E.spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,8 @@ async function proofOfPossessionCallbackFunction(args: Jwt, kid?: string): Promi
}

describe('ismapolis bug report #63, https://github.com/Sphereon-Opensource/OID4VC-demo/issues/63, should', () => {
it('work as expected provided a correct JWT is supplied', async () => {
// Sphereon infra is not working currently
it.skip('work as expected provided a correct JWT is supplied', async () => {
debug.enable('*');
const { uri } = await getCredentialOffer('jwt_vc_json');
const client = await OpenID4VCIClient.fromURI({ uri: uri, clientId: 'test-clientID' });
Expand Down
4 changes: 1 addition & 3 deletions packages/client/lib/functions/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
export * from '@sphereon/oid4vci-common/dist/functions/Encoding';
export * from '@sphereon/oid4vci-common/dist/functions/HttpUtils';
export * from './ProofUtil';
export * from './AuthorizationUtil';
6 changes: 6 additions & 0 deletions packages/client/lib/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { VCI_LOGGERS } from '@sphereon/oid4vci-common';
import { ISimpleLogger, LogMethod } from '@sphereon/ssi-types';

export const LOG: ISimpleLogger<string> = VCI_LOGGERS.options('sphereon:oid4vci:client', { methods: [LogMethod.EVENT, LogMethod.DEBUG_PKG] }).get(
'sphereon:oid4vci:client',
);
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.23.0",
"@sphereon/ssi-types": "0.24.1-unstable.111",
"cross-fetch": "^3.1.8",
"debug": "^4.3.4"
},
Expand Down
Loading

0 comments on commit c4d3483

Please sign in to comment.