chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @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;
}
}