// getServiceAuth — the peer mints an ES256K inter-service JWT (signed by the #atproto key) so the
// keyless membrane can authenticate user-scoped AppView proxying. The token MUST verify against the
// account's signing pubkey and carry { iss, aud, lxm, exp }.
import '../lib/bare-globals.js'
import test from 'brittle'
import b4a from 'b4a'
import { secp256k1 } from '@noble/curves/secp256k1'
import { SwarmDevice } from '../lib/swarm-device.js'
import { verifyAssertion } from 'atproto-identity/lib/auth.js'
const DID = 'did:plc:svcauth2222222222222222'
const PRIV = b4a.from('01'.repeat(32), 'hex') // fixed valid secp256k1 scalar
test('getServiceAuth: mints a verifiable ES256K token with iss/aud/lxm/exp', (t) => {
// getServiceAuth reads only this.did + this.privateKey — exercise it off the prototype (no statebase).
const dev = Object.create(SwarmDevice.prototype)
dev.did = DID
dev.privateKey = PRIV
const pub = secp256k1.getPublicKey(PRIV, true) // compressed pubkey the AppView would resolve from the DID
const { token, exp } = dev.getServiceAuth({ aud: 'did:web:api.bsky.app', lxm: 'app.bsky.feed.getTimeline' })
t.ok(token && token.split('.').length === 3, 'three-segment JWT')
const payload = verifyAssertion(token, pub) // throws on bad sig / expiry
t.is(payload.iss, DID, 'iss = the account DID')
t.is(payload.aud, 'did:web:api.bsky.app', 'aud = the AppView service DID')
t.is(payload.lxm, 'app.bsky.feed.getTimeline', 'lxm = the bound method')
t.ok(payload.exp === exp && exp > Math.floor(Date.now() / 1000), 'exp is future + returned alongside')
t.exception(() => dev.getServiceAuth({}), /aud required/, 'aud is required')
// a token minted for one method still verifies structurally (lxm optional when omitted)
const { token: t2 } = dev.getServiceAuth({ aud: 'did:web:api.bsky.app' })
const p2 = verifyAssertion(t2, pub)
t.absent(p2.lxm, 'no lxm claim when not requested')
})
// The HANDLER, not the primitive. getServiceAuth is deliberately general (it is atproto's
// com.atproto.server.getServiceAuth), so the authority boundary lives at the exposed capability. A
// minted token authenticates AS the DID using the same #atproto key that signs every repo commit —
// verified against both hosted DIDs' published PLC documents — so this is a signing oracle and the
// gating is the security property, not a nicety.
//
// Exercised through the same logic the UDS handler applies. Kept in lockstep with
// lib/serve-device-uds.js: if that allowlist widens, this must be the edit that says so.
test('service-auth handler: lxm is REQUIRED and allowlisted, aud must be a DID, TTL capped', (t) => {
const ALLOW = ['app.bsky.notification.listNotifications']
const MAX_TTL = 120
const dev = Object.create(SwarmDevice.prototype)
dev.did = DID
dev.privateKey = PRIV
const pub = secp256k1.getPublicKey(PRIV, true)
// The gate, mirroring the handler.
const mint = (op = {}) => {
if (typeof op.aud !== 'string' || !op.aud.startsWith('did:')) throw new Error('aud must be a service DID')
if (typeof op.lxm !== 'string' || !op.lxm) throw new Error('lxm required')
if (!ALLOW.includes(op.lxm)) throw new Error('lxm not allowlisted')
const now = Math.floor(Date.now() / 1000)
const ttl = Math.min(Number(op.ttlSec) > 0 ? Number(op.ttlSec) : MAX_TTL, MAX_TTL)
return dev.getServiceAuth({ aud: op.aud, lxm: op.lxm, exp: now + ttl })
}
const { token, exp } = mint({ aud: 'did:web:api.bsky.app', lxm: 'app.bsky.notification.listNotifications' })
const payload = verifyAssertion(token, pub)
t.is(payload.lxm, 'app.bsky.notification.listNotifications', 'the allowlisted method mints')
t.is(payload.aud, 'did:web:api.bsky.app', 'bound to the AppView service DID')
t.ok(exp - Math.floor(Date.now() / 1000) <= MAX_TTL, 'TTL is capped short, so a leaked token expires on its own')
// An UNSCOPED token is the dangerous case: the primitive allows it (lxm optional) and it is then valid
// for ANY method at that audience. The handler must refuse rather than pass it through.
t.exception(() => mint({ aud: 'did:web:api.bsky.app' }), /lxm required/, 'refuses to mint a token with no lxm')
// Anything not on the list is refused — including plausible, tempting reads.
t.exception(() => mint({ aud: 'did:web:api.bsky.app', lxm: 'app.bsky.feed.getTimeline' }), /not allowlisted/, 'a non-allowlisted method is refused')
t.exception(() => mint({ aud: 'did:web:api.bsky.app', lxm: 'com.atproto.repo.applyWrites' }), /not allowlisted/, 'a WRITE method is refused')
t.exception(() => mint({ lxm: 'app.bsky.notification.listNotifications' }), /aud/, 'aud is required')
t.exception(() => mint({ aud: 'https://evil.example', lxm: 'app.bsky.notification.listNotifications' }), /aud/, 'aud must be a DID, not a url')
// A caller cannot ask for a longer life than the cap.
const long = mint({ aud: 'did:web:api.bsky.app', lxm: 'app.bsky.notification.listNotifications', ttlSec: 86400 })
t.ok(long.exp - Math.floor(Date.now() / 1000) <= MAX_TTL, 'a caller-supplied TTL cannot exceed the cap')
})
// The DEVICE (swarm) surface handler — the link the bridge actually calls. atproto-did-peer declares the
// RPC (did-peer.js:379 `get-service-auth`), the bridge's server-bare caches and calls front.getServiceAuth,
// proxyToAppView attaches the Bearer — and nothing answered, so every user-scoped AppView read silently
// fell back to the public proxy. I first shipped this on the UDS surface, having chosen the surface before
// checking the consumer.
//
// TWO gates, both required, answering different questions. Exercised through the same logic the handler
// applies; kept in lockstep with lib/serve-device.js.
test('device-surface service-auth: canWrite AND a valid session, else refuse', (t) => {
const ALLOW = ['app.bsky.notification.listNotifications']
const TTL = 120
const dev = Object.create(SwarmDevice.prototype)
dev.did = DID
dev.privateKey = PRIV
const pub = secp256k1.getPublicKey(PRIV, true)
const BRIDGE = 'aa'.repeat(32)
const canWrite = (hex) => hex === BRIDGE
const goodSessions = { canLogin: true, verifyAccess: (tok) => { if (tok !== 'good') throw new Error('verifyAccess: bad token'); return { sub: DID } } }
const noSessions = { canLogin: false, verifyAccess: () => { throw new Error('not configured') } }
const mint = (remote, sessions, op = {}) => {
if (!remote || !canWrite(remote)) throw new Error('unauthorized: caller may not mint service auth')
if (!sessions || !sessions.canLogin) throw new Error('sessions not configured — refusing to mint ungated')
if (typeof op.aud !== 'string' || !op.aud.startsWith('did:')) throw new Error('aud must be a service DID')
if (typeof op.lxm !== 'string' || !op.lxm) throw new Error('lxm required')
if (!ALLOW.includes(op.lxm)) throw new Error('lxm not allowlisted')
sessions.verifyAccess(op.token)
return dev.getServiceAuth({ aud: op.aud, lxm: op.lxm, exp: Math.floor(Date.now() / 1000) + TTL })
}
const OK = { aud: 'did:web:api.bsky.app', lxm: 'app.bsky.notification.listNotifications', token: 'good' }
const { token } = mint(BRIDGE, goodSessions, OK)
const payload = verifyAssertion(token, pub)
t.is(payload.lxm, OK.lxm, 'an allowlisted peer with a valid session mints for the allowlisted method')
t.is(payload.iss, DID, 'issued as the account')
// Gate 1: the peer. An arbitrary peer knowing the DID must not reach a signing oracle.
t.exception(() => mint('bb'.repeat(32), goodSessions, OK), /unauthorized/, 'a non-allowlisted peer is refused')
t.exception(() => mint(null, goodSessions, OK), /unauthorized/, 'an unidentified caller is refused')
// Gate 2: the user. An allowlisted bridge must not mint with nobody logged in — this is the gate that
// stops the bridge itself becoming an unlimited signing authority.
t.exception(() => mint(BRIDGE, goodSessions, { ...OK, token: 'forged' }), /bad token/, 'an invalid bearer is refused even from the allowlisted peer')
t.exception(() => mint(BRIDGE, goodSessions, { ...OK, token: undefined }), /bad token/, 'a MISSING bearer is refused — no silent unauthenticated mint')
// Unconfigured sessions: refuse rather than mint ungated. Correct for a co-owned feed like loser-club,
// which has no appPasswordHash/jwtSecret because nobody logs in AS the feed.
t.exception(() => mint(BRIDGE, noSessions, OK), /not configured/, 'a DID with no session config refuses')
t.exception(() => mint(BRIDGE, null, OK), /not configured/, 'and so does a missing sessions object')
// The scope constraints still apply on this surface.
t.exception(() => mint(BRIDGE, goodSessions, { ...OK, lxm: undefined }), /lxm required/, 'an UNSCOPED token is refused')
t.exception(() => mint(BRIDGE, goodSessions, { ...OK, lxm: 'com.atproto.repo.applyWrites' }), /not allowlisted/, 'a write method is refused')
t.exception(() => mint(BRIDGE, goodSessions, { ...OK, aud: 'https://evil.example' }), /aud/, 'aud must be a DID')
})