@sphereon/oid4vci-issuer
Version:
OpenID 4 Verifiable Credential Issuance issuer REST endpoints
233 lines (207 loc) • 8.57 kB
text/typescript
import { uuidv4 } from '@sphereon/oid4vc-common'
import {
AssertedUniformCredentialOffer,
CredentialIssuerMetadataOpts,
CredentialIssuerMetadataOptsV1_0_13,
CredentialIssuerMetadataV1_0_11,
CredentialOfferMode,
CredentialOfferPayloadV1_0_11,
CredentialOfferPayloadV1_0_13,
CredentialOfferSession,
CredentialOfferV1_0_13,
Grant,
GrantAuthorizationCode,
GrantUrnIetf,
IssuerMetadataV1_0_13,
PIN_NOT_MATCH_ERROR,
PRE_AUTH_GRANT_LITERAL,
UniformCredentialOffer,
} from '@sphereon/oid4vci-common'
export interface CredentialOfferGrantInput {
authorization_code?: Partial<GrantAuthorizationCode>
[PRE_AUTH_GRANT_LITERAL]?: Partial<GrantUrnIetf>
}
function createCredentialOfferGrants(inputGrants?: CredentialOfferGrantInput) {
// Grants is optional
if (!inputGrants || Object.keys(inputGrants).length === 0) {
return undefined
}
const grants: Grant = {}
if (inputGrants?.[PRE_AUTH_GRANT_LITERAL]) {
const grant = {
...inputGrants[PRE_AUTH_GRANT_LITERAL],
'pre-authorized_code': inputGrants[PRE_AUTH_GRANT_LITERAL]['pre-authorized_code'] ?? uuidv4(),
}
if (grant.tx_code && !grant.tx_code.length) {
grant.tx_code.length = 4
}
grants[PRE_AUTH_GRANT_LITERAL] = grant
}
if (inputGrants?.authorization_code) {
grants.authorization_code = {
...inputGrants.authorization_code,
// TODO: it should be possible to create offer without issuer_state
// this is added to avoid breaking changes.
issuer_state: inputGrants.authorization_code.issuer_state ?? uuidv4(),
}
}
return grants
}
function parseCredentialOfferSchemeAndBaseUri(scheme?: string, baseUri?: string, credentialIssuer?: string): { scheme: string; baseUri: string } {
const newScheme = scheme?.replace('://', '') ?? (baseUri?.includes('://') ? baseUri.split('://')[0] : 'openid-credential-offer')
let newBaseUri: string
if (baseUri) {
newBaseUri = baseUri
} else if (newScheme.startsWith('http')) {
if (credentialIssuer) {
newBaseUri = credentialIssuer
if (!newBaseUri.startsWith(`${newScheme}://`)) {
throw Error(`scheme ${newScheme} is different from base uri ${newBaseUri}`)
}
} else {
throw Error(`A '${newScheme}' scheme requires a URI to be present as baseUri`)
}
} else {
newBaseUri = ''
}
newBaseUri = newBaseUri?.replace(`${newScheme}://`, '')
return { scheme: newScheme, baseUri: newBaseUri }
}
export function createCredentialOfferObject(
issuerMetadata?: CredentialIssuerMetadataOptsV1_0_13,
// todo: probably it's wise to create another builder for CredentialOfferPayload that will generate different kinds of CredentialOfferPayload
opts?: {
credentialOffer?: CredentialOfferPayloadV1_0_13
credentialOfferUri?: string
grants?: CredentialOfferGrantInput
client_id?: string
},
): AssertedUniformCredentialOffer {
if (!issuerMetadata && !opts?.credentialOffer && !opts?.credentialOfferUri) {
throw new Error('You have to provide issuerMetadata or credentialOffer object for creating a deeplink')
}
const grants = createCredentialOfferGrants(opts?.grants)
let credential_offer: CredentialOfferPayloadV1_0_13
if (opts?.credentialOffer) {
credential_offer = {
...opts.credentialOffer,
}
} else {
if (!issuerMetadata?.credential_configurations_supported) {
throw new Error('credential_configurations_supported is mandatory in the metadata')
}
credential_offer = {
credential_issuer: issuerMetadata.credential_issuer,
credential_configuration_ids: Object.keys(issuerMetadata.credential_configurations_supported),
}
}
if (grants) {
credential_offer.grants = grants
}
if (opts?.client_id) {
credential_offer.client_id = opts.client_id
}
// todo: check payload against issuer metadata. Especially strings in the credentials array: When processing, the Wallet MUST resolve this string value to the respective object.
return { credential_offer, credential_offer_uri: opts?.credentialOfferUri }
}
export function createCredentialOfferObjectv1_0_11(
issuerMetadata?: CredentialIssuerMetadataOpts,
// todo: probably it's wise to create another builder for CredentialOfferPayload that will generate different kinds of CredentialOfferPayload
opts?: {
credentialOffer?: CredentialOfferPayloadV1_0_11
credentialOfferUri?: string
scheme?: string
baseUri?: string
grants?: CredentialOfferGrantInput
},
): AssertedUniformCredentialOffer {
if (!issuerMetadata && !opts?.credentialOffer && !opts?.credentialOfferUri) {
throw new Error('You have to provide issuerMetadata or credentialOffer object for creating a deeplink')
}
// v13 to v11 grant
const grants = createCredentialOfferGrants(opts?.grants)
if (grants?.[PRE_AUTH_GRANT_LITERAL]?.tx_code) {
const { tx_code, ...rest } = grants[PRE_AUTH_GRANT_LITERAL]
grants[PRE_AUTH_GRANT_LITERAL] = {
user_pin_required: true,
...rest,
}
}
let credential_offer: CredentialOfferPayloadV1_0_11
if (opts?.credentialOffer) {
credential_offer = {
...opts.credentialOffer,
credentials:
opts.credentialOffer?.credentials ?? issuerMetadata?.credentials_supported.map((s) => s.id).filter((i): i is string => i !== undefined),
}
} else {
if (!issuerMetadata) {
throw new Error('Issuer metadata is required when no credential offer is provided')
}
credential_offer = {
credential_issuer: issuerMetadata.credential_issuer,
credentials: issuerMetadata?.credentials_supported.map((s) => s.id).filter((i): i is string => i !== undefined),
}
}
return { credential_offer, credential_offer_uri: opts?.credentialOfferUri }
}
export function createCredentialOfferURIFromObject(
credentialOffer: CredentialOfferV1_0_13 | UniformCredentialOffer,
offerMode: CredentialOfferMode,
opts?: { scheme?: string; baseUri?: string },
) {
const { scheme, baseUri } = parseCredentialOfferSchemeAndBaseUri(opts?.scheme, opts?.baseUri, credentialOffer.credential_offer?.credential_issuer)
if (offerMode === 'REFERENCE') {
if (!credentialOffer.credential_offer_uri) {
throw Error(`credential_offer_uri must be set for offerMode ${offerMode}`)
}
if (credentialOffer.credential_offer_uri.includes('credential_offer_uri=')) {
// discard the scheme. Apparently a URI is set and it already contains the actual uri, so assume that takes priority
return credentialOffer.credential_offer_uri
}
return `${scheme}://${baseUri}?credential_offer_uri=${encodeURIComponent(credentialOffer.credential_offer_uri)}`
} else if (offerMode === 'VALUE') {
return `${scheme}://${baseUri}?credential_offer=${encodeURIComponent(JSON.stringify(credentialOffer.credential_offer))}`
}
throw Error(`unsupported offerMode ${offerMode}`)
}
export function createCredentialOfferURI(
offerMode: CredentialOfferMode,
issuerMetadata?: IssuerMetadataV1_0_13,
// todo: probably it's wise to create another builder for CredentialOfferPayload that will generate different kinds of CredentialOfferPayload
opts?: {
credentialOffer?: CredentialOfferPayloadV1_0_13
credentialOfferUri?: string
scheme?: string
baseUri?: string
grants?: CredentialOfferGrantInput
},
): string {
const credentialOffer = createCredentialOfferObject(issuerMetadata, opts)
return createCredentialOfferURIFromObject(credentialOffer, offerMode, opts)
}
export function createCredentialOfferURIv1_0_11(
offerMode: CredentialOfferMode,
issuerMetadata?: CredentialIssuerMetadataV1_0_11,
// todo: probably it's wise to create another builder for CredentialOfferPayload that will generate different kinds of CredentialOfferPayload
opts?: {
credentialOffer?: CredentialOfferPayloadV1_0_11
credentialOfferUri?: string
scheme?: string
baseUri?: string
grants?: CredentialOfferGrantInput
},
): string {
const credentialOffer = createCredentialOfferObjectv1_0_11(issuerMetadata, opts)
return createCredentialOfferURIFromObject(credentialOffer, offerMode, opts)
}
export const isPreAuthorizedCodeExpired = (state: CredentialOfferSession, expirationDurationInSeconds: number) => {
const now = +new Date()
const expirationTime = state.createdAt + expirationDurationInSeconds * 1000
return now >= expirationTime
}
export const assertValidPinNumber = (pin?: string, pinLength?: number) => {
if (pin && !RegExp(`[\\d\\D]{${pinLength ?? 6}}`).test(pin)) {
throw Error(`${PIN_NOT_MATCH_ERROR}`)
}
}