@sphereon/oid4vci-issuer
Version:
OpenID 4 Verifiable Credential Issuance issuer REST endpoints
831 lines (774 loc) • 34.3 kB
text/typescript
import { uuidv4 } from '@sphereon/oid4vc-common'
import {
ALG_ERROR,
AUD_ERROR,
AuthorizationServerMetadata,
ClientMetadata,
CNonceState,
CreateCredentialOfferURIResult,
CREDENTIAL_MISSING_ERROR,
CredentialConfigurationSupportedV1_0_13,
CredentialDataSupplierInput,
CredentialEventNames,
CredentialIssuerMetadataOptsV1_0_13,
CredentialOfferEventNames,
CredentialOfferMode,
CredentialOfferSession,
CredentialOfferV1_0_13,
CredentialRequest,
CredentialRequestV1_0_13,
CredentialResponse,
DID_NO_DIDDOC_ERROR,
EVENTS,
IAT_ERROR,
ISSUER_CONFIG_ERROR,
IssueStatus,
IStateManager,
JsonLdIssuerCredentialDefinition,
JWT_VERIFY_CONFIG_ERROR,
JWTVerifyCallback,
JwtVerifyResult,
KID_DID_NO_DID_ERROR,
KID_JWK_X5C_ERROR,
NO_ISS_IN_AUTHORIZATION_CODE_CONTEXT,
NotificationRequest,
OID4VCICredentialFormat,
OpenId4VCIVersion,
PRE_AUTH_GRANT_LITERAL,
QRCodeOpts,
StatusListOpts,
TokenErrorResponse,
toUniformCredentialOfferRequest,
TxCode,
TYP_ERROR,
URIState,
} from '@sphereon/oid4vci-common'
import { CompactSdJwtVc, CredentialMapper, InitiatorType, SubSystem, System, W3CVerifiableCredential } from '@sphereon/ssi-types'
import ShortUUID from 'short-uuid'
import { assertValidPinNumber, createCredentialOfferObject, createCredentialOfferURIFromObject, CredentialOfferGrantInput } from './functions'
import { LookupStateManager, lookupStateManagerMultiGetAsserted, MemoryStates } from './state-manager'
import { CredentialDataSupplier, CredentialDataSupplierArgs, CredentialIssuanceInput, CredentialSignerCallback } from './types'
import { LOG } from './index'
const shortUUID = ShortUUID()
export class VcIssuer {
private readonly _issuerMetadata: CredentialIssuerMetadataOptsV1_0_13
private readonly _authorizationServerMetadata: AuthorizationServerMetadata
private readonly _defaultCredentialOfferBaseUri?: string
private readonly _credentialSignerCallback?: CredentialSignerCallback
private readonly _jwtVerifyCallback?: JWTVerifyCallback
private readonly _credentialDataSupplier?: CredentialDataSupplier
private readonly _credentialOfferSessions: IStateManager<CredentialOfferSession>
private readonly _cNonces: IStateManager<CNonceState>
private readonly _uris: IStateManager<URIState>
private readonly _cNonceExpiresIn: number
private readonly _asClientOpts?: ClientMetadata
constructor(
issuerMetadata: CredentialIssuerMetadataOptsV1_0_13,
authorizationServerMetadata: AuthorizationServerMetadata,
args: {
txCode?: TxCode
baseUri?: string
credentialOfferSessions: IStateManager<CredentialOfferSession>
defaultCredentialOfferBaseUri?: string
cNonces: IStateManager<CNonceState>
uris?: IStateManager<URIState>
credentialSignerCallback?: CredentialSignerCallback
jwtVerifyCallback?: JWTVerifyCallback
credentialDataSupplier?: CredentialDataSupplier
cNonceExpiresIn?: number | undefined // expiration duration in seconds
asClientOpts?: ClientMetadata
},
) {
this._issuerMetadata = issuerMetadata
this._authorizationServerMetadata = authorizationServerMetadata
this._defaultCredentialOfferBaseUri = args.defaultCredentialOfferBaseUri
this._credentialOfferSessions = args.credentialOfferSessions ?? new MemoryStates()
this._uris = args.uris ?? new MemoryStates()
this._cNonces = args.cNonces
this._credentialSignerCallback = args?.credentialSignerCallback
this._jwtVerifyCallback = args?.jwtVerifyCallback
this._credentialDataSupplier = args?.credentialDataSupplier
this._cNonceExpiresIn = (args?.cNonceExpiresIn ?? (process.env.C_NONCE_EXPIRES_IN ? parseInt(process.env.C_NONCE_EXPIRES_IN) : 300)) as number
this._asClientOpts = args?.asClientOpts
}
public async getCredentialOfferSessionById(
id: string,
lookups: Array<'uri' | 'preAuthorizedCode' | 'issuerState' | 'correlationId'> = ['preAuthorizedCode', 'issuerState', 'correlationId'],
): Promise<CredentialOfferSession> {
// preAuth and issuerState can be looked up directly
if (Array.isArray(lookups) && lookups.length > 0) {
if (!this.uris) {
return Promise.reject(Error('Cannot lookup credential offer by id if URI state manager is not set'))
}
return lookupStateManagerMultiGetAsserted({
id,
keyValueMapper: this._uris,
valueStateManager: this._credentialOfferSessions,
lookups: ['preAuthorizedCode', 'issuerState', 'correlationId'],
})
// return new LookupStateManager<URIState, CredentialOfferSession>(this.uris, this._credentialOfferSessions, lookup).getFromMultiple(id)
}
const session = await this._credentialOfferSessions.get(id)
if (!session) {
return Promise.reject(Error(`No session found for id ${id}`))
}
return session
}
public async deleteCredentialOfferSessionById(
id: string,
lookups: Array<'uri' | 'preAuthorizedCode' | 'issuerState' | 'correlationId'> = ['preAuthorizedCode', 'issuerState'],
): Promise<CredentialOfferSession> {
const session = await this.getCredentialOfferSessionById(id, lookups)
if (session) {
if (session.preAuthorizedCode && (await this._credentialOfferSessions.has(session.preAuthorizedCode))) {
await this._credentialOfferSessions.delete(session.preAuthorizedCode)
}
if (session.issuerState && (await this._credentialOfferSessions.has(session.issuerState))) {
await this._credentialOfferSessions.delete(session.issuerState)
}
}
return session
}
public async processNotification({
preAuthorizedCode,
issuerState,
notification,
}: {
preAuthorizedCode?: string
issuerState?: string
notification: NotificationRequest
}): Promise<Error | CredentialOfferSession> {
const sessionId = preAuthorizedCode ?? issuerState
const session = sessionId ? await this.getCredentialOfferSessionById(sessionId) : undefined
if (!session || !sessionId) {
LOG.error(`No session or session id found ${sessionId}`)
return Error('invalid_notification_request')
}
if (notification.notification_id !== session.notification_id) {
LOG.error(`Notification id ${notification.notification_id} not found in session. session notification id ${session.notification_id}`)
return Error('invalid_notification_id')
} else if (session.notification) {
LOG.info(`Overwriting existing notification, as a new notification came in ${session.notification_id}`)
}
await this.updateSession({ preAuthorizedCode: preAuthorizedCode, issuerState: issuerState, notification })
LOG.info(`Processed notification ${notification} for ${session.notification_id}`)
return session
}
public async createCredentialOfferURI(opts: {
offerMode?: CredentialOfferMode
grants?: CredentialOfferGrantInput
client_id?: string
redirectUri?: string
credential_configuration_ids?: Array<string>
credentialDefinition?: JsonLdIssuerCredentialDefinition
credentialOfferUri?: string
credentialDataSupplierInput?: CredentialDataSupplierInput // Optional storage that can help the credential Data Supplier. For instance to store credential input data during offer creation, if no additional data can be supplied later on
baseUri?: string
scheme?: string
pinLength?: number
qrCodeOpts?: QRCodeOpts
correlationId?: string
statusListOpts?: Array<StatusListOpts>
sessionLifeTimeInSec?: number
}): Promise<CreateCredentialOfferURIResult> {
const {
offerMode = 'VALUE',
correlationId = shortUUID.generate(),
credential_configuration_ids,
statusListOpts,
credentialOfferUri,
redirectUri,
} = opts
if (offerMode === 'REFERENCE' && !credentialOfferUri) {
return Promise.reject(Error('credentialOfferUri must be supplied for offerMode REFERENCE!'))
}
const grants = opts.grants ? { ...opts.grants } : {}
// for backwards compat, would be better if user sets the prop on the grants directly
if (opts.pinLength !== undefined) {
if (grants[PRE_AUTH_GRANT_LITERAL]) {
grants[PRE_AUTH_GRANT_LITERAL].tx_code = {
...grants[PRE_AUTH_GRANT_LITERAL].tx_code,
length: grants[PRE_AUTH_GRANT_LITERAL].tx_code?.length ?? opts.pinLength,
}
}
}
if (grants[PRE_AUTH_GRANT_LITERAL]?.tx_code && !grants[PRE_AUTH_GRANT_LITERAL]?.tx_code?.length) {
grants[PRE_AUTH_GRANT_LITERAL].tx_code.length = 4
}
const baseUri = opts?.baseUri ?? this.defaultCredentialOfferBaseUri
const credentialOfferObject = createCredentialOfferObject(this._issuerMetadata, {
...opts,
grants,
credentialOffer: credential_configuration_ids
? {
credential_issuer: this._issuerMetadata.credential_issuer,
credential_configuration_ids,
}
: undefined,
})
const preAuthGrant = credentialOfferObject.credential_offer.grants?.[PRE_AUTH_GRANT_LITERAL]
const authGrant = credentialOfferObject.credential_offer.grants?.authorization_code
const preAuthorizedCode = preAuthGrant?.['pre-authorized_code']
const issuerState = authGrant?.issuer_state
const txCode = preAuthGrant?.tx_code
let userPin: string | undefined
if (preAuthGrant?.tx_code) {
const pinLength = preAuthGrant.tx_code.length ?? 4
userPin = ('' + Math.round((Math.pow(10, pinLength) - 1) * Math.random())).padStart(pinLength, '0')
assertValidPinNumber(userPin, pinLength)
}
const createdAt = +new Date()
const lastUpdatedAt = createdAt
const expirationInMs = (opts.sessionLifeTimeInSec ?? 10 * 60) * 1000
const expiresAt = createdAt + Math.abs(expirationInMs)
if (offerMode === 'REFERENCE') {
if (!this.uris) {
throw Error('No URI state manager set, whilst apparently credential offer by reference is being used')
}
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,
expiresAt,
preAuthorizedCode,
issuerState,
correlationId: correlationId,
})
}
const credentialOffer = await toUniformCredentialOfferRequest(
{
credential_offer: credentialOfferObject.credential_offer,
credential_offer_uri: credentialOfferObject.credential_offer_uri,
} as CredentialOfferV1_0_13,
{
version: OpenId4VCIVersion.VER_1_0_13,
resolve: false, // We are creating the object, so do not resolve
},
)
const status = IssueStatus.OFFER_CREATED
const session: CredentialOfferSession = {
redirectUri,
preAuthorizedCode,
issuerState,
createdAt,
lastUpdatedAt,
expiresAt,
status,
notification_id: uuidv4(),
...(opts.client_id && { clientId: opts.client_id }),
...(userPin && { txCode: userPin }), // We used to use userPin according to older specs. We map these onto txCode now. If both are used, txCode in the end wins, even if they are different
...(opts.credentialDataSupplierInput && { credentialDataSupplierInput: opts.credentialDataSupplierInput }),
credentialOffer,
statusLists: statusListOpts,
}
const uri = createCredentialOfferURIFromObject(credentialOffer, offerMode, { ...opts, baseUri })
if (preAuthorizedCode) {
const lookupManager = new LookupStateManager<URIState, CredentialOfferSession>(this.uris, this._credentialOfferSessions, 'correlationId')
await lookupManager.setMapped(preAuthorizedCode, { preAuthorizedCode, uri, createdAt, expiresAt, correlationId, issuerState }, session)
// await this.credentialOfferSessions.set(preAuthorizedCode, session)
}
// todo: check whether we could have the same value for issuer state and pre auth code if both are supported.
if (issuerState) {
const lookupManager = new LookupStateManager<URIState, CredentialOfferSession>(this.uris, this._credentialOfferSessions, 'correlationId')
await lookupManager.setMapped(issuerState, { preAuthorizedCode, uri, createdAt, expiresAt, correlationId, issuerState }, session)
// await this.credentialOfferSessions.set(issuerState, session)
}
let qrCodeDataUri: string | undefined
if (opts.qrCodeOpts) {
const { AwesomeQR } = await import('awesome-qr')
const qrCode = new AwesomeQR({ ...opts.qrCodeOpts, text: uri })
qrCodeDataUri = `data:image/png;base64,${(await qrCode.draw())!.toString('base64')}`
}
const credentialOfferResult = {
session,
uri,
qrCodeDataUri,
correlationId,
txCode,
...(userPin !== undefined && { userPin, pinLength: userPin?.length ?? 0 }),
}
EVENTS.emit(CredentialOfferEventNames.OID4VCI_OFFER_CREATED, {
eventName: CredentialOfferEventNames.OID4VCI_OFFER_CREATED,
id: correlationId,
data: credentialOfferResult,
initiator: '<Unknown>',
initiatorType: InitiatorType.EXTERNAL,
system: System.OID4VCI,
issuer: this.issuerMetadata.credential_issuer,
subsystem: SubSystem.API,
createdAt,
expiresAt,
})
return credentialOfferResult
}
/**
* issueCredentialFromIssueRequest
* @param opts issuerRequestParams
* - issueCredentialsRequest the credential request
* - issuerState the state of the issuer
* - jwtVerifyCallback callback that verifies the Proof of Possession JWT
* - issuerCallback callback to issue a Verifiable Credential
* - cNonce an existing c_nonce
*/
public async issueCredential(opts: {
credentialRequest: CredentialRequest
credential?: CredentialIssuanceInput
credentialDataSupplier?: CredentialDataSupplier
credentialDataSupplierInput?: CredentialDataSupplierInput
newCNonce?: string
cNonceExpiresIn?: number // expiration duration in seconds
tokenExpiresIn?: number // expiration duration in seconds
jwtVerifyCallback?: JWTVerifyCallback
credentialSignerCallback?: CredentialSignerCallback
responseCNonce?: string
}): Promise<CredentialResponse> {
/*if (!('credential_identifier' in opts.credentialRequest)) {
throw new Error('credential request should be of spec version 1.0.13 or above')
}*/
const credentialRequest = opts.credentialRequest as CredentialRequestV1_0_13
let preAuthorizedCode: string | undefined
let issuerState: string | undefined
try {
if (!('credential_identifier' in credentialRequest) && !credentialRequest.format) {
throw new Error('credential request should either have a credential_identifier or format and type')
}
if (credentialRequest.format && !this.isMetadataSupportCredentialRequestFormat(credentialRequest.format)) {
throw new Error(TokenErrorResponse.invalid_request)
}
const validated = await this.validateCredentialRequestProof({
...opts,
tokenExpiresIn: opts.tokenExpiresIn ?? 180,
})
preAuthorizedCode = validated.preAuthorizedCode
issuerState = validated.issuerState
const { preAuthSession, authSession, cNonceState, jwtVerifyResult } = validated
const did = jwtVerifyResult.did
const jwk = jwtVerifyResult.jwk
const kid = jwtVerifyResult.kid
const newcNonce = opts.newCNonce ? opts.newCNonce : uuidv4()
const newcNonceState = {
cNonce: newcNonce,
createdAt: +new Date(),
...(authSession?.issuerState && { issuerState: authSession.issuerState }),
...(preAuthSession && { preAuthorizedCode: preAuthSession.preAuthorizedCode }),
}
await this.cNonces.set(newcNonce, newcNonceState)
if (!opts.credential && this._credentialDataSupplier === undefined && opts.credentialDataSupplier === undefined) {
throw Error(`Either a credential needs to be supplied or a credentialDataSupplier`)
}
let credential: CredentialIssuanceInput | undefined
let format: OID4VCICredentialFormat | undefined = credentialRequest.format
let signerCallback: CredentialSignerCallback | undefined = opts.credentialSignerCallback
const session: CredentialOfferSession | undefined = preAuthorizedCode && preAuthSession ? preAuthSession : authSession
if (opts.credential) {
credential = opts.credential
} else {
const credentialDataSupplier: CredentialDataSupplier | undefined =
typeof opts.credentialDataSupplier === 'function' ? opts.credentialDataSupplier : this._credentialDataSupplier
if (typeof credentialDataSupplier !== 'function') {
throw Error('Data supplier is mandatory if no credential is supplied')
}
if (!session) {
throw Error('Either a preAuth or Auth session is required, none found')
}
const credentialOffer = session.credentialOffer
if (!credentialOffer) {
throw Error('Credential Offer missing')
}
const credentialDataSupplierInput = opts.credentialDataSupplierInput ?? session.credentialDataSupplierInput
const result = await credentialDataSupplier({
...(cNonceState ? { ...cNonceState } : { ...authSession }),
credentialRequest: opts.credentialRequest,
credentialSupplierConfig: this._issuerMetadata.credential_supplier_config,
credentialOffer /*todo: clientId: */,
...(credentialDataSupplierInput && { credentialDataSupplierInput }),
} as CredentialDataSupplierArgs)
credential = result.credential
if (result.format) {
format = result.format
}
if (typeof result.signCallback === 'function') {
signerCallback = result.signCallback
}
}
if (!credential) {
throw Error('A credential needs to be supplied at this point')
}
// Bind credential to the provided proof of possession
if (CredentialMapper.isSdJwtDecodedCredentialPayload(credential) && (kid || jwk) && !credential.cnf) {
if (kid) {
credential.cnf = {
kid,
}
}
// else TODO temp workaround IATAB2B-57
if (jwk) {
credential.cnf = {
jwk,
}
}
} else if (did && !CredentialMapper.isSdJwtDecodedCredentialPayload(credential) && credential.credentialSubject !== undefined) {
const credentialSubjects = Array.isArray(credential.credentialSubject) ? credential.credentialSubject : [credential.credentialSubject]
credentialSubjects.map((subject) => {
if (!subject.id) {
subject.id = did
}
return subject
})
credential.credentialSubject = Array.isArray(credential.credentialSubject) ? credentialSubjects : credentialSubjects[0]
} else {
// Mdoc Format
// Nothing to do here
}
let issuer: string | undefined = undefined
if (credential.iss) {
issuer = credential.iss
} else if (credential.issuer) {
if (typeof credential.issuer === 'string') {
issuer = credential.issuer
} else if (typeof credential.issuer === 'object' && 'id' in credential.issuer && typeof credential.issuer.id === 'string') {
issuer = credential.issuer.id
}
}
const verifiableCredential = await this.issueCredentialImpl(
{
credentialRequest: opts.credentialRequest,
format,
credential,
jwtVerifyResult,
issuer,
...(session && { statusLists: session.statusLists }),
},
signerCallback,
)
// TODO implement acceptance_token (deferred response)
// TODO update verification accordingly
if (!verifiableCredential) {
// credential: OPTIONAL. Contains issued Credential. MUST be present when acceptance_token is not returned. MAY be a JSON string or a JSON object, depending on the Credential format. See Appendix E for the Credential format specific encoding requirements
throw new Error(CREDENTIAL_MISSING_ERROR)
}
if (cNonceState) {
// remove the previous nonce
await this.cNonces.delete(cNonceState.cNonce)
}
let notification_id: string | undefined
if (preAuthorizedCode && preAuthSession) {
preAuthSession.lastUpdatedAt = +new Date()
preAuthSession.status = IssueStatus.CREDENTIAL_ISSUED
notification_id = preAuthSession.notification_id
await this._credentialOfferSessions.set(preAuthorizedCode, preAuthSession)
} else if (issuerState && authSession) {
// If both were set we used the pre auth flow above as well, hence the else if
authSession.lastUpdatedAt = +new Date()
authSession.status = IssueStatus.CREDENTIAL_ISSUED
notification_id = authSession.notification_id
await this._credentialOfferSessions.set(issuerState, authSession)
}
const response: CredentialResponse = {
credential: verifiableCredential,
// format: credentialRequest.format,
c_nonce: newcNonce,
c_nonce_expires_in: this._cNonceExpiresIn,
...(notification_id && { notification_id }),
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const experimentalSubjectIssuance = opts.credentialRequest.credential_subject_issuance
if (experimentalSubjectIssuance?.subject_proof_mode) {
if (experimentalSubjectIssuance.subject_proof_mode !== 'proof_replace') {
throw Error('Only proof replace is supported currently')
}
response.transaction_id = authSession?.issuerState
response.credential_subject_issuance = experimentalSubjectIssuance
}
return response
} catch (error: unknown) {
await this.updateSession({ preAuthorizedCode, issuerState, error })
throw error
}
}
private async updateSession({
preAuthorizedCode,
error,
issuerState,
notification,
}: {
preAuthorizedCode?: string
issuerState?: string
error?: unknown
notification?: NotificationRequest
}) {
let issueState: IssueStatus | undefined = undefined
if (error) {
issueState = IssueStatus.ERROR
} else if (notification) {
if (notification.event == 'credential_accepted') {
issueState = IssueStatus.NOTIFICATION_CREDENTIAL_ACCEPTED
} else if (notification.event == 'credential_deleted') {
issueState = IssueStatus.NOTIFICATION_CREDENTIAL_DELETED
} else if (notification.event == 'credential_failure') {
issueState = IssueStatus.NOTIFICATION_CREDENTIAL_FAILURE
}
}
if (preAuthorizedCode) {
const preAuthSession = await this._credentialOfferSessions.get(preAuthorizedCode)
if (preAuthSession) {
preAuthSession.lastUpdatedAt = +new Date()
if (issueState) {
preAuthSession.status = issueState
}
if (error) {
preAuthSession.error = error instanceof Error ? error.message : error?.toString()
}
preAuthSession.notification_id
if (notification) {
preAuthSession.notification = notification
}
await this._credentialOfferSessions.set(preAuthorizedCode, preAuthSession)
}
}
if (issuerState) {
const authSession = await this._credentialOfferSessions.get(issuerState)
if (authSession) {
authSession.lastUpdatedAt = +new Date()
if (issueState) {
authSession.status = issueState
}
if (error) {
authSession.error = error instanceof Error ? error.message : error?.toString()
}
if (notification) {
authSession.notification = notification
}
await this._credentialOfferSessions.set(issuerState, authSession)
}
}
}
/*
private async retrieveGrantsAndCredentialOfferSession(id: string): Promise<{
clientId?: string;
grants?: Grant,
session: CredentialOfferSession
}> {
const session: CredentialOfferSession | undefined = await this._credentialOfferSessions.getAsserted(id)
const clientId = session?.clientId
const grants = session?.credentialOffer?.credential_offer?.grants
if (!grants?.authorization_code?.issuer_state && !grants?.[PRE_AUTH_GRANT_LITERAL]?.[PRE_AUTH_CODE_LITERAL]) {
throw new Error(GRANTS_MUST_NOT_BE_UNDEFINED)
}
return { session, clientId, grants }
}*/
private async validateCredentialRequestProof({
credentialRequest,
jwtVerifyCallback,
tokenExpiresIn,
}: {
credentialRequest: CredentialRequest
tokenExpiresIn: number // expiration duration in seconds
// grants?: Grant,
clientId?: string
jwtVerifyCallback?: JWTVerifyCallback
}) {
let preAuthorizedCode: string | undefined
let issuerState: string | undefined
const supportedIssuanceFormats = ['jwt_vc_json', 'jwt_vc_json-ld', 'vc+sd-jwt', 'ldp_vc', 'mso_mdoc']
try {
if (credentialRequest.format && !supportedIssuanceFormats.includes(credentialRequest.format)) {
throw Error(`Format ${credentialRequest.format} not supported yet`)
} else if (typeof this._jwtVerifyCallback !== 'function' && typeof jwtVerifyCallback !== 'function') {
throw new Error(JWT_VERIFY_CONFIG_ERROR)
} else if (!credentialRequest.proof) {
throw Error('Proof of possession is required. No proof value present in credential request')
}
const jwtVerifyResult = jwtVerifyCallback
? await jwtVerifyCallback(credentialRequest.proof)
: // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await this._jwtVerifyCallback!(credentialRequest.proof)
const { didDocument, did, jwt } = jwtVerifyResult
const { header, payload } = jwt
const { iss, aud, iat, nonce } = payload
const issuer_state = 'issuer_state' in credentialRequest && credentialRequest.issuer_state ? credentialRequest.issuer_state : undefined
if (!nonce && !issuer_state) {
throw Error('No nonce was found in the Proof of Possession')
}
let createdAt: number
let cNonceState: CNonceState | undefined
if (nonce) {
cNonceState = await this.cNonces.getAsserted(nonce)
preAuthorizedCode = cNonceState.preAuthorizedCode
issuerState = cNonceState.issuerState
createdAt = cNonceState.createdAt
} else if (issuer_state) {
const session = await this._credentialOfferSessions.getAsserted(issuer_state as string)
issuerState = issuer_state as string | undefined
createdAt = session.createdAt
} else {
throw Error('No nonce or issuer_state was found in the Proof of Possession')
}
// The verify callback should set the correct values, but let's look at the JWT ourselves to to be sure
const alg = jwtVerifyResult.alg ?? header.alg
const kid = jwtVerifyResult.kid ?? header.kid
const jwk = jwtVerifyResult.jwk ?? header.jwk
const x5c = jwtVerifyResult.x5c ?? header.x5c
const typ = header.typ
if (typ !== 'openid4vci-proof+jwt') {
throw Error(TYP_ERROR)
} else if (!alg) {
throw Error(ALG_ERROR)
} else if (x5c && (kid || jwk)) {
// x5c cannot be used together with kid or jwk
throw Error(KID_JWK_X5C_ERROR)
} else if (kid && !did) {
if (!jwk && !x5c) {
// Make sure the callback function extracts the DID from the kid
throw Error(KID_DID_NO_DID_ERROR)
} else {
// If JWK or x5c is present, log the information and proceed
console.log(`KID present but no DID, using JWK or x5c`)
}
} else if (did && !didDocument) {
// Make sure the callback function does DID resolution when a did is present
throw Error(DID_NO_DIDDOC_ERROR)
}
const preAuthSession = preAuthorizedCode ? await this.credentialOfferSessions.get(preAuthorizedCode) : undefined
const authSession = issuerState ? await this.credentialOfferSessions.get(issuerState) : undefined
if (!preAuthSession && !authSession) {
throw Error('Either a pre-authorized code or issuer state needs to be present')
}
if (preAuthSession) {
if (!preAuthSession.preAuthorizedCode || preAuthSession.preAuthorizedCode !== preAuthorizedCode) {
throw Error('Invalid pre-authorized code')
}
preAuthSession.lastUpdatedAt = +new Date()
preAuthSession.status = IssueStatus.CREDENTIAL_REQUEST_RECEIVED
await this._credentialOfferSessions.set(preAuthorizedCode, preAuthSession)
}
if (authSession) {
if (!authSession.issuerState || authSession.issuerState !== issuerState) {
throw Error('Invalid issuer state')
}
authSession.lastUpdatedAt = +new Date()
authSession.status = IssueStatus.CREDENTIAL_REQUEST_RECEIVED
}
// https://www.rfc-editor.org/rfc/rfc6749.html#section-3.2.1
// A client MAY use the "client_id" request parameter to identify itself
// when sending requests to the token endpoint. In the
// "authorization_code" "grant_type" request to the token endpoint, an
// unauthenticated client MUST send its "client_id" to prevent itself
// from inadvertently accepting a code intended for a client with a
// different "client_id". This protects the client from substitution of
// the authentication code. (It provides no additional security for the
// protected resource.)
if (!iss && authSession?.credentialOffer.credential_offer?.grants?.authorization_code) {
throw new Error(NO_ISS_IN_AUTHORIZATION_CODE_CONTEXT)
}
// iss: OPTIONAL (string). The value of this claim MUST be the client_id of the client making the credential request.
// This claim MUST be omitted if the Access Token authorizing the issuance call was obtained from a Pre-Authorized Code Flow through anonymous access to the Token Endpoint.
// TODO We need to investigate further what the comment above means, because it's not clear if the client or the user may be authorized anonymously
// if (iss && grants && grants[PRE_AUTH_GRANT_LITERAL]) {
// throw new Error(ISS_PRESENT_IN_PRE_AUTHORIZED_CODE_CONTEXT)
// }
/*if (iss && iss !== clientId) {
throw new Error(ISS_MUST_BE_CLIENT_ID + `iss: ${iss}, client_id: ${clientId}`)
}*/
if (!aud || aud !== this._issuerMetadata.credential_issuer) {
throw new Error(AUD_ERROR)
}
if (!iat) {
throw new Error(IAT_ERROR)
} else if (iat > Math.round(createdAt / 1000) + tokenExpiresIn) {
// createdAt is in milliseconds whilst iat and tokenExpiresIn are in seconds
throw new Error(IAT_ERROR)
}
// todo: Add a check of iat against current TS on server with a skew
return { jwtVerifyResult, preAuthorizedCode, preAuthSession, issuerState, authSession, cNonceState }
} catch (error: unknown) {
await this.updateSession({ preAuthorizedCode, issuerState, error })
throw error
}
}
private isMetadataSupportCredentialRequestFormat(requestFormat: string | string[]): boolean {
if (!this._issuerMetadata.credential_configurations_supported) {
return false
}
for (const credentialSupported of Object.values(
this._issuerMetadata['credential_configurations_supported'] as Record<string, CredentialConfigurationSupportedV1_0_13>,
)) {
if (!Array.isArray(requestFormat) && credentialSupported.format === requestFormat) {
return true
} else if (Array.isArray(requestFormat)) {
for (const format of requestFormat as string[]) {
if (credentialSupported.format === format) {
return true
}
}
}
}
return false
}
private async issueCredentialImpl(
opts: {
credentialRequest: CredentialRequest
credential: CredentialIssuanceInput
jwtVerifyResult: JwtVerifyResult
format?: OID4VCICredentialFormat
issuer?: string
statusLists?: Array<StatusListOpts>
},
issuerCallback?: CredentialSignerCallback,
): Promise<W3CVerifiableCredential | CompactSdJwtVc> {
if ((!opts.credential && !opts.credentialRequest) || !this._credentialSignerCallback) {
throw new Error(ISSUER_CONFIG_ERROR)
}
const credential = issuerCallback ? await issuerCallback(opts) : await this._credentialSignerCallback(opts)
// TODO: Create builder
EVENTS.emit(CredentialEventNames.OID4VCI_CREDENTIAL_ISSUED, {
eventName: CredentialEventNames.OID4VCI_CREDENTIAL_ISSUED,
id: uuidv4(),
data: credential,
// TODO: Format, request etc
initiator: opts.issuer ?? '<unknown>',
initiatorType: InitiatorType.EXTERNAL,
system: System.OID4VCI,
subsystem: SubSystem.VC_ISSUER,
})
return credential
}
get credentialSignerCallback(): CredentialSignerCallback | undefined {
return this._credentialSignerCallback
}
get jwtVerifyCallback(): JWTVerifyCallback | undefined {
return this._jwtVerifyCallback
}
get credentialDataSupplier(): CredentialDataSupplier | undefined {
return this._credentialDataSupplier
}
get uris(): IStateManager<URIState> {
return this._uris
}
get cNonceExpiresIn(): number {
return this._cNonceExpiresIn
}
public get credentialOfferSessions(): IStateManager<CredentialOfferSession> {
return this._credentialOfferSessions
}
public get cNonces(): IStateManager<CNonceState> {
return this._cNonces
}
get defaultCredentialOfferBaseUri(): string | undefined {
return this._defaultCredentialOfferBaseUri
}
public get issuerMetadata() {
return this._issuerMetadata
}
public get authorizationServerMetadata() {
return this._authorizationServerMetadata
}
get asClientOpts() {
return this._asClientOpts
}
}