OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / check.js

184 lines · javascript ref:
// Auth checks and request middleware — PORTED from pear-git-v3 `lib/auth/check.js`.
//
// Two layers, same as pgv3:
//
//   1. extractIdentity(req, sessions) — reads the bearer token from `Authorization: Bearer <token>`
//      OR the `favela_session` cookie, resolves it against the SessionStore, returns the identity or
//      null. Never throws; the caller decides whether anonymous is acceptable.
//
//   2. requireIdentity(ctx) / requireMember(ctx) — handler-side guards that THROW an AuthError.
//      Having these as guards rather than as an inline `if (!viewer)` in every route is the actual
//      lesson from pgv3: its read gate sits at ONE choke point above ~14 routes precisely because
//      fourteen hand-written checks are fourteen chances to forget. favela already paid that bill —
//      /api/post had no gate until 2026-08-11, when an anonymous request posted as wj62, while the
//      images branch of the same handler DID check. A named guard makes the omission visible.
//
// WHAT WAS DELIBERATELY NOT PORTED, and why:
//
//   - The `transport === 'uds'` bypass. pgv3 trusts a local caller because holding the UDS socket is
//     itself proof of local access. favela-web serves HTTP only; inventing a "local is trusted" rule
//     here would be a bypass with nothing behind it. (favela's loopback handoff in lib/auth.js is a
//     different thing: it converts a token the caller ALREADY holds into cookie form, granting nothing.)
//
//   - `globalPermissions` / IdentityRegistry. pgv3 resolves a pubkey to a User record with global
//     perms. favela has no such registry, and it should not grow a server-side one: membership belongs
//     in the community room (`add-member` / `ban` ops, DID-rooted), which is replicated P2P and is the
//     same authority the feed's roster already reads. So `requireMember` takes an INJECTED policy
//     rather than assuming a store.
//
// AUTHENTICATION IS NOT AUTHORISATION. A valid session proves only "this browser holds a sub-key that
// the DID delegated". Any atproto DID can prove that about ITSELF, so a session alone must never imply
// the right to write — that is what `requireMember` is for, and why it fails CLOSED with no policy.

export const COOKIE_NAME = 'favela_session'

export class AuthError extends Error {
  constructor (message, status = 401) {
    super(message)
    this.name = 'AuthError'
    this.status = status
    this.code = 'EAUTH'
  }
}

/** Extract the bearer token from a request, header first, then cookie. */
export function extractToken (req) {
  if (!req || !req.headers) return null

  const auth = req.headers.authorization
  // Header before cookie: the cookie is what a browser attaches automatically, the header is what a
  // client sets deliberately. When both are present, honour the deliberate one.
  if (auth && auth.startsWith('Bearer ')) return auth.slice(7).trim() || null

  const cookieHeader = req.headers.cookie
  if (cookieHeader) {
    for (const part of cookieHeader.split(';')) {
      const idx = part.indexOf('=')
      if (idx === -1) continue
      if (part.slice(0, idx).trim() !== COOKIE_NAME) continue
      const raw = part.slice(idx + 1).trim()
      if (!raw) return null
      try { return decodeURIComponent(raw) } catch { return raw }
    }
  }

  return null
}

/**
 * Resolve the request's identity via the SessionStore. Sliding renewal is applied by the store.
 * @returns {{ did: string, subKey?: string } | null}
 */
export function extractIdentity (req, sessions) {
  const token = extractToken(req)
  if (!token || !sessions) return null
  const entry = sessions.get(token)
  return entry ? entry.identity : null
}

/**
 * Is this device revoked?
 *
 * pgv3's twin (isIdentityRevoked) exists because revocation lived in a SEPARATE view index — the
 * identity record's own flag always read false, so testing it silently missed every revocation. Here
 * the authoritative source is hpp's device registry, injected as `isDeviceRevoked(subKey)`.
 *
 * FAILURE POLICY is explicit, because the right answer differs by caller. pgv3 fails OPEN so that a
 * transient registry read cannot deny every caller at once, and that is right for reads. It is NOT
 * right for a write: there, an unreachable registry must not wave through a device that may have been
 * revoked. Hence `failOpen`, defaulting to pgv3's behaviour and flipped by the write guards.
 */
export const UNKNOWN = 'unknown'

export async function isRevoked ({ subKey, isDeviceRevoked, failOpen = true } = {}) {
  if (!subKey || typeof isDeviceRevoked !== 'function') return false
  try {
    return !!(await isDeviceRevoked(subKey))
  } catch {
    // ⛔ A CHECK THAT COULD NOT RUN IS NOT A POSITIVE REVOCATION. This returned `true` on the write
    // path, so a read-only instance — one with no hpp to ask — told a signed-in user "this device has
    // been revoked". Their device was fine; nobody could check. Reported live on favela.7rnx.net.
    // Fail-closed still DENIES; it must simply not assert something untrue about the user's device.
    return failOpen ? false : UNKNOWN
  }
}

/**
 * Require an authenticated, non-revoked identity. Does NOT imply any right to write.
 * For self-scoped reads (your own devices, your own bookmarks) this is the correct guard.
 */
export async function requireIdentity (ctx, opts = {}) {
  const identity = ctx && ctx.identity
  if (!identity || !identity.did) throw new AuthError('authentication required')
  const verdict = await isRevoked({ subKey: identity.subKey, ...opts })
  if (verdict === UNKNOWN) throw new AuthError('device status could not be verified', 403)
  if (verdict) throw new AuthError('this device has been revoked', 403)
}

/**
 * Require a member — favela's analog of pgv3's requireWriter.
 *
 * `isMember(did)` is injected: today nothing supplies it, and that is why this DENIES without one.
 * A guard whose unconfigured state is "allow" is not a guard; the whole reason this exists is that a
 * valid session proves identity and nothing else.
 *
 * Order matters: authentication → revocation → membership. Revocation outranks membership so that a
 * revoked device belonging to a member is still refused, and a membership lookup that THROWS denies
 * rather than admits (an unreachable room is not consent).
 */
export async function requireMember (ctx, opts = {}) {
  await requireIdentity(ctx, { ...opts, failOpen: opts.failOpen === undefined ? false : opts.failOpen })
  const { isMember } = opts
  if (typeof isMember !== 'function') {
    throw new AuthError('membership policy not configured', 403)
  }
  let member = false
  try {
    member = !!(await isMember(ctx.identity.did))
  } catch {
    throw new AuthError('membership could not be verified', 403)
  }
  if (!member) throw new AuthError('membership required', 403)
}

/**
 * Require that the caller OWNS the repo being written to.
 *
 * ⛔ THE HOLE THIS CLOSES. `Writer` connects to ONE hpp control socket — the primary DID's — so
 * `writer.post()` authors as THAT DID regardless of who is signed in. Meanwhile `auth.login()` admits
 * any DID that proves a delegation from its own #atproto key, and unknown DIDs are registered on
 * sight. Composed, those two facts mean any atproto account could sign in here and post as wj62.
 * A session proves WHO YOU ARE; it must never be mistaken for a right to write as SOMEONE ELSE.
 *
 * `repoDid` is the DID the writer is actually bound to — ask the hpp (`writer.state().did`), never
 * assume. An unknown target DENIES: not knowing whose history you are about to append to is the
 * worst possible moment to guess.
 *
 * This does not YET permit a co-owner to write to a community DID — that needs per-DID socket routing
 * (hpp already exposes a socket per DID). It is the safety property; routing is additive on top.
 */
export async function requireRepoOwner (ctx, opts = {}) {
  await requireIdentity(ctx, { ...opts, failOpen: opts.failOpen === undefined ? false : opts.failOpen })
  const repoDid = opts.repoDid
  if (!repoDid) throw new AuthError('cannot determine which repo this node writes to', 403)
  // Exact match. A DID is an opaque, case-sensitive identifier; near-misses are not owners.
  if (ctx.identity.did !== repoDid) {
    throw new AuthError('you may only write to your own repo', 403)
  }
}

/**
 * Non-throwing probe: may this caller write? For SSR — decide whether to RENDER the composer at all,
 * rather than rendering it and letting the POST fail. Mirrors pgv3's isAdmin(). Never throws.
 */
export async function mayWrite (ctx, opts = {}) {
  try {
    await requireMember(ctx, opts)
    return true
  } catch {
    return false
  }
}

export default { UNKNOWN, extractToken, extractIdentity, requireIdentity, requireMember, requireRepoOwner, mayWrite, isRevoked, AuthError, COOKIE_NAME }