chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
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<string, {name: string, hash: string, privileged: boolean, createdAt: string}>} */
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);
});
});
});