~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
189 lines · 6.9 KB Raw History
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);
  });
});