~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
162 lines · 5.7 KB Raw History
// 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 <handle> <password> [--reject] [--create]
//       [--ca <cert>] [--cookie <name=value>] <authorize-url>
//
// `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 <handle> with <password> 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
}