~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs

0
0
main
171 lines · 9.3 KB Raw History
// Server OAuth sessions for Node hosts: a server acts for each signed-in
// user with THEIR OAuth session (@atproto/oauth-client-node: PAR + PKCE + DPoP, refresh, revoke), any number
// of runs at once, for any number of users. Optional: javascript/src's core (run-module, compose, server)
// never imports it; it plugs into a host as a fetch-style handler (Request -> Response | undefined) plus a
// session lookup per request. Its dependencies are this directory's package.json (`npm install` here).
//
//   const oauth = await oauthServer(ctx, { origin: process.env.ATLIBS_ORIGIN, scope })   // ctx: createNodeContext
//   app.all("/oauth/*", async (c) => (await oauth.handle(c.req.raw)) ?? c.notFound())     // Hono
//   const session = await oauth.sessionOf(request)   // { did, pds, xrpc } of the request's user, or undefined
//   // held to the module's own session record's scope (javascript/src/server sessionCall; stage.json is a policy)
//   runModule(ctx, module, entry, { did: session.did, ...input }, sessionCall(stage, key, hostCall({ ctx, xrpc: session.xrpc, pds: session.pds })))
//
// Routes under `path` (default /oauth/):
//   GET  login?handle=<handle|did>   pushes the authorization request, redirects the browser to the user's server
//   GET  callback                    redeems the code; sets the atlibs_session cookie (also usable as a bearer)
//   POST logout                      revokes the user's tokens and forgets the session
//   GET  client-metadata.json, jwks.json   the confidential client's documents (a public https origin)
// On a public https origin the client is confidential (private_key_jwt, its key sealed in the state dir); on a
// loopback origin (http://127.0.0.1:<port>) it is the loopback development client. The origin is configuration:
// client_id, redirect_uri and the callback are on it, behind a reverse proxy too; the request's Host is not read.
// Sessions: sealed per DID in the state dir (ctx.secrets "oauth-node:<did>"); a token runs out once for all of a
// user's runs, in this process and across processes: one refresh, under the client's per-DID lock (ctx.lock), and a
// refused token is renewed only while it is still the saved one (run-module/host-call.js renewOnce).
import { createHash, randomBytes } from "node:crypto";

import { JoseKey } from "@atproto/jwk-jose";
import { NodeOAuthClient } from "@atproto/oauth-client-node";

import { sessionXrpc } from "../run-module/host-call.js";

const LOOPBACK = new Set(["localhost", "127.0.0.1", "[::1]"]);
export const SESSION_COOKIE = "atlibs_session";
const STATE_COOKIE = "atlibs_oauth_state";

// A SimpleStore over sealed settings (NodeSavedSessionStore / NodeSavedStateStore).
const sealed = (ctx, prefix) => ({
	get: async (key) => (await ctx.secrets.get(`${prefix}${key}`)) ?? undefined,
	set: (key, value) => ctx.secrets.set(`${prefix}${key}`, value),
	del: (key) => ctx.secrets.delete(`${prefix}${key}`),
});

// The confidential client's signing key, made once per state dir (under a lock, so processes agree).
const clientKey = (ctx) =>
	ctx.lock("oauth-node:clientKey", async () => {
		const saved = await ctx.secrets.get("oauth-node:clientKey");
		if (saved) return JoseKey.fromJWK(saved, saved.kid);
		const key = await JoseKey.generate(["ES256"], `atlibs-${randomBytes(6).toString("hex")}`);
		await ctx.secrets.set("oauth-node:clientKey", key.privateJwk);
		return key;
	});

const cookieOf = (request, name) =>
	request.headers
		.get("cookie")
		?.split(";")
		.map((c) => c.trim())
		.find((c) => c.startsWith(`${name}=`))
		?.slice(name.length + 1);

/** The session handle a request carries: `Authorization: Bearer <it>`, else the atlibs_session cookie. */
export const handleOf = (request) => request.headers.get("authorization")?.match(/^Bearer\s+(\S+)$/i)?.[1] ?? (cookieOf(request, SESSION_COOKIE) || undefined);

/**
 * @param {object} ctx  createNodeContext's (secrets, lock, kv)
 * @param {{ origin: string, scope?: string, clientName?: string, path?: string } & Record<string, unknown>} options
 *   origin: the public origin (https://fqdn, behind a reverse proxy too) or a loopback one (http://127.0.0.1:<port>);
 *   the rest goes to NodeOAuthClient (fetch, handleResolver, plcDirectoryUrl, allowHttp: tests)
 */
export async function oauthServer(ctx, { origin, scope = "atproto", clientName = "atlibs", path = "/oauth/", ...options }) {
	if (!origin) throw new Error("oauthServer needs its origin (ATLIBS_ORIGIN): https://<public host>, or http://127.0.0.1:<port>");
	const base = new URL(origin).origin;
	const prefix = `/${path.replace(/^\/+|\/+$/g, "")}/`.replace("//", "/");
	const loopback = LOOPBACK.has(new URL(base).hostname);
	const redirectUri = `${base}${prefix}callback`;
	const clientMetadata = loopback
		? { client_id: `http://localhost?${new URLSearchParams({ redirect_uri: redirectUri, scope })}`, redirect_uris: [redirectUri], scope, token_endpoint_auth_method: "none", dpop_bound_access_tokens: true, grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], application_type: "native" }
		: {
				client_id: `${base}${prefix}client-metadata.json`,
				client_name: clientName,
				client_uri: base,
				redirect_uris: [redirectUri],
				scope,
				grant_types: ["authorization_code", "refresh_token"],
				response_types: ["code"],
				application_type: "web",
				token_endpoint_auth_method: "private_key_jwt",
				token_endpoint_auth_signing_alg: "ES256",
				jwks_uri: `${base}${prefix}jwks.json`,
				dpop_bound_access_tokens: true,
			};
	const client = new NodeOAuthClient({
		clientMetadata,
		keyset: loopback ? undefined : [await clientKey(ctx)],
		stateStore: sealed(ctx, "oauth-node-state:"),
		sessionStore: sealed(ctx, "oauth-node:"),
		requestLock: (name, fn) => ctx.lock(name, fn, 30000),
		...options,
	});
	const secure = base.startsWith("https:") ? "; Secure" : "";
	const setCookie = (name, value, maxAge) => `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`;
	// Session handles: only their sha256 is kept (ctx.kv "oauth-handle:<hex>" -> { did }).
	const handleKey = (handle) => `oauth-handle:${createHash("sha256").update(handle).digest("hex")}`;
	const json = (status, body, headers = []) => new Response(JSON.stringify(body), { status, headers: [["content-type", "application/json"], ...headers] });

	/** The DID a request is signed in as; undefined without a handle; throws (status 401) for an unknown one. */
	async function didOf(request) {
		const handle = handleOf(request);
		if (!handle) return undefined;
		const found = await ctx.kv.get(handleKey(handle));
		if (!found?.did) throw Object.assign(new Error("unknown or expired session: sign in again"), { status: 401 });
		return found.did;
	}

	/** `did`'s session as a host needs it: { did, pds, xrpc } (refreshed as it expires, by this client alone). */
	async function session(did) {
		const oauth = await client.restore(did);
		const { aud } = await oauth.getTokenInfo(false);
		return { did, pds: aud, xrpc: sessionXrpc(oauth), oauth };
	}

	async function handle(request) {
		const url = new URL(request.url);
		if (!url.pathname.startsWith(prefix)) return undefined;
		const route = url.pathname.slice(prefix.length);
		const method = request.method;
		try {
			if (method === "GET" && route === "client-metadata.json" && !loopback) return json(200, client.clientMetadata);
			if (method === "GET" && route === "jwks.json" && !loopback) return json(200, client.jwks);
			if (method === "GET" && route === "login") {
				const who = url.searchParams.get("handle")?.trim().replace(/^@/, "");
				if (!who) return json(400, { error: "InvalidRequest", message: `GET ${prefix}login?handle=<handle or DID>` });
				const state = randomBytes(16).toString("base64url");
				const to = await client.authorize(who, { scope, state });
				return new Response(null, { status: 302, headers: [["location", to.href], ["set-cookie", setCookie(STATE_COOKIE, state, 600)]] });
			}
			if (method === "GET" && route === "callback") {
				const { session: signedIn, state } = await client.callback(url.searchParams);
				// the browser that left must be the one back: the state it carried is in its cookie
				if (!state || state !== cookieOf(request, STATE_COOKIE)) {
					await signedIn.signOut().catch(() => {});
					return json(403, { error: "Forbidden", message: "this sign-in was not started by this browser" });
				}
				const handle = randomBytes(32).toString("base64url");
				await ctx.kv.put(handleKey(handle), { did: signedIn.did, createdAt: new Date().toISOString() });
				return new Response(null, { status: 303, headers: [["location", `${base}/`], ["set-cookie", setCookie(SESSION_COOKIE, handle, 30 * 86400)], ["set-cookie", setCookie(STATE_COOKIE, "", 0)]] });
			}
			if (method === "POST" && route === "logout") {
				const did = await didOf(request);
				if (!did) return json(401, { error: "AuthRequired", message: "not signed in" });
				await client.revoke(did);
				await ctx.kv.delete(handleKey(handleOf(request)));
				return json(200, { signedOut: did }, [["set-cookie", setCookie(SESSION_COOKIE, "", 0)]]);
			}
		} catch (error) {
			return json(error.status ?? 400, { error: "AuthFailed", message: error.message });
		}
		return undefined;
	}

	return {
		client,
		handle,
		session,
		didOf,
		/** The request's user's session ({ did, pds, xrpc }), undefined when it carries none; throws (status 401) for a bad one. */
		sessionOf: async (request) => {
			const did = await didOf(request);
			return did && session(did);
		},
	};
}