OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / auth / check.js

244 lines · javascript ref:
/**
 * Auth checks and request middleware for pear-git-v3.
 *
 * Two layers:
 *
 *  1. extractIdentity(req, sessions) — reads the bearer token from
 *     `Authorization: Bearer <token>` header OR `pgv3-session=<token>` cookie,
 *     looks it up in the SessionStore, returns the identity record or null.
 *     Does NOT throw — caller decides if missing identity is OK.
 *
 *  2. requireWriter(ctx) / requireAdmin(ctx) — handler-side checks. Throw a
 *     401-style Error if the caller is not allowed to mutate.
 *     Pass automatically when ctx.transport === 'uds' (local user is
 *     trusted by virtue of having the UDS socket open).
 *     Pass when ctx.identity.ownerMatched (envelope root = daemon's
 *     hyperident identity root — the intrinsic owner).
 *     Otherwise look up the pubkey in IdentityRegistry and check
 *     globalPermissions for 'admin' / 'write'.
 *
 * IdentityRegistry (hyper-identity Autobase) is the source of truth for
 * user → perms mapping. Replaced the legacy trust-map in F.6.
 */

const COOKIE_NAME = 'pgv3-session'

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

export { AuthError, COOKIE_NAME }

/**
 * Is this identity revoked? (org #14)
 *
 * The registry keeps revocation in a SEPARATE `revoked:<identityId>` view
 * index — the record returned by getIdentity does NOT carry the flag, so
 * `identity.revoked` is always false and testing it silently misses every
 * revocation. isRevoked() is the authoritative read.
 *
 * This check lived only in the SSH branch of git-acl.js and in resolveToken,
 * so revoking an identity did not stop it pushing over an HTTP session or
 * punch://, nor invoking writer/admin RPCs. One helper, called on every
 * resolution path, is what keeps the next transport from re-opening the hole.
 *
 * Fails OPEN on a read error, matching resolveToken and the original SSH
 * branch: a transient autobase read must not deny every caller at once. A
 * genuine revocation is a durable view entry, not a transient failure.
 */
export async function isIdentityRevoked (registry, identityId) {
  if (!registry || !identityId) return false
  if (typeof registry.isRevoked !== 'function') return false
  return registry.isRevoked(identityId).catch(() => false)
}

/**
 * Extract the bearer token from a request.
 * @param {http.IncomingMessage} req
 * @returns {string|null}
 */
export function extractToken (req) {
  if (!req || !req.headers) return null

  const auth = req.headers.authorization
  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
      const k = part.slice(0, idx).trim()
      if (k === COOKIE_NAME) {
        return decodeURIComponent(part.slice(idx + 1).trim()) || null
      }
    }
  }

  return null
}

/**
 * Look up the identity for the request, given a SessionStore.
 * Returns the session record (with sliding renewal applied) or null.
 *
 * @param {http.IncomingMessage} req
 * @param {SessionStore} sessions
 * @returns {{ publicKey: string, expiresAt: number } | null}
 */
export function extractIdentity (req, sessions) {
  const token = extractToken(req)
  if (!token) return null
  return sessions.get(token)
}

/**
 * Require the caller to be a "writer" — has 'admin' or 'write' in their
 * User.globalPermissions, OR connected via UDS (local user is always
 * trusted). Lookup is session.publicKey → registry.getUserByIdentity →
 * user.globalPermissions.
 *
 * @param {object} ctx - handler ctx ({ transport, identity, ... })
 * @param {IdentityRegistry} registry - hyper-identity IdentityRegistry
 * @throws {AuthError} if not allowed
 */
export async function requireWriter (ctx, registry) {
  if (ctx && ctx.transport === 'uds') return // local UDS = trusted

  const identity = ctx && ctx.identity
  if (!identity || !identity.publicKey) {
    throw new AuthError('authentication required')
  }

  // Intrinsic owner: envelope's root matches the daemon's own service
  // identity root (set by router.js at dispatch time). The key that runs
  // the daemon IS the owner — no registry record required.
  if (identity.ownerMatched) return

  if (!registry) {
    throw new AuthError('identity registry not available', 500)
  }

  // #14: a revoked identity keeps its User record and its globalPermissions,
  // so the perms check below passes for it. Deny before consulting perms.
  if (await isIdentityRevoked(registry, identity.publicKey)) {
    throw new AuthError('identity revoked', 403)
  }

  const user = await registry.getUserByIdentity(identity.publicKey).catch(() => null)
  const perms = user && Array.isArray(user.globalPermissions) ? user.globalPermissions : []
  if (perms.includes('admin') || perms.includes('write')) return

  throw new AuthError('writer permission required', 403)
}

/**
 * Require an AUTHENTICATED, non-revoked identity — but NOT write permission.
 *
 * For SELF-SCOPED reads: handlers returning only the caller's own data, which
 * leak nothing cross-user. A newly TOFU-registered user gets `['read']` by
 * default (handlers/users.js:457), and requireWriter above passes only 'admin'
 * or 'write' — so a read-only user was locked out of its own profile surface.
 * That is what stopped a fresh identity from listing its own devices and
 * copying its link invite (regression surfaced 2026-08-09; the writer call in
 * devices.js predates it, but #12's default-deny removed the anonymous path
 * that used to mask it).
 *
 * Strictly WEAKER than requireWriter, strictly STRONGER than anonymous:
 *   - anonymous is still rejected — you need an identity to ask for "my"
 *     anything — so #12's default-deny surface is NOT reopened.
 *   - the #14 revocation gate is KEPT. A revoked identity retains its User
 *     record and globalPermissions, so any perms-only check still passes for
 *     it; revocation must be denied first. That bypass was real once.
 *
 * Additive: requireWriter and requireAdmin are untouched.
 */
export async function requireIdentity (ctx, registry) {
  if (ctx && ctx.transport === 'uds') return // local UDS = trusted

  const identity = ctx && ctx.identity
  if (!identity || !identity.publicKey) {
    throw new AuthError('authentication required')
  }

  // Intrinsic owner: same short-circuit as requireWriter — the key running the
  // daemon IS the owner, no registry record required.
  if (identity.ownerMatched) return

  if (!registry) {
    throw new AuthError('identity registry not available', 500)
  }

  if (await isIdentityRevoked(registry, identity.publicKey)) {
    throw new AuthError('identity revoked', 403)
  }
}

/**
 * Non-throwing probe — does ctx belong to a user who would pass
 * requireAdmin? Used by SSR page renderers to decide whether to SHOW
 * an admin-only form in the first place (vs. rendering it and letting
 * the handler reject on submit). UDS always passes. HTTP callers
 * check the identity's globalPermissions.
 *
 * Returns false (not throw) when unauthenticated, not an owner, or
 * the registry isn't available. Never throws.
 */
export async function isAdmin (ctx, registry) {
  if (ctx && ctx.transport === 'uds') return true
  const identity = ctx && ctx.identity
  if (!identity || !identity.publicKey) return false
  // Intrinsic owner — see requireWriter.
  if (identity.ownerMatched) return true
  if (!registry || typeof registry.getUserByIdentity !== 'function') return false
  try {
    // #14 — keep the probe in step with requireAdmin, so a revoked admin is
    // not shown a form that the handler will then refuse.
    if (await isIdentityRevoked(registry, identity.publicKey)) return false
    const user = await registry.getUserByIdentity(identity.publicKey)
    const perms = user && Array.isArray(user.globalPermissions) ? user.globalPermissions : []
    return perms.includes('admin')
  } catch {
    return false
  }
}

/**
 * Require the caller to be an "owner" — strict 'admin' in globalPermissions.
 * UDS bypasses (the local user runs the daemon).
 */
export async function requireAdmin (ctx, registry) {
  if (ctx && ctx.transport === 'uds') return

  const identity = ctx && ctx.identity
  if (!identity || !identity.publicKey) {
    throw new AuthError('authentication required')
  }

  // Intrinsic owner: see requireWriter. The daemon's service identity is
  // always the owner by construction.
  if (identity.ownerMatched) return

  if (!registry) {
    throw new AuthError('identity registry not available', 500)
  }

  // #14 — see requireWriter.
  if (await isIdentityRevoked(registry, identity.publicKey)) {
    throw new AuthError('identity revoked', 403)
  }

  const user = await registry.getUserByIdentity(identity.publicKey).catch(() => null)
  const perms = user && Array.isArray(user.globalPermissions) ? user.globalPermissions : []
  if (perms.includes('admin')) return

  throw new AuthError('owner permission required', 403)
}