chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import { useMutation } from '@tanstack/react-query';
import { Check, Play, X } from 'lucide-react';
import { Badge } from '#/components/atoms/badge.jsx';
import { Button } from '#/components/atoms/button.jsx';
import { Card } from '#/components/atoms/card.jsx';
import { Link } from '#/components/atoms/link.jsx';
import { Notice } from '#/components/atoms/notice.jsx';
import { AtUri } from '#/components/molecules/at-uri.jsx';
import { Copyable } from '#/components/molecules/copyable.jsx';
import { TokenView } from '#/components/molecules/token-view.jsx';
import {
exchangeDelegation,
heldCredential,
requestDelegation,
} from '#/lib/credential.js';
import { until } from '#/lib/format.js';
import { decodeJwt } from '#/lib/jwt.js';
import { href } from '#/lib/navigation.jsx';
import { useSession } from '#/lib/session.jsx';
/**
* Run the exchange, keeping whatever each step produced.
*
* The delegation token is the useful thing to see when the second step fails,
* so a failure at the authority does not throw away what the first step
* returned. Each step reports its own error.
*
* @param {import('#/lib/oauth.js').Session} session
* @param {string} space
*/
async function runExchange(session, space) {
/** @type {Record<string, any>} */
const result = {};
try {
const delegation = await requestDelegation(session, space);
result.delegation = delegation.token;
result.delegationHost = delegation.host;
} catch (error) {
result.delegationError = error.message;
return result;
}
try {
const exchanged = await exchangeDelegation(space, result.delegation);
result.credential = exchanged.credential;
result.credentialHost = exchanged.host;
result.keyId = exchanged.keyId;
} catch (error) {
result.credentialError = error.message;
}
return result;
}
/** Whether the credential names the key this page made for it. */
function BindingCheck({ credential, keyId }) {
const bound = decodeJwt(credential)?.payload.cnf?.kid;
const matches = bound === keyId;
return (
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<span className="text-muted-foreground text-xs">Bound to</span>
<span className="flex min-w-0 items-baseline gap-2">
<Copyable value={String(bound)} className="text-[12px]">
{String(bound).slice(0, 16)}…
</Copyable>
{matches ? (
<Badge variant="scope">
<Check className="mr-1 inline size-3" />
this page's key
</Badge>
) : (
<Badge>
<X className="mr-1 inline size-3" />
another key
</Badge>
)}
</span>
</div>
);
}
export function CredentialPage({ space }) {
const { session } = useSession();
const held = heldCredential(space);
const exchange = useMutation({
mutationFn: () => runExchange(session, space),
});
const result = exchange.data;
return (
<div className="flex flex-col gap-5">
<div>
<Link href={href('/space', { uri: space })} className="text-[13px]">
← Back to the space
</Link>
<h1 className="mt-2 font-semibold text-[16px] text-foreground">
Credential
</h1>
<AtUri uri={space} className="mt-1 text-[11px]" />
</div>
<p className="text-muted-foreground text-sm leading-relaxed">
Reading another member's repo takes three steps. Your PDS mints a token
saying you delegated to this page. The space authority exchanges that
for a credential. Each repo host is then given the credential, signed by
the key it names together with the account the read is for. Run it here
to see what each server actually returns.
</p>
<Card className="flex flex-col gap-2 p-4">
<h2 className="font-semibold text-[13px] text-foreground">
The credential's key
</h2>
<p className="text-muted-foreground text-xs leading-relaxed">
Each credential gets a key of its own. It is made non-extractable,
held only in this page, and gone when the page closes. The exchange
names it, the credential is bound to it, and every request that
presents the credential is signed by it.
</p>
{held && (
<Copyable value={held.keyId} className="text-[12px]">
{held.keyId}
</Copyable>
)}
{held && (
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 pt-1">
<span className="text-muted-foreground text-xs">
Credential in hand
</span>
<span className="text-[12px] text-foreground">
{until(held.expiresAt)}
</span>
</div>
)}
</Card>
<Button
className="self-start"
onClick={() => exchange.mutate()}
disabled={exchange.isPending}
>
<Play />
{exchange.isPending ? 'Running…' : 'Run the exchange'}
</Button>
{result?.delegationError && (
<Notice variant="error">
Step 1, your PDS: {result.delegationError}
</Notice>
)}
{result?.delegation && (
<TokenView
title="1. Delegation token"
description="Signed by your account's own key and addressed to this space's authority. It says you delegated to somebody, not which app; a client attestation is what states that separately."
host={result.delegationHost}
jwt={result.delegation}
/>
)}
{result?.credentialError && (
<Notice variant="error">
Step 2, the authority: {result.credentialError}
</Notice>
)}
{result?.credential && (
<TokenView
title="2. Space credential"
description="Signed by the authority, so a repo host verifies it without contacting them. It carries no audience: one credential is presented to every repo host in the space, which is why it is bound to a key instead."
host={result.credentialHost}
jwt={result.credential}
>
<BindingCheck credential={result.credential} keyId={result.keyId} />
</TokenView>
)}
{result?.credential && (
<Notice>
Step 3 happens on every read of another member's repo. The credential
goes in an{' '}
<code className="font-mono">
Authorization: Atproto-Space-Credential
</code>{' '}
header, with the account the read is for in{' '}
<code className="font-mono">Atproto-Space-Audience</code> and a
signature over both. A host refuses a signature made for another
account. A read of a commit or a record also sends{' '}
<code className="font-mono">Atproto-Space-Want-Mac</code>, and the
answer comes back with a MAC from the author's key.
</Notice>
)}
</div>
);
}