chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import { base64UrlEncode } from '@pdsjs/core/crypto';
import {
getLoopbackClientMetadata,
isLoopbackClient,
isValidDpopNonce,
mintDpopNonce,
validateClientMetadata,
} from '@pdsjs/core/oauth';
import { PersonalDataServer } from '@pdsjs/core/pds';
import { describe, expect, test } from 'vitest';
describe('Client Metadata', () => {
test('isLoopbackClient detects localhost', () => {
expect(isLoopbackClient('http://localhost:8080')).toBe(true);
expect(isLoopbackClient('http://127.0.0.1:3000')).toBe(true);
expect(isLoopbackClient('https://example.com')).toBe(false);
});
test('getLoopbackClientMetadata returns permissive defaults', () => {
const metadata = getLoopbackClientMetadata('http://localhost:8080');
expect(metadata.client_id).toBe('http://localhost:8080');
expect(metadata.grant_types.includes('authorization_code')).toBe(true);
expect(metadata.dpop_bound_access_tokens).toBe(true);
});
test('validateClientMetadata rejects mismatched client_id', () => {
const metadata = {
client_id: 'https://other.com/metadata.json',
redirect_uris: ['https://example.com/callback'],
grant_types: ['authorization_code'],
response_types: ['code'],
};
expect(() =>
validateClientMetadata(metadata, 'https://example.com/metadata.json'),
).toThrow(/client_id mismatch/);
});
});
describe('DPoP nonces', () => {
const SECRET = 'test-secret';
const WINDOW_MS = 5 * 60 * 1000;
test('nonce validates within its own window', async () => {
const now = Date.now();
const nonce = await mintDpopNonce(SECRET, now);
expect(typeof nonce).toBe('string');
expect(nonce.length).toBeGreaterThan(10);
expect(await isValidDpopNonce(SECRET, nonce, now)).toBe(true);
});
test('nonce from previous window still validates', async () => {
const now = Date.now();
const nonce = await mintDpopNonce(SECRET, now - WINDOW_MS);
expect(await isValidDpopNonce(SECRET, nonce, now)).toBe(true);
});
test('nonce two windows old is rejected', async () => {
const now = Date.now();
const nonce = await mintDpopNonce(SECRET, now - 2 * WINDOW_MS);
expect(await isValidDpopNonce(SECRET, nonce, now)).toBe(false);
});
test('garbage nonces are rejected', async () => {
expect(await isValidDpopNonce(SECRET, 'not-a-nonce', Date.now())).toBe(
false,
);
expect(await isValidDpopNonce(SECRET, '', Date.now())).toBe(false);
});
test('nonces are secret-bound', async () => {
const now = Date.now();
const nonce = await mintDpopNonce('other-secret', now);
expect(await isValidDpopNonce(SECRET, nonce, now)).toBe(false);
});
});
// The resource-server side of OAuth: keeping a client's DPoP nonce fresh on
// ordinary traffic, and telling it to refresh (not re-login) when its access
// token expires. Mirrors the reference PDS resource verifier.
const JWT_SECRET = 'test-secret';
const XRPC_URL = 'https://pds.example/xrpc/com.atproto.repo.getRecord';
/** The smallest server exposing the resource-auth helpers. */
function makeServer() {
return new PersonalDataServer(/** @type {any} */ ({ jwtSecret: JWT_SECRET }));
}
/**
* A fake DPoP access token whose payload decodes to `payload`. The header and
* signature are placeholders: verifyDpopToken checks token expiry (and the
* presence of a DPoP proof) before it ever verifies the signature, so these
* tests never need the account signing key.
* @param {Record<string, unknown>} payload
*/
function fakeAccessToken(payload) {
const b64 = (/** @type {unknown} */ obj) =>
base64UrlEncode(new TextEncoder().encode(JSON.stringify(obj)));
return `${b64({ typ: 'at+jwt', alg: 'ES256' })}.${b64(payload)}.sig`;
}
describe('resource DPoP-Nonce emission', () => {
test('adds a fresh, valid nonce to responses for DPoP requests', async () => {
const pds = makeServer();
const request = new Request(XRPC_URL, {
headers: { authorization: 'DPoP sometoken', DPoP: 'proof' },
});
const decorated = await pds.withDpopNonce(
request,
Response.json({ ok: true }),
);
const nonce = decorated.headers.get('DPoP-Nonce');
expect(nonce).toBeTruthy();
// The nonce the client caches must validate on its next token refresh.
expect(await isValidDpopNonce(JWT_SECRET, nonce || '')).toBe(true);
expect(decorated.status).toBe(200);
});
test('leaves non-DPoP (Bearer / anonymous) responses untouched', async () => {
const pds = makeServer();
const request = new Request(XRPC_URL, {
headers: { authorization: 'Bearer legacy' },
});
const decorated = await pds.withDpopNonce(
request,
Response.json({ ok: true }),
);
expect(decorated.headers.get('DPoP-Nonce')).toBe(null);
});
test('does not overwrite a nonce an OAuth endpoint already set', async () => {
const pds = makeServer();
const request = new Request('https://pds.example/oauth/token', {
headers: { authorization: 'DPoP sometoken' },
});
const decorated = await pds.withDpopNonce(
request,
Response.json({ ok: true }, { headers: { 'DPoP-Nonce': 'preset' } }),
);
expect(decorated.headers.get('DPoP-Nonce')).toBe('preset');
});
});
describe('resource DPoP auth challenge', () => {
test('expired access token yields an invalid_token challenge', async () => {
const pds = makeServer();
const token = fakeAccessToken({
sub: 'did:plc:test',
exp: Math.floor(Date.now() / 1000) - 100,
});
const request = new Request(XRPC_URL, {
headers: { authorization: `DPoP ${token}`, DPoP: 'proof' },
});
const { auth, challenge, tokenError } =
await pds.authenticateResource(request);
expect(auth).toBe(null);
// invalid_token is the signal a spec-compliant client refreshes on, rather
// than treating the 401 as a hard logout.
expect(challenge).toContain('error="invalid_token"');
expect(challenge).toContain('Access token expired');
// The response body carries the same `invalid_token` code, so clients that
// branch on the body's `error` rather than the WWW-Authenticate header
// still know to refresh. Mirrors the reference PDS.
expect(tokenError).toBe('invalid_token');
});
test('missing DPoP proof header yields a challenge, no auth', async () => {
const pds = makeServer();
const token = fakeAccessToken({ sub: 'did:plc:test' });
const request = new Request(XRPC_URL, {
headers: { authorization: `DPoP ${token}` },
});
const { auth, challenge, tokenError } =
await pds.authenticateResource(request);
expect(auth).toBe(null);
expect(challenge).toContain('error="invalid_token"');
expect(tokenError).toBe('invalid_token');
});
test('no Authorization header yields no auth, no challenge, no tokenError', async () => {
const pds = makeServer();
const { auth, challenge, tokenError } = await pds.authenticateResource(
new Request(XRPC_URL),
);
expect(auth).toBe(null);
expect(challenge).toBe(null);
expect(tokenError).toBe(undefined);
});
});