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} */ 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 (
Bound to {String(bound).slice(0, 16)}… {matches ? ( this page's key ) : ( another key )}
); } export function CredentialPage({ space }) { const { session } = useSession(); const held = heldCredential(space); const exchange = useMutation({ mutationFn: () => runExchange(session, space), }); const result = exchange.data; return (
← Back to the space

Credential

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.

The credential's key

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.

{held && ( {held.keyId} )} {held && (
Credential in hand {until(held.expiresAt)}
)}
{result?.delegationError && ( Step 1, your PDS: {result.delegationError} )} {result?.delegation && ( )} {result?.credentialError && ( Step 2, the authority: {result.credentialError} )} {result?.credential && ( )} {result?.credential && ( Step 3 happens on every read of another member's repo. The credential goes in an{' '} Authorization: Atproto-Space-Credential {' '} header, with the account the read is for in{' '} Atproto-Space-Audience and a signature over both. A host refuses a signature made for another account. A read of a commit or a record also sends{' '} Atproto-Space-Want-Mac, and the answer comes back with a MAC from the author's key. )}
); }