OverviewFilesCommitsIssuesDocsReleasesPeers

favela-web / lib / auth.js

118 lines · javascript ref:
// favela auth — DID web sign-in, atproto-native (the pgv3 method). Thin glue: the reusable crypto
// verify lives in atproto-identity (makeAtprotoWebVerify — secp256k1 sub-key delegated by the DID's
// #atproto key, PLC-anchored), the identity/registration in hyper-identity (AtprotoProvider). favela
// owns only the single-use challenge store + the session cookie. No keet, no anproto; favela is keyless.
//
// The DID is the identity anchor; each browser sub-key is a device delegated by the DID's #atproto key
// (the atproto analog of pgv3's root ← device chain). The delegation is re-verified every login, so
// there is no TOFU and no per-DID root bookkeeping.
import { AtprotoProvider } from 'hyper-identity/auth/providers/atproto'
import { makeAtprotoWebVerify } from 'atproto-identity/lib/web-auth.js'
import { SessionStore } from './sessions.js'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'

const CHALLENGE_TTL_MS = 5 * 60 * 1000
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000

// Minimal DID registry satisfying AtprotoProvider (getIdentityByDid + registerIdentity). favela is a
// keyless VIEW; it doesn't need hyper-identity's full autobase IdentityRegistry.
export class DidRegistry {
  constructor () { this._byDid = new Map() }
  async getIdentityByDid (did) { return this._byDid.get(String(did)) || null }
  async registerIdentity (identity) {
    const did = (identity.credentials && identity.credentials.did) || identity.identityId
    this._byDid.set(String(did), identity)
    return identity
  }
}

export class FavelaAuth {
  // resolveVerifyKey(did) → the DID's #atproto compressed pubkey (favela wires plc.js over the PLC
  // directory). Required for the delegation check — without it (or on PLC-resolve failure) logins fail
  // closed.
  constructor ({ registry, resolveVerifyKey, now = () => Date.now(), sessions = null } = {}) {
    this._now = now
    this._challenges = new Map() // hex -> exp (single-use)
    // Sessions live in the ported pgv3 SessionStore (lib/sessions.js): sliding renewal so an ACTIVE
    // user is not logged out the moment the fixed window elapses, plus a sweeper so an abandoned
    // session does not sit in memory until the process dies. The bare Map this replaces had neither.
    this.sessions = sessions || new SessionStore({ ttl: SESSION_TTL_MS })
    this.registry = registry || new DidRegistry()
    const webVerify = resolveVerifyKey ? makeAtprotoWebVerify({ resolveVerifyKey }) : async () => false
    // The AtprotoProvider's injected verify: burn the single-use challenge FIRST (replay guard), then
    // run the atproto crypto verify (challenge sig by the sub-key + the DID's #atproto delegation).
    const verify = async (proof) => {
      if (!proof || !this._consume(proof.challenge)) return false
      return webVerify(proof)
    }
    this.provider = new AtprotoProvider(this.registry, { verify })
  }

  mintChallenge () {
    const c = b4a.toString(crypto.randomBytes(32), 'hex')
    this._challenges.set(c, this._now() + CHALLENGE_TTL_MS)
    return c
  }

  _consume (c) {
    const exp = this._challenges.get(c)
    if (exp == null || this._now() >= exp) { this._challenges.delete(c); return false }
    this._challenges.delete(c) // burn on use
    return true
  }

  // proof = { did, subKey(hex), challenge(hex), sig, delegation, handle?, didKey? }. Verifies +
  // registers via the provider, then mints a session. Throws (provider: 'Invalid atproto proof') on
  // any failure.
  async login (proof) {
    const result = await this.provider.authenticate({ proof })
    const token = this.sessions.create({ did: result.identityId, subKey: proof.subKey })
    return { token, did: result.identityId, handle: (result.credentials && result.credentials.handle) || null }
  }

  // Unchanged contract: { did, subKey } for a live session, null otherwise. The store applies the
  // sliding renewal on the way through.
  get (token) {
    const entry = this.sessions.get(token)
    return entry ? entry.identity : null
  }

  revoke (token) { return this.sessions.revoke(token) }

  /** Stop the session sweeper (tests, shutdown). Challenges are plain state and need no teardown. */
  close () { this.sessions.close() }
}

const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])

/**
 * Should a session-cookie handoff be performed for this request?
 *
 * WHY IT EXISTS: an embedded WebView (favela-android) has its own cookie jar, and the session is
 * established by the WORKLET over the loopback API — so the UI would render anonymously forever unless
 * the token it already holds can be turned into the cookie form of the same session.
 *
 * That grants nothing new — possession of the token IS the session — but it must not become either of
 * the two things it resembles:
 *   - an OPEN COOKIE-SETTER: an unknown or expired token sets nothing, so a crafted link cannot stamp
 *     an attacker-chosen value into a visitor's jar.
 *   - an OPEN REDIRECT: `next` is confined to a same-origin absolute path. A protocol-relative `//host`
 *     is a URL, not a path — exactly the case a naive "starts with /" check waves through.
 *
 * LOOPBACK ONLY. Fleet instances bind 0.0.0.0, and a token in a query string leaks through logs and
 * Referer in a way a cookie does not. Only the app hosting the server has business calling this, and an
 * unknown peer address is refused rather than assumed local.
 *
 * @returns {{ ok: boolean, token?: string, next: string, reason?: string }}
 */
export function handoffDecision ({ token, remoteAddress, isValid, next } = {}) {
  const to = (typeof next === 'string' && next.startsWith('/') && !next.startsWith('//')) ? next : '/'
  if (!LOOPBACK.has(String(remoteAddress || ''))) return { ok: false, next: to, reason: 'not loopback' }
  const t = String(token || '')
  if (!t || typeof isValid !== 'function' || !isValid(t)) return { ok: false, next: to, reason: 'unknown or expired token' }
  return { ok: true, token: t, next: to }
}

export { CHALLENGE_TTL_MS, SESSION_TTL_MS }