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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
1385 lines · 45.8 KB Raw History
// The OAuth authorization-server endpoints, driven through the full flow a
// client walks: PAR, the consent page, approval, the code and refresh grants,
// and revocation. A loopback client keeps client-metadata resolution local.
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
  base64UrlEncode,
  generateKeyPair,
  importPrivateKey,
} from '../packages/core/src/crypto.js';
import { createOAuthHandlers } from '../packages/core/src/handlers/oauth.js';
import { mintDpopNonce } from '../packages/core/src/oauth.js';
import { createPasskeyCeremony } from '../packages/core/src/passkey.js';

const DID = 'did:plc:oauthendpoints234567abcd';
const SECRET = 'test-secret';
const PASSWORD = 'account-password';
const ORIGIN = 'https://pds.example.com';
const CLIENT_ID = 'http://localhost';
const REDIRECT_URI = 'http://localhost/callback';

/**
 * @param {{
 *   readOnly?: boolean, hasAccountApp?: boolean, passkeys?: boolean,
 *   did?: string|null, signingKey?: boolean, spacesEnabled?: boolean,
 *   lexiconResolver?: import('../packages/core/src/ports.js').LexiconResolverPort,
 * }} [options]
 */
function createServer({
  readOnly = false,
  hasAccountApp = false,
  passkeys = false,
  did = DID,
  signingKey = true,
  spacesEnabled = false,
  lexiconResolver = undefined,
} = {}) {
  /** @type {Map<string, any>} */
  const oauthRequests = new Map();
  /** @type {Map<string, any>} */
  const oauthTokens = new Map();
  /** @type {Set<string>} */
  const jtis = new Set();
  /** @type {Object[]} */
  const bootstraps = [];

  const sharedStorage = /** @type {any} */ ({
    getOAuthRequest: async (/** @type {string} */ uri) =>
      oauthRequests.get(uri) || null,
    putOAuthRequest: async (
      /** @type {string} */ uri,
      /** @type {any} */ data,
    ) => void oauthRequests.set(uri, data),
    deleteOAuthRequest: async (/** @type {string} */ uri) =>
      void oauthRequests.delete(uri),
    getOAuthToken: async (/** @type {string} */ id) =>
      oauthTokens.get(id) || null,
    putOAuthToken: async (/** @type {string} */ id, /** @type {any} */ data) =>
      void oauthTokens.set(id, data),
    deleteOAuthToken: async (/** @type {string} */ id) =>
      void oauthTokens.delete(id),
    checkAndStoreDpopJti: async (/** @type {string} */ jti) => {
      if (jtis.has(jti)) return false;
      jtis.add(jti);
      return true;
    },
  });
  if (passkeys) sharedStorage.getPasskey = async () => null;

  /** @type {Promise<import('../packages/core/src/crypto.js').SigningKey>|null} */
  let keyPromise = null;
  const getSigningKey = () => {
    if (!signingKey) return Promise.resolve(null);
    keyPromise ??= generateKeyPair().then((kp) =>
      importPrivateKey(kp.privateKey),
    );
    return keyPromise;
  };

  const handlers = createOAuthHandlers({
    sharedStorage,
    passkeys: createPasskeyCeremony(sharedStorage, SECRET),
    jwtSecret: SECRET,
    verifyAccountPassword: async (candidate) => candidate === PASSWORD,
    passwordConfigured: async () => true,
    readOnly,
    spacesEnabled,
    hasAccountApp,
    lexiconResolver,
    getDid: async () => did,
    getSigningKey,
    getPublicKeyJwk: async () =>
      signingKey ? { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' } : null,
    readJsonBounded: (request) => request.json(),
    accountAppResponse: (bootstrap) => {
      bootstraps.push(/** @type {Object} */ (bootstrap));
      return Response.json({ mounted: true });
    },
    readAccountUiState: async () => ({ theme: 'dark' }),
    readOnlyError: () =>
      Response.json(
        { error: 'AuthenticationRequired', message: 'This PDS is read-only' },
        { status: 401 },
      ),
  });

  return { handlers, oauthRequests, oauthTokens, bootstraps };
}

/**
 * A DPoP key pair whose proofs all present the same public key, so the jkt
 * PAR captures matches the one the token endpoint checks.
 */
async function dpopKeys() {
  const keys = await crypto.subtle.generateKey(
    { name: 'ECDSA', namedCurve: 'P-256' },
    true,
    ['sign', 'verify'],
  );
  const jwk = await crypto.subtle.exportKey('jwk', keys.publicKey);
  return { keys, jwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y } };
}

/**
 * @param {Awaited<ReturnType<typeof dpopKeys>>} client
 * @param {string} htu
 */
async function dpopProof(client, htu) {
  const header = { typ: 'dpop+jwt', alg: 'ES256', jwk: client.jwk };
  const payload = {
    jti: crypto.randomUUID(),
    htm: 'POST',
    htu,
    iat: Math.floor(Date.now() / 1000),
    nonce: await mintDpopNonce(SECRET),
  };
  const enc = (/** @type {object} */ o) =>
    base64UrlEncode(new TextEncoder().encode(JSON.stringify(o)));
  const input = `${enc(header)}.${enc(payload)}`;
  const sig = new Uint8Array(
    await crypto.subtle.sign(
      { name: 'ECDSA', hash: 'SHA-256' },
      client.keys.privateKey,
      new TextEncoder().encode(input),
    ),
  );
  return `${input}.${base64UrlEncode(sig)}`;
}

/** A verifier string and the S256 challenge PAR registers for it. */
async function pkcePair() {
  const verifier = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
  const digest = await crypto.subtle.digest(
    'SHA-256',
    new TextEncoder().encode(verifier),
  );
  return { verifier, challenge: base64UrlEncode(new Uint8Array(digest)) };
}

/**
 * @param {ReturnType<typeof createServer>['handlers']} handlers
 * @param {string} path
 * @param {{method?: string, headers?: Record<string, string>, body?: string, search?: Record<string, string>}} [options]
 */
async function call(handlers, path, { method, headers, body, search } = {}) {
  const url = new URL(`${ORIGIN}${path}`);
  for (const [k, v] of Object.entries(search || {})) {
    url.searchParams.set(k, v);
  }
  const route = handlers.routes[path];
  const handler =
    /** @type {(request: Request, url: URL) => Promise<Response>|Response} */ (
      route.handler
    );
  return handler(
    new Request(url, {
      method: method || route.method || 'GET',
      headers,
      body,
    }),
    url,
  );
}

/**
 * Push an authorization request and return its request_uri.
 * @param {ReturnType<typeof createServer>['handlers']} handlers
 * @param {Awaited<ReturnType<typeof dpopKeys>>} client
 * @param {string} challenge
 * @param {Record<string, string>} [extra]
 */
async function par(handlers, client, challenge, extra = {}) {
  const response = await call(handlers, '/oauth/par', {
    headers: {
      'content-type': 'application/x-www-form-urlencoded',
      DPoP: await dpopProof(client, `${ORIGIN}/oauth/par`),
    },
    body: new URLSearchParams({
      client_id: CLIENT_ID,
      redirect_uri: REDIRECT_URI,
      response_type: 'code',
      code_challenge: challenge,
      code_challenge_method: 'S256',
      scope: 'atproto',
      state: 'st4te',
      ...extra,
    }).toString(),
  });
  expect(response.status).toBe(201);
  return (await response.json()).request_uri;
}

/**
 * Approve a pushed request with the account password; returns the code from
 * the redirect.
 * @param {ReturnType<typeof createServer>['handlers']} handlers
 * @param {string} requestUri
 */
async function approve(handlers, requestUri) {
  const response = await call(handlers, '/oauth/authorize', {
    method: 'POST',
    headers: { 'content-type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      request_uri: requestUri,
      client_id: CLIENT_ID,
      password: PASSWORD,
      action: 'approve',
    }).toString(),
  });
  expect(response.status).toBe(302);
  const redirect = new URL(response.headers.get('Location') || '');
  expect(redirect.origin).toBe(new URL(REDIRECT_URI).origin);
  expect(redirect.searchParams.get('iss')).toBe(ORIGIN);
  expect(redirect.searchParams.get('state')).toBe('st4te');
  return /** @type {string} */ (redirect.searchParams.get('code'));
}

/**
 * Redeem a code for tokens.
 * @param {ReturnType<typeof createServer>['handlers']} handlers
 * @param {Awaited<ReturnType<typeof dpopKeys>>} client
 * @param {Record<string, string>} params
 */
async function token(handlers, client, params) {
  return call(handlers, '/oauth/token', {
    headers: {
      'content-type': 'application/x-www-form-urlencoded',
      DPoP: await dpopProof(client, `${ORIGIN}/oauth/token`),
    },
    body: new URLSearchParams(params).toString(),
  });
}

describe('server metadata', () => {
  it('names the issuer in the authorization-server document', async () => {
    const { handlers } = createServer();
    const response = await call(
      handlers,
      '/.well-known/oauth-authorization-server',
    );
    const body = await response.json();

    expect(body.issuer).toBe(ORIGIN);
    expect(body.pushed_authorization_request_endpoint).toBe(
      `${ORIGIN}/oauth/par`,
    );
  });

  it('names the resource in the protected-resource document', async () => {
    const { handlers } = createServer();
    const response = await call(
      handlers,
      '/.well-known/oauth-protected-resource',
    );
    const body = await response.json();

    expect(body.resource).toBe(ORIGIN);
    expect(body.authorization_servers).toEqual([ORIGIN]);
  });

  it('serves the signing key as a JWKS', async () => {
    const { handlers } = createServer();
    const { keys } = await (await call(handlers, '/oauth/jwks')).json();

    expect(keys[0].kid).toBe('pds-oauth-key');
    expect(keys[0].alg).toBe('ES256');
  });

  it('reports a server with no signing key', async () => {
    const { handlers } = createServer({ signingKey: false });
    const response = await call(handlers, '/oauth/jwks');

    expect(response.status).toBe(500);
  });
});

describe('the authorization flow', () => {
  it('walks PAR, consent, approval, and the code grant', async () => {
    const { handlers, oauthRequests, oauthTokens } = createServer();
    const client = await dpopKeys();
    const { verifier, challenge } = await pkcePair();

    const requestUri = await par(handlers, client, challenge);
    expect(requestUri).toMatch(/^urn:ietf:params:oauth:request_uri:/);

    // The consent page names the client.
    const consent = await call(handlers, '/oauth/authorize', {
      search: { request_uri: requestUri, client_id: CLIENT_ID },
    });
    expect(consent.status).toBe(200);
    expect(await consent.text()).toContain('localhost');

    const code = await approve(handlers, requestUri);
    expect(code).toBeTruthy();

    const response = await token(handlers, client, {
      grant_type: 'authorization_code',
      code,
      redirect_uri: REDIRECT_URI,
      client_id: CLIENT_ID,
      code_verifier: verifier,
    });
    expect(response.status).toBe(200);
    const body = await response.json();
    expect(body.token_type).toBe('DPoP');
    expect(body.sub).toBe(DID);
    expect(body.access_token.split('.')).toHaveLength(3);
    expect(oauthTokens.get(body.refresh_token).did).toBe(DID);
    // The code is one-time use.
    expect(oauthRequests.has(`code:${code}`)).toBe(false);
  });

  it('sends a denial back to the client as access_denied', async () => {
    const { handlers } = createServer();
    const client = await dpopKeys();
    const { challenge } = await pkcePair();
    const requestUri = await par(handlers, client, challenge);

    const response = await call(handlers, '/oauth/authorize', {
      method: 'POST',
      headers: { 'content-type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        request_uri: requestUri,
        client_id: CLIENT_ID,
        action: 'deny',
      }).toString(),
    });

    expect(response.status).toBe(302);
    const redirect = new URL(response.headers.get('Location') || '');
    expect(redirect.searchParams.get('error')).toBe('access_denied');
    expect(redirect.searchParams.get('state')).toBe('st4te');
  });

  it('re-renders consent when the password is wrong', async () => {
    const { handlers } = createServer();
    const client = await dpopKeys();
    const { challenge } = await pkcePair();
    const requestUri = await par(handlers, client, challenge);

    const response = await call(handlers, '/oauth/authorize', {
      method: 'POST',
      headers: { 'content-type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        request_uri: requestUri,
        client_id: CLIENT_ID,
        password: 'wrong',
        action: 'approve',
      }).toString(),
    });

    expect(response.status).toBe(200);
    expect(await response.text()).toContain('Invalid password');
  });

  it('answers an approval that asks for JSON with the redirect URL', async () => {
    const { handlers } = createServer();
    const client = await dpopKeys();
    const { challenge } = await pkcePair();
    const requestUri = await par(handlers, client, challenge);

    const response = await call(handlers, '/oauth/authorize', {
      method: 'POST',
      headers: {
        'content-type': 'application/x-www-form-urlencoded',
        accept: 'application/json',
      },
      body: new URLSearchParams({
        request_uri: requestUri,
        client_id: CLIENT_ID,
        password: PASSWORD,
        action: 'approve',
      }).toString(),
    });

    expect(response.status).toBe(200);
    expect(response.headers.get('Cache-Control')).toBe('no-store');
    const redirect = new URL((await response.json()).redirect);
    expect(redirect.origin).toBe(new URL(REDIRECT_URI).origin);
    expect(redirect.searchParams.get('code')).toBeTruthy();
    expect(redirect.searchParams.get('state')).toBe('st4te');
  });

  it('answers a denial that asks for JSON with the redirect URL', async () => {
    const { handlers } = createServer();
    const client = await dpopKeys();
    const { challenge } = await pkcePair();
    const requestUri = await par(handlers, client, challenge);

    const response = await call(handlers, '/oauth/authorize', {
      method: 'POST',
      headers: {
        'content-type': 'application/x-www-form-urlencoded',
        accept: 'application/json',
      },
      body: new URLSearchParams({
        request_uri: requestUri,
        client_id: CLIENT_ID,
        action: 'deny',
      }).toString(),
    });

    expect(response.status).toBe(200);
    const redirect = new URL((await response.json()).redirect);
    expect(redirect.searchParams.get('error')).toBe('access_denied');
  });

  it('answers a wrong password with JSON rather than the consent page', async () => {
    const { handlers } = createServer();
    const client = await dpopKeys();
    const { challenge } = await pkcePair();
    const requestUri = await par(handlers, client, challenge);

    const response = await call(handlers, '/oauth/authorize', {
      method: 'POST',
      headers: {
        'content-type': 'application/x-www-form-urlencoded',
        accept: 'application/json',
      },
      body: new URLSearchParams({
        request_uri: requestUri,
        client_id: CLIENT_ID,
        password: 'wrong',
        action: 'approve',
      }).toString(),
    });

    expect(response.status).toBe(401);
    expect((await response.json()).error).toBe('Invalid password');
  });

  it('refuses an approval for a request it does not hold', async () => {
    const { handlers } = createServer();
    const response = await call(handlers, '/oauth/authorize', {
      method: 'POST',
      headers: { 'content-type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        request_uri: 'urn:ietf:params:oauth:request_uri:ghost',
        client_id: CLIENT_ID,
        password: PASSWORD,
        action: 'approve',
      }).toString(),
    });

    expect(response.status).toBe(400);
  });

  it('refuses a consent view whose client does not match the request', async () => {
    const { handlers } = createServer();
    const client = await dpopKeys();
    const { challenge } = await pkcePair();
    const requestUri = await par(handlers, client, challenge);

    const response = await call(handlers, '/oauth/authorize', {
      search: { request_uri: requestUri, client_id: 'http://other.example' },
    });

    expect(response.status).toBe(400);
    expect(await response.text()).toBe('Client mismatch');
  });

  it('refuses to show a consent page for a spent request', async () => {
    const { handlers } = createServer();
    const client = await dpopKeys();
    const { verifier, challenge } = await pkcePair();
    const requestUri = await par(handlers, client, challenge);
    const code = await approve(handlers, requestUri);
    // Redeeming deletes the request; re-approving must not find it.
    await token(handlers, client, {
      grant_type: 'authorization_code',
      code,
      redirect_uri: REDIRECT_URI,
      client_id: CLIENT_ID,
      code_verifier: verifier,
    });

    const response = await call(handlers, '/oauth/authorize', {
      search: { request_uri: requestUri, client_id: CLIENT_ID },
    });

    expect(response.status).toBe(400);
  });

  it('starts a passkey ceremony with the consent page when storage holds passkeys', async () => {
    const { handlers } = createServer({ passkeys: true });
    const client = await dpopKeys();
    const { challenge } = await pkcePair();
    const requestUri = await par(handlers, client, challenge);

    const response = await call(handlers, '/oauth/authorize', {
      search: { request_uri: requestUri, client_id: CLIENT_ID },
    });

    expect(response.headers.get('Set-Cookie')).toContain('pdsjs_webauthn=');
    expect(response.headers.get('Set-Cookie')).toContain('Path=/oauth');
  });

  it('renders consent through the mounted account app with parsed permissions', async () => {
    const { handlers, bootstraps } = createServer({ hasAccountApp: true });
    const client = await dpopKeys();
    const { challenge } = await pkcePair();
    const requestUri = await par(handlers, client, challenge, {
      scope: 'atproto repo:app.bsky.feed.post?action=create',
    });

    const response = await call(handlers, '/oauth/authorize', {
      search: { request_uri: requestUri, client_id: CLIENT_ID },
    });

    expect((await response.json()).mounted).toBe(true);
    const bootstrap = /** @type {any} */ (bootstraps[0]);
    expect(bootstrap.mode).toBe('consent');
    expect(bootstrap.theme).toBe('dark');
    expect(bootstrap.permissions.hasAtproto).toBe(true);
    expect(bootstrap.permissions.repo).toEqual([
      {
        collection: 'app.bsky.feed.post',
        create: true,
        update: false,
        delete: false,
      },
    ]);
  });

  it('joins an include scope with its permission set in the consent bootstrap', async () => {
    const SET_NSID = 'social.grain.authFullApp';
    const AUD = 'did:web:api.grain.social#grain';
    const set = {
      lexicon: 1,
      id: SET_NSID,
      defs: {
        main: {
          type: 'permission-set',
          title: 'Full grain access',
          'title:fr': 'Acces complet',
          detail: 'Manage galleries.',
          permissions: [
            {
              type: 'permission',
              resource: 'repo',
              collection: ['social.grain.gallery'],
            },
          ],
        },
      },
    };
    const { handlers, bootstraps } = createServer({
      hasAccountApp: true,
      lexiconResolver: {
        assertValid: async () => /** @type {'valid'} */ ('valid'),
        getLexicon: async (nsid) => (nsid === SET_NSID ? set : null),
      },
    });
    const client = await dpopKeys();
    const { challenge } = await pkcePair();
    const requestUri = await par(handlers, client, challenge, {
      scope: `atproto include:${SET_NSID}?aud=${AUD}`,
    });

    await call(handlers, '/oauth/authorize', {
      search: { request_uri: requestUri, client_id: CLIENT_ID },
    });

    const include = /** @type {any} */ (bootstraps[0]).permissions.includes[0];
    expect(include.nsid).toBe(SET_NSID);
    expect(include.title).toBe('Full grain access');
    expect(include.titleLangs).toEqual({ fr: 'Acces complet' });
    expect(include.repo).toEqual([
      {
        collection: 'social.grain.gallery',
        create: true,
        update: true,
        delete: true,
      },
    ]);
  });
});

describe('space scopes that name a type alone', () => {
  const FORUM = 'com.example.forum';
  const forum = {
    lexicon: 1,
    id: FORUM,
    defs: {
      main: {
        type: 'space',
        collections: ['com.example.post', 'com.example.reply'],
      },
    },
  };
  /** @type {import('../packages/core/src/ports.js').LexiconResolverPort} */
  const lexiconResolver = {
    assertValid: async () => /** @type {'valid'} */ ('valid'),
    getLexicon: async (nsid) => (nsid === FORUM ? forum : null),
  };

  it('grants the collections the type declares', async () => {
    const { handlers, oauthTokens } = createServer({
      spacesEnabled: true,
      lexiconResolver,
    });
    const client = await dpopKeys();
    const { verifier, challenge } = await pkcePair();
    const requestUri = await par(handlers, client, challenge, {
      scope: `atproto space:${FORUM}?authority=*&manage=create`,
    });
    const code = await approve(handlers, requestUri);

    const response = await token(handlers, client, {
      grant_type: 'authorization_code',
      code,
      redirect_uri: REDIRECT_URI,
      client_id: CLIENT_ID,
      code_verifier: verifier,
    });
    expect(response.status).toBe(200);
    const body = await response.json();
    const granted = `atproto space:${FORUM}?authority=*&manage=create&collection=com.example.post&collection=com.example.reply`;
    expect(body.scope).toBe(granted);
    expect(oauthTokens.get(body.refresh_token).scope).toBe(granted);
  });

  it('grants a type that does not resolve to a space as asked', async () => {
    const { handlers, oauthRequests } = createServer({
      spacesEnabled: true,
      lexiconResolver: {
        ...lexiconResolver,
        getLexicon: async (nsid) => {
          if (nsid === 'com.example.broken') throw new Error('unreachable');
          return nsid === FORUM ? forum : null;
        },
      },
    });
    const client = await dpopKeys();
    const { challenge } = await pkcePair();
    const scope = `atproto space:com.example.unknown space:com.example.broken space:${FORUM}`;
    const requestUri = await par(handlers, client, challenge, { scope });
    await approve(handlers, requestUri);

    expect(oauthRequests.get(requestUri).scope).toBe(
      `atproto space:com.example.unknown space:com.example.broken space:${FORUM}?collection=com.example.post&collection=com.example.reply`,
    );
  });

  it('grants every type as asked with no resolver, on the direct path', async () => {
    const { handlers, oauthRequests } = createServer({ spacesEnabled: true });
    const response = await call(handlers, '/oauth/authorize', {
      search: {
        client_id: CLIENT_ID,
        redirect_uri: REDIRECT_URI,
        response_type: 'code',
        code_challenge: 'c'.repeat(43),
        code_challenge_method: 'S256',
        scope: `atproto space:${FORUM}`,
      },
    });

    expect(response.status).toBe(200);
    const [stored] = [...oauthRequests.values()];
    expect(stored.scope).toBe(`atproto space:${FORUM}`);
    expect(stored.spaceTypes).toBeUndefined();
  });

  it('grants the declared collections on the direct path', async () => {
    const { handlers, oauthRequests } = createServer({
      spacesEnabled: true,
      lexiconResolver,
    });
    await call(handlers, '/oauth/authorize', {
      search: {
        client_id: CLIENT_ID,
        redirect_uri: REDIRECT_URI,
        response_type: 'code',
        code_challenge: 'c'.repeat(43),
        code_challenge_method: 'S256',
        scope: `atproto space:${FORUM}`,
      },
    });

    const [stored] = [...oauthRequests.values()];
    expect(stored.spaceTypes).toEqual({
      [FORUM]: ['com.example.post', 'com.example.reply'],
    });
  });
});

// RFC 9449 §10.1 lets a pushed request carry the key thumbprint in dpop_jkt
// instead of a proof, and the reference authorization server accepts a request
// that carries neither. Clients in the wild send both shapes.
describe('PAR without a DPoP proof', () => {
  /**
   * @param {ReturnType<typeof createServer>['handlers']} handlers
   * @param {string} challenge
   * @param {Record<string, string>} [extra]
   */
  function parWithoutProof(handlers, challenge, extra = {}) {
    return call(handlers, '/oauth/par', {
      headers: { 'content-type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        client_id: CLIENT_ID,
        redirect_uri: REDIRECT_URI,
        response_type: 'code',
        code_challenge: challenge,
        code_challenge_method: 'S256',
        scope: 'atproto',
        state: 'st4te',
        ...extra,
      }).toString(),
    });
  }

  it('takes the key thumbprint from dpop_jkt', async () => {
    const { handlers, oauthRequests } = createServer();
    const { challenge } = await pkcePair();
    const response = await parWithoutProof(handlers, challenge, {
      dpop_jkt: 'client-key-thumbprint',
    });

    expect(response.status).toBe(201);
    const { request_uri } = await response.json();
    expect(oauthRequests.get(request_uri).dpopJkt).toBe(
      'client-key-thumbprint',
    );
  });

  it('binds the key at the token endpoint when neither is sent', async () => {
    const { handlers, oauthRequests, oauthTokens } = createServer();
    const client = await dpopKeys();
    const { verifier, challenge } = await pkcePair();

    const response = await parWithoutProof(handlers, challenge);
    expect(response.status).toBe(201);
    const { request_uri } = await response.json();
    expect(oauthRequests.get(request_uri).dpopJkt).toBeNull();
    // The client still needs a nonce for the token request.
    expect(response.headers.get('DPoP-Nonce')).toBeTruthy();

    const code = await approve(handlers, request_uri);
    const tokens = await token(handlers, client, {
      grant_type: 'authorization_code',
      code,
      redirect_uri: REDIRECT_URI,
      client_id: CLIENT_ID,
      code_verifier: verifier,
    });

    expect(tokens.status).toBe(200);
    const { refresh_token } = await tokens.json();
    expect(oauthTokens.get(refresh_token).dpopJkt).toBeTruthy();
  });
});

describe('PAR refusals', () => {
  it('refuses PAR on a read-only server', async () => {
    const { handlers } = createServer({ readOnly: true });
    const response = await call(handlers, '/oauth/par', {
      headers: { 'content-type': 'application/x-www-form-urlencoded' },
      body: '',
    });

    expect(response.status).toBe(401);
  });

  it('rejects a dpop_jkt that contradicts the proof', async () => {
    const { handlers } = createServer();
    const client = await dpopKeys();
    const { challenge } = await pkcePair();
    const response = await call(handlers, '/oauth/par', {
      headers: {
        'content-type': 'application/x-www-form-urlencoded',
        DPoP: await dpopProof(client, `${ORIGIN}/oauth/par`),
      },
      body: new URLSearchParams({
        client_id: CLIENT_ID,
        redirect_uri: REDIRECT_URI,
        response_type: 'code',
        code_challenge: challenge,
        code_challenge_method: 'S256',
        dpop_jkt: 'some-other-thumbprint',
      }).toString(),
    });

    expect(response.status).toBe(400);
    expect((await response.json()).error).toBe('invalid_dpop_proof');
  });

  it('hands a nonce to a proof that lacks one', async () => {
    const { handlers } = createServer();
    const client = await dpopKeys();
    // A proof built without the server nonce.
    const header = { typ: 'dpop+jwt', alg: 'ES256', jwk: client.jwk };
    const payload = {
      jti: crypto.randomUUID(),
      htm: 'POST',
      htu: `${ORIGIN}/oauth/par`,
      iat: Math.floor(Date.now() / 1000),
    };
    const enc = (/** @type {object} */ o) =>
      base64UrlEncode(new TextEncoder().encode(JSON.stringify(o)));
    const input = `${enc(header)}.${enc(payload)}`;
    const sig = new Uint8Array(
      await crypto.subtle.sign(
        { name: 'ECDSA', hash: 'SHA-256' },
        client.keys.privateKey,
        new TextEncoder().encode(input),
      ),
    );

    const response = await call(handlers, '/oauth/par', {
      headers: {
        'content-type': 'application/x-www-form-urlencoded',
        DPoP: `${input}.${base64UrlEncode(sig)}`,
      },
      body: new URLSearchParams({ client_id: CLIENT_ID }).toString(),
    });

    expect(response.status).toBe(400);
    expect((await response.json()).error).toBe('use_dpop_nonce');
    expect(response.headers.get('DPoP-Nonce')).toBeTruthy();
  });

  it('accepts a loopback redirect on any port', async () => {
    const { handlers } = createServer();
    const { challenge } = await pkcePair();
    const requestUri = await par(handlers, await dpopKeys(), challenge, {
      client_id:
        'http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback',
      redirect_uri: 'http://127.0.0.1:53817/callback',
    });

    expect(requestUri).toMatch(/^urn:ietf:params:oauth:request_uri:/);
  });

  it('rejects a loopback redirect to another host', async () => {
    const { handlers } = createServer();
    const client = await dpopKeys();
    const { challenge } = await pkcePair();
    const response = await call(handlers, '/oauth/par', {
      headers: {
        'content-type': 'application/x-www-form-urlencoded',
        DPoP: await dpopProof(client, `${ORIGIN}/oauth/par`),
      },
      body: new URLSearchParams({
        client_id:
          'http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback',
        redirect_uri: 'http://[::1]:53817/callback',
        response_type: 'code',
        code_challenge: challenge,
        code_challenge_method: 'S256',
      }).toString(),
    });

    expect(response.status).toBe(400);
    expect((await response.json()).error_description).toBe(
      'redirect_uri not registered for client',
    );
  });

  it('rejects PKCE methods other than S256', async () => {
    const { handlers } = createServer();
    const client = await dpopKeys();
    const response = await call(handlers, '/oauth/par', {
      headers: {
        'content-type': 'application/x-www-form-urlencoded',
        DPoP: await dpopProof(client, `${ORIGIN}/oauth/par`),
      },
      body: new URLSearchParams({
        client_id: CLIENT_ID,
        redirect_uri: REDIRECT_URI,
        response_type: 'code',
        code_challenge: 'abc',
        code_challenge_method: 'plain',
      }).toString(),
    });

    expect(response.status).toBe(400);
    expect((await response.json()).error_description).toBe(
      'PKCE with S256 required',
    );
  });
});

describe('the token endpoint', () => {
  it('refuses token requests on a read-only server', async () => {
    const { handlers } = createServer({ readOnly: true });
    const response = await call(handlers, '/oauth/token', { body: '' });

    expect(response.status).toBe(401);
  });

  it('requires a DPoP proof', async () => {
    const { handlers } = createServer();
    const response = await call(handlers, '/oauth/token', {
      headers: { 'content-type': 'application/x-www-form-urlencoded' },
      body: '',
    });

    expect(response.status).toBe(400);
    expect((await response.json()).error).toBe('invalid_dpop_proof');
  });

  it('names a grant type it does not serve', async () => {
    const { handlers } = createServer();
    const client = await dpopKeys();
    const response = await token(handlers, client, {
      grant_type: 'client_credentials',
    });

    expect(response.status).toBe(400);
    expect((await response.json()).error).toBe('unsupported_grant_type');
  });

  it('rejects a code it never issued', async () => {
    const { handlers } = createServer();
    const client = await dpopKeys();
    const response = await token(handlers, client, {
      grant_type: 'authorization_code',
      code: 'forged',
      redirect_uri: REDIRECT_URI,
      client_id: CLIENT_ID,
      code_verifier: 'v'.repeat(43),
    });

    expect(response.status).toBe(400);
    expect((await response.json()).error).toBe('invalid_grant');
  });

  it('rejects a wrong code_verifier', async () => {
    const { handlers } = createServer();
    const client = await dpopKeys();
    const { challenge } = await pkcePair();
    const requestUri = await par(handlers, client, challenge);
    const code = await approve(handlers, requestUri);

    const response = await token(handlers, client, {
      grant_type: 'authorization_code',
      code,
      redirect_uri: REDIRECT_URI,
      client_id: CLIENT_ID,
      code_verifier: 'not-the-verifier-that-made-the-challenge-123',
    });

    expect(response.status).toBe(400);
    expect((await response.json()).message).toContain('PKCE');
  });

  it('rejects a redeem from a key other than the one PAR bound', async () => {
    const { handlers } = createServer();
    const parKeys = await dpopKeys();
    const { verifier, challenge } = await pkcePair();
    const requestUri = await par(handlers, parKeys, challenge);
    const code = await approve(handlers, requestUri);

    const response = await token(handlers, await dpopKeys(), {
      grant_type: 'authorization_code',
      code,
      redirect_uri: REDIRECT_URI,
      client_id: CLIENT_ID,
      code_verifier: verifier,
    });

    expect(response.status).toBe(400);
    expect((await response.json()).error_description).toBe('DPoP key mismatch');
  });

  it('rejects a redeem whose redirect_uri or client_id differ', async () => {
    const { handlers } = createServer();
    const client = await dpopKeys();
    const { verifier, challenge } = await pkcePair();
    const requestUri = await par(handlers, client, challenge);
    const code = await approve(handlers, requestUri);

    const wrongRedirect = await token(handlers, client, {
      grant_type: 'authorization_code',
      code,
      redirect_uri: 'http://localhost/elsewhere2',
      client_id: CLIENT_ID,
      code_verifier: verifier,
    });
    expect((await wrongRedirect.json()).error_description).toBe(
      'Redirect URI mismatch',
    );

    const wrongClient = await token(handlers, client, {
      grant_type: 'authorization_code',
      code,
      redirect_uri: REDIRECT_URI,
      client_id: 'http://other.example',
      code_verifier: verifier,
    });
    expect((await wrongClient.json()).error_description).toBe(
      'Client ID mismatch',
    );
  });
});

describe('the refresh grant', () => {
  /** Walk the code grant and return what the client holds afterwards. */
  async function establishSession(
    /** @type {ReturnType<typeof createServer>} */ server,
  ) {
    const client = await dpopKeys();
    const { verifier, challenge } = await pkcePair();
    const requestUri = await par(server.handlers, client, challenge);
    const code = await approve(server.handlers, requestUri);
    const response = await token(server.handlers, client, {
      grant_type: 'authorization_code',
      code,
      redirect_uri: REDIRECT_URI,
      client_id: CLIENT_ID,
      code_verifier: verifier,
    });
    const body = await response.json();
    return { client, refreshToken: body.refresh_token };
  }

  it('rotates the refresh token and keeps createdAt', async () => {
    const server = createServer();
    const { client, refreshToken } = await establishSession(server);
    const before = server.oauthTokens.get(refreshToken);

    const response = await token(server.handlers, client, {
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: CLIENT_ID,
    });

    expect(response.status).toBe(200);
    const body = await response.json();
    expect(body.refresh_token).not.toBe(refreshToken);
    expect(server.oauthTokens.get(body.refresh_token).createdAt).toBe(
      before.createdAt,
    );
    // The one just used is kept a moment, spent, pointing at its replacement.
    expect(server.oauthTokens.get(refreshToken).rotatedTo).toBe(
      body.refresh_token,
    );
  });

  // A browser app is many pages of one session. A reload, or a link followed
  // while a refresh is in flight, tears down the page holding the new tokens
  // before it can store them; the next page comes back with the old one.
  it('answers a page that comes back with the token just used', async () => {
    const server = createServer();
    const { client, refreshToken } = await establishSession(server);
    const first = await (
      await token(server.handlers, client, {
        grant_type: 'refresh_token',
        refresh_token: refreshToken,
        client_id: CLIENT_ID,
      })
    ).json();

    const again = await token(server.handlers, client, {
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: CLIENT_ID,
    });

    expect(again.status).toBe(200);
    const body = await again.json();
    // Both pages end up holding the same session, not two.
    expect(body.refresh_token).toBe(first.refresh_token);
    expect(body.sub).toBe(DID);
    expect(body.access_token).toBeTruthy();
  });

  // A laptop that goes to sleep while a refresh is in flight never gets the
  // reply, and comes back with the old token hours later.
  it('answers the token just used however much later, until its replacement is used', async () => {
    const server = createServer();
    const { client, refreshToken } = await establishSession(server);
    const first = await (
      await token(server.handlers, client, {
        grant_type: 'refresh_token',
        refresh_token: refreshToken,
        client_id: CLIENT_ID,
      })
    ).json();
    vi.useFakeTimers({
      now: Date.now() + 7 * 60 * 60 * 1000,
      toFake: ['Date'],
    });
    try {
      const late = await token(server.handlers, client, {
        grant_type: 'refresh_token',
        refresh_token: refreshToken,
        client_id: CLIENT_ID,
      });
      expect(late.status).toBe(200);
      expect((await late.json()).refresh_token).toBe(first.refresh_token);
    } finally {
      vi.useRealTimers();
    }
  });

  it('refuses the spent token once its replacement has been used', async () => {
    const server = createServer();
    const { client, refreshToken } = await establishSession(server);
    const first = await (
      await token(server.handlers, client, {
        grant_type: 'refresh_token',
        refresh_token: refreshToken,
        client_id: CLIENT_ID,
      })
    ).json();
    // Used, but the one it replaced still held, as a race between the two
    // could leave it.
    const spent = server.oauthTokens.get(refreshToken);
    await token(server.handlers, client, {
      grant_type: 'refresh_token',
      refresh_token: first.refresh_token,
      client_id: CLIENT_ID,
    });
    server.oauthTokens.set(refreshToken, spent);

    const response = await token(server.handlers, client, {
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: CLIENT_ID,
    });

    expect(response.status).toBe(400);
    expect((await response.json()).error_description).toBe(
      'Invalid refresh token',
    );
    // And it is not kept around after that.
    expect(server.oauthTokens.has(refreshToken)).toBe(false);
  });

  it('answers nothing for a session revoked since', async () => {
    const server = createServer();
    const { client, refreshToken } = await establishSession(server);
    const { refresh_token: current } = await (
      await token(server.handlers, client, {
        grant_type: 'refresh_token',
        refresh_token: refreshToken,
        client_id: CLIENT_ID,
      })
    ).json();
    server.oauthTokens.delete(current);

    const response = await token(server.handlers, client, {
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: CLIENT_ID,
    });

    expect(response.status).toBe(400);
  });

  it('keeps one spent token at a time, however often it refreshes', async () => {
    const server = createServer();
    const { client, refreshToken } = await establishSession(server);
    let held = refreshToken;
    const used = [];
    for (let i = 0; i < 3; i++) {
      used.push(held);
      const body = await (
        await token(server.handlers, client, {
          grant_type: 'refresh_token',
          refresh_token: held,
          client_id: CLIENT_ID,
        })
      ).json();
      held = body.refresh_token;
    }
    const kept = [...used, held].filter((t) => server.oauthTokens.has(t));
    expect(kept).toEqual([used[2], held]);
  });

  it('rejects a refresh token it does not hold', async () => {
    const server = createServer();
    const { client } = await establishSession(server);

    const response = await token(server.handlers, client, {
      grant_type: 'refresh_token',
      refresh_token: 'forged',
      client_id: CLIENT_ID,
    });

    expect(response.status).toBe(400);
    expect((await response.json()).error_description).toBe(
      'Invalid refresh token',
    );
  });

  it('rejects a refresh from another client or another key', async () => {
    const server = createServer();
    const { client, refreshToken } = await establishSession(server);

    const wrongClient = await token(server.handlers, client, {
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: 'http://other.example',
    });
    expect((await wrongClient.json()).error_description).toBe(
      'Client mismatch',
    );

    const wrongKey = await token(server.handlers, await dpopKeys(), {
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: CLIENT_ID,
    });
    expect((await wrongKey.json()).error_description).toBe('DPoP key mismatch');
  });

  it('retires a refresh token past its two-week lifetime', async () => {
    const server = createServer();
    const { client, refreshToken } = await establishSession(server);
    const row = server.oauthTokens.get(refreshToken);
    row.createdAt = Date.now() - 15 * 24 * 60 * 60 * 1000;

    const response = await token(server.handlers, client, {
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: CLIENT_ID,
    });

    expect(response.status).toBe(400);
    expect((await response.json()).error_description).toBe(
      'Refresh token expired',
    );
    expect(server.oauthTokens.has(refreshToken)).toBe(false);
  });

  describe('of a confidential client', () => {
    const CONFIDENTIAL_ID = 'https://client.example/metadata.json';
    const DAY = 24 * 60 * 60 * 1000;

    afterEach(() => vi.unstubAllGlobals());

    async function refreshAged(
      /** @type {{createdDaysAgo: number, updatedDaysAgo: number}} */ age,
    ) {
      const server = createServer();
      const { client, refreshToken } = await establishSession(server);
      const row = server.oauthTokens.get(refreshToken);
      row.clientId = CONFIDENTIAL_ID;
      row.createdAt = Date.now() - age.createdDaysAgo * DAY;
      row.updatedAt = Date.now() - age.updatedDaysAgo * DAY;

      const pair = await crypto.subtle.generateKey(
        { name: 'ECDSA', namedCurve: 'P-256' },
        true,
        ['sign', 'verify'],
      );
      const jwk = await crypto.subtle.exportKey('jwk', pair.publicKey);
      const metadata = {
        client_id: CONFIDENTIAL_ID,
        token_endpoint_auth_method: 'private_key_jwt',
        jwks: { keys: [{ ...jwk, kid: 'k' }] },
      };
      vi.stubGlobal('fetch', async () => Response.json(metadata));

      const encode = (/** @type {object} */ part) =>
        base64UrlEncode(new TextEncoder().encode(JSON.stringify(part)));
      const seconds = Math.floor(Date.now() / 1000);
      const signed = `${encode({ alg: 'ES256', kid: 'k' })}.${encode({
        iss: CONFIDENTIAL_ID,
        sub: CONFIDENTIAL_ID,
        aud: ORIGIN,
        jti: crypto.randomUUID(),
        iat: seconds,
        exp: seconds + 60,
      })}`;
      const signature = await crypto.subtle.sign(
        { name: 'ECDSA', hash: 'SHA-256' },
        pair.privateKey,
        new TextEncoder().encode(signed),
      );

      return token(server.handlers, client, {
        grant_type: 'refresh_token',
        refresh_token: refreshToken,
        client_id: CONFIDENTIAL_ID,
        client_assertion_type:
          'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
        client_assertion: `${signed}.${base64UrlEncode(new Uint8Array(signature))}`,
      });
    }

    it('refreshes past two weeks while it is used', async () => {
      const response = await refreshAged({
        createdDaysAgo: 400,
        updatedDaysAgo: 89,
      });
      expect(response.status).toBe(200);
    });

    it('retires a session unused for three months', async () => {
      const response = await refreshAged({
        createdDaysAgo: 100,
        updatedDaysAgo: 91,
      });
      expect((await response.json()).error_description).toBe(
        'Refresh token expired',
      );
    });

    it('retires a session past two years, however used', async () => {
      const response = await refreshAged({
        createdDaysAgo: 731,
        updatedDaysAgo: 0,
      });
      expect((await response.json()).error_description).toBe(
        'Refresh token expired',
      );
    });
  });
});

describe('revocation', () => {
  it('refuses revocation on a read-only server', async () => {
    const { handlers } = createServer({ readOnly: true });
    const response = await call(handlers, '/oauth/revoke', { body: '' });

    expect(response.status).toBe(401);
  });

  it('requires a token and client_id', async () => {
    const { handlers } = createServer();
    const response = await call(handlers, '/oauth/revoke', {
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({}),
    });

    expect(response.status).toBe(400);
  });

  it('deletes the named refresh token', async () => {
    const server = createServer();
    server.oauthTokens.set('rt1', { clientId: CLIENT_ID, did: DID });

    const response = await call(server.handlers, '/oauth/revoke', {
      headers: { 'content-type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        token: 'rt1',
        client_id: CLIENT_ID,
      }).toString(),
    });

    expect(response.status).toBe(200);
    expect(server.oauthTokens.has('rt1')).toBe(false);
  });

  it('answers 200 for a token held by another client, without deleting it', async () => {
    const server = createServer();
    server.oauthTokens.set('rt1', {
      clientId: 'http://other.example',
      did: DID,
    });

    const response = await call(server.handlers, '/oauth/revoke', {
      headers: { 'content-type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        token: 'rt1',
        client_id: CLIENT_ID,
      }).toString(),
    });

    expect(response.status).toBe(200);
    expect(server.oauthTokens.has('rt1')).toBe(true);
  });
});