Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/indieauth-dispatch-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@dwk/indieauth": patch
---

Wrap the handler's route dispatch in a try/catch so an unexpected exception
(e.g. a D1 failure) returns a structured `server_error` OAuth response instead
of crashing unhandled. Also add a runtime shape guard on the stored `profile`
JSON before trusting it as `ProfileInfo`, instead of blind-casting it.
6 changes: 6 additions & 0 deletions .changeset/microsub-background-follow-poll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@dwk/microsub": patch
---

Send the poll-priming queue message in `follow` via `ctx.waitUntil` instead of
blocking the HTTP response on it.
7 changes: 7 additions & 0 deletions .changeset/vc-per-instance-signer-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@dwk/vc": patch
---

Scope the signer cache to each `createVc()` instance instead of a module-level
global, so independently configured instances in the same isolate no longer
share cache state.
33 changes: 19 additions & 14 deletions examples/deploy-to-cloudflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,21 +94,26 @@ into this Worker — IndieAuth, Micropub, Webmention, an edge Solid Pod, Activit

export default {
async fetch(request, env, ctx): Promise<Response> {
const url = new URL(request.url);
const { webfinger, hostMeta } = createHandlers(url.hostname);
try {
const url = new URL(request.url);
const { webfinger, hostMeta } = createHandlers(url.hostname);

if (url.pathname === "/.well-known/webfinger") {
return webfinger(request, env, ctx);
if (url.pathname === "/.well-known/webfinger") {
return await webfinger(request, env, ctx);
}
if (
url.pathname === "/.well-known/host-meta" ||
url.pathname === "/.well-known/host-meta.json"
) {
return await hostMeta(request, env, ctx);
}
if (url.pathname === "/") {
return landing(url.hostname);
}
return new Response("Not found", { status: 404 });
} catch (error) {
console.error("dwk-discovery-starter: unhandled fetch error", error);
return new Response("Internal Server Error", { status: 500 });
}
if (
url.pathname === "/.well-known/host-meta" ||
url.pathname === "/.well-known/host-meta.json"
) {
return hostMeta(request, env, ctx);
}
if (url.pathname === "/") {
return landing(url.hostname);
}
return new Response("Not found", { status: 404 });
},
} satisfies ExportedHandler<WebfingerEnv & HostMetaEnv>;
5 changes: 3 additions & 2 deletions packages/conformance-target/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,10 @@ export default {
// drainBody middleware otherwise reads it after the response and, across
// the Durable Object fetch boundary, that late read crashes workerd
// (litmus `locks` never finished locally until this; harmless in
// production, where no such middleware exists).
// production, where no such middleware exists). Backgrounded via
// waitUntil so the isolate isn't torn down before it settles.
if (request.body !== null && !request.bodyUsed) {
request.body.cancel().catch(() => undefined);
ctx.waitUntil(request.body.cancel().catch(() => undefined));
}
return response;
} catch (error) {
Expand Down
2 changes: 1 addition & 1 deletion packages/conformance-target/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "node_modules/wrangler/config-schema.json",
"name": "dwk-conformance-target",
"main": "src/index.ts",
"compatibility_date": "2025-01-01",
"compatibility_date": "2026-07-01",
"compatibility_flags": ["nodejs_compat"],
"routes": [{ "pattern": "conformance.dwk.io", "custom_domain": true }],
"vars": {
Expand Down
77 changes: 54 additions & 23 deletions packages/indieauth/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,13 @@ function oauthError(
/**
* Emit a structured event on both the logger and the metrics seam, which share
* one event vocabulary (see `@dwk/log`): `warn` for handled-but-notable
* rejections, `info` for normal outcomes. Honors the redaction policy — callers
* rejections, `info` for normal outcomes, `error` for an unexpected exception
* that escaped normal request handling. Honors the redaction policy — callers
* pass only reason codes, sanitized hosts, and scopes, never codes or tokens.
*/
function emit(
config: ResolvedConfig,
level: "info" | "warn",
level: "info" | "warn" | "error",
event: string,
fields?: LogFields,
): void {
Expand Down Expand Up @@ -112,10 +113,29 @@ function redirectError(
return Response.redirect(url.toString(), 302);
}

/** Optional `ProfileInfo` keys, each required to be a string when present. */
const PROFILE_INFO_KEYS = ["name", "url", "photo", "email"] as const;

/**
* Whether `value` has the {@link ProfileInfo} shape: a non-null, non-array
* object whose `name`/`url`/`photo`/`email` keys, if present, are strings. All
* keys are optional, so an empty object passes.
*/
function isProfileInfo(value: unknown): value is ProfileInfo {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false;
}
const record = value as Record<string, unknown>;
return PROFILE_INFO_KEYS.every(
(key) => !(key in record) || typeof record[key] === "string",
);
}

function parseProfile(profile: string | null): ProfileInfo | undefined {
if (profile === null) return undefined;
try {
return JSON.parse(profile) as ProfileInfo;
const parsed: unknown = JSON.parse(profile);
return isProfileInfo(parsed) ? parsed : undefined;
} catch {
return undefined;
}
Expand Down Expand Up @@ -654,32 +674,43 @@ export function createIndieAuth(config: IndieAuthConfig): IndieAuthHandler {
const { pathname } = new URL(request.url);
const method = request.method.toUpperCase();

if (pathname === resolved.metadataPath) {
if (method !== "GET") return methodNotAllowed("GET");
return json(buildServerMetadata(resolved));
}
// Everything below can throw on an unexpected failure (e.g. D1 rejecting a
// query) after the fail-loudly binding checks above have already run — a
// bare exception here must not escape as an unhandled Worker crash, so it
// is reported as a well-formed (and non-leaky) OAuth error instead.
try {
if (pathname === resolved.metadataPath) {
if (method !== "GET") return methodNotAllowed("GET");
return json(buildServerMetadata(resolved));
}

if (pathname === resolved.authorizationPath) {
if (method === "GET") {
return handleAuthorizationGet(request, resolved, store);
if (pathname === resolved.authorizationPath) {
if (method === "GET") {
return await handleAuthorizationGet(request, resolved, store);
}
if (method === "POST") {
return await handleProfileExchange(request, store, resolved);
}
return methodNotAllowed("GET, POST");
}
if (method === "POST") {
return handleProfileExchange(request, store, resolved);

if (pathname === resolved.tokenPath) {
if (method !== "POST") return methodNotAllowed("POST");
return await handleToken(request, resolved, store, signingKey);
}
return methodNotAllowed("GET, POST");
}

if (pathname === resolved.tokenPath) {
if (method !== "POST") return methodNotAllowed("POST");
return handleToken(request, resolved, store, signingKey);
}
if (pathname === resolved.revocationPath) {
if (method !== "POST") return methodNotAllowed("POST");
return await handleRevocation(request, store, resolved, signingKey);
}

if (pathname === resolved.revocationPath) {
if (method !== "POST") return methodNotAllowed("POST");
return handleRevocation(request, store, resolved, signingKey);
return new Response("Not Found", { status: 404 });
} catch (err) {
emit(resolved, "error", IndieAuthLogEvent.UnhandledError, {
message: err instanceof Error ? err.message : "unknown error",
});
return oauthError("server_error", "an unexpected error occurred", 500);
}

return new Response("Not Found", { status: 404 });
};
}

Expand Down
117 changes: 117 additions & 0 deletions packages/indieauth/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,76 @@ describe("@dwk/indieauth profile-URL exchange", () => {
});
});

describe("@dwk/indieauth stored-profile shape validation", () => {
async function codeWithStoredProfile(profile: string): Promise<string> {
const store = createIndieAuthStore(harness);
const code = `profile-shape-${crypto.randomUUID()}`;
await store.saveAuthorizationCode({
code,
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
scope: "create",
me: ME,
codeChallenge: await s256(CODE_VERIFIER),
codeChallengeMethod: "S256",
profile,
expiresAt: Math.floor(Date.now() / 1000) + 600,
});
return code;
}

async function redeemAtAuthorizeEndpoint(
handler: ReturnType<typeof createIndieAuth>,
code: string,
): Promise<Record<string, unknown>> {
const res = await handler(
new Request(`${BASE}/authorize`, {
method: "POST",
body: new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
code_verifier: CODE_VERIFIER,
}),
}),
harness,
ctx,
);
expect(res.status).toBe(200);
return (await res.json()) as Record<string, unknown>;
}

it("omits a stored profile that is not an object", async () => {
const handler = autoApproveHandler();
const code = await codeWithStoredProfile(
JSON.stringify(["not", "an", "object"]),
);
const body = await redeemAtAuthorizeEndpoint(handler, code);
expect(body.me).toBe(ME);
expect(body.profile).toBeUndefined();
});

it("omits a stored profile with a wrong-typed field", async () => {
const handler = autoApproveHandler();
const code = await codeWithStoredProfile(JSON.stringify({ name: 42 }));
const body = await redeemAtAuthorizeEndpoint(handler, code);
expect(body.profile).toBeUndefined();
});

it("still returns a well-formed stored profile", async () => {
const handler = autoApproveHandler();
const code = await codeWithStoredProfile(
JSON.stringify({ name: "Alice", url: "https://alice.example.com/" }),
);
const body = await redeemAtAuthorizeEndpoint(handler, code);
expect(body.profile).toEqual({
name: "Alice",
url: "https://alice.example.com/",
});
});
});

describe("@dwk/indieauth revocation", () => {
it("revokes an issued token", async () => {
const handler = autoApproveHandler();
Expand Down Expand Up @@ -1193,3 +1263,50 @@ describe("@dwk/indieauth fails loudly on missing bindings", () => {
).rejects.toThrow(/AUTH_DB/);
});
});

describe("@dwk/indieauth unhandled dispatch errors", () => {
it("returns a structured 500 instead of throwing when the approval hook throws", async () => {
const handler = createIndieAuth(
baseConfig(() => {
throw new Error("sensitive backend detail");
}),
);
const res = await handler(
new Request(await authorizeUrl(await s256(CODE_VERIFIER)), {
redirect: "manual",
}),
harness,
ctx,
);
expect(res.status).toBe(500);
const body = (await res.json()) as {
error: string;
error_description: string;
};
expect(body.error).toBe("server_error");
expect(body.error_description).not.toContain("sensitive backend detail");
});

it("returns a structured 500 instead of throwing on an unexpected D1 failure", async () => {
const handler = autoApproveHandler();
const brokenDb = {
prepare: () => {
throw new Error("simulated D1 outage: internal detail");
},
} as unknown as D1Database;
const res = await handler(
new Request(await authorizeUrl(await s256(CODE_VERIFIER)), {
redirect: "manual",
}),
{ ...harness, AUTH_DB: brokenDb },
ctx,
);
expect(res.status).toBe(500);
const body = (await res.json()) as {
error: string;
error_description: string;
};
expect(body.error).toBe("server_error");
expect(body.error_description).not.toContain("simulated D1 outage");
});
});
6 changes: 6 additions & 0 deletions packages/indieauth/src/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ export const IndieAuthLogEvent = {
TokenRejected: "indieauth.token.rejected",
/** A token was revoked at the revocation endpoint. */
TokenRevoked: "indieauth.token.revoked",
/**
* An unexpected exception escaped route dispatch (e.g. a D1 failure) instead
* of being reported as a structured rejection. Field: `message` (the
* exception's message, logged only — never returned to the client).
*/
UnhandledError: "indieauth.error.unhandled",
} as const;

/** Union of the event-name string literals in {@link IndieAuthLogEvent}. */
Expand Down
Loading