v4: an instance can be named by the address of its pages — sign-in and the trust list took only an instance's issuer, its API's origin, so where the pages live elsewhere the address people see in their browser named no instance: in development, localhost:3000 answered its /.well-known with index.html, and the sign-in failed as "doesn't answer as a gerbeur". resolveIssuer (remote/gerbeur-instance.ts) now reads the metadata at the address given and, when it names another issuer, takes it only if that issuer's own metadata names those pages back as where its /authorize is, so an impostor can't claim one; sign-in, the trust list and the check against this instance go through it. The Vite dev server passes /.well-known on to its API, as whatever serves the pages must in production (README). A handle still carries the API's host.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,8 @@ const USER_AGENT = `gerbeur (${OG_SITE_NAME}; federation)`;
|
||||
const metadataCache = new TtlCache<InstanceMetadata>(10 * 60_000, 500);
|
||||
const keysCache = new TtlCache<JSONWebKeySet>(10 * 60_000, 500);
|
||||
const keysRefetched = new TtlCache<true>(60_000, 500);
|
||||
/** Keeps which issuer an instance's pages belong to ({@link resolveIssuer}). */
|
||||
const issuerCache = new TtlCache<string>(10 * 60_000, 500);
|
||||
|
||||
/**
|
||||
* Checks that `url` may be reached: https on a public address, or anything
|
||||
@@ -97,15 +99,12 @@ async function readJson(res: Response, what: string): Promise<unknown> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the metadata of the instance at `origin`, from its
|
||||
* `/.well-known/openid-configuration`, cached. It must name `origin` as its
|
||||
* issuer and speak this protocol's version.
|
||||
* Returns the metadata `origin` serves at `/.well-known/openid-configuration`,
|
||||
* whichever issuer it names.
|
||||
*
|
||||
* @throws {Error} When it can't be reached or isn't a gerbeur instance.
|
||||
* @throws {Error} When it can't be reached or isn't a gerbeur instance's.
|
||||
*/
|
||||
export async function fetchMetadata(origin: string): Promise<InstanceMetadata> {
|
||||
const hit = metadataCache.get(origin);
|
||||
if (hit) return hit;
|
||||
async function metadataAt(origin: string): Promise<InstanceMetadata> {
|
||||
const body = await readJson(
|
||||
await request(`${origin}/.well-known/openid-configuration`),
|
||||
origin,
|
||||
@@ -113,12 +112,54 @@ export async function fetchMetadata(origin: string): Promise<InstanceMetadata> {
|
||||
if (!isInstanceMetadata(body)) {
|
||||
throw new Error(`${origin} is not a gerbeur instance this one can trust`);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the metadata of the instance at `origin`, its issuer, cached. It
|
||||
* must name `origin` as its issuer and speak this protocol's version.
|
||||
*
|
||||
* @throws {Error} When it can't be reached or isn't a gerbeur instance.
|
||||
*/
|
||||
export async function fetchMetadata(origin: string): Promise<InstanceMetadata> {
|
||||
const hit = metadataCache.get(origin);
|
||||
if (hit) return hit;
|
||||
const body = await metadataAt(origin);
|
||||
if (body.issuer !== origin) {
|
||||
throw new Error(`${origin} names itself ${body.issuer}`);
|
||||
}
|
||||
return metadataCache.set(origin, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the issuer of the instance at `origin`, cached: `origin` itself, or,
|
||||
* when `origin` is where the instance's pages are and its API lives at
|
||||
* another, the issuer the pages' metadata names, whose own metadata must name
|
||||
* those pages back as where its `/authorize` is. People know an instance by
|
||||
* the address in their browser, which a sign-in form or the trust list may be
|
||||
* given; a handle and the stored identities name the issuer.
|
||||
*
|
||||
* @throws {Error} When `origin` can't be reached, isn't a gerbeur instance,
|
||||
* or names an issuer that doesn't name it back.
|
||||
*/
|
||||
export async function resolveIssuer(origin: string): Promise<string> {
|
||||
const hit = issuerCache.get(origin);
|
||||
if (hit) return hit;
|
||||
if (metadataCache.get(origin)) return origin;
|
||||
const body = await metadataAt(origin);
|
||||
if (body.issuer === origin) {
|
||||
metadataCache.set(origin, body);
|
||||
return issuerCache.set(origin, origin);
|
||||
}
|
||||
const pages =
|
||||
new URL((await fetchMetadata(body.issuer)).authorization_endpoint)
|
||||
.origin;
|
||||
if (pages !== origin) {
|
||||
throw new Error(`${origin} names ${body.issuer}, whose pages are ${pages}`);
|
||||
}
|
||||
return issuerCache.set(origin, body.issuer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the public keys of the instance at `origin`, cached unless `fresh`
|
||||
* asks to refetch them and they weren't in the last minute.
|
||||
@@ -224,6 +265,7 @@ export async function fetchAvatar(url: string): Promise<Uint8Array> {
|
||||
/** Empties the caches, for the tests' reset between cases. */
|
||||
export function clearInstanceCaches(): void {
|
||||
metadataCache.clear();
|
||||
issuerCache.clear();
|
||||
keysCache.clear();
|
||||
keysRefetched.clear();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user