chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// com.atproto.server.* handler tests: the refusals each endpoint owes a
// caller, and the two paths by which an account arrives on an empty server.
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createAccessJwt,
createRefreshJwt,
} from '../packages/core/src/auth.js';
import {
generateKeyPair,
importPrivateKey,
} from '../packages/core/src/crypto.js';
import { createXrpcServerHandlers } from '../packages/core/src/handlers/xrpc-server.js';
import { kemDidKey } from '../packages/core/src/kem.js';
import { publicKeyToDidKey } from '../packages/core/src/plc.js';
import { createSessionRegistry } from '../packages/core/src/session.js';
const DID = 'did:plc:xrpcserveraccount2345672';
const HOSTNAME = 'pds.example.com';
const SECRET = 'test-secret';
const PASSWORD = 'operator-password';
/**
* @param {{
* did?: string|null, handle?: string|null, readOnly?: boolean,
* noPassword?: boolean, appPasswords?: boolean,
* verifier?: {verify: (didKey: string, data: Uint8Array, sig: Uint8Array) => Promise<boolean>}|null,
* didResolver?: ((did: string) => Promise<Object|null>)|null,
* spacesEnabled?: boolean,
* }} [options]
*/
function createServer({
did = null,
handle = null,
readOnly = false,
noPassword = false,
appPasswords = true,
verifier = null,
didResolver = null,
spacesEnabled = false,
} = {}) {
const stored = {
did,
handle,
/** @type {Uint8Array|null} */ privateKey: null,
/** @type {Uint8Array|null} */ kemKey: null,
/** @type {{address: string, verified: boolean}|null} */ email: null,
/** @type {string|null} */ status: null,
};
/** @type {Map<string, any>} */
const sessionRows = new Map();
/** @type {Map<string, any>} */
const appPasswordRows = new Map();
/** @type {Array<{type: string, body: Object}>} */
const events = [];
/** @type {string[]} */
const crawled = [];
const sharedStorage = /** @type {any} */ ({
putSession: async (/** @type {any} */ s) =>
void sessionRows.set(s.jti, { ...s }),
getSession: async (/** @type {string} */ jti) =>
sessionRows.get(jti) || null,
listSessions: async () => [...sessionRows.values()],
deleteSession: async (/** @type {string} */ jti) => sessionRows.delete(jti),
putActor: async () => {},
listAppPasswords: async () => [...appPasswordRows.values()],
deleteAppPassword: async (
/** @type {string} */ _d,
/** @type {string} */ n,
) => appPasswordRows.delete(n),
putAppPassword: async (
/** @type {string} */ _d,
/** @type {string} */ name,
/** @type {string} */ hash,
/** @type {boolean} */ privileged,
/** @type {string} */ createdAt,
) => {
if (appPasswordRows.has(name)) return false;
appPasswordRows.set(name, { name, hash, privileged, createdAt });
return true;
},
});
// A deployment whose storage predates app passwords answers no lookup.
if (appPasswords) {
sharedStorage.getAppPasswordByHash = async (
/** @type {string} */ _d,
/** @type {string} */ hash,
) => [...appPasswordRows.values()].find((r) => r.hash === hash) || null;
}
const handlers = createXrpcServerHandlers({
actorStorage: /** @type {any} */ ({
getDid: async () => stored.did,
setDid: async (/** @type {string} */ d) => {
stored.did = d;
},
getHandle: async () => stored.handle,
setHandle: async (/** @type {string} */ h) => {
stored.handle = h;
},
setPrivateKey: async (/** @type {Uint8Array} */ k) => {
stored.privateKey = k;
},
getKemKey: async () => stored.kemKey ?? null,
setKemKey: async (/** @type {Uint8Array} */ k) => {
stored.kemKey = k;
},
setEmail: async (/** @type {any} */ e) => {
stored.email = e;
},
setAccountStatus: async (/** @type {string} */ s) => {
stored.status = s;
},
}),
sharedStorage,
sessions: createSessionRegistry(sharedStorage),
jwtSecret: SECRET,
hostname: HOSTNAME,
plcUrl: 'https://plc.example.com',
blobUploadLimit: 50 * 1024 * 1024,
password: noPassword ? undefined : PASSWORD,
verifyAccountPassword: async (candidate) =>
!noPassword && candidate === PASSWORD,
passwordConfigured: async () => !noPassword,
readOnly,
spacesEnabled,
verifier: /** @type {any} */ (verifier),
didResolver,
getDid: async () => stored.did,
getSigningKey: async () => null,
checkRpcScope: () => null,
generateSigningKey: async () => {
const keyPair = await generateKeyPair();
return {
stored: keyPair.privateKey,
didKey: publicKeyToDidKey(keyPair.publicKey),
signingKey: await importPrivateKey(keyPair.privateKey),
};
},
emailSessionFields: async () => ({}),
emitEvent: async (type, body) => {
events.push({ type, body });
},
notifyRelay: async (d) => {
crawled.push(d);
},
readOnlyError: () =>
Response.json(
{ error: 'AuthenticationRequired', message: 'This PDS is read-only' },
{ status: 401 },
),
setCachedIdentity: () => {},
});
return { handlers, stored, sessionRows, appPasswordRows, events, crawled };
}
/**
* @param {ReturnType<typeof createXrpcServerHandlers>} handlers
* @param {string} path
* @param {{body?: unknown, raw?: string, headers?: Record<string, string>, auth?: {did: string, scope: string}|null}} [options]
*/
async function call(handlers, path, { body, raw, headers, auth = null } = {}) {
const route = handlers.routes[path];
const url = new URL(`https://${HOSTNAME}${path}`);
/** @type {RequestInit} */
const init = { method: route.method || 'GET', headers };
if (raw !== undefined) init.body = raw;
else if (body !== undefined) init.body = JSON.stringify(body);
const handler =
/** @type {(request: Request, url: URL, auth: {did: string, scope: string}|null) => Promise<Response>} */ (
route.handler
);
return handler(new Request(url, init), url, auth);
}
const P = {
describe: '/xrpc/com.atproto.server.describeServer',
createSession: '/xrpc/com.atproto.server.createSession',
createAccount: '/xrpc/com.atproto.server.createAccount',
getSession: '/xrpc/com.atproto.server.getSession',
createAppPassword: '/xrpc/com.atproto.server.createAppPassword',
listAppPasswords: '/xrpc/com.atproto.server.listAppPasswords',
revokeAppPassword: '/xrpc/com.atproto.server.revokeAppPassword',
deleteSession: '/xrpc/com.atproto.server.deleteSession',
refreshSession: '/xrpc/com.atproto.server.refreshSession',
onboarding: '/account/onboarding/create',
};
const FULL = { did: DID, scope: 'com.atproto.access' };
const APP_PASS = { did: DID, scope: 'com.atproto.appPass' };
afterEach(() => {
vi.unstubAllGlobals();
});
describe('describeServer', () => {
it('names the did:web identity before an account is hosted', async () => {
const { handlers } = createServer();
const response = await call(handlers, P.describe);
const body = await response.json();
expect(body.did).toBe(`did:web:${HOSTNAME}`);
expect(body.availableUserDomains).toEqual([`.${HOSTNAME}`]);
});
it('reports the largest blob it accepts', async () => {
const { handlers } = createServer();
const response = await call(handlers, P.describe);
const body = await response.json();
expect(body.blobUploadLimit).toBe(50 * 1024 * 1024);
});
it('says it serves the spaces alpha only when spaces are on', async () => {
const off = await call(createServer().handlers, P.describe);
expect((await off.json()).spacesAlpha).toBeUndefined();
const on = await call(
createServer({ spacesEnabled: true }).handlers,
P.describe,
);
expect((await on.json()).spacesAlpha).toBe(true);
});
});
describe('createSession', () => {
it('refuses a login on a read-only server', async () => {
const { handlers } = createServer({ readOnly: true });
const response = await call(handlers, P.createSession, {
body: { identifier: DID, password: PASSWORD },
});
expect(response.status).toBe(401);
});
it('reports an uninitialized server', async () => {
const { handlers } = createServer();
const response = await call(handlers, P.createSession, {
body: { identifier: DID, password: PASSWORD },
});
expect(response.status).toBe(400);
expect((await response.json()).message).toBe('PDS not initialized');
});
it('refuses an identifier that is neither the DID nor the handle', async () => {
const { handlers } = createServer({ did: DID, handle: 'alice.test' });
const response = await call(handlers, P.createSession, {
body: { identifier: 'bob.test', password: PASSWORD },
});
expect(response.status).toBe(401);
expect((await response.json()).message).toBe('Invalid identifier');
});
it('refuses every password when none is configured', async () => {
const { handlers } = createServer({ did: DID, noPassword: true });
const response = await call(handlers, P.createSession, {
body: { identifier: DID, password: '' },
});
expect(response.status).toBe(401);
expect((await response.json()).message).toContain(
'Password login is not configured',
);
});
it('opens a session for the account password and records it', async () => {
const { handlers, sessionRows } = createServer({
did: DID,
handle: 'alice.test',
});
const response = await call(handlers, P.createSession, {
body: { identifier: 'alice.test', password: PASSWORD },
});
const body = await response.json();
expect(body.did).toBe(DID);
expect(body.handle).toBe('alice.test');
expect(body.accessJwt).toBeTruthy();
expect([...sessionRows.values()][0].label).toBe('Account password');
});
it('opens a restricted session for an app password', async () => {
const { handlers, sessionRows } = createServer({ did: DID });
const created = await call(handlers, P.createAppPassword, {
body: { name: 'bot' },
auth: FULL,
});
const { password } = await created.json();
const response = await call(handlers, P.createSession, {
body: { identifier: DID, password },
auth: null,
});
const body = await response.json();
expect(body.did).toBe(DID);
expect([...sessionRows.values()][0].label).toBe('App password: bot');
expect([...sessionRows.values()][0].scope).toBe('com.atproto.appPass');
});
it('refuses a wrong password', async () => {
const { handlers } = createServer({ did: DID });
const response = await call(handlers, P.createSession, {
body: { identifier: DID, password: 'wrong' },
});
expect(response.status).toBe(401);
expect((await response.json()).message).toBe('Invalid password');
});
it('refuses an app password when storage keeps none', async () => {
const { handlers } = createServer({ did: DID, appPasswords: false });
const response = await call(handlers, P.createSession, {
body: { identifier: DID, password: 'whatever' },
});
expect(response.status).toBe(401);
});
});
describe('createAccount', () => {
/** @param {Record<string, unknown>} claims */
function serviceToken(claims) {
const encode = (/** @type {Object} */ part) =>
btoa(JSON.stringify(part))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return `${encode({ alg: 'ES256' })}.${encode(claims)}.c2ln`;
}
/** @param {number} [offset] - Seconds from now the token expires */
const migrationClaims = (offset = 60) => ({
iss: DID,
aud: `did:web:${HOSTNAME}`,
lxm: 'com.atproto.server.createAccount',
exp: Math.floor(Date.now() / 1000) + offset,
});
it('refuses a migration on a read-only server', async () => {
const { handlers } = createServer({ readOnly: true });
const response = await call(handlers, P.createAccount, {
body: { did: DID, handle: 'alice.test' },
});
expect(response.status).toBe(401);
});
it('refuses a body that is not JSON', async () => {
const { handlers } = createServer();
const response = await call(handlers, P.createAccount, { raw: 'not json' });
expect(response.status).toBe(400);
expect((await response.json()).message).toBe('Invalid request body');
});
it('refuses a second account', async () => {
const { handlers } = createServer({ did: DID });
const response = await call(handlers, P.createAccount, {
body: { did: DID, handle: 'alice.test' },
});
expect(response.status).toBe(400);
expect((await response.json()).message).toBe(
'This server already hosts an account',
);
});
it('requires an existing DID', async () => {
const { handlers } = createServer();
const response = await call(handlers, P.createAccount, {
body: { handle: 'alice.test' },
});
expect((await response.json()).error).toBe('UnsupportedDomain');
});
it('requires a valid handle', async () => {
const { handlers } = createServer();
const response = await call(handlers, P.createAccount, {
body: { did: DID, handle: 'not a handle' },
});
expect((await response.json()).error).toBe('InvalidHandle');
});
it('adopts the DID and stores the email the migration carries', async () => {
const { handlers, stored } = createServer();
const response = await call(handlers, P.createAccount, {
body: {
did: DID,
handle: 'alice.test',
email: 'alice@example.com',
password: PASSWORD,
},
});
const body = await response.json();
expect(body.did).toBe(DID);
expect(stored.status).toBe('deactivated');
// The PLC operation the migration submits publishes it.
expect(stored.kemKey).toHaveLength(32);
expect(stored.email).toEqual({
address: 'alice@example.com',
verified: false,
});
});
it('refuses a caller with neither a token nor the password', async () => {
const { handlers } = createServer();
const response = await call(handlers, P.createAccount, {
body: { did: DID, handle: 'alice.test' },
});
expect(response.status).toBe(401);
expect((await response.json()).message).toContain('service token');
});
it('refuses a service token whose claims do not match', async () => {
const { handlers } = createServer();
const response = await call(handlers, P.createAccount, {
body: { did: DID, handle: 'alice.test' },
headers: {
authorization: `Bearer ${serviceToken({ ...migrationClaims(), aud: 'did:web:elsewhere.test' })}`,
},
});
expect(response.status).toBe(401);
expect((await response.json()).message).toContain('Invalid service token');
});
it('refuses an expired service token', async () => {
const { handlers } = createServer();
const response = await call(handlers, P.createAccount, {
body: { did: DID, handle: 'alice.test' },
headers: {
authorization: `Bearer ${serviceToken(migrationClaims(-60))}`,
},
});
expect(response.status).toBe(401);
});
/**
* The multibase of a real P-256 did:key. The handler reads the curve to
* check it against the token header's `alg`, which the stub verifier below
* never sees.
*/
async function atprotoMultibase() {
const { publicKey } = await generateKeyPair();
return publicKeyToDidKey(publicKey).replace('did:key:', '');
}
it('accepts a service token whose signature verifies', async () => {
const multibase = await atprotoMultibase();
const { handlers } = createServer({
verifier: { verify: async () => true },
didResolver: async () => ({
verificationMethod: [
{ id: `${DID}#atproto`, publicKeyMultibase: multibase },
],
}),
});
const response = await call(handlers, P.createAccount, {
body: { did: DID, handle: 'alice.test' },
headers: { authorization: `Bearer ${serviceToken(migrationClaims())}` },
});
expect((await response.json()).did).toBe(DID);
});
it('refuses a service token whose signature does not verify', async () => {
const multibase = await atprotoMultibase();
const { handlers } = createServer({
verifier: { verify: async () => false },
didResolver: async () => ({
verificationMethod: [
{ id: `${DID}#atproto`, publicKeyMultibase: multibase },
],
}),
});
const response = await call(handlers, P.createAccount, {
body: { did: DID, handle: 'alice.test' },
headers: { authorization: `Bearer ${serviceToken(migrationClaims())}` },
});
expect(response.status).toBe(401);
});
it('refuses a token whose issuer publishes no atproto key', async () => {
const { handlers } = createServer({
verifier: { verify: async () => true },
didResolver: async () => ({ verificationMethod: [] }),
});
const response = await call(handlers, P.createAccount, {
body: { did: DID, handle: 'alice.test' },
headers: { authorization: `Bearer ${serviceToken(migrationClaims())}` },
});
expect(response.status).toBe(401);
});
it('refuses a token whose issuer cannot be resolved', async () => {
const { handlers } = createServer({
verifier: { verify: async () => true },
didResolver: async () => {
throw new Error('unreachable directory');
},
});
const response = await call(handlers, P.createAccount, {
body: { did: DID, handle: 'alice.test' },
headers: { authorization: `Bearer ${serviceToken(migrationClaims())}` },
});
expect(response.status).toBe(401);
});
});
describe('onboarding', () => {
const signup = { password: PASSWORD, handle: 'alice.example.com' };
it('refuses onboarding on a read-only server', async () => {
const { handlers } = createServer({ readOnly: true });
const response = await call(handlers, P.onboarding, { body: signup });
expect(response.status).toBe(401);
});
it('refuses a cross-origin post', async () => {
const { handlers } = createServer();
const response = await call(handlers, P.onboarding, {
body: signup,
headers: { origin: 'https://attacker.test' },
});
expect(response.status).toBe(403);
});
it('refuses a body that is not JSON', async () => {
const { handlers } = createServer();
const response = await call(handlers, P.onboarding, { raw: '{' });
expect(response.status).toBe(400);
});
it('refuses to claim a server that already hosts an account', async () => {
const { handlers } = createServer({ did: DID });
const response = await call(handlers, P.onboarding, { body: signup });
expect(response.status).toBe(400);
});
it('refuses when no operator password is configured', async () => {
const { handlers } = createServer({ noPassword: true });
const response = await call(handlers, P.onboarding, { body: signup });
expect(response.status).toBe(401);
expect((await response.json()).message).toContain('PDS_PASSWORD');
});
it('refuses a wrong operator password', async () => {
const { handlers } = createServer();
const response = await call(handlers, P.onboarding, {
body: { ...signup, password: 'wrong' },
});
expect(response.status).toBe(401);
expect((await response.json()).message).toBe('Invalid server password');
});
it('refuses a handle that is not a domain', async () => {
const { handlers } = createServer();
const response = await call(handlers, P.onboarding, {
body: { ...signup, handle: 'alice' },
});
expect((await response.json()).error).toBe('InvalidHandle');
});
it('reports a PLC directory it cannot reach', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => {
throw new Error('connect ECONNREFUSED');
}),
);
const { handlers, stored } = createServer();
const response = await call(handlers, P.onboarding, { body: signup });
expect(response.status).toBe(502);
expect((await response.json()).message).toContain('connect ECONNREFUSED');
// Nothing local was written, so the operator can retry.
expect(stored.did).toBeNull();
});
it('reports a PLC directory that rejects the identity', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('handle taken', { status: 400 })),
);
const { handlers, stored } = createServer();
const response = await call(handlers, P.onboarding, { body: signup });
expect(response.status).toBe(502);
expect((await response.json()).message).toContain('handle taken');
expect(stored.did).toBeNull();
});
it('registers the identity and opens an account cookie session', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('', { status: 200 })),
);
const { handlers, stored, events, crawled } = createServer();
const response = await call(handlers, P.onboarding, {
body: { ...signup, email: 'alice@example.com' },
});
const body = await response.json();
expect(body.ok).toBe(true);
expect(body.did).toMatch(/^did:plc:[a-z2-7]{24}$/);
expect(stored.handle).toBe('alice.example.com');
expect(stored.email?.address).toBe('alice@example.com');
expect(response.headers.get('Set-Cookie')).toContain('pdsjs_account=');
expect(events.map((e) => e.type)).toEqual(['identity', 'account']);
expect(crawled).toEqual([body.did]);
});
it('defaults the handle to the server hostname', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('', { status: 200 })),
);
const { handlers, stored } = createServer();
await call(handlers, P.onboarding, { body: { password: PASSWORD } });
expect(stored.handle).toBe(HOSTNAME);
});
it('publishes the KEM key it stores in the genesis operation', async () => {
const fetchMock = vi.fn(async () => new Response('', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const { handlers, stored } = createServer();
await call(handlers, P.onboarding, { body: signup });
const [, init] = /** @type {[string, RequestInit]} */ (
/** @type {unknown} */ (fetchMock.mock.calls[0])
);
const operation = JSON.parse(/** @type {string} */ (init.body));
expect(stored.kemKey).toHaveLength(32);
expect(operation.verificationMethods.atproto_kem).toBe(
await kemDidKey(/** @type {Uint8Array} */ (stored.kemKey)),
);
});
});
describe('getSession', () => {
it('requires authentication', async () => {
const { handlers } = createServer({ did: DID });
const response = await call(handlers, P.getSession);
expect(response.status).toBe(401);
});
it('answers with the DID and handle', async () => {
const { handlers } = createServer({ did: DID, handle: 'alice.test' });
const response = await call(handlers, P.getSession, { auth: FULL });
expect(await response.json()).toEqual({ did: DID, handle: 'alice.test' });
});
});
describe('app passwords', () => {
it('refuses creation on a read-only server', async () => {
const { handlers } = createServer({ readOnly: true });
const response = await call(handlers, P.createAppPassword, {
body: { name: 'bot' },
auth: FULL,
});
expect(response.status).toBe(401);
});
it('requires authentication to create', async () => {
const { handlers } = createServer();
const response = await call(handlers, P.createAppPassword, {
body: { name: 'bot' },
});
expect(response.status).toBe(401);
});
it('refuses an app-password session managing app passwords', async () => {
const { handlers } = createServer();
const response = await call(handlers, P.createAppPassword, {
body: { name: 'bot' },
auth: APP_PASS,
});
expect(response.status).toBe(403);
});
it('requires a name', async () => {
const { handlers } = createServer();
const response = await call(handlers, P.createAppPassword, {
body: {},
auth: FULL,
});
expect(response.status).toBe(400);
});
it('refuses a name already taken', async () => {
const { handlers } = createServer();
await call(handlers, P.createAppPassword, {
body: { name: 'bot' },
auth: FULL,
});
const response = await call(handlers, P.createAppPassword, {
body: { name: 'bot' },
auth: FULL,
});
expect(response.status).toBe(400);
expect((await response.json()).message).toContain('already exists');
});
it('lists them for a full session only', async () => {
const { handlers } = createServer();
await call(handlers, P.createAppPassword, {
body: { name: 'bot', privileged: true },
auth: FULL,
});
expect((await call(handlers, P.listAppPasswords)).status).toBe(401);
expect(
(await call(handlers, P.listAppPasswords, { auth: APP_PASS })).status,
).toBe(403);
const response = await call(handlers, P.listAppPasswords, { auth: FULL });
const { passwords } = await response.json();
expect(passwords[0].name).toBe('bot');
expect(passwords[0].privileged).toBe(true);
});
it('refuses revocation on a read-only server', async () => {
const { handlers } = createServer({ readOnly: true });
const response = await call(handlers, P.revokeAppPassword, {
body: { name: 'bot' },
auth: FULL,
});
expect(response.status).toBe(401);
});
it('requires authentication and a full session to revoke', async () => {
const { handlers } = createServer();
expect(
(await call(handlers, P.revokeAppPassword, { body: { name: 'bot' } }))
.status,
).toBe(401);
expect(
(
await call(handlers, P.revokeAppPassword, {
body: { name: 'bot' },
auth: APP_PASS,
})
).status,
).toBe(403);
});
it('requires a name to revoke', async () => {
const { handlers } = createServer();
const response = await call(handlers, P.revokeAppPassword, {
body: {},
auth: FULL,
});
expect(response.status).toBe(400);
});
it('reports a name it does not know', async () => {
const { handlers } = createServer();
const response = await call(handlers, P.revokeAppPassword, {
body: { name: 'ghost' },
auth: FULL,
});
expect(response.status).toBe(400);
expect((await response.json()).message).toContain('No app password named');
});
it('ends the logins the credential opened', async () => {
const { handlers, sessionRows } = createServer({ did: DID });
const created = await call(handlers, P.createAppPassword, {
body: { name: 'bot' },
auth: FULL,
});
const { password } = await created.json();
await call(handlers, P.createSession, {
body: { identifier: DID, password },
});
expect(sessionRows.size).toBe(1);
const response = await call(handlers, P.revokeAppPassword, {
body: { name: 'bot' },
auth: FULL,
});
expect(response.status).toBe(200);
expect(sessionRows.size).toBe(0);
});
});
describe('deleteSession', () => {
it('requires a refresh token', async () => {
const { handlers } = createServer();
const response = await call(handlers, P.deleteSession);
expect(response.status).toBe(401);
expect((await response.json()).message).toBe('Refresh token required');
});
it('refuses a token it cannot verify', async () => {
const { handlers } = createServer();
const response = await call(handlers, P.deleteSession, {
headers: { authorization: 'Bearer not.a.token' },
});
expect(response.status).toBe(401);
expect((await response.json()).message).toBe('Invalid refresh token');
});
it('ends the session its token names', async () => {
const { handlers, sessionRows } = createServer({ did: DID });
const opened = await call(handlers, P.createSession, {
body: { identifier: DID, password: PASSWORD },
});
const { refreshJwt } = await opened.json();
expect(sessionRows.size).toBe(1);
const response = await call(handlers, P.deleteSession, {
headers: { authorization: `Bearer ${refreshJwt}` },
});
expect(response.status).toBe(200);
expect(sessionRows.size).toBe(0);
});
});
describe('refreshSession', () => {
it('refuses on a read-only server', async () => {
const { handlers } = createServer({ readOnly: true });
const response = await call(handlers, P.refreshSession);
expect(response.status).toBe(401);
});
it('requires an authorization header', async () => {
const { handlers } = createServer();
const response = await call(handlers, P.refreshSession);
expect(response.status).toBe(401);
expect((await response.json()).message).toBe('Refresh token required');
});
it('requires a Bearer scheme', async () => {
const { handlers } = createServer();
const response = await call(handlers, P.refreshSession, {
headers: { authorization: 'Basic abc' },
});
expect(response.status).toBe(401);
expect((await response.json()).message).toBe(
'Invalid authorization header',
);
});
it('names an access token used in place of a refresh token', async () => {
const { handlers } = createServer();
const accessJwt = await createAccessJwt(DID, SECRET, 3600);
const response = await call(handlers, P.refreshSession, {
headers: { authorization: `Bearer ${accessJwt}` },
});
expect(response.status).toBe(400);
expect((await response.json()).message).toContain('Refresh token required');
});
it('refuses a token signed with another secret', async () => {
const { handlers } = createServer();
const foreign = await createRefreshJwt(DID, 'other-secret', 3600);
const response = await call(handlers, P.refreshSession, {
headers: { authorization: `Bearer ${foreign}` },
});
expect(response.status).toBe(401);
});
it('refuses a token whose session the registry does not hold', async () => {
const { handlers } = createServer({ did: DID });
const orphan = await createRefreshJwt(DID, SECRET, 3600);
const response = await call(handlers, P.refreshSession, {
headers: { authorization: `Bearer ${orphan}` },
});
expect(response.status).toBe(401);
expect((await response.json()).message).toBe('Session has been revoked');
});
});