import { beforeEach, describe, expect, it } from 'vitest'; import { generateAppPassword, hashAppPassword, SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, } from '../packages/core/src/app-password.js'; import { verifyAccessJwt, verifyRefreshJwt, } from '../packages/core/src/auth.js'; import { PersonalDataServer } from '../packages/core/src/pds.js'; const DID = 'did:plc:testapppassword'; const JWT_SECRET = 'test-secret'; const ACCOUNT_PASSWORD = 'account-password'; /** Minimal in-memory shared storage covering the app-password surface. */ function createSharedStorage() { /** @type {Map} */ const byName = new Map(); return { async putAppPassword( /** @type {string} */ did, /** @type {string} */ name, /** @type {string} */ hash, /** @type {boolean} */ privileged, /** @type {string} */ createdAt, ) { if (byName.has(`${did}:${name}`)) return false; byName.set(`${did}:${name}`, { name, hash, privileged, createdAt }); return true; }, async getAppPasswordByHash( /** @type {string} */ did, /** @type {string} */ hash, ) { for (const [key, v] of byName) { if (key.startsWith(`${did}:`) && v.hash === hash) { return { name: v.name, privileged: v.privileged, createdAt: v.createdAt, }; } } return null; }, async listAppPasswords(/** @type {string} */ did) { return [...byName.entries()] .filter(([key]) => key.startsWith(`${did}:`)) .map(([, v]) => ({ name: v.name, privileged: v.privileged, createdAt: v.createdAt, })); }, async deleteAppPassword( /** @type {string} */ did, /** @type {string} */ name, ) { return byName.delete(`${did}:${name}`); }, }; } function createPds() { return new PersonalDataServer({ actorStorage: /** @type {any} */ ({ async getHandle() { return 'alice.test'; }, async getDid() { return DID; }, }), sharedStorage: /** @type {any} */ (createSharedStorage()), blobs: /** @type {any} */ ({}), jwtSecret: JWT_SECRET, hostname: 'pds.example.com', password: ACCOUNT_PASSWORD, }); } /** * @param {PersonalDataServer} pds * @param {string} password */ async function login(pds, password) { const response = await pds.fetch( new Request( 'https://pds.example.com/xrpc/com.atproto.server.createSession', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ identifier: DID, password }), }, ), ); return { status: response.status, body: await response.json() }; } /** * @param {PersonalDataServer} pds * @param {string} method * @param {string} jwt * @param {Object} [body] */ async function call(pds, method, jwt, body) { const isPost = body !== undefined; const response = await pds.fetch( new Request(`https://pds.example.com/xrpc/${method}`, { method: isPost ? 'POST' : 'GET', headers: { authorization: `Bearer ${jwt}`, ...(isPost ? { 'content-type': 'application/json' } : {}), }, ...(isPost ? { body: JSON.stringify(body) } : {}), }), ); const text = await response.text(); return { status: response.status, body: text ? JSON.parse(text) : null }; } describe('app passwords', () => { /** @type {PersonalDataServer} */ let pds; /** @type {string} */ let accessJwt; beforeEach(async () => { pds = createPds(); accessJwt = (await login(pds, ACCOUNT_PASSWORD)).body.accessJwt; }); describe('generation', () => { it('produces the atproto xxxx-xxxx-xxxx-xxxx shape', () => { expect(generateAppPassword()).toMatch(/^[a-z2-7]{4}(-[a-z2-7]{4}){3}$/); }); it('does not repeat', () => { const seen = new Set(Array.from({ length: 200 }, generateAppPassword)); expect(seen.size).toBe(200); }); it('hashes the same password differently for different accounts', async () => { const password = generateAppPassword(); const a = await hashAppPassword('did:plc:one', password); const b = await hashAppPassword('did:plc:two', password); expect(a).not.toBe(b); }); }); describe('createAppPassword', () => { it('returns the password exactly once', async () => { const created = await call( pds, 'com.atproto.server.createAppPassword', accessJwt, { name: 'my-client' }, ); expect(created.status).toBe(200); expect(created.body.name).toBe('my-client'); expect(created.body.password).toMatch(/^[a-z2-7]{4}(-[a-z2-7]{4}){3}$/); expect(created.body.privileged).toBe(false); // Listing must never expose it again. const listed = await call( pds, 'com.atproto.server.listAppPasswords', accessJwt, ); expect(listed.body.passwords).toHaveLength(1); expect(listed.body.passwords[0]).not.toHaveProperty('password'); expect(listed.body.passwords[0].name).toBe('my-client'); }); it('rejects a duplicate name rather than replacing it', async () => { const body = { name: 'dupe' }; const first = await call( pds, 'com.atproto.server.createAppPassword', accessJwt, body, ); const second = await call( pds, 'com.atproto.server.createAppPassword', accessJwt, body, ); expect(second.status).toBe(400); // The original must still work — a rejected duplicate that silently // invalidated the first password would be worse than the error. const session = await login(pds, first.body.password); expect(session.status).toBe(200); }); it('requires a name', async () => { const result = await call( pds, 'com.atproto.server.createAppPassword', accessJwt, {}, ); expect(result.status).toBe(400); }); }); describe('login', () => { it('opens a restricted session', async () => { const created = await call( pds, 'com.atproto.server.createAppPassword', accessJwt, { name: 'client' }, ); const session = await login(pds, created.body.password); expect(session.status).toBe(200); expect(session.body.did).toBe(DID); const payload = await verifyAccessJwt(session.body.accessJwt, JWT_SECRET); expect(payload.scope).toBe(SCOPE_APP_PASS); }); it('marks privileged app passwords distinctly', async () => { const created = await call( pds, 'com.atproto.server.createAppPassword', accessJwt, { name: 'trusted', privileged: true }, ); const session = await login(pds, created.body.password); const payload = await verifyAccessJwt(session.body.accessJwt, JWT_SECRET); expect(payload.scope).toBe(SCOPE_APP_PASS_PRIVILEGED); }); it('still gives the account password a full session', async () => { const session = await login(pds, ACCOUNT_PASSWORD); const payload = await verifyAccessJwt(session.body.accessJwt, JWT_SECRET); expect(payload.scope).toBe(SCOPE_ACCESS); }); it('rejects a wrong password', async () => { expect((await login(pds, 'nope-nope-nope-nope')).status).toBe(401); }); it('rejects every password when none is configured', async () => { // `if (this.password && password !== this.password)` short-circuited // when no password was set, so any string — including the empty one — // opened a full-access session on an unconfigured server. const openPds = new PersonalDataServer({ actorStorage: /** @type {any} */ ({ async getHandle() { return 'alice.test'; }, async getDid() { return DID; }, }), sharedStorage: /** @type {any} */ (createSharedStorage()), blobs: /** @type {any} */ ({}), jwtSecret: JWT_SECRET, hostname: 'pds.example.com', // password deliberately omitted }); for (const attempt of ['anything', '', 'hunter2']) { const result = await login(openPds, attempt); expect(result.status).toBe(401); expect(result.body.accessJwt).toBeUndefined(); } }); it('rejects a revoked password', async () => { const created = await call( pds, 'com.atproto.server.createAppPassword', accessJwt, { name: 'temp' }, ); expect((await login(pds, created.body.password)).status).toBe(200); const revoked = await call( pds, 'com.atproto.server.revokeAppPassword', accessJwt, { name: 'temp' }, ); expect(revoked.status).toBe(200); expect((await login(pds, created.body.password)).status).toBe(401); }); it('rejects revoking a name that does not exist', async () => { const result = await call( pds, 'com.atproto.server.revokeAppPassword', accessJwt, { name: 'never-made' }, ); expect(result.status).toBe(400); }); }); describe('privilege containment', () => { /** @type {string} */ let appJwt; beforeEach(async () => { const created = await call( pds, 'com.atproto.server.createAppPassword', accessJwt, { name: 'client', privileged: true }, ); appJwt = (await login(pds, created.body.password)).body.accessJwt; }); it('refuses to mint app passwords from an app-password session', async () => { // Otherwise one leaked password mints its own replacements and // revoking it accomplishes nothing. const result = await call( pds, 'com.atproto.server.createAppPassword', appJwt, { name: 'escalated' }, ); expect(result.status).toBe(403); }); it('refuses to list or revoke from an app-password session', async () => { expect( (await call(pds, 'com.atproto.server.listAppPasswords', appJwt)).status, ).toBe(403); expect( ( await call(pds, 'com.atproto.server.revokeAppPassword', appJwt, { name: 'client', }) ).status, ).toBe(403); }); it('does not let a refresh escalate to a full session', async () => { const created = await call( pds, 'com.atproto.server.createAppPassword', accessJwt, { name: 'refresher' }, ); const session = await login(pds, created.body.password); const refreshed = await pds.fetch( new Request( 'https://pds.example.com/xrpc/com.atproto.server.refreshSession', { method: 'POST', headers: { authorization: `Bearer ${session.body.refreshJwt}` }, }, ), ); const body = await refreshed.json(); expect(refreshed.status).toBe(200); const payload = await verifyAccessJwt(body.accessJwt, JWT_SECRET); expect(payload.scope).toBe(SCOPE_APP_PASS); // And the new refresh token must carry the restriction forward too, // or the second refresh escalates instead of the first. const refreshPayload = await verifyRefreshJwt( body.refreshJwt, JWT_SECRET, ); expect(refreshPayload.sessionScope).toBe(SCOPE_APP_PASS); }); }); });