Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .idea/.gitignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions .idea/biome.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions .idea/eudi-wallet-functionality.iml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions .idea/jsLibraryMappings.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions .idea/modules.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions .idea/vcs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
},
"publishConfig": {
"access": "public",
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
Expand Down Expand Up @@ -52,6 +53,8 @@
"typescript": "~5.9.3"
},
"dependencies": {
"@animo-id/eudi-wallet-ts12-validation": "workspace:*",
"@animo-id/eudi-wallet-ts12-credential-metadata-query": "workspace:*",
"zod": "^4.3.5"
}
}
126 changes: 126 additions & 0 deletions packages/credential-metadata-provider/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# @animo-id/eudi-wallet-ts12-credential-metadata-provider

Issuer-side: serve signed per-credential metadata JWTs per TS12 Section 5. Constructs, locale-filters, and caches signed JWTs on demand.

No framework dependency — bring your own JWT signer (jose, credo-ts, node:crypto, etc.).

## Install

```bash
pnpm add @animo-id/eudi-wallet-ts12-credential-metadata-provider
```

## Handler

The `CredentialMetadataProvider` handles requests to the `credential_metadata_uri` endpoint. It loads unsigned metadata from your store, filters it by the requested locale, signs it via the signer you provide, caches it, and returns the appropriate response.

Allowed locales are derived automatically from the metadata per TS12 Section 3.5.4 — only locales that fully resolve across all display arrays are served. A warning is emitted for locales that appear in the metadata but cannot fully resolve.

```ts
import { CredentialMetadataProvider } from '@animo-id/eudi-wallet-ts12-credential-metadata-provider'
import type { CredentialMetadataStore, JwtSigner } from '@animo-id/eudi-wallet-ts12-credential-metadata-provider'

// Bring your own signer — any library that produces compact JWS.
// The signer owns alg, x5c, and key material. It MUST set
// typ: 'credential-metadata+jwt' and include x5c per Section 5.
import { SignJWT, importPKCS8 } from 'jose'

const signer: JwtSigner = async (payload) => {
const key = await importPKCS8(signingKeyPem, 'ES256')
return new SignJWT(payload)
.setProtectedHeader({ typ: 'credential-metadata+jwt', alg: 'ES256', x5c })
.sign(key)
}

// Implement the store interface — adapt to your database/storage
const store: CredentialMetadataStore = {
// Lightweight identity — called on every request
async getCredentialInfo(credentialId) {
const row = await db.findCredential(credentialId)
if (!row) return undefined
return {
credentialType: row.vct,
format: row.format,
credentialMetadataUri: `https://issuer.example.com/credential-metadata/${credentialId}`,
updatedAt: row.updatedAt, // epoch ms — busts the derived locale cache on change
}
},

// Full metadata with all locales — only called on cache miss
async getCredentialMetadata(credentialId) {
return db.getMetadata(credentialId)
// Returns the OID4VCI Section 12.2.4 credential_metadata object:
// {
// display: [
// { name: 'SuperBank Payment', locale: 'en', logo: {...}, ... },
// { name: 'SuperBank Zahlung', locale: 'de', logo: {...}, ... },
// ],
// claims: [
// { path: ['payment_network'], display: [{ locale: 'en', name: 'Payment network' }, ...] },
// ],
// transaction_data_types: {
// 'urn:eudi:sca:eu.europa.ec:payment:single:1': {
// claims: [...],
// ui_labels: { affirmative_action_label: [...] },
// },
// },
// }
},

// Cached signed JWTs, keyed by (credentialId, canonicalLocale)
async getSignedJwt(credentialId, canonicalLocale) {
return cache.get(`${credentialId}:${canonicalLocale}`)
},
async saveSignedJwt(credentialId, canonicalLocale, jwt) {
cache.set(`${credentialId}:${canonicalLocale}`, jwt)
},
}

const provider = new CredentialMetadataProvider({
store,
signer,
issuerIdentifier: 'https://issuer.example.com',
expiresInSeconds: 2592000, // 30 days
logger: console, // warns about non-resolvable locales
})
```

In your HTTP handler:

```ts
app.get('/credential-metadata/:id', async (req, res) => {
const response = await provider.handle(req.params.id, {
accept: req.headers.accept,
acceptLanguage: req.headers['accept-language'],
})
res.setHeader('Content-Type', response.contentType)
res.send(response.body)
})
// Accept: application/jwt → signed JWT (constructed + cached if missing)
// Accept: application/json → locale-filtered JSON (no signing)
// Accept absent → application/json (Section 5 default)
```

## How locale filtering works

1. The provider derives allowed locales from the metadata: only locales that fully resolve per TS12 Section 3.5.4 (every `display` array across claims and UI labels produces a match). Non-resolvable locales are logged via `logger.warn`.

2. The `Accept-Language` header is parsed (RFC 9110 §12.5.4, quality-sorted, `q=0` excluded) and intersected with the allowed locales.

3. The default locale canonicalizer picks the first (highest-quality) locale and reduces it to its primary language subtag (`en-US` → `en`). Override with `canonicalizeLocale` in the config.

4. The result is used as a cache key. Signed JWTs are cached by `(credentialId, canonicalLocale)` and busted when the store's `updatedAt` changes.

## JWT structure per Section 5

The handler builds the payload and passes it to your signer:

```
Payload (built by the provider):
{ iss, sub, format, iat, exp, credential_metadata_uri, credential_metadata: {...} }

Header (built by your signer):
{ typ: 'credential-metadata+jwt', alg: 'ES256', x5c: [...] }
```

The signer receives the payload, adds the JOSE header with its own `alg`, `x5c`, and key material, and returns a compact JWS string.
42 changes: 42 additions & 0 deletions packages/credential-metadata-provider/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"name": "@animo-id/eudi-wallet-ts12-credential-metadata-provider",
"description": "EUDI Wallet TS12 credential metadata provider — create and serve signed per-credential metadata JWTs per Section 5",
"version": "0.1.0",
"license": "Apache-2.0",
"author": "Frederic Artus Nieto for DSGV",
"exports": "./src/index.ts",
"files": [
"dist"
],
"engines": {
"node": ">=22"
},
"publishConfig": {
"access": "public",
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": "./dist/index.mjs",
"./package.json": "./package.json"
}
},
"repository": {
"type": "git",
"url": "https://github.com/animo/eudi-wallet-functionality",
"directory": "packages/credential-metadata-provider"
},
"scripts": {
"types:check": "tsc --noEmit",
"build": "tsdown src/index.ts --format esm --dts --clean --sourcemap"
},
"dependencies": {
"@animo-id/eudi-wallet-ts12-validation": "workspace:*",
"@hapi/accept": "^6.0.3",
"bcp-47-match": "^2.0.3"
},
"devDependencies": {
"tsdown": "^0.18.4",
"typescript": "~5.9.3"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import type { CredentialMetadata } from '@animo-id/eudi-wallet-ts12-validation'
import {
buildLocaleKey,
defaultLocaleCanonicalizer,
deriveAllowedLocales,
filterByAllowList,
filterMetadataByLocales,
negotiateMediaType,
parseAcceptLanguage,
} from './filter-locale'
import type { CredentialMetadataProviderConfig, CredentialMetadataResponse } from './types'

function jsonResponse(status: number, body: string): CredentialMetadataResponse {
return { status, contentType: 'application/json', body }
}

function jwtResponse(jwt: string): CredentialMetadataResponse {
return { status: 200, contentType: 'application/jwt', body: jwt }
}

function errorResponse(status: number, error: string, supportedLocales?: string[]): CredentialMetadataResponse {
return jsonResponse(status, JSON.stringify({ error, supported_locales: supportedLocales }))
}

/**
* TS12 Section 5 — Handler for the `credential_metadata_uri` endpoint.
*/
export class CredentialMetadataProvider {
private readonly config: CredentialMetadataProviderConfig
private derivedLocaleCache = new Map<string, { updatedAt: number; locales: string[]; metadata: CredentialMetadata }>()

constructor(config: CredentialMetadataProviderConfig) {
this.config = config
}

async handle(
credentialId: string,
headers: { accept?: string; acceptLanguage?: string }
): Promise<CredentialMetadataResponse> {
const { store, canonicalizeLocale = defaultLocaleCanonicalizer } = this.config

// Content type negotiation (TS12 Section 5: absent Accept defaults to JSON)
let acceptsJwt = false
if (headers.accept !== undefined) {
try {
acceptsJwt = negotiateMediaType(headers.accept, ['application/jwt', 'application/json']) === 'application/jwt'
} catch {
return errorResponse(400, 'invalid_accept_header')
}
}

const info = await store.getCredentialInfo(credentialId)
if (!info) return errorResponse(404, 'not_found')

const derived = await this.getOrDeriveLocales(credentialId, info.updatedAt)
if (!derived) return errorResponse(404, 'not_found')

// Language negotiation
let inputLocales: string[]
if (headers.acceptLanguage === undefined) {
inputLocales = derived.locales
} else {
let parsed: { locales: string[]; acceptsAll: boolean }
try {
parsed = parseAcceptLanguage(headers.acceptLanguage)
} catch {
return errorResponse(400, 'invalid_accept_language')
}

if (parsed.acceptsAll) {
inputLocales = derived.locales
} else if (parsed.locales.length === 0) {
return errorResponse(404, 'no_matching_locale', derived.locales)
} else {
inputLocales = filterByAllowList(parsed.locales, derived.locales)
if (inputLocales.length === 0) {
return errorResponse(404, 'no_matching_locale', derived.locales)
}
}
}

const selectedLocales = canonicalizeLocale(inputLocales)
const localeKey = buildLocaleKey(selectedLocales)

if (acceptsJwt) {
const cached = await store.getSignedJwt(credentialId, localeKey)
if (cached) return jwtResponse(cached)
}

const filteredMetadata = filterMetadataByLocales(derived.metadata, selectedLocales)

if (!acceptsJwt) return jsonResponse(200, JSON.stringify(filteredMetadata))

const nowSeconds = Math.floor(Date.now() / 1000)
const payload: Record<string, unknown> = {
iss: this.config.issuerIdentifier,
sub: info.credentialType,
format: info.format,
iat: nowSeconds,
exp: nowSeconds + this.config.expiresInSeconds,
credential_metadata_uri: info.credentialMetadataUri,
credential_metadata: filteredMetadata,
}

const jwt = await this.config.signer(payload)
await store.saveSignedJwt(credentialId, localeKey, jwt)

return jwtResponse(jwt)
}

private async getOrDeriveLocales(
credentialId: string,
updatedAt: number
): Promise<{ locales: string[]; metadata: CredentialMetadata } | undefined> {
const cached = this.derivedLocaleCache.get(credentialId)
if (cached && cached.updatedAt === updatedAt) {
return cached
}
const metadata = await this.config.store.getCredentialMetadata(credentialId)
if (!metadata) return undefined
const locales = deriveAllowedLocales(metadata, this.config.logger)
this.derivedLocaleCache.set(credentialId, { updatedAt, locales, metadata })
return { locales, metadata }
}
}
Loading
Loading