// Stands in for the user's browser on the reference PDS's authorization // page: signs in, approves (or rejects) the request, and follows the // redirect back to the client's loopback listener, and on from there. // // node harness/authorize.mjs [--reject] [--create] // [--ca ] [--cookie ] // // `knotless login` and `knotless account delegate` run it as KNOTLESS_BROWSER, with // the URL last. With --ca, it reaches the harness's gateway hostname as // pds.mjs does: at 127.0.0.1, trusting that certificate. It speaks plain // node:http(s), as fetch sets the sec-fetch-* headers itself. // // With --cookie, it sends that cookie to the client on the way back, as a // browser sends a site's cookies to it, and prints each cookie the client // sets: a web page's login, as knotless-serve's pages log in. // // With --create, it signs up as with rather than // signing in, as the page a request with prompt=create shows asks. import { request as http } from 'node:http' import { request as https } from 'node:https' import { readFileSync } from 'node:fs' const args = process.argv.slice(2) const reject = args.includes('--reject') const create = args.includes('--create') const at = args.indexOf('--ca') const ca = at === -1 ? undefined : readFileSync(args.splice(at, 2)[1]) const sends = args.indexOf('--cookie') const theirs = sends === -1 ? undefined : args.splice(sends, 2)[1] const [username, password, url] = args.filter((a) => !['--reject', '--create'].includes(a)) const authorize = new URL(url) const origin = authorize.origin const api = `${origin}/@atproto/oauth-provider/~api` // The Referer a browser sends from the authorization page: Chrome and // Firefox cut one longer than 4096 bytes to its origin, which the // provider refuses ("Invalid referer"), as a client ID that carries a long // scope makes the page's URL. const REFERER_LIMIT = 4096 const referer = authorize.href.length > REFERER_LIMIT ? `${origin}/` : authorize.href const jar = new Map() const cookies = () => [...jar].map(([name, value]) => `${name}=${value}`).join('; ') // The gateway hostname, as pds.mjs resolves it. const local = (_hostname, options, callback) => options?.all ? callback(null, [{ address: '127.0.0.1', family: 4 }]) : callback(null, '127.0.0.1', 4) // One request: its status, headers and body. const send = (url, { method = 'GET', headers = {}, body } = {}) => new Promise((done, fail) => { const secure = new URL(url).protocol === 'https:' const tls = secure ? { ca, lookup: local } : {} const req = (secure ? https : http)(url, { method, headers, ...tls }, (res) => { const chunks = [] res.on('data', (c) => chunks.push(c)) res.on('end', () => done({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks).toString(), }), ) }) req.on('error', fail) req.end(body) }) const keep = (res) => { for (const cookie of res.headers['set-cookie'] ?? []) { const [pair] = cookie.split(';') const at = pair.indexOf('=') jar.set(pair.slice(0, at), pair.slice(at + 1)) } } const page = await send(authorize, { headers: { 'sec-fetch-mode': 'navigate', 'sec-fetch-dest': 'document', 'sec-fetch-site': 'cross-site', }, }) keep(page) // A request the server refuses goes straight back to the client. if (page.headers.location) { await send(page.headers.location) process.exit(0) } if (page.status !== 200) throw new Error(`authorize page: ${page.status} ${page.body}`) // `token`, an ephemeral token a sign-in or sign-up not remembered on the // device answers, stands in for the device's session. const call = async (endpoint, body, token) => { const res = await send(`${api}${endpoint}`, { method: 'POST', headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), 'content-type': 'application/json', 'sec-fetch-mode': 'same-origin', 'sec-fetch-site': 'same-origin', origin, referer, 'x-csrf-token': jar.get('csrf-token'), cookie: cookies(), }, body: JSON.stringify(body), }) keep(res) if (res.status !== 200) throw new Error(`${endpoint}: ${res.status} ${res.body}`) return JSON.parse(res.body) } let done if (reject) { done = await call('/reject', {}) } else { // Signing up takes no `remember` on every provider: one that is not // remembered answers an ephemeral token to consent with. const { account, ephemeralToken } = create ? await call('/sign-up', { locale: 'en', handle: username, email: `${username}@test.com`, password, }) : await call('/sign-in', { locale: 'en', username, password, remember: true, }) done = await call('/consent', { did: account.did }, ephemeralToken) } const back = await send(done.url, { headers: { 'sec-fetch-mode': 'navigate', 'sec-fetch-dest': 'document', 'sec-fetch-site': 'same-origin', referer, cookie: cookies(), }, }) let location = back.headers.location if (!location) throw new Error(`redirect: ${back.status} ${back.body}`) // The client may send the browser on again, as an MCP server does to the // assistant it signs in, or a site to the page its login started on. let here = done.url for (let hops = 0; location && hops < 5; hops++) { here = new URL(location, here).href // Where an app takes over from the browser: said, for a test. if (here.includes('/oauth/app-return')) { console.log(`app: ${here}`) break } const res = await send(here, { headers: theirs ? { cookie: theirs } : {} }) if (theirs) for (const cookie of res.headers['set-cookie'] ?? []) console.log(cookie) location = res.headers.location }