Skip to content

Commit

Permalink
feat: Improvements to by reference offers. Also allow setting a corre…
Browse files Browse the repository at this point in the history
…lationId on an offer
  • Loading branch information
nklomp committed Feb 15, 2025
1 parent cf38c18 commit 1020d26
Show file tree
Hide file tree
Showing 8 changed files with 64 additions and 43 deletions.
4 changes: 2 additions & 2 deletions packages/client/lib/CredentialOfferClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ import {
PRE_AUTH_GRANT_LITERAL,
toUniformCredentialOfferRequest,
} from '@sphereon/oid4vci-common';
import { fetch } from 'cross-fetch';
import Debug from 'debug';

import { LOG } from './types';
import { fetch } from 'cross-fetch';
import { isUrlEncoded } from './functions';
import { LOG } from './types';

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

Expand Down
3 changes: 2 additions & 1 deletion packages/client/lib/CredentialOfferClientV1_0_13.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ import {
PRE_AUTH_GRANT_LITERAL,
toUniformCredentialOfferRequest,
} from '@sphereon/oid4vci-common';
import Debug from 'debug';
import { fetch } from 'cross-fetch';
import Debug from 'debug';

import { isUrlEncoded } from './functions';

const debug = Debug('sphereon:oid4vci:offer');
Expand Down
11 changes: 8 additions & 3 deletions packages/issuer-rest/lib/OID4VCIServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
getBasePath,
getCredentialEndpoint,
getCredentialOfferEndpoint,
getIssuePayloadEndpoint,
getCredentialOfferReferenceEndpoint,
getIssueStatusEndpoint,
getMetadataEndpoints,
pushedAuthorizationEndpoint,
Expand Down Expand Up @@ -99,7 +99,9 @@ export interface IDeleteCredentialOfferEndpointOpts extends ISingleEndpointOpts
export interface ICreateCredentialOfferEndpointOpts extends ISingleEndpointOpts {
getOfferPath?: string
qrCodeOpts?: QRCodeOpts
defaultCredentialOfferPayloadMode?: CredentialOfferMode
baseUrl?: string
credentialOfferReferenceBasePath?: string
defaultCredentialOfferMode?: CredentialOfferMode
}

export interface IGetIssueStatusEndpointOpts extends ISingleEndpointOpts {
Expand Down Expand Up @@ -170,7 +172,10 @@ export class OID4VCIServer {
getMetadataEndpoints(this.router, this.issuer)
let issuerPayloadPath: string | undefined
if (this.isGetIssuePayloadEndpointEnabled(opts?.endpointOpts?.getIssuePayloadOpts)) {
issuerPayloadPath = getIssuePayloadEndpoint(this.router, this.issuer, { ...opts?.endpointOpts?.getIssuePayloadOpts, baseUrl: this.baseUrl })
issuerPayloadPath = getCredentialOfferReferenceEndpoint(this.router, this.issuer, {
...opts?.endpointOpts?.getIssuePayloadOpts,
baseUrl: this.baseUrl,
})
}

if (opts?.endpointOpts?.createCredentialOfferOpts?.enabled !== false || process.env.CREDENTIAL_OFFER_ENDPOINT_ENABLED === 'true') {
Expand Down
45 changes: 27 additions & 18 deletions packages/issuer-rest/lib/oid4vci-api-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,9 @@ export function getIssueStatusEndpoint(router: Router, issuer: VcIssuer, opts: I
})
}

export function getIssuePayloadEndpoint(router: Router, issuer: VcIssuer, opts: IGetIssueStatusEndpointOpts): string {
export function getCredentialOfferReferenceEndpoint(router: Router, issuer: VcIssuer, opts: IGetIssueStatusEndpointOpts): string {
const path = determinePath(opts.baseUrl, opts?.path ?? '/credential-offers/:id', { stripBasePath: true })
LOG.log(`[OID4VCI] getIssuePayloadEndpoint endpoint enabled at ${path}`)
LOG.log(`[OID4VCI] getCredentialOfferReferenceEndpoint endpoint enabled at ${path}`)
router.get(path, async (request: Request, response: Response) => {
try {
const { id } = request.params
Expand All @@ -97,8 +97,13 @@ export function getIssuePayloadEndpoint(router: Router, issuer: VcIssuer, opts:
error_description: `query parameter 'id' is missing`,
})
}
const session = await issuer.getCredentialOfferSessionById(id as string, 'preAuthorizedCode')
if (!session || !session.credentialOffer) {
const session = await issuer.getCredentialOfferSessionById(id as string, 'id')
if (!session || !session.credentialOffer || session.status !== 'OFFER_CREATED') {
if (session?.status) {
LOG.warning(
`[OID4VCI] credential offer reference URI request with ${id}, but request was already received earlier. Session status: ${session.status}`,
)
}
return sendErrorResponse(response, 404, {
error: 'invalid_request',
error_description: `Credential offer ${id} not found`,
Expand Down Expand Up @@ -398,7 +403,7 @@ export function getCredentialOfferEndpoint(router: Router, issuer: VcIssuer, opt
router.get(path, async (request: Request, response: Response) => {
try {
const { id } = request.params
const session = await issuer.credentialOfferSessions.get(id)
const session = await issuer.getCredentialOfferSessionById(id, 'id')
if (!session || !session.credentialOffer) {
return sendErrorResponse(response, 404, {
error: 'invalid_request',
Expand All @@ -420,16 +425,18 @@ export function getCredentialOfferEndpoint(router: Router, issuer: VcIssuer, opt
})
}

export function deleteCredentialOfferEndpoint(
router: Router,
issuer: VcIssuer,
opts?: IGetCredentialOfferEndpointOpts,
) {
export function deleteCredentialOfferEndpoint(router: Router, issuer: VcIssuer, opts?: IGetCredentialOfferEndpointOpts) {
const path = determinePath(opts?.baseUrl, opts?.path ?? '/webapp/credential-offers/:id', { stripBasePath: true })
LOG.log(`[OID4VCI] deleteCredentialOffer endpoint enabled at ${path}`)
router.delete(path, async (request: Request, response: Response) => {
try {
const { id } = request.params
if (!id) {
return sendErrorResponse(response, 400, {
error: 'invalid_request',
error_description: 'id must be present',
})
}
await issuer.deleteCredentialOfferSessionById(id)
return response.sendStatus(204)
} catch (e) {
Expand All @@ -446,8 +453,8 @@ export function deleteCredentialOfferEndpoint(
})
}

function buildIssuerPayloadUri(request: Request<CredentialOfferRESTRequest>, issuerPayloadPathConst?: string) {
if (!issuerPayloadPathConst) {
function buildCredentialOfferReferenceUri(request: Request<CredentialOfferRESTRequest>, offerReferencePath?: string) {
if (!offerReferencePath) {
return Promise.reject(Error('issuePayloadPath must bet set for offerMode REFERENCE!'))
}

Expand All @@ -461,17 +468,18 @@ function buildIssuerPayloadUri(request: Request<CredentialOfferRESTRequest>, iss

const forwardedPrefix = request.headers['x-forwarded-prefix']?.toString() ?? ''

return `${protocol}://${host}${forwardedPrefix}${request.baseUrl}${issuerPayloadPathConst}`
return `${protocol}://${host}${forwardedPrefix}${request.baseUrl}${offerReferencePath}`
}

export function createCredentialOfferEndpoint(
router: Router,
issuer: VcIssuer,
opts?: ICreateCredentialOfferEndpointOpts & { baseUrl?: string },
issuerPayloadPath?: string,
issuerPayloadPath?: string, // backwards compat, sigh
) {
const issuerPayloadPathConst = issuerPayloadPath
const path = determinePath(opts?.baseUrl, opts?.path ?? '/webapp/credential-offers', { stripBasePath: true })
const offerReferencePath =
opts?.credentialOfferReferenceBasePath ?? issuerPayloadPath ?? determinePath(opts?.baseUrl, '/credential-offers', { stripBasePath: true })

LOG.log(`[OID4VCI] createCredentialOffer endpoint enabled at ${path}`)
router.post(path, async (request: Request<CredentialOfferRESTRequest>, response: Response<ICreateCredentialOfferURIResponse>) => {
Expand All @@ -497,14 +505,15 @@ export function createCredentialOfferEndpoint(
})
}
const qrCodeOpts = request.body.qrCodeOpts ?? opts?.qrCodeOpts
const offerMode: CredentialOfferMode = request.body.offerMode ?? opts?.defaultCredentialOfferPayloadMode ?? 'VALUE' // default to existing mode when nothing specified
const offerMode: CredentialOfferMode = request.body.offerMode ?? opts?.defaultCredentialOfferMode ?? 'VALUE' // default to existing mode when nothing specified

const client_id: string | undefined = request.body.client_id ?? request.body.original_credential_offer?.client_id
const result = await issuer.createCredentialOfferURI({
...request.body,
offerMode: offerMode,
offerMode,
client_id,
...(offerMode === 'REFERENCE' && { issuerPayloadUri: buildIssuerPayloadUri(request, issuerPayloadPathConst) }),
...(request.body.correlationId && { correlationId: request.body.correlationId }),
...(offerMode === 'REFERENCE' && { credentialOfferUri: buildCredentialOfferReferenceUri(request, offerReferencePath) }),
qrCodeOpts,
grants,
})
Expand Down
25 changes: 15 additions & 10 deletions packages/issuer/lib/VcIssuer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,7 @@ export class VcIssuer {
}

public async createCredentialOfferURI(opts: {
offerMode: CredentialOfferMode
issuerPayloadUri?: string
offerMode?: CredentialOfferMode
grants?: CredentialOfferGrantInput
client_id?: string
redirectUri?: string
Expand All @@ -164,11 +163,12 @@ export class VcIssuer {
scheme?: string
pinLength?: number
qrCodeOpts?: QRCodeOpts
correlationId?: string
statusListOpts?: Array<StatusListOpts>
}): Promise<CreateCredentialOfferURIResult> {
const { offerMode, issuerPayloadUri, credential_configuration_ids, statusListOpts } = opts
if (offerMode === 'REFERENCE' && !issuerPayloadUri) {
return Promise.reject(Error('issuePayloadPath must bet set for offerMode REFERENCE!'))
const { offerMode = 'VALUE', correlationId = shortUUID.generate(), credential_configuration_ids, statusListOpts, credentialOfferUri } = opts
if (offerMode === 'REFERENCE' && !credentialOfferUri) {
return Promise.reject(Error('credentialOfferUri must be supplied for offerMode REFERENCE!'))
}

const grants = opts.grants ? { ...opts.grants } : {}
Expand Down Expand Up @@ -217,14 +217,19 @@ export class VcIssuer {
if (!this.uris) {
throw Error('No URI state manager set, whilst apparently credential offer URIs are being used')
}
const credentialOfferCorrelationId = shortUUID.generate() // TODO allow to be supplied
credentialOfferObject.credential_offer_uri = opts.credentialOfferUri ?? `${issuerPayloadUri?.replace(':id', credentialOfferCorrelationId)}` // TODO how is this going to work with auth code flow?
await this.uris.set(credentialOfferCorrelationId, {
uri: credentialOfferObject.credential_offer_uri,

const offerUri = opts.credentialOfferUri?.replace(':id', correlationId) // TODO how is this going to work with auth code flow?
if (!offerUri) {
return Promise.reject(Error('credentialOfferUri must be supplied for offerMode REFERENCE!'))
}

credentialOfferObject.credential_offer_uri = offerUri
await this.uris.set(correlationId, {
uri: offerUri,
createdAt: createdAt,
preAuthorizedCode,
issuerState,
credentialOfferCorrelationId,
credentialOfferCorrelationId: correlationId,
})
}

Expand Down
13 changes: 6 additions & 7 deletions packages/issuer/lib/__tests__/VcIssuer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ describe('VcIssuer', () => {
vcIssuer
.createCredentialOfferURI({
offerMode: 'REFERENCE',
issuerPayloadUri: 'http://issuer-example.com/:id',
credentialOfferUri: 'http://issuer-example.com/:id',
grants: {
authorization_code: {
issuer_state: issuerState,
Expand All @@ -296,10 +296,9 @@ describe('VcIssuer', () => {
scheme: 'http',
baseUri: 'issuer-example.com',
credential_configuration_ids: ['VerifiableCredential'],
credentialOfferUri: 'https://somehost.com/offer-id',
})
.then((response) => response.uri),
).resolves.toEqual('http://issuer-example.com?credential_offer_uri=https%3A%2F%2Fsomehost.com%2Foffer-id')
).resolves.toContain('http://issuer-example.com?credential_offer_uri=http%3A%2F%2Fissuer-example.com%2F')
})

// Of course this doesn't work. The state is part of the proof to begin with
Expand Down Expand Up @@ -711,7 +710,7 @@ describe('VcIssuer without did', () => {
it('should create credential offer uri with REFERENCE mode', async () => {
const result = await vcIssuer.createCredentialOfferURI({
offerMode: 'REFERENCE',
issuerPayloadUri: 'https://example.com/api/credentials/:id',
credentialOfferUri: 'https://example.com/api/credentials/:id',
grants: {
authorization_code: {
issuer_state: issuerState,
Expand All @@ -726,7 +725,7 @@ describe('VcIssuer without did', () => {
expect(result.session.credentialOffer.credential_offer_uri).toMatch(/https:\/\/example\.com\/api\/credentials\/[\w-]+/)
})

it('should throw error if issuePayloadPath is missing with REFERENCE mode', async () => {
it('should throw error if credential offer Uri is missing with REFERENCE mode', async () => {
await expect(
vcIssuer.createCredentialOfferURI({
offerMode: 'REFERENCE',
Expand All @@ -736,13 +735,13 @@ describe('VcIssuer without did', () => {
},
},
}),
).rejects.toThrow('issuePayloadPath must bet set for offerMode REFERENCE!')
).rejects.toThrow('credentialOfferUri must be supplied for offerMode REFERENCE!')
})

it('should get credential offer session by uri', async () => {
const result = await vcIssuer.createCredentialOfferURI({
offerMode: 'REFERENCE',
issuerPayloadUri: 'https://example.com/api/credentials/:id',
credentialOfferUri: 'https://example.com/api/credentials/:id',
grants: {
authorization_code: {
issuer_state: issuerState,
Expand Down
4 changes: 2 additions & 2 deletions packages/oid4vci-common/lib/functions/CredentialOfferUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,10 +412,10 @@ export function determineGrantTypes(

const types: GrantTypes[] = [];
if (grants) {
if (grants.authorization_code) {
if ('authorization_code' in grants) {
types.push(GrantTypes.AUTHORIZATION_CODE);
}
if (grants[PRE_AUTH_GRANT_LITERAL] && grants[PRE_AUTH_GRANT_LITERAL][PRE_AUTH_CODE_LITERAL]) {
if (PRE_AUTH_GRANT_LITERAL in grants) {
types.push(GrantTypes.PRE_AUTHORIZED_CODE);
}
}
Expand Down
2 changes: 2 additions & 0 deletions packages/oid4vci-common/lib/types/v1_0_13.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ export interface CredentialOfferRESTRequest extends Partial<CredentialOfferPaylo
baseUri?: string;
scheme?: string;
// auth_session?: string; Would be a nice extension to support, to allow external systems to determine what the auth_session value should be
// @Deprecated use tx_code in the grant object
correlationId?: string;
pinLength?: number;
qrCodeOpts?: QRCodeOpts;
client_id?: string;
Expand Down

0 comments on commit 1020d26

Please sign in to comment.