// 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 }