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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
115 lines · 3.4 KB Raw History
// @pdsjs/spaces/attestation - which application is asking for a credential.
//
// A delegation token says the user delegated to somebody. The client
// attestation says who. It is a `private_key_jwt` signed by the client's own
// authentication key, so establishing the client_id means resolving it to its
// client-metadata.json, fetching the key set that document publishes, and
// checking the signature against the key its `kid` names.
//
// All of it, or a memberspace `allowedApps` list states a rule the authority
// cannot enforce: the client_id is otherwise a claim the caller writes for itself.

import {
  fetchClientMetadata,
  resolveClientJwk,
  verifyEs256WithJwk,
} from '@pdsjs/core/oauth';
import { spend } from './single-use.js';
import { parseSpaceToken, SpaceTokenError } from './token.js';

/**
 * Verify a client attestation and return the client_id it establishes.
 *
 * @param {Object} opts
 * @param {string} opts.attestation - the raw JWT
 * @param {string} opts.aud - the space host the attestation must address
 * @returns {Promise<string>} the attested client_id
 * @throws {SpaceTokenError}
 */
export async function verifyClientAttestation({ attestation, aud }) {
  const { header, payload, signingInput, sig } = parseSpaceToken(
    'clientAttestation',
    attestation,
  );
  const clientId = payload.iss;

  if (payload.aud !== aud) {
    throw new SpaceTokenError(
      'Client attestation is addressed to another authority',
      'BadJwtAudience',
    );
  }
  const now = Math.floor(Date.now() / 1000);
  if (payload.exp <= now) {
    throw new SpaceTokenError('Client attestation has expired', 'JwtExpired');
  }
  if (header.alg !== 'ES256') {
    throw new SpaceTokenError(
      `Client attestation "alg" must be ES256, got "${header.alg}"`,
      'BadJwt',
    );
  }
  if (typeof header.kid !== 'string' || !header.kid) {
    throw new SpaceTokenError('Client attestation needs a "kid"', 'BadJwt');
  }

  /** @type {import('@pdsjs/core/oauth').ClientMetadata} */
  let metadata;
  try {
    metadata = await fetchClientMetadata(clientId);
  } catch (err) {
    throw new SpaceTokenError(
      `Could not resolve client metadata for ${clientId}: ${err instanceof Error ? err.message : String(err)}`,
      'BadJwtIss',
    );
  }

  const jwk = await resolveClientJwk(metadata, header.kid);
  if (!jwk) {
    throw new SpaceTokenError(
      `Client ${clientId} publishes no key "${header.kid}" to verify an attestation against`,
      'BadJwtSignature',
    );
  }

  let valid = false;
  try {
    valid = await verifyEs256WithJwk(
      jwk,
      new TextDecoder().decode(signingInput),
      base64Url(sig),
    );
  } catch {
    valid = false;
  }
  if (!valid) {
    throw new SpaceTokenError(
      'Client attestation signature is invalid',
      'BadJwtSignature',
    );
  }

  // Spent after the signature, so an unsigned guess cannot burn the jti of an
  // attestation the client is about to present.
  if (!spend(`attestation@${clientId}`, payload.jti, payload.exp * 1000)) {
    throw new SpaceTokenError(
      `Client attestation for ${clientId} has already been used`,
      'BadJwt',
    );
  }

  return clientId;
}

/**
 * @param {Uint8Array} bytes
 * @returns {string} base64url, unpadded
 */
function base64Url(bytes) {
  let binary = '';
  for (const b of bytes) binary += String.fromCharCode(b);
  return btoa(binary)
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}