// 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= 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:) 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:"); 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 `, 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} options * origin: the public origin (https://fqdn, behind a reverse proxy too) or a loopback one (http://127.0.0.1:); * 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://, or http://127.0.0.1:"); 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:" -> { 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=` }); 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); }, }; }