// Guards the OAuth client metadata against the failure that shipped in // v1.2.1: the extension bundles oauth/client-metadata.json (see // src/lib/oauth.ts), and at runtime asks for the redirect URI of whatever // extension id it is actually running under. The store build runs under the // store-assigned id, so a metadata file that lists only the unpacked dev id // makes @atproto/oauth-client throw "Invalid redirect_uri" before the // authorization request is ever pushed — sign-in is dead for every store // user, while every server-side check still passes. // // The build cannot discover its own store id, so the ids the extension ships // under are declared in oauth/extension-ids.json. The unpacked id is not // taken on faith: it is derived from the manifest `key` and compared, so // rotating the key without updating the metadata fails here too. // // Usage: // node scripts/check-oauth-metadata.mjs # offline checks only // node scripts/check-oauth-metadata.mjs --dist dist # also check the build // node scripts/check-oauth-metadata.mjs --hosted # also fetch client_id // // Plain node, no dependencies. The pure helpers are exported for the tests. import { createHash } from 'node:crypto' import { existsSync, readFileSync, readdirSync } from 'node:fs' import { join, resolve } from 'node:path' /** * The extension id Chrome derives from a manifest `key`: sha256 of the DER * public key (which is what `key` base64-decodes to), first 16 bytes, hex, * with each hex digit mapped 0-f -> a-p. */ export function extensionIdFromKey(base64Key) { const digest = createHash('sha256').update(Buffer.from(base64Key, 'base64')).digest('hex') return [...digest.slice(0, 32)] .map((c) => String.fromCharCode(97 + Number.parseInt(c, 16))) .join('') } /** Mirrors oauthRedirectUri() in src/lib/authflow.ts. Must stay in step. */ export function redirectUriFor(extensionId) { return `https://${extensionId}.chromiumapp.org/oauth2` } /** * Every declared id has a redirect URI, and every redirect URI belongs to a * declared id. The second half matters as much as the first: an unrecognized * URI means the id list and the metadata have drifted apart, and the next * person to read either one learns the wrong thing. */ export function checkRedirectUris(metadata, ids) { const problems = [] const declared = Object.entries(ids) const want = new Map(declared.map(([role, id]) => [redirectUriFor(id), role])) const have = new Set(metadata.redirect_uris ?? []) for (const [uri, role] of want) { if (!have.has(uri)) { problems.push(`client-metadata.json has no redirect_uri for the ${role} id: ${uri}`) } } for (const uri of have) { if (!want.has(uri)) { problems.push(`client-metadata.json has redirect_uri ${uri}, which is not a declared id`) } } return problems } /** The declared unpacked id must be the one the manifest `key` produces. */ export function checkManifestKey(manifest, ids) { if (!manifest.key) return [] // store builds have the key stripped const derived = extensionIdFromKey(manifest.key) if (derived !== ids.unpacked) { return [ `manifest key derives extension id ${derived}, but extension-ids.json declares unpacked ${ids.unpacked}`, ] } return [] } /** * The hosted copy at client_id is what a PDS reads; the bundled copy is what * the extension validates against. They must agree or sign-in fails on one * side of the flow only, which is exactly the shape of bug this file exists * to catch. * * One problem per differing field, naming the field. The message used to print * `redirect_uris` whichever field had drifted, so adding a scope to the * metadata failed the release with two identical URI lists as the evidence — * the check was right and unreadable. Every field is compared, because every * field is one a PDS may read. */ export function checkHostedMatchesLocal(local, hosted) { const keys = [...new Set([...Object.keys(local), ...Object.keys(hosted)])].sort() const show = (value) => (value === undefined ? '(absent)' : JSON.stringify(value)) return keys .filter((key) => JSON.stringify(local[key]) !== JSON.stringify(hosted[key])) .map( (key) => `the hosted metadata at ${local.client_id} differs from` + ` oauth/client-metadata.json in ${key}:` + ` hosted=${show(hosted[key])} local=${show(local[key])}` + ` — deploy the site (scripts/deploy-site.sh) before packaging`, ) } /** * Every declared redirect URI must appear literally in the built JS. Vite * inlines the imported JSON, so a stale bundle is visible as a missing * string — the one check that looks at the artifact actually being uploaded * rather than at the sources it was supposed to come from. */ export function checkBundle(distDir, ids) { const problems = [] const js = readdirSync(distDir, { recursive: true }).filter((f) => String(f).endsWith('.js')) const sources = js.map((name) => readFileSync(join(distDir, String(name)), 'utf8')) for (const [role, id] of Object.entries(ids)) { const uri = redirectUriFor(id) if (!sources.some((src) => src.includes(uri))) { problems.push(`no built JS in ${distDir} contains the ${role} redirect URI ${uri}`) } } return problems } // --- CLI --------------------------------------------------------------------- async function main(argv) { const root = resolve(import.meta.dirname, '..') const readJson = (rel) => JSON.parse(readFileSync(join(root, rel), 'utf8')) const distFlag = argv.indexOf('--dist') const distDir = distFlag === -1 ? undefined : resolve(argv[distFlag + 1] ?? 'dist') const hosted = argv.includes('--hosted') const metadata = readJson('oauth/client-metadata.json') const ids = readJson('oauth/extension-ids.json') const manifest = readJson('public/manifest.json') const problems = [ ...checkManifestKey(manifest, ids), ...checkRedirectUris(metadata, ids), ] if (distDir) { if (!existsSync(distDir)) problems.push(`${distDir} does not exist — run \`npm run build\` first`) else problems.push(...checkBundle(distDir, ids)) } if (hosted) { try { const res = await fetch(metadata.client_id, { signal: AbortSignal.timeout(15_000) }) if (!res.ok) problems.push(`fetching ${metadata.client_id} returned ${res.status}`) else problems.push(...checkHostedMatchesLocal(metadata, await res.json())) } catch (err) { problems.push(`could not fetch ${metadata.client_id}: ${err.message}`) } } if (problems.length > 0) { console.error(`check-oauth-metadata: FAILED (${problems.length} problem(s)):`) for (const p of problems) console.error(` - ${p}`) process.exit(1) } const scope = ['ids', distDir && 'bundle', hosted && 'hosted'].filter(Boolean).join(' + ') console.log(`check-oauth-metadata: OK — ${scope}`) } if (process.argv[1] === import.meta.filename) await main(process.argv.slice(2))