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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
689 lines · 22.0 KB Raw History
// @pdsjs/spaces/handlers/auth - the space auth chain.
//
//   app -> PDS.getDelegationToken        (OAuth) proves the user delegated
//   app -> authority.getSpaceCredential  exchanges it for a credential
//   app -> repo host.<read endpoints>    presents the credential
//
// The delegation token says only that the user delegated to *someone*; which
// app is acting is established separately by a client attestation, which the
// authority verifies itself. A memberspace with publicRead set issues a
// credential without a delegation token.

import { ScopePermissions } from '@pdsjs/core/scope';
import { verifyClientAttestation } from '../attestation.js';
import {
  atprotoSigningKey,
  didKeyResolver,
  namedServiceEndpoint,
  spaceHostAudience,
} from '../authority.js';
import { isP256DidKey, verifyCredentialSig } from '../http-signature.js';
import {
  isAppAuthorized,
  isPublicRead,
  MANAGEDSPACE,
  MEMBERSPACE,
} from '../policy.js';
import { createServiceAuth } from '../service-auth.js';
import { spend } from '../single-use.js';
import {
  createSpaceToken,
  SpaceTokenError,
  verifySpaceToken,
} from '../token.js';
import { parseSpaceUri } from '../uri.js';

/**
 * @param {string} error
 * @param {string} message
 * @param {number} [status]
 * @returns {Response}
 */
function errorResponse(error, message, status = 400) {
  return Response.json({ error, message }, { status });
}

/**
 * @param {Request} request
 * @returns {Promise<any>}
 */
async function readJson(request) {
  try {
    return JSON.parse(await request.text());
  } catch {
    return null;
  }
}

/**
 * @param {Object} ctx
 * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
 * @param {() => Promise<string|null>} ctx.getDid
 * @param {() => Promise<import('../token.js').SpaceSigner>} ctx.getSigner
 * @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid
 * @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
 * @param {typeof fetch} [ctx.fetch]
 * @returns {import('@pdsjs/core/pds').Routes}
 */
export function createAuthRoutes(ctx) {
  const { spaceStorage, getDid, getSigner, resolveDid, verifier } = ctx;
  const getSigningKey = didKeyResolver(resolveDid);
  const requireCredential = createCredentialAuthorizer({
    spaceStorage,
    resolveDid,
    verifier,
  });

  return {
    // PDS role: mint a token proving this user delegated to the caller. Signed
    // by the user's own #atproto key and addressed to the authority's space
    // host, so it is useless anywhere else and expires in a minute.
    '/xrpc/com.atproto.space.getDelegationToken': {
      auth: 'required',
      handler: async (_request, url, auth) => {
        const space = url.searchParams.get('space');
        if (!space) return errorResponse('InvalidRequest', 'space is required');
        /** @type {{spaceDid: string, spaceType: string, skey: string}} */
        let parsed;
        try {
          parsed = parseSpaceUri(space);
        } catch {
          return errorResponse('InvalidSpaceUri', `Not a space uri: ${space}`);
        }

        const did = /** @type {{did: string}} */ (auth).did;

        // `read` grants whole-space access and with it the delegation token;
        // `read_self` deliberately does not, since it only ever covers the
        // holder's own repo and needs no credential.
        const permissions = new ScopePermissions(
          /** @type {{scope?: string}} */ (auth).scope,
        );
        if (
          !permissions.allowsSpace({
            spaceType: parsed.spaceType,
            authority: parsed.spaceDid,
            skey: parsed.skey,
            action: 'read',
            userDid: did,
          })
        ) {
          return errorResponse(
            'InvalidToken',
            `Scope does not permit reading ${space}`,
            403,
          );
        }

        const hosted = await getDid();
        if (did !== hosted) {
          return errorResponse(
            'Forbidden',
            'Can only mint a delegation token for the hosted account',
            403,
          );
        }

        const token = await createSpaceToken(
          'delegation',
          {
            iss: did,
            sub: space,
            aud: spaceHostAudience(parsed.spaceDid),
          },
          await getSigner(),
        );
        return Response.json({ token });
      },
    },

    // Authority role: issue a space credential, bound to the P-256 key the
    // body names. Unauthenticated at the PDS layer. A delegation token in the
    // body says which user the app acts for, and a space that admits only some
    // users needs one. A space whose read policy is public issues a credential
    // without one.
    '/xrpc/com.atproto.space.getSpaceCredential': {
      method: 'POST',
      handler: async (request) => {
        const body = await readJson(request);
        if (!body) return errorResponse('InvalidRequest', 'Invalid JSON body');
        const { space, credentialKid, delegationToken, clientAttestation } =
          body;
        if (typeof space !== 'string') {
          return errorResponse('InvalidRequest', 'space is required');
        }
        try {
          parseSpaceUri(space);
        } catch {
          return errorResponse('InvalidSpaceUri', `Not a space uri: ${space}`);
        }
        if (!isP256DidKey(credentialKid)) {
          return errorResponse(
            'InvalidRequest',
            'credentialKid must be a P-256 did:key',
          );
        }

        const authorityDid = await getDid();
        if (!authorityDid) {
          return errorResponse('SpaceNotFound', 'Server not initialised', 404);
        }

        /** @type {string|undefined} */
        let userDid;
        if (delegationToken !== undefined && delegationToken !== null) {
          /** @type {import('../token.js').SpaceTokenPayload} */
          let delegation;
          try {
            delegation = (
              await verifySpaceToken('delegation', String(delegationToken), {
                getSigningKey,
                verifier,
                aud: spaceHostAudience(authorityDid),
                sub: space,
              })
            ).payload;
          } catch (err) {
            if (err instanceof SpaceTokenError) {
              return errorResponse('InvalidDelegationToken', err.message);
            }
            throw err;
          }
          // One token buys one credential.
          if (
            !spend(
              `delegation@${delegation.iss}`,
              delegation.jti,
              delegation.exp * 1000,
            )
          ) {
            return errorResponse(
              'InvalidDelegationToken',
              'Delegation token has already been used',
            );
          }
          userDid = delegation.iss;
        }

        if (parseSpaceUri(space).spaceDid !== authorityDid) {
          return errorResponse('SpaceNotFound', 'Space not found', 404);
        }

        /** @type {string|undefined} */
        let clientId;
        if (clientAttestation) {
          try {
            clientId = await verifyClientAttestation({
              attestation: clientAttestation,
              aud: spaceHostAudience(authorityDid),
            });
          } catch (err) {
            return errorResponse(
              'InvalidClientAttestation',
              err instanceof Error ? err.message : String(err),
            );
          }
        }

        // A syncer that missed notifySpaceDeleted learns it here, on its next
        // renewal, with or without a delegation token.
        const spaceRow = await spaceStorage.getSpace(space);
        if (spaceRow?.isOwner && spaceRow.deletedAt) {
          return errorResponse('SpaceDeleted', 'Space has been deleted');
        }
        if (!spaceRow?.isOwner) {
          return errorResponse('SpaceNotFound', 'Space not found', 404);
        }

        // The user goes first. A user the policy refuses gets the answer for
        // a space that does not exist, so a refusal discloses nothing about
        // the space. Without a delegation token, only a space anyone may read
        // has a user to admit.
        const userAuthorized =
          userDid === undefined
            ? isPublicRead(spaceRow.policy)
            : await authorizeSpaceUser({
                spaceStorage,
                spaceRow,
                access: 'read',
                userDid,
                clientId,
                resolveDid,
                getSigner,
                fetch: ctx.fetch,
              });
        if (!userAuthorized) {
          return errorResponse('SpaceNotFound', 'Space not found', 404);
        }
        if (!isAppAuthorized(spaceRow.policy, clientId)) {
          return errorResponse(
            'AppNotAuthorized',
            'Application not authorized for this space',
            403,
          );
        }

        const credential = await createSpaceToken(
          'credential',
          { iss: authorityDid, sub: space, keyId: credentialKid },
          await getSigner(),
        );
        return Response.json({ credential });
      },
    },

    // Authority role: the writer set, which is the sync boundary. Accounts that
    // have written at least one record — not the broader set allowed to write,
    // which the authority may not even track.
    //
    // A credential, not a session. The writer set names who is in a space and
    // when each of them last wrote, so serving it openly discloses the
    // membership of a private space to anyone who can name it. The reference
    // holds this method, registerNotify and unregisterNotify to the same bar.
    '/xrpc/com.atproto.space.listRepos': {
      handler: async (request, url) => {
        const space = url.searchParams.get('space');
        if (!space) return errorResponse('InvalidRequest', 'space is required');

        // Before the space lookup, so a caller with no credential cannot learn
        // which spaces this server is the authority for.
        const denied = await requireCredential(request, space);
        if (denied) return denied;

        const raw = url.searchParams.get('limit');
        const limit = raw === null ? 100 : Number(raw);
        if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
          return errorResponse(
            'InvalidRequest',
            'limit must be an integer between 1 and 1000',
          );
        }

        const row = await spaceStorage.getSpace(space);
        if (!row?.isOwner) {
          return errorResponse('SpaceNotFound', 'Space not found', 404);
        }

        const { writers, cursor } = await spaceStorage.listSpaceWriters(
          space,
          url.searchParams.get('cursor'),
          limit,
        );
        // Ascending spaceRev. A page with entries ends on a cursor even when
        // it is short; only an empty page has none, so a syncer pages until
        // one comes back empty.
        return Response.json({
          repos: writers.map((w) => ({
            did: w.did,
            repoRev: w.repoRev,
            data: toJsonBytes(w.data),
            spaceRev: w.spaceRev,
          })),
          ...(cursor ? { cursor } : {}),
        });
      },
    },
  };
}

/**
 * @param {Uint8Array} bytes
 * @returns {{$bytes: string}}
 */
function toJsonBytes(bytes) {
  let binary = '';
  for (const b of bytes) binary += String.fromCharCode(b);
  return { $bytes: btoa(binary).replace(/=+$/, '') };
}

/**
 * Build the read check the space endpoints share: the hosted account's own
 * session, or a space credential for the space being read.
 *
 * A credential read is addressed to the hosted account, which owns every repo
 * this server holds.
 *
 * @param {Object} ctx
 * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
 * @param {() => Promise<string|null>} ctx.getDid
 * @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid
 * @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
 * @returns {(request: Request, space: string, auth: {did: string}|null) => Promise<Response|null>}
 *   a Response to return, or null to proceed
 */
export function createReadAuthorizer({
  spaceStorage,
  getDid,
  resolveDid,
  verifier,
}) {
  return async (request, space, auth) => {
    const hosted = await getDid();
    if (auth && auth.did === hosted) return null;

    const credential = credentialFromRequest(request);
    if (!credential) {
      return errorResponse(
        'AuthenticationRequired',
        'A session or space credential is required',
        401,
      );
    }
    try {
      await verifyPresentedCredential({
        request,
        credential,
        space,
        audience: /** @type {string} */ (hosted),
        spaceStorage,
        resolveDid,
        verifier,
      });
      return null;
    } catch (err) {
      if (err instanceof SpaceTokenError) {
        return errorResponse(err.code, err.message, 401);
      }
      throw err;
    }
  };
}

/**
 * Build the check the authority-role methods share: a space credential for the
 * space being addressed, and nothing else.
 *
 * Held apart from createReadAuthorizer because these methods admit no session.
 * A repo read is an account acting on its own data, so its own session speaks
 * for it. An authority-role method is an application acting on the space, and
 * the credential is what says the authority admitted it.
 *
 * The request is addressed to the space's authority.
 *
 * @param {Object} ctx
 * @param {import('@pdsjs/core/ports').SpaceStoragePort} ctx.spaceStorage
 * @param {import('@pdsjs/core/ports').DidResolverPort} ctx.resolveDid
 * @param {import('@pdsjs/core/ports').SignatureVerifierPort} ctx.verifier
 * @returns {(request: Request, space: string) => Promise<Response|null>}
 *   a Response to return, or null to proceed
 */
export function createCredentialAuthorizer({
  spaceStorage,
  resolveDid,
  verifier,
}) {
  return async (request, space) => {
    const credential = credentialFromRequest(request);
    if (!credential) {
      return errorResponse(
        'AuthenticationRequired',
        'A space credential is required',
        401,
      );
    }
    /** @type {string} */
    let authority;
    try {
      authority = parseSpaceUri(space).spaceDid;
    } catch {
      return errorResponse('InvalidSpaceUri', `Not a space uri: ${space}`);
    }
    try {
      await verifyPresentedCredential({
        request,
        credential,
        space,
        audience: authority,
        spaceStorage,
        resolveDid,
        verifier,
      });
      return null;
    } catch (err) {
      if (err instanceof SpaceTokenError) {
        return errorResponse(err.code, err.message, 401);
      }
      throw err;
    }
  };
}

/**
 * The space credential a request presents, or null.
 *
 * `Atproto-Space-Credential`, never `Bearer`: a credential names the key it is bound to,
 * and a presentation that proves nothing about that key is what the binding
 * exists to refuse.
 *
 * @param {Request} request
 * @returns {string|null}
 */
export function credentialFromRequest(request) {
  const match = (request.headers.get('authorization') ?? '').match(
    /^Atproto-Space-Credential\s+(\S+)$/i,
  );
  return match ? match[1] : null;
}

/**
 * Check a presented space credential: the token itself, that it has not been
 * revoked, that the request is addressed here, and the caller's possession of
 * the key it names.
 *
 * The questions are one function so that no endpoint accepting a credential
 * can answer the first and forget another.
 *
 * @param {Object} opts
 * @param {Request} opts.request - the request the signature must cover
 * @param {string} opts.credential - the raw JWT
 * @param {string} opts.space - the space the request targets
 * @param {string} opts.audience - the DID this request must be addressed to
 * @param {import('@pdsjs/core/ports').SpaceStoragePort} opts.spaceStorage
 * @param {import('@pdsjs/core/ports').DidResolverPort} opts.resolveDid
 * @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
 * @returns {Promise<{iss: string}>}
 */
export async function verifyPresentedCredential({
  request,
  credential,
  space,
  audience,
  spaceStorage,
  resolveDid,
  verifier,
}) {
  const { iss, keyId, jti } = await verifySpaceCredential({
    credential,
    space,
    resolveDid,
    verifier,
  });
  if (await spaceStorage.isCredentialRevoked(space, jti)) {
    throw new SpaceTokenError(
      'Space credential has been revoked',
      'CredentialRevoked',
    );
  }
  const signed = await verifyCredentialSig(request.headers, {
    keyId,
    verifier,
  });
  if (signed.audience !== audience) {
    throw new SpaceTokenError(
      'space audience does not match the request',
      'BadSpaceAudience',
    );
  }
  return { iss };
}

/**
 * Verify a space credential presented to a repo host.
 *
 * @param {Object} opts
 * @param {string} opts.credential - the raw JWT
 * @param {string} opts.space - the space the request targets
 * @param {import('@pdsjs/core/ports').DidResolverPort} opts.resolveDid
 * @param {import('@pdsjs/core/ports').SignatureVerifierPort} opts.verifier
 * @returns {Promise<{iss: string, keyId: string, jti: string}>} the authority,
 *   the did:key whose signatures present it, and its id for revocation
 */
export async function verifySpaceCredential({
  credential,
  space,
  resolveDid,
  verifier,
}) {
  const { spaceDid } = parseSpaceUri(space);
  const { payload } = await verifySpaceToken('credential', credential, {
    verifier,
    // A credential is signed by the authority's #atproto key, so it is
    // verified without contacting the authority.
    getSigningKey: async (iss, forceRefresh = false) => {
      const didDoc = await resolveDid(iss, { forceRefresh });
      if (!didDoc) {
        throw new SpaceTokenError(`Could not resolve ${iss}`, 'DidNotFound');
      }
      return atprotoSigningKey(didDoc, iss);
    },
    sub: space,
  });
  if (payload.iss !== spaceDid) {
    throw new SpaceTokenError(
      'Credential was not issued by this space authority',
      'BadJwtIss',
    );
  }
  // parseSpaceToken refuses a credential without one, so this is reachable only
  // if that check is relaxed. It stays because the cost of it being wrong is a
  // credential for a whole space that anyone holding a copy may present.
  const keyId = payload.cnf?.kid;
  if (!keyId) {
    throw new SpaceTokenError('Credential is not bound to a key', 'BadJwtCnf');
  }
  return { iss: payload.iss, keyId, jti: payload.jti };
}

/**
 * Whether a space's policy admits this user for one kind of access.
 *
 * The authority is the only party who can reconfigure a space, so no policy may
 * lock it out of its own space.
 *
 * @param {Object} opts
 * @param {import('@pdsjs/core/ports').SpaceStoragePort} opts.spaceStorage
 * @param {import('@pdsjs/core/ports').SpaceRow} opts.spaceRow
 * @param {'read'|'write'} opts.access
 * @param {string} opts.userDid
 * @param {string} [opts.clientId] - the attested client, on a read
 * @param {import('@pdsjs/core/ports').DidResolverPort} opts.resolveDid
 * @param {() => Promise<import('../token.js').SpaceSigner>} opts.getSigner
 * @param {typeof fetch} [opts.fetch]
 * @returns {Promise<boolean>}
 */
export async function authorizeSpaceUser({
  spaceStorage,
  spaceRow,
  access,
  userDid,
  clientId,
  resolveDid,
  getSigner,
  fetch: doFetch,
}) {
  const { spaceDid } = parseSpaceUri(spaceRow.uri);
  if (userDid === spaceDid) return true;

  const { policy } = spaceRow;
  if (policy?.$type === MEMBERSPACE) {
    const isPublic = access === 'read' ? policy.publicRead : policy.publicWrite;
    if (isPublic === true) return true;
    const member = await spaceStorage.getMember(spaceRow.uri, userDid);
    if (!member) return false;
    return access === 'read' ? member.read : member.write;
  }
  if (policy?.$type === MANAGEDSPACE) {
    // The named service decides for each user. Any failure denies: an
    // unresolvable DID, an unreachable app, or a malformed answer.
    return checkUserAccess({
      managingApp: /** @type {string} */ (policy.managingApp),
      authorityDid: spaceDid,
      space: spaceRow.uri,
      access,
      userDid,
      clientId,
      resolveDid,
      getSigner,
      fetch: doFetch,
    });
  }
  // A personalspace admits only the authority.
  return false;
}

/**
 * Ask a space's managing app whether to authorize a user.
 *
 * Fails closed: anything short of an explicit `authorized: true` is a refusal,
 * so an app being down denies access rather than granting it.
 *
 * @param {Object} opts
 * @param {string|null} opts.managingApp - a `did#serviceId` identifier
 * @param {string} opts.authorityDid
 * @param {string} opts.space
 * @param {'read'|'write'} opts.access
 * @param {string} opts.userDid
 * @param {string|undefined} [opts.clientId]
 * @param {import('@pdsjs/core/ports').DidResolverPort} opts.resolveDid
 * @param {() => Promise<import('../token.js').SpaceSigner>} opts.getSigner
 * @param {typeof fetch} [opts.fetch]
 * @returns {Promise<boolean>}
 */
export async function checkUserAccess({
  managingApp,
  authorityDid,
  space,
  access,
  userDid,
  clientId,
  resolveDid,
  getSigner,
  fetch: doFetch,
}) {
  if (!managingApp) return false;
  try {
    const [appDid] = managingApp.split('#');
    const didDoc = await resolveDid(appDid);
    if (!didDoc) return false;
    const endpoint = namedServiceEndpoint(didDoc, managingApp);
    if (!endpoint) return false;

    const token = await createServiceAuth({
      iss: authorityDid,
      aud: managingApp,
      lxm: 'com.atproto.managedspace.checkUserAccess',
      signer: await getSigner(),
    });

    const url = new URL(
      `${endpoint}/xrpc/com.atproto.managedspace.checkUserAccess`,
    );
    url.searchParams.set('space', space);
    url.searchParams.set('user', userDid);
    url.searchParams.set('access', access);
    // A write check has no attestation to carry: the notice comes from the
    // writer's PDS rather than from an app.
    if (access === 'read' && clientId) {
      url.searchParams.set('clientId', clientId);
    }

    const res = await (doFetch ?? fetch)(url, {
      headers: { authorization: `Bearer ${token}` },
    });
    if (!res.ok) return false;
    const body = await res.json();
    return body?.authorized === true;
  } catch {
    return false;
  }
}