1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
// 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);
},
};
}