// @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. 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} */ 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} ctx.getDid * @param {() => Promise} 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} 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} * 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} * 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} opts.getSigner * @param {typeof fetch} [opts.fetch] * @returns {Promise} */ 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} opts.getSigner * @param {typeof fetch} [opts.fetch] * @returns {Promise} */ 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; } }