// favela auth — DID sign-in through hyper-identity's AtprotoProvider + atproto-identity's web-auth
// verify, simulated end-to-end (no browser / no PLC network). The DID's #atproto key delegates a
// browser sub-key; the sub-key signs a fresh challenge; FavelaAuth logs in. resolveVerifyKey is mocked
// as the PLC directory would return the DID's #atproto pubkey. Run: node test/auth.test.mjs
import { deriveAtprotoKey, generateAtprotoKey } from 'atproto-identity/lib/atproto-identity.js'
import { signAssertion } from 'atproto-identity/lib/auth.js'
import { FavelaAuth, handoffDecision } from '../lib/auth.js'
let pass = 0
const ok = (c, m) => { if (!c) { console.error('FAIL:', m); process.exit(1) } console.log(' ok -', m); pass++ }
const eq = (a, b, m) => ok(a === b, m + ` (got ${JSON.stringify(a)})`)
const threw = async (fn, re, m) => { try { await fn() } catch (e) { return ok(re.test(e.message), m + ` (threw: ${e.message})`) } ok(false, m + ' (did NOT throw)') }
const DID = 'did:plc:favelaviewer2222222222222'
const hex = (u8) => Array.from(u8).map((b) => b.toString(16).padStart(2, '0')).join('')
const nowSec = () => Math.floor(Date.now() / 1000)
const atproto = deriveAtprotoKey('legal winner thank year wave sausage worth useful legal winner thank yellow') // the DID's #atproto key (hpp holds it)
const sub = generateAtprotoKey() // the browser sub-key
const subHex = hex(sub.publicKey)
const resolveVerifyKey = async (did) => (did === DID ? atproto.publicKey : null) // PLC mock
const delegation = signAssertion({ iss: DID, sub: subHex, iat: nowSec(), exp: nowSec() + 3600 }, atproto.privateKey) // minted once by the hpp
const makeProof = (auth, over = {}) => {
const challenge = auth.mintChallenge()
const sig = signAssertion({ challenge, iat: nowSec(), exp: nowSec() + 300 }, sub.privateKey) // browser signs each challenge
return { did: DID, subKey: subHex, challenge, sig, delegation, handle: 'viewer.test', ...over }
}
// ── happy path ──
{
const auth = new FavelaAuth({ resolveVerifyKey })
const { token, did, handle } = await auth.login(makeProof(auth))
eq(did, DID, 'login resolves to the DID')
eq(handle, 'viewer.test', 'handle carried through the provider')
ok(token && token.length === 64, 'a session token is minted')
const s = auth.get(token)
ok(s && s.did === DID && s.subKey === subHex, 'session resolves to { did, subKey }')
const reg = await auth.registry.getIdentityByDid(DID)
ok(reg && reg.method === 'atproto' && reg.credentials.did === DID, 'AtprotoProvider registered the DID (method=atproto)')
ok(auth.revoke(token) && auth.get(token) === null, 'revoke drops the session')
}
// ── replay: a burned challenge cannot be reused ──
{
const auth = new FavelaAuth({ resolveVerifyKey })
const proof = makeProof(auth)
await auth.login(proof)
await threw(() => auth.login(proof), /Invalid atproto proof/, 'replaying the same challenge is rejected')
}
// ── fail closed: no PLC resolver → the delegation can't be verified → refused ──
{
const auth = new FavelaAuth() // no resolveVerifyKey
await threw(() => auth.login(makeProof(auth)), /Invalid atproto proof/, 'login without a PLC resolver FAILS CLOSED')
}
// ── forgery: an UNDELEGATED sub-key is rejected (even with a valid self-signature) ──
{
const auth = new FavelaAuth({ resolveVerifyKey })
const rogue = generateAtprotoKey()
const challenge = auth.mintChallenge()
const rogueSig = signAssertion({ challenge, exp: nowSec() + 300 }, rogue.privateKey)
await threw(() => auth.login({ did: DID, subKey: hex(rogue.publicKey), challenge, sig: rogueSig, delegation }), /Invalid atproto proof/, 'an undelegated sub-key is rejected (delegation names a different key)')
}
// ── tamper: a challenge-sig for a DIFFERENT challenge is rejected ──
{
const auth = new FavelaAuth({ resolveVerifyKey })
const challenge = auth.mintChallenge()
const wrongSig = signAssertion({ challenge: 'f'.repeat(64), exp: nowSec() + 300 }, sub.privateKey) // signs the WRONG challenge
await threw(() => auth.login({ did: DID, subKey: subHex, challenge, sig: wrongSig, delegation }), /Invalid atproto proof/, 'a challenge-sig for a different challenge is rejected')
}
// ── session handoff into an embedded WebView ─────────────────────────────────────────────────────────
// favela-android renders favela-web in a WebView. The WORKLET holds the session (it signs in over the
// loopback API and gets a token back), but the WebView has its own cookie jar and no way to be handed
// one — so without this every page it loads is anonymous: no liked/reposted state, every write refused.
//
// The handoff swaps a token the caller ALREADY HOLDS for the cookie form of the same session. That grants
// nothing new (possession of the token IS the session), but two properties still have to hold:
// - an UNKNOWN or EXPIRED token must set NOTHING. Otherwise this is an open cookie-setter that stamps
// attacker-chosen values into every visitor's jar.
// - it must be LOOPBACK-ONLY. Deployed instances bind 0.0.0.0 (FAVELA_HOST on the fleet), and a token
// in a URL leaks through logs and Referer in a way a cookie does not; there is no reason for this to
// answer anyone but the app hosting the server.
console.log('\nhandoff')
{
const ok = (d, why) => { if (!d) { console.error('FAIL:', why); process.exit(1) } pass++; console.log(' ok -', why) }
const valid = (t) => t === 'good-token'
const a = handoffDecision({ token: 'good-token', remoteAddress: '127.0.0.1', isValid: valid })
ok(a.ok === true && a.token === 'good-token', 'a valid token from loopback is handed off')
ok(handoffDecision({ token: 'nope', remoteAddress: '127.0.0.1', isValid: valid }).ok === false,
'an UNKNOWN token sets no cookie')
ok(handoffDecision({ token: '', remoteAddress: '127.0.0.1', isValid: valid }).ok === false,
'an empty token sets no cookie')
ok(handoffDecision({ token: 'good-token', remoteAddress: '10.22.0.4', isValid: valid }).ok === false,
'a valid token from OFF-BOX is refused — loopback only')
ok(handoffDecision({ token: 'good-token', remoteAddress: '::1', isValid: valid }).ok === true,
'IPv6 loopback counts as loopback')
ok(handoffDecision({ token: 'good-token', remoteAddress: '::ffff:127.0.0.1', isValid: valid }).ok === true,
'IPv4-mapped IPv6 loopback counts as loopback')
ok(handoffDecision({ token: 'good-token', remoteAddress: undefined, isValid: valid }).ok === false,
'an UNKNOWN peer address is refused, not assumed local')
const nx = handoffDecision({ token: 'good-token', remoteAddress: '127.0.0.1', isValid: valid, next: '/profile/me' })
ok(nx.next === '/profile/me', 'redirects onward to ?next=')
ok(handoffDecision({ token: 'good-token', remoteAddress: '127.0.0.1', isValid: valid, next: 'https://evil.example/x' }).next === '/',
'an ABSOLUTE next is refused — the handoff must not become an open redirect')
ok(handoffDecision({ token: 'good-token', remoteAddress: '127.0.0.1', isValid: valid, next: '//evil.example/x' }).next === '/',
'a protocol-relative next is refused too')
}
console.log(`\nAUTH OK: ${pass} assertions`)