Something went wrong. Try again.
Browser extension: detect and subscribe to standard.site publications on ATProto
Something went wrong. Try again.
7.0 kB · 168 lines
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169// 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))