From 7df24c076d8d9e03f5ea66628c98ac8d3542928f Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Fri, 25 Sep 2026 16:25:30 -0700 Subject: [PATCH 01/33] Make sure metadata statements are fresh --- .../src/services/metadataService.test.ts | 193 +++++++++++++++++- .../server/src/services/metadataService.ts | 26 +-- 2 files changed, 205 insertions(+), 14 deletions(-) diff --git a/packages/server/src/services/metadataService.test.ts b/packages/server/src/services/metadataService.test.ts index b7631ad19..7a6c5ffb2 100644 --- a/packages/server/src/services/metadataService.test.ts +++ b/packages/server/src/services/metadataService.test.ts @@ -1,10 +1,20 @@ -import { assertEquals, assertRejects } from '@std/assert'; +import { assertEquals, assertNotEquals, assertRejects } from '@std/assert'; import { afterEach, beforeEach, describe, it } from '@std/testing/bdd'; -import { assertSpyCallArg, assertSpyCalls, spy, type Stub, stub } from '@std/testing/mock'; +import { + assertSpyCallArg, + assertSpyCalls, + returnsNext, + spy, + type Stub, + stub, +} from '@std/testing/mock'; import { _fetchInternals } from '../helpers/fetch.ts'; +import { isoBase64URL } from '../helpers/iso/index.ts'; +import { generateLeafCert, generateRootCert } from '../helpers/tests/x509Utils.ts'; import { BaseMetadataService, MetadataService } from './metadataService.ts'; +import { SettingsService } from './settingsService.ts'; import type { MetadataStatement } from '../metadata/mdsTypes.ts'; let mockFetch: Stub; @@ -95,6 +105,94 @@ describe('Method: getStatement()', () => { }); }); +describe('Behavior: BLOB refresh', () => { + const mdsServerURL = 'https://mds.example.com/'; + let mockFetch: Stub; + let originalMDSRootCerts: string[]; + const refreshableAAGUID = '00000000-0000-0000-0000-000000000001'; + + beforeEach(async () => { + /** + * Our BLOBs are signed by certs generated at runtime, so temporarily trust the root cert they + * chain back to. No CRL is specified for these certs, so revocation checking won't reach out + * to the network either. + */ + const { rootCertPEM } = await getBLOBSigner(); + originalMDSRootCerts = SettingsService.getRootCertificates({ identifier: 'mds' }); + SettingsService.setRootCertificates({ identifier: 'mds', certificates: [rootCertPEM] }); + }); + + afterEach(() => { + SettingsService.setRootCertificates({ + identifier: 'mds', + certificates: originalMDSRootCerts, + }); + mockFetch.restore(); + }); + + it('should return an up-to-date statement after refreshing a stale BLOB', async () => { + const staleStatement: MetadataStatement = { + ...localStatement, + aaguid: refreshableAAGUID, + description: 'Statement from the stale BLOB', + authenticatorVersion: 1, + }; + const freshStatement: MetadataStatement = { + ...localStatement, + aaguid: refreshableAAGUID, + description: 'Statement from the refreshed BLOB', + authenticatorVersion: 2, + }; + + mockFetch = stub( + _fetchInternals, + 'stubThis', + returnsNext([ + // The BLOB cached during initialization, which expired in the past + generateBLOBResponse({ no: 1, nextUpdate: '2023-01-01', statement: staleStatement }), + // The BLOB that should get downloaded when `getStatement()` notices the staleness + generateBLOBResponse({ no: 2, nextUpdate: '2999-01-01', statement: freshStatement }), + ]), + ); + + await MetadataService.initialize({ mdsServers: [mdsServerURL] }); + + assertSpyCalls(mockFetch, 1); + + const statement = await MetadataService.getStatement(refreshableAAGUID); + + // The stale BLOB should have been re-downloaded... + assertSpyCalls(mockFetch, 2); + assertSpyCallArg(mockFetch, 1, 0, mdsServerURL); + // ...and the statement from the newer BLOB returned + assertEquals(statement, freshStatement); + assertNotEquals(statement, staleStatement); + }); + + it('should not refresh a BLOB that is still current', async () => { + const statement: MetadataStatement = { + ...localStatement, + aaguid: refreshableAAGUID, + description: 'Statement from a current BLOB', + }; + + mockFetch = stub( + _fetchInternals, + 'stubThis', + returnsNext([ + generateBLOBResponse({ no: 3, nextUpdate: '2999-01-01', statement }), + ]), + ); + + await MetadataService.initialize({ mdsServers: [mdsServerURL] }); + + const returnedStatement = await MetadataService.getStatement(refreshableAAGUID); + + assertSpyCalls(mockFetch, 1); + assertEquals(returnedStatement, statement); + }); +}); + describe('Behavior: logging', () => { it('should use provided logger', async () => { const _debugSpy = spy(); @@ -164,3 +262,94 @@ const localStatement: MetadataStatement = { maxMsgSize: 1200, }, }; + +type BLOBSigner = { + /** The trust anchor that the BLOB's leaf cert chains back to */ + rootCertPEM: string; + /** The BLOB's leaf cert, ready to drop into a JWT header's `x5c` */ + leafCertBase64: string; + /** The leaf cert's keys, used to sign the BLOB itself */ + leafKeys: CryptoKeyPair; +}; + +/** + * A root cert, and the leaf cert it signed, for signing the MDS BLOBs generated below. + */ +let blobSigner: BLOBSigner | undefined; +/** + * Generate a BLOB signer once, and then reuse it so that every use of `generateBLOBResponse()` in + * here generates a BLOB that can chains back to the same trust anchor. + */ +async function getBLOBSigner(): Promise { + if (!blobSigner) { + const notBefore = new Date(Date.now() - 60_000); + const notAfter = new Date(Date.now() + 3_600_000); + + const root = await generateRootCert({ + notBefore, + notAfter, + name: 'CN=FAKE Metadata BLOB Root FAKE', + }); + const leaf = await generateLeafCert({ + notBefore, + notAfter, + issuer: root, + subject: 'CN=FAKE Metadata BLOB Signer FAKE', + }); + + blobSigner = { + rootCertPEM: root.certificate.toString('pem'), + leafCertBase64: isoBase64URL.fromBuffer( + new Uint8Array(leaf.certificate.rawData), + 'base64', + ), + leafKeys: leaf.keys, + }; + } + + return blobSigner; +} + +/** + * Generate a `Response` containing a signed, verifiable MDS BLOB with a single entry in it, so + * that BLOB downloads can be exercised without network access + */ +async function generateBLOBResponse(opts: { + no: number; + nextUpdate: string; + statement: MetadataStatement; +}): Promise { + const { no, nextUpdate, statement } = opts; + + const { leafCertBase64, leafKeys } = await getBLOBSigner(); + + const header = isoBase64URL.fromUTF8String(JSON.stringify({ + typ: 'JWT', + alg: 'ES256', + x5c: [leafCertBase64], + })); + const payload = isoBase64URL.fromUTF8String(JSON.stringify({ + legalHeader: 'https://fidoalliance.org/metadata/metadata-legal-header/', + no, + nextUpdate, + entries: [ + { + aaguid: statement.aaguid, + metadataStatement: statement, + statusReports: [], + timeOfLastStatusChange: '1970-01-01', + }, + ], + })); + + const signatureBytes = await crypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, + leafKeys.privateKey, + new TextEncoder().encode(`${header}.${payload}`), + ); + const signature = isoBase64URL.fromBuffer(new Uint8Array(signatureBytes)); + + const blob = `${header}.${payload}.${signature}`; + + return new Response(blob); +} diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index 4c6add665..c25de1c87 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -200,20 +200,10 @@ export class BaseMetadataService implements MetadataService { await this.pauseUntilReady(); // Try to grab a cached statement - const cachedStatement = this.statementCache[aaguid]; - - if (!cachedStatement) { - if (this.verificationMode === 'strict') { - // FIDO conformance requires RP's to only support registered AAGUID's - throw new Error(`No metadata statement found for aaguid "${aaguid}"`); - } - - // Allow registration verification to continue without using metadata - return; - } + let cachedStatement = this.statementCache[aaguid]; // If the statement points to an MDS API, check the MDS' nextUpdate to see if we need to refresh - if (cachedStatement.url) { + if (cachedStatement?.url) { const mds = this.mdsCache[cachedStatement.url]; const now = new Date(); if (now > mds.nextUpdate) { @@ -221,12 +211,24 @@ export class BaseMetadataService implements MetadataService { this.setState(SERVICE_STATE.REFRESHING); const blob = await this.downloadBlob(mds); await this.verifyBlob(blob, mds); + // Refresh the cached statement in case the cache got updated just now + cachedStatement = this.statementCache[aaguid]; } finally { this.setState(SERVICE_STATE.READY); } } } + if (!cachedStatement) { + if (this.verificationMode === 'strict') { + // FIDO conformance requires RP's to only support registered AAGUID's + throw new Error(`No metadata statement found for aaguid "${aaguid}"`); + } + + // Allow registration verification to continue without using metadata + return; + } + const { entry } = cachedStatement; // Check to see if the this aaguid has a status report with a "compromised" status From 5898c9406984adf7d0ebc146fcc02299916bc196 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Wed, 7 Oct 2026 18:45:52 -0700 Subject: [PATCH 02/33] Add convenience MDS types --- packages/server/src/metadata/mdsTypes.ts | 47 +++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/server/src/metadata/mdsTypes.ts b/packages/server/src/metadata/mdsTypes.ts index 51728da5e..fe5344a93 100644 --- a/packages/server/src/metadata/mdsTypes.ts +++ b/packages/server/src/metadata/mdsTypes.ts @@ -169,9 +169,14 @@ export type MetadataStatement = { tcDisplayPNGCharacteristics?: DisplayPNGCharacteristicsDescriptor[]; attestationRootCertificates: string[]; ecdaaTrustAnchors?: EcdaaTrustAnchor[]; - icon?: string; supportedExtensions?: ExtensionDescriptor[]; authenticatorGetInfo?: AuthenticatorGetInfo; + // Same values as in Convenience Metadata + friendlyNames?: FriendlyNames; + icon?: DataURLPNGOrSVGString; + iconDark?: DataURLSVGString; + providerLogoLight?: DataURLSVGString; + providerLogoDark?: DataURLSVGString; }; /** @@ -314,3 +319,43 @@ export type AuthenticatorGetInfo = { pinProtocols?: number[]; algorithms?: { type: 'public-key'; alg: number }[]; }; + +/** + * Localized names, typically a company's trade name for a given region. + * + * Example: + * ```json + * { + * "en-US": "SimpleWebAuthn", + * "ja-JP": "シンプルウェブオースン" + * } + * ``` + * + * See https://fidoalliance.org/specs/mds/fido-convenience-metadata-service-v1.0-ps-20250521.html#sctn-types-fn + */ +export type FriendlyNames = { + [ietfLanguageCode: string]: string; +}; + +/** + * A `data:` URL-encoded string containing base64-encoded PNG or SVG data. + * + * Example: + * ```json + * "data:image/svg+xml;base64,..." + * ``` + * ```json + * "data:image/png;base64,..." + * ``` + */ +export type DataURLPNGOrSVGString = string; + +/** + * A `data:` URL-encoded string containing base64-encoded SVG data. + * + * Example: + * ```json + * "data:image/svg+xml;base64,..." + * ``` + */ +export type DataURLSVGString = string; From cc083e6e5df28a718e752e1d505b088bda2fbd6f Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Wed, 7 Oct 2026 19:10:40 -0700 Subject: [PATCH 03/33] Add new convenienceMDSServers arg to initialize --- packages/server/src/services/metadataService.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index c25de1c87..ff4c7ca6e 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -35,6 +35,7 @@ type CachedBLOBEntry = { }; const defaultURLMDS = 'https://mds.fidoalliance.org/'; // v3 +const defaultURLConvenienceMDS = 'https://c-mds.fidoalliance.org/'; enum SERVICE_STATE { DISABLED, @@ -56,6 +57,8 @@ interface MetadataService { * * @param opts.mdsServers An array of URLs to FIDO Alliance Metadata Service * (version 3.0)-compatible servers. Defaults to the official FIDO MDS server + * @param opts.convenienceMDSServers An array of URLs to FIDO Alliance Metadata Convenience + * Service-compatible servers. Defaults to the official FIDO Convenience MDS * @param opts.statements An array of local metadata statements. Statements will be loaded but * not refreshed * @param opts.verificationMode How MetadataService will handle unregistered AAGUIDs. Defaults to @@ -65,6 +68,7 @@ interface MetadataService { */ initialize(opts?: { mdsServers?: string[]; + convenienceMDSServers?: string[]; statements?: MetadataStatement[]; verificationMode?: VerificationMode; logger?: SimpleWebAuthnLogger; @@ -94,6 +98,7 @@ export class BaseMetadataService implements MetadataService { async initialize( opts: { mdsServers?: string[]; + convenienceMDSServers?: string[]; statements?: MetadataStatement[]; verificationMode?: VerificationMode; logger?: SimpleWebAuthnLogger; @@ -104,6 +109,7 @@ export class BaseMetadataService implements MetadataService { const { mdsServers = [defaultURLMDS], + convenienceMDSServers = [defaultURLConvenienceMDS], statements, verificationMode, logger = DefaultNoopLogger, From 283cee62cc95c54521fec15efdc5b2ee5b4dc9b6 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Wed, 7 Oct 2026 19:10:50 -0700 Subject: [PATCH 04/33] Add missing logger arg docs --- packages/server/src/services/metadataService.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index ff4c7ca6e..c7e81e818 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -65,6 +65,8 @@ interface MetadataService { * `"strict"` which throws errors during registration response verification when an * unregistered AAGUID is encountered. Set to `"permissive"` to allow registration by * authenticators with unregistered AAGUIDs + * @param opts.logger An instance of `SimpleWebAuthnLogger` that allows for capturing + * MetadataService log output in a project-specific way */ initialize(opts?: { mdsServers?: string[]; From e30d2d135ce23ccbef52398946433653f82f3881 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Wed, 7 Oct 2026 19:13:38 -0700 Subject: [PATCH 05/33] Add internal cache for convenience metadata --- packages/server/src/services/metadataService.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index c7e81e818..e74b8b035 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -8,6 +8,7 @@ import { } from '../helpers/logging.ts'; import { fetch } from '../helpers/fetch.ts'; import type { Uint8Array_ } from '../types/index.ts'; +import { ConvenienceMDSPayload, ProviderMetadata } from '../metadata/convenienceMDSTypes.ts'; // Cached MDS APIs from which BLOBs are downloaded type CachedMDS = { @@ -34,6 +35,11 @@ type CachedBLOBEntry = { url: CachedMDS['url']; }; +type CachedProviderMetadata = { + metadata: ProviderMetadata; + url: CachedMDS['url']; +}; + const defaultURLMDS = 'https://mds.fidoalliance.org/'; // v3 const defaultURLConvenienceMDS = 'https://c-mds.fidoalliance.org/'; @@ -93,6 +99,7 @@ interface MetadataService { export class BaseMetadataService implements MetadataService { private mdsCache: { [url: string]: CachedMDS } = {}; private statementCache: { [aaguid: string]: CachedBLOBEntry } = {}; + private convenienceMetadataCache: { [aaguid: string]: CachedProviderMetadata } = {}; private state: SERVICE_STATE = SERVICE_STATE.DISABLED; private verificationMode: VerificationMode = 'strict'; private logger: Required = DefaultNoopLogger; From 5c98694b812496ef4285da2fd6dcde49347830b4 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Wed, 7 Oct 2026 19:13:59 -0700 Subject: [PATCH 06/33] Add conveniencePayloads args for local payloads --- packages/server/src/services/metadataService.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index e74b8b035..e8f7bc8c0 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -67,6 +67,7 @@ interface MetadataService { * Service-compatible servers. Defaults to the official FIDO Convenience MDS * @param opts.statements An array of local metadata statements. Statements will be loaded but * not refreshed + * @param opts.conveniencePayloads An array of local convenience MDS metadata. Metadata loaded in this way will be loaded but not refreshed. * @param opts.verificationMode How MetadataService will handle unregistered AAGUIDs. Defaults to * `"strict"` which throws errors during registration response verification when an * unregistered AAGUID is encountered. Set to `"permissive"` to allow registration by @@ -78,6 +79,7 @@ interface MetadataService { mdsServers?: string[]; convenienceMDSServers?: string[]; statements?: MetadataStatement[]; + conveniencePayloads?: ConvenienceMDSPayload[]; verificationMode?: VerificationMode; logger?: SimpleWebAuthnLogger; }): Promise; @@ -109,6 +111,7 @@ export class BaseMetadataService implements MetadataService { mdsServers?: string[]; convenienceMDSServers?: string[]; statements?: MetadataStatement[]; + conveniencePayloads?: ConvenienceMDSPayload[]; verificationMode?: VerificationMode; logger?: SimpleWebAuthnLogger; } = {}, @@ -120,6 +123,7 @@ export class BaseMetadataService implements MetadataService { mdsServers = [defaultURLMDS], convenienceMDSServers = [defaultURLConvenienceMDS], statements, + conveniencePayloads, verificationMode, logger = DefaultNoopLogger, } = opts; From 5f233dfc631f2db653b37e518c72ba4e9fb72b87 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Wed, 7 Oct 2026 19:15:06 -0700 Subject: [PATCH 07/33] Add cacheConvenienceMDS to centralize caching --- .../server/src/services/metadataService.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index e8f7bc8c0..a26d91af3 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -328,6 +328,37 @@ export class BaseMetadataService implements MetadataService { } } + /** + * Cache the AAGUID-keyed entries in a Convenience MDS payload. Returns the number of entries + * cached (so you can `count += cacheConvenienceMDS(...)`) + */ + private cacheConvenienceMDS(url: string, payload: ConvenienceMDSPayload): number { + let entriesAdded = 0; + + // Cache entries + Object.keys(payload).forEach((aaguid) => { + // Skip the versioning property + if (aaguid === 'no') { + return; + } + + // The rest of the keys will be AAGUIDs + this.cacheProviderMetadata(aaguid, payload[aaguid], url); + entriesAdded += 1; + }); + + // Set up convenience metadata refresh + this.mdsCache[url] = { + url, + // Store the payload `no` to skip re-downloading metadata we already have + no: payload.no, + // Convenience MDS returns no nextUpdate, so set one that's seven days from now + nextUpdate: new Date(Date.now() + (100 * 60 * 60 * 24 * 7)), + }; + + return entriesAdded; + } + /** * A helper method to pause execution until the service is ready */ From f199e1c074c2f376eecabe98899cbf7e46c11124 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Wed, 7 Oct 2026 19:20:16 -0700 Subject: [PATCH 08/33] Add a method to download convenience MDS data --- .../server/src/services/metadataService.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index a26d91af3..bc15e30f3 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -328,6 +328,41 @@ export class BaseMetadataService implements MetadataService { } } + /** + * Download the convenience metadata, which is surprisingly just a JSON document + */ + private async downloadConvenienceMDS( + url: string, + no: number, + ): Promise { + // Get latest convenience MDS JSON, with a query param that can short-circuit the download + // if we already have the latest version of metadata + const _url = new URL(`/?localCopySerial=${no}`, url); + const resp = await fetch(_url.toString()); + + /** + * We have the latest data, nothing more to do. + * + * See https://fidoalliance.org/specs/mds/fido-convenience-metadata-service-v1.0-ps-20250521.html#sctn-mds-blob-proc-rules + */ + if (resp.status === 304) { + await resp.body?.cancel(); + return; + } + + /** + * The FIDO convenience MDS API aggressively rate-limits so be prepared for that. We'll have to + * try again later. + */ + if (resp.status === 429) { + await resp.body?.cancel(); + return; + } + + const data: ConvenienceMDSPayload = await resp.json(); + return data; + } + /** * Cache the AAGUID-keyed entries in a Convenience MDS payload. Returns the number of entries * cached (so you can `count += cacheConvenienceMDS(...)`) From 9d750783f6b6a6ee20f7f1e2d6650fff4e70ef0a Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Wed, 7 Oct 2026 19:20:48 -0700 Subject: [PATCH 09/33] Load local payloads first --- packages/server/src/services/metadataService.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index bc15e30f3..979c70236 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -193,6 +193,19 @@ export class BaseMetadataService implements MetadataService { ); } + /** + * If passkey provider metadata is provided, load them into the cache first. + */ + if (conveniencePayloads?.length) { + let entriesAdded = 0; + + conveniencePayloads.forEach((payload) => { + entriesAdded += this.cacheConvenienceMDS(NonRefreshingMDS.url, payload); + }); + + this.logger.info(`Cached ${entriesAdded} local metadata entries`); + } + if (verificationMode) { this.verificationMode = verificationMode; } From f8cfd2e9ee7b130ea56971fb4e682f26d3c96f4d Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Wed, 7 Oct 2026 19:21:06 -0700 Subject: [PATCH 10/33] Download convenience metadata from live servers --- .../server/src/services/metadataService.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index 979c70236..7c5887498 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -206,6 +206,38 @@ export class BaseMetadataService implements MetadataService { this.logger.info(`Cached ${entriesAdded} local metadata entries`); } + /** + * If Convenience MDS server URLs are provided, then download and cache the current collection + * of metadata from them. + */ + if (convenienceMDSServers?.length) { + // Get a current count so we know how many new statements we've added from MDS servers + const currentCacheCount = Object.keys(this.convenienceMetadataCache).length; + let numServers = convenienceMDSServers.length; + + for (const url of convenienceMDSServers) { + // Pull convenience metadata + try { + const payload = await this.downloadConvenienceMDS(url, 0); + + if (payload) { + this.cacheConvenienceMDS(url, payload); + } + } catch (err) { + // Notify of the error and move on + this.logger.error(`Could not download convenience metadata from ${url}:`, err); + numServers -= 1; + } + } + + // Calculate the total number of new metadata entries we successfully added + const newCacheCount = Object.keys(this.convenienceMetadataCache).length; + const cacheDiff = newCacheCount - currentCacheCount; + this.logger.info( + `Cached ${cacheDiff} metadata entries from ${numServers} convenience metadata server(s)`, + ); + } + if (verificationMode) { this.verificationMode = verificationMode; } From 19b010bfecaa74de7b32926a1b4e4f2ca2be786a Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Wed, 7 Oct 2026 19:40:35 -0700 Subject: [PATCH 11/33] Make some minor code quality tweaks --- packages/server/src/services/metadataService.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index 7c5887498..8ec51c285 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -8,7 +8,7 @@ import { } from '../helpers/logging.ts'; import { fetch } from '../helpers/fetch.ts'; import type { Uint8Array_ } from '../types/index.ts'; -import { ConvenienceMDSPayload, ProviderMetadata } from '../metadata/convenienceMDSTypes.ts'; +import type { ConvenienceMDSPayload, ProviderMetadata } from '../metadata/convenienceMDSTypes.ts'; // Cached MDS APIs from which BLOBs are downloaded type CachedMDS = { @@ -245,9 +245,7 @@ export class BaseMetadataService implements MetadataService { this.setState(SERVICE_STATE.READY); } - async getStatement( - aaguid: string | Uint8Array_, - ): Promise { + async getStatement(aaguid: string | Uint8Array_): Promise { if (this.state === SERVICE_STATE.DISABLED) { return; } @@ -325,9 +323,10 @@ export class BaseMetadataService implements MetadataService { } /** - * Verify and process the MDS metadata blob + * Verify and process the MDS metadata blob. Caches metadata and sets up refreshing when blobs + * grow stale. */ - private async verifyBlob(blob: string, cachedMDS: CachedMDS) { + private async verifyBlob(blob: string, cachedMDS: CachedMDS): Promise { const { url, no } = cachedMDS; const { payload, parsedNextUpdate } = await verifyMDSBlob(blob); From 0e1e2370788b3d6f83d451d70f0360f623e640ae Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Wed, 7 Oct 2026 19:41:53 -0700 Subject: [PATCH 12/33] Update MetadataService interface definition --- packages/server/src/services/metadataService.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index 8ec51c285..c403aaf25 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -84,12 +84,13 @@ interface MetadataService { logger?: SimpleWebAuthnLogger; }): Promise; /** - * Get a metadata statement for a given AAGUID. - * - * This method will coordinate updating the cache as per the `nextUpdate` property in the initial - * BLOB download. + * Get additional information about the authenticator associated with the given AAGUID. */ getStatement(aaguid: string | Uint8Array): Promise; + /** + * Get localized names and icons for the credential provider associated with the given AAGUID. + */ + getProviderMetadata(aaguid: string | Uint8Array_): Promise; } /** From eb7fb5290e57796da204809f7b94dbb485975bcf Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Wed, 7 Oct 2026 19:43:21 -0700 Subject: [PATCH 13/33] Refactor MDS caching to use helper method --- .../server/src/services/metadataService.ts | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index c403aaf25..ece6b9aff 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -142,18 +142,15 @@ export class BaseMetadataService implements MetadataService { statements.forEach((statement) => { // Only cache statements that are for FIDO2-compatible authenticators - if (statement.aaguid) { - this.statementCache[statement.aaguid] = { - entry: { - metadataStatement: statement, - statusReports: [], - timeOfLastStatusChange: '1970-01-01', - }, - url: NonRefreshingMDS.url, - }; - - statementsAdded += 1; - } + statementsAdded += this.cacheMetadataBlobEntry( + NonRefreshingMDS.url, + { + aaguid: statement.aaguid, + metadataStatement: statement, + statusReports: [], + timeOfLastStatusChange: '1970-01-01', + }, + ); }); this.logger.info(`Cached ${statementsAdded} local statements`); @@ -342,10 +339,7 @@ export class BaseMetadataService implements MetadataService { // Cache statements for FIDO2 devices for (const entry of payload.entries) { - // Only cache entries with an `aaguid` - if (entry.aaguid) { - this.statementCache[entry.aaguid] = { entry, url }; - } + this.cacheMetadataBlobEntry(url, entry); } if (url) { @@ -439,6 +433,22 @@ export class BaseMetadataService implements MetadataService { return entriesAdded; } + /** + * A central place to contain logic and side effects of caching a BLOB entry. Only caches FIDO2 + * statements (i.e. ones with `aaguid` as UAF authenticators won't have one.) Returns `1` if the + * statement was cached, or `0` if it wasn't (so you can `count += cacheMetadataBlobEntry(...)`) + */ + private cacheMetadataBlobEntry(url: string, entry: MetadataBLOBPayloadEntry): number { + // Only cache entries with an `aaguid`, which will weed out UAF statements + if (!entry.aaguid) { + return 0; + } + + this.statementCache[entry.aaguid] = { entry, url }; + + return 1; + } + /** * A helper method to pause execution until the service is ready */ From 4a8c542c8ef3f735527e65aafe1c61db61585ef2 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Wed, 7 Oct 2026 19:44:23 -0700 Subject: [PATCH 14/33] Cache convenience metadata from standard MDS data --- .../server/src/services/metadataService.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index ece6b9aff..bbc0fe627 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -446,9 +446,38 @@ export class BaseMetadataService implements MetadataService { this.statementCache[entry.aaguid] = { entry, url }; + // Cache the convenience metadata contained within the fuller MetadataStatement + const { metadataStatement } = entry; + if (metadataStatement) { + this.cacheProviderMetadata( + entry.aaguid, + { + friendlyNames: metadataStatement.friendlyNames, + icon: metadataStatement.icon, + iconDark: metadataStatement.iconDark, + providerLogoDark: metadataStatement.providerLogoDark, + providerLogoLight: metadataStatement.providerLogoLight, + }, + /** + * We didn't actually fetch this from a Convenience MDS API, so don't try to update this + * metadata like we did. It'll get updated the next time the regular MDS payload gets pulled + * down + */ + NonRefreshingMDS.url, + ); + } + return 1; } + /** + * Cache convenience metadata, whether pulled out of a full-fat `MetadataStatement` or retrieved + * directly from the Convenience MDS + */ + private cacheProviderMetadata(aaguid: string, metadata: ProviderMetadata, url: string): void { + this.convenienceMetadataCache[aaguid] = { metadata, url }; + } + /** * A helper method to pause execution until the service is ready */ From 68b3da116ff8d8a1a5002e4f4e975b886997f853 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Wed, 7 Oct 2026 19:44:33 -0700 Subject: [PATCH 15/33] Define getProviderMetadata() --- .../server/src/services/metadataService.ts | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index bbc0fe627..614212f39 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -307,6 +307,51 @@ export class BaseMetadataService implements MetadataService { return entry.metadataStatement; } + async getProviderMetadata(aaguid: string | Uint8Array_): Promise { + if (this.state === SERVICE_STATE.DISABLED) { + return; + } + + if (!aaguid) { + return; + } + + if (aaguid instanceof Uint8Array) { + aaguid = convertAAGUIDToString(aaguid); + } + + // If a cache refresh is in progress then pause this until the service is ready + await this.pauseUntilReady(); + + // Try to grab a cached statement + let cachedMetadata = this.convenienceMetadataCache[aaguid]; + + // If the metadata points to a convenience MDS API, then check the nextUpdate to see if we need + // to refresh it + if (cachedMetadata?.url) { + const mds = this.mdsCache[cachedMetadata.url]; + const now = new Date(); + if (now > mds.nextUpdate) { + try { + this.setState(SERVICE_STATE.REFRESHING); + const payload = await this.downloadConvenienceMDS(mds.url, mds.no); + if (payload) { + this.cacheConvenienceMDS(mds.url, payload); + } + + // Refresh the cached metadata in case the cache got updated just now + cachedMetadata = this.convenienceMetadataCache[aaguid]; + } finally { + this.setState(SERVICE_STATE.READY); + } + } + } + + const { metadata } = cachedMetadata; + + return metadata; + } + /** * Download and process the latest BLOB from MDS */ From 057a94f0adeb5f135834f01a22361258f6fbba2e Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Wed, 7 Oct 2026 19:44:49 -0700 Subject: [PATCH 16/33] Update existing MDS tests --- packages/server/src/services/metadataService.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/server/src/services/metadataService.test.ts b/packages/server/src/services/metadataService.test.ts index 7a6c5ffb2..e4a6c551d 100644 --- a/packages/server/src/services/metadataService.test.ts +++ b/packages/server/src/services/metadataService.test.ts @@ -28,11 +28,12 @@ describe('Method: initialize()', () => { mockFetch.restore(); }); - it('should default to querying MDS v3', async () => { + it('should default to querying MDS v3 and convenience MDS', async () => { await MetadataService.initialize(); - assertSpyCalls(mockFetch, 1); + assertSpyCalls(mockFetch, 2); assertSpyCallArg(mockFetch, 0, 0, 'https://mds.fidoalliance.org/'); + assertSpyCallArg(mockFetch, 1, 0, 'https://c-mds.fidoalliance.org/?localCopySerial=0'); }); it('should query provided MDS server URLs', async () => { @@ -40,6 +41,7 @@ describe('Method: initialize()', () => { await MetadataService.initialize({ mdsServers, + convenienceMDSServers: [], }); assertSpyCalls(mockFetch, mdsServers.length); @@ -48,7 +50,7 @@ describe('Method: initialize()', () => { }); it('should not query any servers on empty list of URLs', async () => { - await MetadataService.initialize({ mdsServers: [] }); + await MetadataService.initialize({ mdsServers: [], convenienceMDSServers: [] }); assertSpyCalls(mockFetch, 0); }); @@ -155,7 +157,7 @@ describe('Behavior: BLOB refresh', () => { ]), ); - await MetadataService.initialize({ mdsServers: [mdsServerURL] }); + await MetadataService.initialize({ mdsServers: [mdsServerURL], convenienceMDSServers: [] }); assertSpyCalls(mockFetch, 1); @@ -184,7 +186,7 @@ describe('Behavior: BLOB refresh', () => { ]), ); - await MetadataService.initialize({ mdsServers: [mdsServerURL] }); + await MetadataService.initialize({ mdsServers: [mdsServerURL], convenienceMDSServers: [] }); const returnedStatement = await MetadataService.getStatement(refreshableAAGUID); From 23b127efcc8063ad6608f55f51d89433a39e6313 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Wed, 7 Oct 2026 19:46:15 -0700 Subject: [PATCH 17/33] Oops these are the actual new C-MDS types --- .../src/metadata/convenienceMDSTypes.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 packages/server/src/metadata/convenienceMDSTypes.ts diff --git a/packages/server/src/metadata/convenienceMDSTypes.ts b/packages/server/src/metadata/convenienceMDSTypes.ts new file mode 100644 index 000000000..c077c394a --- /dev/null +++ b/packages/server/src/metadata/convenienceMDSTypes.ts @@ -0,0 +1,35 @@ +import type { DataURLPNGOrSVGString, DataURLSVGString, FriendlyNames } from './mdsTypes.ts'; + +/** + * Passkey Provider metadata, including localized names, and light and dark mode icons. + * + * See https://fidoalliance.org/specs/mds/fido-convenience-metadata-service-v1.0-ps-20250521.html#sctn-convenience-details + */ +export type ProviderMetadata = { + friendlyNames?: FriendlyNames; + icon?: DataURLPNGOrSVGString; + iconDark?: DataURLSVGString; + providerLogoLight?: DataURLSVGString; + providerLogoDark?: DataURLSVGString; +}; + +/** + * A dictionary of payload data within a Convenience MDS blob. Except for the `"no"` property, + * keys in this dictionary are AAGUIDs that map to `ProviderMetadata` dictionaries. + * + * Example: + * ```json + * { + * "no": 1, + * "12345678-1234-1234-1234-12345678": {...}, + * "87654321-4321-4321-4321-87654321": {...} + * } + * ``` + * + * See https://fidoalliance.org/specs/mds/fido-convenience-metadata-service-v1.0-ps-20250521.html#sctn-cmds-pe + * + * @property no - A serial number that increments whenever the convenience metadata payload is updated + */ +export type ConvenienceMDSPayload = + & { no: number } + & { [aaguid: string]: ProviderMetadata }; From a4b4b6a559af842001f900de227b6b51ec272c7b Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Wed, 7 Oct 2026 21:45:38 -0700 Subject: [PATCH 18/33] Add tests for new initialize args --- .../src/services/metadataService.test.ts | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/packages/server/src/services/metadataService.test.ts b/packages/server/src/services/metadataService.test.ts index e4a6c551d..a52114550 100644 --- a/packages/server/src/services/metadataService.test.ts +++ b/packages/server/src/services/metadataService.test.ts @@ -16,6 +16,7 @@ import { generateLeafCert, generateRootCert } from '../helpers/tests/x509Utils.t import { BaseMetadataService, MetadataService } from './metadataService.ts'; import { SettingsService } from './settingsService.ts'; import type { MetadataStatement } from '../metadata/mdsTypes.ts'; +import type { ConvenienceMDSPayload, ProviderMetadata } from '../metadata/convenienceMDSTypes.ts'; let mockFetch: Stub; @@ -64,6 +65,117 @@ describe('Method: initialize()', () => { assertEquals(statement, localStatement); }); + + it('should query provided convenience MDS server URLs', async () => { + const convenienceMDSServers = ['https://custom-cmds1.com', 'https://custom-cmds2.com']; + + await MetadataService.initialize({ mdsServers: [], convenienceMDSServers }); + + assertSpyCalls(mockFetch, convenienceMDSServers.length); + assertSpyCallArg(mockFetch, 0, 0, 'https://custom-cmds1.com/?localCopySerial=0'); + assertSpyCallArg(mockFetch, 1, 0, 'https://custom-cmds2.com/?localCopySerial=0'); + }); + + it('should not query any convenience MDS servers on empty list of URLs', async () => { + await MetadataService.initialize({ mdsServers: [], convenienceMDSServers: [] }); + + assertSpyCalls(mockFetch, 0); + }); + + it('should load local convenience payloads', async () => { + await MetadataService.initialize({ + mdsServers: [], + convenienceMDSServers: [], + conveniencePayloads: [localConveniencePayload], + }); + + const metadata = await MetadataService.getProviderMetadata(localStatementAAGUID); + + assertEquals(metadata, localProviderMetadata); + }); + + it('should load convenience payloads without querying any servers', async () => { + await MetadataService.initialize({ + mdsServers: [], + convenienceMDSServers: [], + conveniencePayloads: [localConveniencePayload], + }); + + assertSpyCalls(mockFetch, 0); + }); + + it('should load metadata from multiple convenience payloads', async () => { + const otherAAGUID = '00000000-0000-0000-0000-000000000002'; + const otherMetadata: ProviderMetadata = { friendlyNames: { en: 'Other Provider' } }; + + await MetadataService.initialize({ + mdsServers: [], + convenienceMDSServers: [], + conveniencePayloads: [ + localConveniencePayload, + { no: 1, [otherAAGUID]: otherMetadata } as ConvenienceMDSPayload, + ], + }); + + assertEquals( + await MetadataService.getProviderMetadata(localStatementAAGUID), + localProviderMetadata, + ); + assertEquals(await MetadataService.getProviderMetadata(otherAAGUID), otherMetadata); + }); + + it('should load metadata from a convenience MDS server', async () => { + mockFetch.restore(); + mockFetch = stub( + _fetchInternals, + 'stubThis', + returnsNext([ + Promise.resolve(Response.json(localConveniencePayload)), + ]), + ); + + await MetadataService.initialize({ + mdsServers: [], + convenienceMDSServers: ['https://custom-cmds.com'], + }); + + const metadata = await MetadataService.getProviderMetadata(localStatementAAGUID); + + assertEquals(metadata, localProviderMetadata); + }); + + it('should move on if a convenience MDS server cannot be reached', async () => { + mockFetch.restore(); + mockFetch = stub( + _fetchInternals, + 'stubThis', + returnsNext([ + Promise.reject(new Error('Network down')), + Promise.resolve(Response.json(localConveniencePayload)), + ]), + ); + + await MetadataService.initialize({ + mdsServers: [], + convenienceMDSServers: ['https://broken-cmds.com', 'https://working-cmds.com'], + }); + + const metadata = await MetadataService.getProviderMetadata(localStatementAAGUID); + + assertEquals(metadata, localProviderMetadata); + }); + + it('should cache convenience metadata from local statements', async () => { + await MetadataService.initialize({ + mdsServers: [], + convenienceMDSServers: [], + statements: [{ ...localStatement, friendlyNames: { en: 'From Statement' } }], + }); + + const metadata = await MetadataService.getProviderMetadata(localStatementAAGUID); + + assertEquals(metadata?.friendlyNames, { en: 'From Statement' }); + }); }); describe('Method: getStatement()', () => { @@ -215,6 +327,13 @@ describe('Behavior: logging', () => { }); const localStatementAAGUID = '91dfead7-959e-4475-ad26-9b0d482be089'; +const localProviderMetadata: ProviderMetadata = { + friendlyNames: { en: 'Local Provider', fr: 'Fournisseur local' }, +}; +const localConveniencePayload: ConvenienceMDSPayload = { + no: 1, + [localStatementAAGUID]: localProviderMetadata, +} as ConvenienceMDSPayload; const localStatement: MetadataStatement = { legalHeader: 'https://fidoalliance.org/metadata/metadata-statement-legal-header/', description: 'Virtual FIDO2 EdDSA25519 SHA512 Conformance Testing CTAP2 Authenticator', From cd493a2aaad0bf0e5a67ae8f993fa38ce80c151a Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Wed, 7 Oct 2026 21:46:24 -0700 Subject: [PATCH 19/33] Be more judicious about cache resets in initialize --- packages/server/src/services/metadataService.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index 614212f39..dd174cebf 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -117,8 +117,10 @@ export class BaseMetadataService implements MetadataService { logger?: SimpleWebAuthnLogger; } = {}, ): Promise { - // Reset statement cache + // Reset caches + this.mdsCache = {}; this.statementCache = {}; + this.convenienceMetadataCache = {}; const { mdsServers = [defaultURLMDS], From 5675357eea23484112dfae937091a344dcb93e7b Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Wed, 7 Oct 2026 21:46:53 -0700 Subject: [PATCH 20/33] Tighten up typing on caches --- packages/server/src/services/metadataService.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index dd174cebf..337c84420 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -100,9 +100,11 @@ interface MetadataService { * https://fidoalliance.org/metadata/ */ export class BaseMetadataService implements MetadataService { + // Internal caches private mdsCache: { [url: string]: CachedMDS } = {}; - private statementCache: { [aaguid: string]: CachedBLOBEntry } = {}; - private convenienceMetadataCache: { [aaguid: string]: CachedProviderMetadata } = {}; + private statementCache: { [aaguid: string]: CachedBLOBEntry | undefined } = {}; + private convenienceMetadataCache: { [aaguid: string]: CachedProviderMetadata | undefined } = {}; + // Service State private state: SERVICE_STATE = SERVICE_STATE.DISABLED; private verificationMode: VerificationMode = 'strict'; private logger: Required = DefaultNoopLogger; @@ -349,6 +351,10 @@ export class BaseMetadataService implements MetadataService { } } + if (!cachedMetadata) { + return; + } + const { metadata } = cachedMetadata; return metadata; From 8f31744a449a82eba4fd7539b2854f7caf06dfde Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Wed, 7 Oct 2026 21:47:06 -0700 Subject: [PATCH 21/33] Fix a missing 0 from CMDS nextUpdates --- packages/server/src/services/metadataService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index 337c84420..7f313fe9f 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -480,7 +480,7 @@ export class BaseMetadataService implements MetadataService { // Store the payload `no` to skip re-downloading metadata we already have no: payload.no, // Convenience MDS returns no nextUpdate, so set one that's seven days from now - nextUpdate: new Date(Date.now() + (100 * 60 * 60 * 24 * 7)), + nextUpdate: new Date(Date.now() + (1000 * 60 * 60 * 24 * 7)), }; return entriesAdded; From 19f4e526bbabaaf193d4065cbaf13a7f08417ea9 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Wed, 7 Oct 2026 22:04:11 -0700 Subject: [PATCH 22/33] Add getProviderMetadata() tests --- .../src/services/metadataService.test.ts | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/packages/server/src/services/metadataService.test.ts b/packages/server/src/services/metadataService.test.ts index a52114550..734b4c4eb 100644 --- a/packages/server/src/services/metadataService.test.ts +++ b/packages/server/src/services/metadataService.test.ts @@ -8,6 +8,7 @@ import { type Stub, stub, } from '@std/testing/mock'; +import { FakeTime } from '@std/testing/time'; import { _fetchInternals } from '../helpers/fetch.ts'; import { isoBase64URL } from '../helpers/iso/index.ts'; @@ -219,6 +220,176 @@ describe('Method: getStatement()', () => { }); }); +describe('Method: getProviderMetadata()', () => { + const cmdsURL = 'https://cmds.example.com/'; + const aaguid = '00000000-0000-0000-0000-000000000003'; + const originalMetadata: ProviderMetadata = { friendlyNames: { en: 'Original Name' } }; + const updatedMetadata: ProviderMetadata = { friendlyNames: { en: 'Updated Name' } }; + const eightDaysMS = 1000 * 60 * 60 * 24 * 8; + let mockFetch: Stub; + + beforeEach(() => { + mockFetch = stub(_fetchInternals, 'stubThis'); + }); + + afterEach(() => { + mockFetch.restore(); + }); + + /** Initialize with a convenience MDS server that responds with `no: 1` and `originalMetadata` */ + async function initializeWithServer(...followUps: Promise[]) { + mockFetch.restore(); + mockFetch = stub( + _fetchInternals, + 'stubThis', + returnsNext([ + Promise.resolve(Response.json({ no: 1, [aaguid]: originalMetadata })), + ...followUps, + ]), + ); + + await MetadataService.initialize({ mdsServers: [], convenienceMDSServers: [cmdsURL] }); + } + + it('should return undefined if service not initialized', async () => { + const service = new BaseMetadataService(); + const metadata = await service.getProviderMetadata('not-a-real-aaguid'); + + assertEquals(metadata, undefined); + }); + + it('should return undefined if aaguid is undefined', async () => { + // @ts-ignore: intentionally passing a bad value + const metadata = await MetadataService.getProviderMetadata(undefined); + + assertEquals(metadata, undefined); + }); + + it('should return undefined for an AAGUID with no metadata', async () => { + await MetadataService.initialize({ mdsServers: [], convenienceMDSServers: [] }); + + const metadata = await MetadataService.getProviderMetadata('not-a-real-aaguid'); + + assertEquals(metadata, undefined); + }); + + it('should accept the AAGUID as a Uint8Array', async () => { + await MetadataService.initialize({ + mdsServers: [], + convenienceMDSServers: [], + conveniencePayloads: [localConveniencePayload], + }); + + const aaguidBytes = new Uint8Array([ + 0x91, + 0xdf, + 0xea, + 0xd7, + 0x95, + 0x9e, + 0x44, + 0x75, + 0xad, + 0x26, + 0x9b, + 0x0d, + 0x48, + 0x2b, + 0xe0, + 0x89, + ]); + const metadata = await MetadataService.getProviderMetadata(aaguidBytes); + + assertEquals(metadata, localProviderMetadata); + }); + + it('should not refresh metadata from a current convenience MDS', async () => { + await initializeWithServer(); + + const metadata = await MetadataService.getProviderMetadata(aaguid); + + assertSpyCalls(mockFetch, 1); + assertEquals(metadata, originalMetadata); + }); + + it('should return up-to-date metadata after refreshing stale convenience metadata', async () => { + using _fakedNow = new FakeTime(); + await initializeWithServer( + Promise.resolve(Response.json({ no: 2, [aaguid]: updatedMetadata })), + ); + + _fakedNow.tick(eightDaysMS); + + const metadata = await MetadataService.getProviderMetadata(aaguid); + + assertSpyCalls(mockFetch, 2); + // Confirm a check for an updated payload using the old serial + assertSpyCallArg(mockFetch, 1, 0, `${cmdsURL}?localCopySerial=1`); + // Getting the updated metadata means the internal cache was refreshed correctly + assertEquals(metadata, updatedMetadata); + }); + + it('should keep cached metadata when the stale convenience MDS responds with 304', async () => { + using _fakedNow = new FakeTime(); + await initializeWithServer(Promise.resolve(new Response(null, { status: 304 }))); + + _fakedNow.tick(eightDaysMS); + + const metadata = await MetadataService.getProviderMetadata(aaguid); + + assertSpyCalls(mockFetch, 2); + assertEquals(metadata, originalMetadata); + }); + + it('should keep cached metadata when the stale convenience MDS responds with 429', async () => { + using _fakedNow = new FakeTime(); + await initializeWithServer(Promise.resolve(new Response(null, { status: 429 }))); + + _fakedNow.tick(eightDaysMS); + + const metadata = await MetadataService.getProviderMetadata(aaguid); + + assertSpyCalls(mockFetch, 2); + assertEquals(metadata, originalMetadata); + }); + + it('should not refresh metadata from local payloads', async () => { + using _fakedNow = new FakeTime(); + await MetadataService.initialize({ + mdsServers: [], + convenienceMDSServers: [], + conveniencePayloads: [localConveniencePayload], + }); + + _fakedNow.tick(eightDaysMS); + + const metadata = await MetadataService.getProviderMetadata(localStatementAAGUID); + + assertSpyCalls(mockFetch, 0); + assertEquals(metadata, localProviderMetadata); + }); + + it('should return the service to a ready state if a refresh fails', async () => { + using _fakedNow = new FakeTime(); + await initializeWithServer(Promise.reject(new Error('Network down'))); + + _fakedNow.tick(eightDaysMS); + + await assertRejects(() => MetadataService.getProviderMetadata(aaguid)); + + // A subsequent call shouldn't hang waiting for a ready state + mockFetch.restore(); + mockFetch = stub( + _fetchInternals, + 'stubThis', + returnsNext([Promise.resolve(new Response(null, { status: 304 }))]), + ); + const metadata = await MetadataService.getProviderMetadata(aaguid); + + assertEquals(metadata, originalMetadata); + }); +}); + describe('Behavior: BLOB refresh', () => { const mdsServerURL = 'https://mds.example.com/'; let mockFetch: Stub; From 2ad41254de3ba53e12e70b8a32afa62c7194dd99 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Wed, 7 Oct 2026 22:12:57 -0700 Subject: [PATCH 23/33] Codify and test server data overwriting local data --- .../src/services/metadataService.test.ts | 42 +++++++++++++++++++ .../server/src/services/metadataService.ts | 8 +++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/server/src/services/metadataService.test.ts b/packages/server/src/services/metadataService.test.ts index 734b4c4eb..6dcb1561c 100644 --- a/packages/server/src/services/metadataService.test.ts +++ b/packages/server/src/services/metadataService.test.ts @@ -166,6 +166,48 @@ describe('Method: initialize()', () => { assertEquals(metadata, localProviderMetadata); }); + it('should use local convenience payloads as a fallback to convenience MDS servers', async () => { + const serverOnlyAAGUID = '00000000-0000-0000-0000-000000000004'; + const serverMetadata: ProviderMetadata = { friendlyNames: { en: 'From Server' } }; + + mockFetch.restore(); + mockFetch = stub( + _fetchInternals, + 'stubThis', + returnsNext([ + Promise.resolve(Response.json({ + no: 1, + [localStatementAAGUID]: serverMetadata, + [serverOnlyAAGUID]: serverMetadata, + })), + ]), + ); + + await MetadataService.initialize({ + mdsServers: [], + convenienceMDSServers: ['https://custom-cmds.com'], + conveniencePayloads: [ + localConveniencePayload, + { + no: 1, + '00000000-0000-0000-0000-000000000005': localProviderMetadata, + } as ConvenienceMDSPayload, + ], + }); + + // The server wins on a shared AAGUID + assertEquals( + await MetadataService.getProviderMetadata(localStatementAAGUID), + serverMetadata, + ); + assertEquals(await MetadataService.getProviderMetadata(serverOnlyAAGUID), serverMetadata); + // The local payload still fills in AAGUIDs the server doesn't know about + assertEquals( + await MetadataService.getProviderMetadata('00000000-0000-0000-0000-000000000005'), + localProviderMetadata, + ); + }); + it('should cache convenience metadata from local statements', async () => { await MetadataService.initialize({ mdsServers: [], diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index 7f313fe9f..b5fb02f7c 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -66,8 +66,12 @@ interface MetadataService { * @param opts.convenienceMDSServers An array of URLs to FIDO Alliance Metadata Convenience * Service-compatible servers. Defaults to the official FIDO Convenience MDS * @param opts.statements An array of local metadata statements. Statements will be loaded but - * not refreshed - * @param opts.conveniencePayloads An array of local convenience MDS metadata. Metadata loaded in this way will be loaded but not refreshed. + * not refreshed. When combined with `mdsServers`, a statement for the same AAGUID downloaded + * from an MDS server will replace the local statement and get refreshed from then on + * @param opts.conveniencePayloads An array of local convenience MDS metadata. Metadata loaded in + * this way will be loaded but not refreshed. When combined with `convenienceMDSServers`, metadata + * for the same AAGUID downloaded from a convenience MDS server will replace the local metadata + * and get refreshed from then on * @param opts.verificationMode How MetadataService will handle unregistered AAGUIDs. Defaults to * `"strict"` which throws errors during registration response verification when an * unregistered AAGUID is encountered. Set to `"permissive"` to allow registration by From 697ec81d675f46882247a27de25d58840edc5259 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Thu, 8 Oct 2026 22:02:10 -0700 Subject: [PATCH 24/33] Tweak some comments --- packages/server/src/services/metadataService.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index b5fb02f7c..5f11ee1f7 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -104,11 +104,15 @@ interface MetadataService { * https://fidoalliance.org/metadata/ */ export class BaseMetadataService implements MetadataService { - // Internal caches + /** + * Internal caches + */ private mdsCache: { [url: string]: CachedMDS } = {}; private statementCache: { [aaguid: string]: CachedBLOBEntry | undefined } = {}; private convenienceMetadataCache: { [aaguid: string]: CachedProviderMetadata | undefined } = {}; - // Service State + /** + * Default service state and configuration + */ private state: SERVICE_STATE = SERVICE_STATE.DISABLED; private verificationMode: VerificationMode = 'strict'; private logger: Required = DefaultNoopLogger; From 58416e584ce4c1711a97515616c74229875178a0 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Thu, 8 Oct 2026 22:02:59 -0700 Subject: [PATCH 25/33] Add a mechanism for delaying API refreshes --- packages/server/src/services/metadataService.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index 5f11ee1f7..514fd7c11 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -539,6 +539,15 @@ export class BaseMetadataService implements MetadataService { this.convenienceMetadataCache[aaguid] = { metadata, url }; } + /** + * Delay the next refresh attempt of this server by the specified number of hours + */ + private delayNextRefresh(url: string, numHours: number): void { + this.logger.debug(`Delaying next refresh of ${url} by ${numHours} hours`); + const numHoursMS = 1000 * 60 * 60 * numHours; + this.mdsCache[url].nextUpdate = new Date(Date.now() + numHoursMS); + } + /** * A helper method to pause execution until the service is ready */ From 04ff084e8a94d36e34228ec3693fb7715f5dbd21 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Thu, 8 Oct 2026 22:04:24 -0700 Subject: [PATCH 26/33] Delay next C-MDS refresh after HTTP 304 and 429 --- packages/server/src/services/metadataService.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index 514fd7c11..8ca1c2ce9 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -116,6 +116,10 @@ export class BaseMetadataService implements MetadataService { private state: SERVICE_STATE = SERVICE_STATE.DISABLED; private verificationMode: VerificationMode = 'strict'; private logger: Required = DefaultNoopLogger; + /** How many hours to wait to refresh Convenience MDS data */ + private readonly cmdsDefaultRefreshDelayHours = 24; + /** How many hours to wait to retry a _failed_ attempt to refresh Convenience MDS data */ + private readonly cmdsDefaultRetryDelayHours = 4; async initialize( opts: { @@ -447,6 +451,11 @@ export class BaseMetadataService implements MetadataService { */ if (resp.status === 304) { await resp.body?.cancel(); + this.logger.info( + `The Convenience MDS API at ${_url.toString()} reported nothing new to download (HTTP 304)`, + ); + // There's nothing new to download, wait another interval before checking again + this.delayNextRefresh(url, this.cmdsDefaultRefreshDelayHours); return; } @@ -456,6 +465,11 @@ export class BaseMetadataService implements MetadataService { */ if (resp.status === 429) { await resp.body?.cancel(); + this.logger.warn( + `The Convenience MDS API at ${_url.toString()} rate-limited this request (HTTP 429)`, + ); + // We checked too soon, wait a bit before checking again + this.delayNextRefresh(url, this.cmdsDefaultRetryDelayHours); return; } From 1609674a66028d1f3aa22a6015d486a25499c659 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Thu, 8 Oct 2026 22:06:07 -0700 Subject: [PATCH 27/33] Use same refresh interval on initial C-MDS pull --- packages/server/src/services/metadataService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index 8ca1c2ce9..f5cf06f96 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -501,8 +501,8 @@ export class BaseMetadataService implements MetadataService { url, // Store the payload `no` to skip re-downloading metadata we already have no: payload.no, - // Convenience MDS returns no nextUpdate, so set one that's seven days from now - nextUpdate: new Date(Date.now() + (1000 * 60 * 60 * 24 * 7)), + // Convenience MDS returns no nextUpdate, so pick a time in the future + nextUpdate: new Date(Date.now() + (1000 * 60 * 60 * this.cmdsDefaultRefreshDelayHours)), }; return entriesAdded; From c237be285dd12d52ca8b73aaa87408c35faf7832 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Thu, 8 Oct 2026 22:06:34 -0700 Subject: [PATCH 28/33] Gracefully handle unexpected C-MDS HTTP statuses --- packages/server/src/services/metadataService.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index f5cf06f96..b8ea1d31e 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -357,6 +357,9 @@ export class BaseMetadataService implements MetadataService { // Refresh the cached metadata in case the cache got updated just now cachedMetadata = this.convenienceMetadataCache[aaguid]; + } catch (err) { + this.logger.error(`Could not download convenience metadata from ${mds.url}:`, err); + this.delayNextRefresh(mds.url, this.cmdsDefaultRetryDelayHours); } finally { this.setState(SERVICE_STATE.READY); } @@ -473,6 +476,17 @@ export class BaseMetadataService implements MetadataService { return; } + if (!resp.ok) { + await resp.body?.cancel(); + this.logger.warn( + `The Convenience MDS API at ${_url.toString()} reported an unsuccessful request (HTTP ${resp.status})`, + ); + // Likely an HTTP 500 error, but anything not within HTTP 200-299. Wait a bit before trying + // again + this.delayNextRefresh(url, this.cmdsDefaultRetryDelayHours); + return; + } + const data: ConvenienceMDSPayload = await resp.json(); return data; } From 7a4a5655ac1c53bfd4d0d36dd8b749780cdecf26 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Thu, 8 Oct 2026 22:20:32 -0700 Subject: [PATCH 29/33] Fix tests and add new ones for delay intervals --- .../src/services/metadataService.test.ts | 111 +++++++++++++++--- 1 file changed, 96 insertions(+), 15 deletions(-) diff --git a/packages/server/src/services/metadataService.test.ts b/packages/server/src/services/metadataService.test.ts index 6dcb1561c..1c274838e 100644 --- a/packages/server/src/services/metadataService.test.ts +++ b/packages/server/src/services/metadataService.test.ts @@ -278,8 +278,11 @@ describe('Method: getProviderMetadata()', () => { mockFetch.restore(); }); - /** Initialize with a convenience MDS server that responds with `no: 1` and `originalMetadata` */ - async function initializeWithServer(...followUps: Promise[]) { + /** + * Initialize with a convenience MDS server that responds with `no: 1` and `originalMetadata`. + * `mockFetch` will be incremented by `1` after the execution of this method + */ + async function _initializeWithServer(...followUps: Promise[]) { mockFetch.restore(); mockFetch = stub( _fetchInternals, @@ -346,7 +349,7 @@ describe('Method: getProviderMetadata()', () => { }); it('should not refresh metadata from a current convenience MDS', async () => { - await initializeWithServer(); + await _initializeWithServer(); const metadata = await MetadataService.getProviderMetadata(aaguid); @@ -356,7 +359,7 @@ describe('Method: getProviderMetadata()', () => { it('should return up-to-date metadata after refreshing stale convenience metadata', async () => { using _fakedNow = new FakeTime(); - await initializeWithServer( + await _initializeWithServer( Promise.resolve(Response.json({ no: 2, [aaguid]: updatedMetadata })), ); @@ -373,7 +376,7 @@ describe('Method: getProviderMetadata()', () => { it('should keep cached metadata when the stale convenience MDS responds with 304', async () => { using _fakedNow = new FakeTime(); - await initializeWithServer(Promise.resolve(new Response(null, { status: 304 }))); + await _initializeWithServer(Promise.resolve(new Response(null, { status: 304 }))); _fakedNow.tick(eightDaysMS); @@ -385,7 +388,7 @@ describe('Method: getProviderMetadata()', () => { it('should keep cached metadata when the stale convenience MDS responds with 429', async () => { using _fakedNow = new FakeTime(); - await initializeWithServer(Promise.resolve(new Response(null, { status: 429 }))); + await _initializeWithServer(Promise.resolve(new Response(null, { status: 429 }))); _fakedNow.tick(eightDaysMS); @@ -411,25 +414,102 @@ describe('Method: getProviderMetadata()', () => { assertEquals(metadata, localProviderMetadata); }); - it('should return the service to a ready state if a refresh fails', async () => { + it('should keep cached metadata and remain usable if a refresh throws', async () => { using _fakedNow = new FakeTime(); - await initializeWithServer(Promise.reject(new Error('Network down'))); + await _initializeWithServer(Promise.reject(new Error('Network down'))); _fakedNow.tick(eightDaysMS); - await assertRejects(() => MetadataService.getProviderMetadata(aaguid)); + const metadata = await MetadataService.getProviderMetadata(aaguid); + + assertSpyCalls(mockFetch, 2); + assertEquals(metadata, originalMetadata); // A subsequent call shouldn't hang waiting for a ready state - mockFetch.restore(); - mockFetch = stub( - _fetchInternals, - 'stubThis', - returnsNext([Promise.resolve(new Response(null, { status: 304 }))]), - ); + const metadata2 = await MetadataService.getProviderMetadata(aaguid); + assertEquals(metadata2, originalMetadata); + }); + + it('should keep cached metadata if a refresh returns invalid JSON', async () => { + using _fakedNow = new FakeTime(); + await _initializeWithServer(Promise.resolve(new Response('not json', { status: 200 }))); + + _fakedNow.tick(eightDaysMS); + const metadata = await MetadataService.getProviderMetadata(aaguid); + assertSpyCalls(mockFetch, 2); assertEquals(metadata, originalMetadata); }); + + it('should keep cached metadata when a convenience MDS refresh attempt gets back an HTTP 500', async () => { + using _fakedNow = new FakeTime(); + await _initializeWithServer(Promise.resolve(new Response(null, { status: 500 }))); + + _fakedNow.tick(eightDaysMS); + + const metadata = await MetadataService.getProviderMetadata(aaguid); + + assertSpyCalls(mockFetch, 2); + assertEquals(metadata, originalMetadata); + }); + + describe('refresh delays', () => { + const hourMS = 1000 * 60 * 60; + + /** + * Make a stale refresh attempt that gets `followUp`, then confirm no further requests happen + * until `delayHours` has passed, and that one more happens right after. + */ + async function _assertDelay(followUp: Promise, delayHours: number) { + using _fakedNow = new FakeTime(); + await _initializeWithServer(followUp); + + _fakedNow.tick(eightDaysMS); + await MetadataService.getProviderMetadata(aaguid); + // `mockFetch` incremented from `1` to `2` because of a network request to refresh data + assertSpyCalls(mockFetch, 2); + + // Still within the delay, so no new request + _fakedNow.tick((delayHours * hourMS) - 1000); + await MetadataService.getProviderMetadata(aaguid); + assertSpyCalls(mockFetch, 2); + + // Delay elapsed, so another attempt is made + mockFetch.restore(); + mockFetch = stub( + _fetchInternals, + 'stubThis', + returnsNext([Promise.resolve(new Response(null, { status: 304 }))]), + ); + _fakedNow.tick(2000); + await MetadataService.getProviderMetadata(aaguid); + assertSpyCalls(mockFetch, 1); + } + + it('should wait 24 hours after an HTTP 304', async () => { + await _assertDelay(Promise.resolve(new Response(null, { status: 304 })), 24); + }); + + it('should wait 4 hours after an HTTP 429', async () => { + await _assertDelay(Promise.resolve(new Response(null, { status: 429 })), 4); + }); + + it('should wait 4 hours after an HTTP 500', async () => { + await _assertDelay(Promise.resolve(new Response(null, { status: 500 })), 4); + }); + + it('should wait 4 hours after a fetch error', async () => { + await _assertDelay(Promise.reject(new Error('Network down')), 4); + }); + + it('should wait 24 hours after a successful refresh', async () => { + await _assertDelay( + Promise.resolve(Response.json({ no: 2, [aaguid]: updatedMetadata })), + 24, + ); + }); + }); }); describe('Behavior: BLOB refresh', () => { @@ -526,6 +606,7 @@ describe('Behavior: logging', () => { await MetadataService.initialize({ mdsServers: [], + convenienceMDSServers: [], statements: [], logger: { debug: _debugSpy }, }); From 35f9b6e27c4eaaa0a711212fc1c35aebb011c870 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Thu, 8 Oct 2026 22:33:08 -0700 Subject: [PATCH 30/33] Expand `fetch()` helper functionality --- packages/server/src/helpers/fetch.ts | 6 +++--- packages/server/src/helpers/isCertRevoked.test.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/server/src/helpers/fetch.ts b/packages/server/src/helpers/fetch.ts index 539ed599d..c19fd221a 100644 --- a/packages/server/src/helpers/fetch.ts +++ b/packages/server/src/helpers/fetch.ts @@ -2,8 +2,8 @@ * A simple method for requesting data via standard `fetch`. Should work * across multiple runtimes. */ -export function fetch(url: string): Promise { - return _fetchInternals.stubThis(url); +export function fetch(...args: Parameters): Promise { + return _fetchInternals.stubThis(...args); } /** @@ -11,5 +11,5 @@ export function fetch(url: string): Promise { * @ignore Don't include this in docs output */ export const _fetchInternals = { - stubThis: (url: string) => globalThis.fetch(url), + stubThis: (...args: Parameters) => globalThis.fetch(...args), }; diff --git a/packages/server/src/helpers/isCertRevoked.test.ts b/packages/server/src/helpers/isCertRevoked.test.ts index 971cb607a..3be8c503e 100644 --- a/packages/server/src/helpers/isCertRevoked.test.ts +++ b/packages/server/src/helpers/isCertRevoked.test.ts @@ -315,7 +315,7 @@ Deno.test('should check all CRL URLs in issuer cert for revocation status', asyn using _mockFetch = stub( _fetchInternals, 'stubThis', - (url: string) => { + (url: RequestInfo | URL) => { if (url === CRL_URL) { return Promise.resolve(new Response(crl1.rawData)); } else { From 290cb025f87c4ba07b0e34d9610c8d228e76809d Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Thu, 8 Oct 2026 22:53:06 -0700 Subject: [PATCH 31/33] Time out C-MDS fetch after 10 seconds --- packages/server/src/services/metadataService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index b8ea1d31e..a554e9a6b 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -445,7 +445,7 @@ export class BaseMetadataService implements MetadataService { // Get latest convenience MDS JSON, with a query param that can short-circuit the download // if we already have the latest version of metadata const _url = new URL(`/?localCopySerial=${no}`, url); - const resp = await fetch(_url.toString()); + const resp = await fetch(_url.toString(), { signal: AbortSignal.timeout(10_000) }); /** * We have the latest data, nothing more to do. From e2628792809c1026f9822e329c18484de1cab584 Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Thu, 8 Oct 2026 22:53:24 -0700 Subject: [PATCH 32/33] Harden delayNextRefresh for cache miss --- packages/server/src/services/metadataService.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index a554e9a6b..065d23e11 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -571,6 +571,11 @@ export class BaseMetadataService implements MetadataService { * Delay the next refresh attempt of this server by the specified number of hours */ private delayNextRefresh(url: string, numHours: number): void { + // This should never happen, but just in case... + if (!this.mdsCache[url]) { + return; + } + this.logger.debug(`Delaying next refresh of ${url} by ${numHours} hours`); const numHoursMS = 1000 * 60 * 60 * numHours; this.mdsCache[url].nextUpdate = new Date(Date.now() + numHoursMS); From 63c661463674f197818cb73a7ac02eda8ea2271b Mon Sep 17 00:00:00 2001 From: Matthew Miller Date: Thu, 8 Oct 2026 22:53:53 -0700 Subject: [PATCH 33/33] Clear existing server entries before re-caching --- .../src/services/metadataService.test.ts | 64 +++++++++++++++++++ .../server/src/services/metadataService.ts | 20 ++++++ 2 files changed, 84 insertions(+) diff --git a/packages/server/src/services/metadataService.test.ts b/packages/server/src/services/metadataService.test.ts index 1c274838e..4ed526689 100644 --- a/packages/server/src/services/metadataService.test.ts +++ b/packages/server/src/services/metadataService.test.ts @@ -398,6 +398,70 @@ describe('Method: getProviderMetadata()', () => { assertEquals(metadata, originalMetadata); }); + it('should evict metadata for AAGUIDs removed from the convenience MDS on refresh', async () => { + using _fakedNow = new FakeTime(); + const removedAAGUID = '00000000-0000-0000-0000-000000000004'; + const removedMetadata: ProviderMetadata = { friendlyNames: { en: 'Removed Name' } }; + + mockFetch.restore(); + mockFetch = stub( + _fetchInternals, + 'stubThis', + returnsNext([ + // Initial payload includes two AAGUIDs + Promise.resolve( + Response.json({ no: 1, [aaguid]: originalMetadata, [removedAAGUID]: removedMetadata }), + ), + // The refreshed payload no longer includes `removedAAGUID` + Promise.resolve(Response.json({ no: 2, [aaguid]: updatedMetadata })), + ]), + ); + await MetadataService.initialize({ mdsServers: [], convenienceMDSServers: [cmdsURL] }); + + // Sanity check: the second AAGUID is cached before the refresh + assertEquals(await MetadataService.getProviderMetadata(removedAAGUID), removedMetadata); + + _fakedNow.tick(eightDaysMS); + + // Trigger the refresh via the AAGUID that's still present + assertEquals(await MetadataService.getProviderMetadata(aaguid), updatedMetadata); + assertSpyCalls(mockFetch, 2); + + // The server dropped this AAGUID, so it shouldn't linger in the cache + assertEquals(await MetadataService.getProviderMetadata(removedAAGUID), undefined); + }); + + it('should keep local payload metadata when a convenience MDS refresh evicts entries', async () => { + using _fakedNow = new FakeTime(); + + mockFetch.restore(); + mockFetch = stub( + _fetchInternals, + 'stubThis', + returnsNext([ + Promise.resolve(Response.json({ no: 1, [aaguid]: originalMetadata })), + Promise.resolve(Response.json({ no: 2, [aaguid]: updatedMetadata })), + ]), + ); + await MetadataService.initialize({ + mdsServers: [], + convenienceMDSServers: [cmdsURL], + conveniencePayloads: [localConveniencePayload], + }); + + _fakedNow.tick(eightDaysMS); + + // Trigger the refresh + assertEquals(await MetadataService.getProviderMetadata(aaguid), updatedMetadata); + assertSpyCalls(mockFetch, 2); + + // Evicting stale entries from the server shouldn't touch entries that came from local data + assertEquals( + await MetadataService.getProviderMetadata(localStatementAAGUID), + localProviderMetadata, + ); + }); + it('should not refresh metadata from local payloads', async () => { using _fakedNow = new FakeTime(); await MetadataService.initialize({ diff --git a/packages/server/src/services/metadataService.ts b/packages/server/src/services/metadataService.ts index 065d23e11..1337afe44 100644 --- a/packages/server/src/services/metadataService.ts +++ b/packages/server/src/services/metadataService.ts @@ -498,6 +498,9 @@ export class BaseMetadataService implements MetadataService { private cacheConvenienceMDS(url: string, payload: ConvenienceMDSPayload): number { let entriesAdded = 0; + // Remove existing entries for this URL + this.clearCacheEntriesByURL(url); + // Cache entries Object.keys(payload).forEach((aaguid) => { // Skip the versioning property @@ -567,6 +570,23 @@ export class BaseMetadataService implements MetadataService { this.convenienceMetadataCache[aaguid] = { metadata, url }; } + /** + * Remove cached Convenience Metadata service data by the URL. If the URL indicates data that + * should not be refreshed, then simply exit. + */ + private clearCacheEntriesByURL(url: string): void { + // Do _not_ clear out entries that aren't associated with a Convenience MDS service + if (url === NonRefreshingMDS.url) { + return; + } + + for (const [aaguid, cached] of Object.entries(this.convenienceMetadataCache)) { + if (cached?.url === url) { + delete this.convenienceMetadataCache[aaguid]; + } + } + } + /** * Delay the next refresh attempt of this server by the specified number of hours */