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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
276 lines · 9.0 KB Raw History
// Confidential OAuth clients (token_endpoint_auth_method: private_key_jwt)
// authenticate at PAR and the token endpoint with a client_assertion JWT signed
// by a key in their published JWKS. These tests pin the verification
// (PersonalDataServer#authenticateClient) against RFC 7523: a valid assertion
// passes, public clients pass untouched, and every tampered/malformed/replayed
// assertion is rejected as invalid_client.

import { describe, expect, it } from 'vitest';
import { getAuthServerMetadata } from '../packages/core/src/oauth.js';
import { PersonalDataServer } from '../packages/core/src/pds.js';

const HOSTNAME = 'pds.example.com';
const ISSUER = `https://${HOSTNAME}`;
const CLIENT_ID = 'https://client.example.com/client-metadata.json';
const ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';

/** @param {ArrayBuffer|Uint8Array} bytes */
function b64url(bytes) {
  let bin = '';
  for (const b of new Uint8Array(bytes)) bin += String.fromCharCode(b);
  return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/** @param {object} obj */
const b64urlJson = (obj) =>
  b64url(new TextEncoder().encode(JSON.stringify(obj)));

/** A fresh ES256 client key plus its public JWK (with a kid). */
async function makeClientKey(kid = 'test-key-1') {
  const kp = await crypto.subtle.generateKey(
    { name: 'ECDSA', namedCurve: 'P-256' },
    true,
    ['sign', 'verify'],
  );
  const jwk = /** @type {any} */ (
    await crypto.subtle.exportKey('jwk', kp.publicKey)
  );
  jwk.kid = kid;
  delete jwk.key_ops;
  delete jwk.ext;
  return { privateKey: kp.privateKey, jwk };
}

/**
 * Sign a client_assertion JWT (ES256, raw r||s — the JWS format).
 * @param {CryptoKey} privateKey
 * @param {string} kid
 * @param {any} claims
 */
async function signAssertion(privateKey, kid, claims) {
  const signingInput = `${b64urlJson({ alg: 'ES256', typ: 'JWT', kid })}.${b64urlJson(claims)}`;
  const sig = await crypto.subtle.sign(
    { name: 'ECDSA', hash: 'SHA-256' },
    privateKey,
    new TextEncoder().encode(signingInput),
  );
  return `${signingInput}.${b64url(sig)}`;
}

function createServer() {
  /** @type {Set<string>} */
  const jtis = new Set();
  return new PersonalDataServer({
    actorStorage: /** @type {any} */ ({}),
    sharedStorage: /** @type {any} */ ({
      checkAndStoreDpopJti: async (/** @type {string} */ jti) => {
        if (jtis.has(jti)) return false;
        jtis.add(jti);
        return true;
      },
    }),
    blobs: /** @type {any} */ ({}),
    jwtSecret: 'test-secret',
    hostname: HOSTNAME,
    plcUrl: 'https://plc.test',
  });
}

const confidentialClient = (/** @type {any} */ jwk) => ({
  client_id: CLIENT_ID,
  token_endpoint_auth_method: 'private_key_jwt',
  jwks: { keys: [jwk] },
});

function validClaims(overrides = {}) {
  const now = Math.floor(Date.now() / 1000);
  return {
    iss: CLIENT_ID,
    sub: CLIENT_ID,
    aud: ISSUER,
    jti: crypto.randomUUID(),
    iat: now,
    exp: now + 60,
    ...overrides,
  };
}

/**
 * Run authenticateClient with an assertion and return its Response|null.
 * @param {PersonalDataServer} pds
 * @param {any} jwk
 * @param {string} assertion
 */
async function auth(pds, jwk, assertion) {
  return pds._oauth.authenticateClient(
    confidentialClient(jwk),
    { client_assertion: assertion, client_assertion_type: ASSERTION_TYPE },
    ISSUER,
  );
}

/** @param {Response|null} res */
async function expectRejected(res) {
  if (!res) throw new Error('expected a rejection Response, got null');
  expect(res).not.toBeNull();
  expect(res.status).toBe(400);
  const body = await res.json();
  expect(body.error).toBe('invalid_client');
}

describe('OAuth confidential client authentication', () => {
  it('advertises private_key_jwt and ES256 in the AS metadata', () => {
    const md = /** @type {any} */ (getAuthServerMetadata(ISSUER));
    expect(md.token_endpoint_auth_methods_supported).toContain(
      'private_key_jwt',
    );
    expect(md.token_endpoint_auth_methods_supported).toContain('none');
    expect(md.token_endpoint_auth_signing_alg_values_supported).toContain(
      'ES256',
    );
  });

  it('accepts a valid client_assertion', async () => {
    const { privateKey, jwk } = await makeClientKey();
    const assertion = await signAssertion(privateKey, jwk.kid, validClaims());
    expect(await auth(createServer(), jwk, assertion)).toBeNull();
  });

  it('resolves the key from jwks_uri when jwks is absent', async () => {
    const { privateKey, jwk } = await makeClientKey();
    const pds = createServer();
    const assertion = await signAssertion(privateKey, jwk.kid, validClaims());
    // Metadata with jwks_uri instead of inline jwks; stub the fetch.
    const orig = globalThis.fetch;
    globalThis.fetch = async () =>
      new Response(JSON.stringify({ keys: [jwk] }), { status: 200 });
    try {
      const res = await pds._oauth.authenticateClient(
        {
          client_id: CLIENT_ID,
          token_endpoint_auth_method: 'private_key_jwt',
          jwks_uri: 'https://client.example.com/jwks.json',
        },
        { client_assertion: assertion, client_assertion_type: ASSERTION_TYPE },
        ISSUER,
      );
      expect(res).toBeNull();
    } finally {
      globalThis.fetch = orig;
    }
  });

  it('passes a public client (none) through untouched', async () => {
    const res = await createServer()._oauth.authenticateClient(
      { client_id: CLIENT_ID, token_endpoint_auth_method: 'none' },
      {},
      ISSUER,
    );
    expect(res).toBeNull();
  });

  it('rejects a missing client_assertion', async () => {
    const res = await createServer()._oauth.authenticateClient(
      { client_id: CLIENT_ID, token_endpoint_auth_method: 'private_key_jwt' },
      {},
      ISSUER,
    );
    await expectRejected(res);
  });

  it('rejects a signature from the wrong key', async () => {
    const { jwk } = await makeClientKey();
    const attacker = await makeClientKey(jwk.kid); // same kid, different key
    const assertion = await signAssertion(
      attacker.privateKey,
      jwk.kid,
      validClaims(),
    );
    await expectRejected(await auth(createServer(), jwk, assertion));
  });

  it('rejects a wrong audience', async () => {
    const { privateKey, jwk } = await makeClientKey();
    const assertion = await signAssertion(
      privateKey,
      jwk.kid,
      validClaims({ aud: 'https://someone-else.example.com' }),
    );
    await expectRejected(await auth(createServer(), jwk, assertion));
  });

  it('rejects iss/sub that are not the client_id', async () => {
    const { privateKey, jwk } = await makeClientKey();
    const assertion = await signAssertion(
      privateKey,
      jwk.kid,
      validClaims({ iss: 'https://evil.example.com', sub: CLIENT_ID }),
    );
    await expectRejected(await auth(createServer(), jwk, assertion));
  });

  it('rejects an expired assertion', async () => {
    const { privateKey, jwk } = await makeClientKey();
    const now = Math.floor(Date.now() / 1000);
    const assertion = await signAssertion(
      privateKey,
      jwk.kid,
      validClaims({ iat: now - 120, exp: now - 60 }),
    );
    await expectRejected(await auth(createServer(), jwk, assertion));
  });

  it('rejects an assertion issued too long ago', async () => {
    const { privateKey, jwk } = await makeClientKey();
    const now = Math.floor(Date.now() / 1000);
    // No exp, but iat well beyond the max age.
    const assertion = await signAssertion(
      privateKey,
      jwk.kid,
      validClaims({ iat: now - 600, exp: undefined }),
    );
    await expectRejected(await auth(createServer(), jwk, assertion));
  });

  it('rejects a missing jti', async () => {
    const { privateKey, jwk } = await makeClientKey();
    const assertion = await signAssertion(
      privateKey,
      jwk.kid,
      validClaims({ jti: undefined }),
    );
    await expectRejected(await auth(createServer(), jwk, assertion));
  });

  it('rejects an unknown kid', async () => {
    const { privateKey, jwk } = await makeClientKey('real-kid');
    const assertion = await signAssertion(
      privateKey,
      'unknown-kid',
      validClaims(),
    );
    await expectRejected(await auth(createServer(), jwk, assertion));
  });

  it('rejects a non-ES256 alg', async () => {
    const { privateKey, jwk } = await makeClientKey();
    // Hand-forge a header claiming HS256 while still ES256-signing.
    const claims = validClaims();
    const signingInput = `${b64urlJson({ alg: 'HS256', typ: 'JWT', kid: jwk.kid })}.${b64urlJson(claims)}`;
    const sig = await crypto.subtle.sign(
      { name: 'ECDSA', hash: 'SHA-256' },
      privateKey,
      new TextEncoder().encode(signingInput),
    );
    await expectRejected(
      await auth(createServer(), jwk, `${signingInput}.${b64url(sig)}`),
    );
  });

  it('rejects a replayed jti', async () => {
    const { privateKey, jwk } = await makeClientKey();
    const pds = createServer();
    const assertion = await signAssertion(privateKey, jwk.kid, validClaims());
    expect(await auth(pds, jwk, assertion)).toBeNull();
    await expectRejected(await auth(pds, jwk, assertion));
  });
});