chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// @pdsjs/spaces/attestation - which application is asking for a credential.
//
// A delegation token says the user delegated to somebody. The client
// attestation says who. It is a `private_key_jwt` signed by the client's own
// authentication key, so establishing the client_id means resolving it to its
// client-metadata.json, fetching the key set that document publishes, and
// checking the signature against the key its `kid` names.
//
// All of it, or a memberspace `allowedApps` list states a rule the authority
// cannot enforce: the client_id is otherwise a claim the caller writes for itself.
import {
fetchClientMetadata,
resolveClientJwk,
verifyEs256WithJwk,
} from '@pdsjs/core/oauth';
import { spend } from './single-use.js';
import { parseSpaceToken, SpaceTokenError } from './token.js';
/**
* Verify a client attestation and return the client_id it establishes.
*
* @param {Object} opts
* @param {string} opts.attestation - the raw JWT
* @param {string} opts.aud - the space host the attestation must address
* @returns {Promise<string>} the attested client_id
* @throws {SpaceTokenError}
*/
export async function verifyClientAttestation({ attestation, aud }) {
const { header, payload, signingInput, sig } = parseSpaceToken(
'clientAttestation',
attestation,
);
const clientId = payload.iss;
if (payload.aud !== aud) {
throw new SpaceTokenError(
'Client attestation is addressed to another authority',
'BadJwtAudience',
);
}
const now = Math.floor(Date.now() / 1000);
if (payload.exp <= now) {
throw new SpaceTokenError('Client attestation has expired', 'JwtExpired');
}
if (header.alg !== 'ES256') {
throw new SpaceTokenError(
`Client attestation "alg" must be ES256, got "${header.alg}"`,
'BadJwt',
);
}
if (typeof header.kid !== 'string' || !header.kid) {
throw new SpaceTokenError('Client attestation needs a "kid"', 'BadJwt');
}
/** @type {import('@pdsjs/core/oauth').ClientMetadata} */
let metadata;
try {
metadata = await fetchClientMetadata(clientId);
} catch (err) {
throw new SpaceTokenError(
`Could not resolve client metadata for ${clientId}: ${err instanceof Error ? err.message : String(err)}`,
'BadJwtIss',
);
}
const jwk = await resolveClientJwk(metadata, header.kid);
if (!jwk) {
throw new SpaceTokenError(
`Client ${clientId} publishes no key "${header.kid}" to verify an attestation against`,
'BadJwtSignature',
);
}
let valid = false;
try {
valid = await verifyEs256WithJwk(
jwk,
new TextDecoder().decode(signingInput),
base64Url(sig),
);
} catch {
valid = false;
}
if (!valid) {
throw new SpaceTokenError(
'Client attestation signature is invalid',
'BadJwtSignature',
);
}
// Spent after the signature, so an unsigned guess cannot burn the jti of an
// attestation the client is about to present.
if (!spend(`attestation@${clientId}`, payload.jti, payload.exp * 1000)) {
throw new SpaceTokenError(
`Client attestation for ${clientId} has already been used`,
'BadJwt',
);
}
return clientId;
}
/**
* @param {Uint8Array} bytes
* @returns {string} base64url, unpadded
*/
function base64Url(bytes) {
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}