@njinlabs/njin 0.10.2 → 0.11.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/package.json +19 -4
- package/src/cli/build-assets.ts +4 -1
- package/src/cli/build.ts +47 -23
- package/src/cli/create.ts +35 -9
- package/src/cli/index.ts +2 -1
- package/src/cli/update.ts +93 -16
- package/src/config/module.ts +9 -0
- package/src/core/adapters/bun_filesystem.ts +13 -5
- package/src/core/adapters/s3.ts +18 -6
- package/src/core/admin_schema.ts +54 -0
- package/src/core/banner.ts +18 -4
- package/src/core/config.ts +49 -11
- package/src/core/helper.ts +4 -1
- package/src/core/html_page.ts +63 -0
- package/src/core/model/data_type/array.ts +16 -5
- package/src/core/model/data_type/boolean.ts +4 -1
- package/src/core/model/data_type/date.ts +4 -1
- package/src/core/model/data_type/email.ts +4 -1
- package/src/core/model/data_type/file.ts +17 -4
- package/src/core/model/data_type/multi_file.ts +9 -4
- package/src/core/model/data_type/numeric.ts +4 -1
- package/src/core/model/data_type/object.ts +8 -2
- package/src/core/model/data_type/relation.ts +21 -5
- package/src/core/model/data_type/relation_many.ts +24 -6
- package/src/core/model/data_type/richtext.ts +4 -1
- package/src/core/model/data_type/select.ts +17 -3
- package/src/core/model/data_type/text.ts +4 -1
- package/src/core/model/hooks.ts +42 -15
- package/src/core/model/index.ts +101 -37
- package/src/core/path_guard.ts +10 -3
- package/src/core/plugin.ts +8 -1
- package/src/core/public_url.ts +24 -0
- package/src/core/route.ts +2 -1
- package/src/core/vars/index.ts +22 -8
- package/src/core/worker.ts +22 -6
- package/src/models/file.ts +1 -1
- package/src/models/user.ts +1 -1
- package/src/modules/admin.ts +2 -2
- package/src/modules/analytics.ts +60 -25
- package/src/modules/api.ts +67 -95
- package/src/modules/auth.ts +25 -8
- package/src/modules/elysia.ts +1 -1
- package/src/modules/file.ts +50 -33
- package/src/modules/img.ts +33 -14
- package/src/modules/logger.ts +1 -1
- package/src/modules/mcp.ts +437 -0
- package/src/modules/mcp_token.ts +295 -0
- package/src/modules/mcp_upload.ts +313 -0
- package/src/modules/oauth.ts +550 -0
- package/src/modules/setup.ts +13 -5
- package/src/modules/surreal.ts +43 -13
- package/src/modules/users.ts +23 -5
- package/src/modules/vars.ts +1 -1
- package/src/modules/view.ts +88 -54
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import Elysia from "elysia";
|
|
3
|
+
import moment from "moment";
|
|
4
|
+
import { eq, RecordId, Table } from "surrealdb";
|
|
5
|
+
import { escapeHtml, noStore, page } from "../core/html_page";
|
|
6
|
+
import { makeModule } from "../core/module";
|
|
7
|
+
import { publicBase } from "../core/public_url";
|
|
8
|
+
import elysia from "./elysia";
|
|
9
|
+
import {
|
|
10
|
+
issueOAuthTokens,
|
|
11
|
+
revokeOAuthToken,
|
|
12
|
+
rotateOAuthTokens,
|
|
13
|
+
} from "./mcp_token";
|
|
14
|
+
import surreal from "./surreal";
|
|
15
|
+
|
|
16
|
+
// OAuth 2.1 authorization server for MCP clients (Claude etc.), living in the same process as
|
|
17
|
+
// the MCP endpoint: the user signs in with their normal njin account on a page served here and
|
|
18
|
+
// the agent gets its own revocable token — nobody pastes a token anywhere. Public clients only
|
|
19
|
+
// (PKCE S256, no client secret), registered through RFC 7591 dynamic client registration.
|
|
20
|
+
|
|
21
|
+
const clientTable = new Table("oauth_client");
|
|
22
|
+
const codeTable = new Table("oauth_code");
|
|
23
|
+
|
|
24
|
+
const CODE_TTL_MINUTES = 10;
|
|
25
|
+
const UNUSED_CLIENT_TTL_HOURS = 24;
|
|
26
|
+
|
|
27
|
+
// Dynamic registration is open to anyone who can reach the server, so the redirect URI is the
|
|
28
|
+
// only thing stopping a rogue client from receiving an authorization code — only the hosted
|
|
29
|
+
// Claude callbacks and loopback addresses (native/dev clients, RFC 8252) are accepted.
|
|
30
|
+
const HOSTED_CALLBACKS = new Set([
|
|
31
|
+
"https://claude.ai/api/mcp/auth_callback",
|
|
32
|
+
"https://claude.com/api/mcp/auth_callback",
|
|
33
|
+
]);
|
|
34
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
35
|
+
|
|
36
|
+
const parseUrl = (value: string) => {
|
|
37
|
+
try {
|
|
38
|
+
return new URL(value);
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const isAllowedRedirectUri = (value: string) => {
|
|
45
|
+
const url = parseUrl(value);
|
|
46
|
+
if (!url || url.hash) return false;
|
|
47
|
+
if (HOSTED_CALLBACKS.has(value)) return true;
|
|
48
|
+
|
|
49
|
+
return url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Exact match, except that a loopback client's ephemeral port is ignored (RFC 8252 §7.3) —
|
|
53
|
+
// a native app can't know its port at registration time.
|
|
54
|
+
const redirectMatches = (requested: string, registered: string[]) => {
|
|
55
|
+
if (registered.includes(requested)) return true;
|
|
56
|
+
|
|
57
|
+
const req = parseUrl(requested);
|
|
58
|
+
if (req?.protocol !== "http:" || !LOOPBACK_HOSTS.has(req.hostname)) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return registered.some((candidate) => {
|
|
63
|
+
const reg = parseUrl(candidate);
|
|
64
|
+
return (
|
|
65
|
+
reg?.protocol === "http:" &&
|
|
66
|
+
reg.hostname === req.hostname &&
|
|
67
|
+
reg.pathname === req.pathname &&
|
|
68
|
+
reg.search === req.search
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type OAuthClient = {
|
|
74
|
+
id: RecordId;
|
|
75
|
+
clientName: string;
|
|
76
|
+
redirectUris: string[];
|
|
77
|
+
// Flipped once any user completes sign-in for it, so abandoned registrations (Claude
|
|
78
|
+
// registers a fresh client per connection attempt) can be pruned without touching real ones.
|
|
79
|
+
used: boolean;
|
|
80
|
+
createdAt: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
type OAuthCode = {
|
|
84
|
+
id: RecordId;
|
|
85
|
+
clientId: string;
|
|
86
|
+
userId: RecordId;
|
|
87
|
+
redirectUri: string;
|
|
88
|
+
codeChallenge: string;
|
|
89
|
+
expiresAt: string;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const json = (body: unknown, status = 200, headers: HeadersInit = {}) =>
|
|
93
|
+
new Response(JSON.stringify(body), {
|
|
94
|
+
status,
|
|
95
|
+
headers: { "Content-Type": "application/json", ...noStore, ...headers },
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const oauthError = (
|
|
99
|
+
error: string,
|
|
100
|
+
description: string,
|
|
101
|
+
status = 400,
|
|
102
|
+
headers: HeadersInit = {},
|
|
103
|
+
) => json({ error, error_description: description }, status, headers);
|
|
104
|
+
|
|
105
|
+
const errorPage = (message: string) =>
|
|
106
|
+
page(
|
|
107
|
+
"Cannot authorize",
|
|
108
|
+
`<h1>Cannot authorize</h1><p>${escapeHtml(message)}</p>`,
|
|
109
|
+
400,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
type AuthorizeParams = {
|
|
113
|
+
clientId: string;
|
|
114
|
+
redirectUri: string;
|
|
115
|
+
codeChallenge: string;
|
|
116
|
+
state: string | null;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const consentPage = (
|
|
120
|
+
client: OAuthClient,
|
|
121
|
+
params: AuthorizeParams,
|
|
122
|
+
error?: string,
|
|
123
|
+
status = 200,
|
|
124
|
+
) => {
|
|
125
|
+
const hidden = (name: string, value: string | null) =>
|
|
126
|
+
value === null
|
|
127
|
+
? ""
|
|
128
|
+
: `<input type="hidden" name="${name}" value="${escapeHtml(value)}">`;
|
|
129
|
+
|
|
130
|
+
// The redirect host is spelled out because that — not the client's self-chosen display
|
|
131
|
+
// name — is where the authorization code actually goes (MCP authorization spec).
|
|
132
|
+
const host = parseUrl(params.redirectUri)?.host ?? params.redirectUri;
|
|
133
|
+
|
|
134
|
+
return page(
|
|
135
|
+
"Authorize access",
|
|
136
|
+
`<h1>Authorize ${escapeHtml(client.clientName)}</h1>
|
|
137
|
+
<p><strong>${escapeHtml(client.clientName)}</strong> wants to manage this site on your behalf: read, create, change and delete content, settings and files. It will return to <span class="host">${escapeHtml(host)}</span>.</p>
|
|
138
|
+
${error ? `<p class="error">${escapeHtml(error)}</p>` : ""}
|
|
139
|
+
<form method="post" action="/oauth/authorize">
|
|
140
|
+
${hidden("client_id", params.clientId)}
|
|
141
|
+
${hidden("redirect_uri", params.redirectUri)}
|
|
142
|
+
${hidden("code_challenge", params.codeChallenge)}
|
|
143
|
+
${hidden("state", params.state)}
|
|
144
|
+
<label for="email">Email</label>
|
|
145
|
+
<input id="email" name="email" type="email" autocomplete="username" required autofocus>
|
|
146
|
+
<label for="password">Password</label>
|
|
147
|
+
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
|
148
|
+
<div class="row">
|
|
149
|
+
<button type="submit" name="action" value="deny" formnovalidate>Deny</button>
|
|
150
|
+
<button type="submit" name="action" value="approve" class="primary">Allow</button>
|
|
151
|
+
</div>
|
|
152
|
+
</form>`,
|
|
153
|
+
status,
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const redirectWith = (
|
|
158
|
+
redirectUri: string,
|
|
159
|
+
values: Record<string, string | null>,
|
|
160
|
+
) => {
|
|
161
|
+
const url = new URL(redirectUri);
|
|
162
|
+
for (const [key, value] of Object.entries(values)) {
|
|
163
|
+
if (value !== null) url.searchParams.set(key, value);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return new Response(null, {
|
|
167
|
+
status: 303,
|
|
168
|
+
headers: { Location: url.toString(), ...noStore },
|
|
169
|
+
});
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Wrong-password attempts are capped per account (in-memory, per worker — a speed bump, not a
|
|
173
|
+
// guarantee: njin has no general rate limiting yet, and this page takes a password).
|
|
174
|
+
const MAX_FAILED_SIGN_INS = 10;
|
|
175
|
+
const FAILED_WINDOW_MS = 15 * 60 * 1000;
|
|
176
|
+
const failedSignIns = new Map<string, { count: number; resetAt: number }>();
|
|
177
|
+
|
|
178
|
+
const isLockedOut = (email: string) => {
|
|
179
|
+
const entry = failedSignIns.get(email);
|
|
180
|
+
if (!entry) return false;
|
|
181
|
+
if (Date.now() > entry.resetAt) {
|
|
182
|
+
failedSignIns.delete(email);
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
return entry.count >= MAX_FAILED_SIGN_INS;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const recordFailedSignIn = (email: string) => {
|
|
189
|
+
const entry = failedSignIns.get(email);
|
|
190
|
+
if (!entry || Date.now() > entry.resetAt) {
|
|
191
|
+
failedSignIns.set(email, {
|
|
192
|
+
count: 1,
|
|
193
|
+
resetAt: Date.now() + FAILED_WINDOW_MS,
|
|
194
|
+
});
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
entry.count += 1;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// Verified against when the email is unknown, so a wrong email and a wrong password cost the
|
|
201
|
+
// same amount of time and the page doesn't reveal which accounts exist.
|
|
202
|
+
const DUMMY_HASH = Bun.password.hashSync("njin-dummy-password");
|
|
203
|
+
|
|
204
|
+
const hashCode = (code: string) =>
|
|
205
|
+
createHash("sha256").update(code).digest("hex");
|
|
206
|
+
|
|
207
|
+
const pkceChallenge = (verifier: string) =>
|
|
208
|
+
createHash("sha256").update(verifier).digest("base64url");
|
|
209
|
+
|
|
210
|
+
const readForm = async (request: Request) =>
|
|
211
|
+
new URLSearchParams(await request.text());
|
|
212
|
+
|
|
213
|
+
const pruneExpired = async () => {
|
|
214
|
+
await surreal().query(
|
|
215
|
+
"DELETE oauth_code WHERE expiresAt < $now; DELETE oauth_client WHERE used = false AND createdAt < $cutoff;",
|
|
216
|
+
{
|
|
217
|
+
now: moment().toISOString(),
|
|
218
|
+
cutoff: moment().subtract(UNUSED_CLIENT_TTL_HOURS, "hours").toISOString(),
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const oauth = makeModule(() => {
|
|
224
|
+
const fn = () => {};
|
|
225
|
+
|
|
226
|
+
fn.init = async () => {
|
|
227
|
+
const resourceMetadata = ({ request }: { request: Request }) => {
|
|
228
|
+
const base = publicBase(request);
|
|
229
|
+
|
|
230
|
+
return json({
|
|
231
|
+
resource: `${base}/mcp`,
|
|
232
|
+
authorization_servers: [base],
|
|
233
|
+
bearer_methods_supported: ["header"],
|
|
234
|
+
resource_name: "njin",
|
|
235
|
+
});
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const lookupClient = async (clientId: string) =>
|
|
239
|
+
clientId
|
|
240
|
+
? ((await surreal().select<OAuthClient>(
|
|
241
|
+
new RecordId(clientTable, clientId),
|
|
242
|
+
)) ?? null)
|
|
243
|
+
: null;
|
|
244
|
+
|
|
245
|
+
// Shared by GET (show the page) and POST (submit it): resolves the client and validates the
|
|
246
|
+
// request. A bad client_id or redirect_uri is shown to the user and never redirected to —
|
|
247
|
+
// redirecting to an unvalidated URI would turn this endpoint into an open redirector.
|
|
248
|
+
const resolveAuthorize = async (values: URLSearchParams) => {
|
|
249
|
+
const clientId = values.get("client_id") ?? "";
|
|
250
|
+
const redirectUri = values.get("redirect_uri") ?? "";
|
|
251
|
+
|
|
252
|
+
const client = await lookupClient(clientId);
|
|
253
|
+
if (!client) {
|
|
254
|
+
return { response: errorPage("Unknown client."), client: null };
|
|
255
|
+
}
|
|
256
|
+
if (!redirectMatches(redirectUri, client.redirectUris)) {
|
|
257
|
+
return {
|
|
258
|
+
response: errorPage("The redirect address is not registered."),
|
|
259
|
+
client: null,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const state = values.get("state");
|
|
264
|
+
const reject = (error: string, description: string) =>
|
|
265
|
+
redirectWith(redirectUri, {
|
|
266
|
+
error,
|
|
267
|
+
error_description: description,
|
|
268
|
+
state,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
if (values.get("response_type") !== "code" && !values.has("action")) {
|
|
272
|
+
return {
|
|
273
|
+
response: reject(
|
|
274
|
+
"unsupported_response_type",
|
|
275
|
+
"Only code is supported.",
|
|
276
|
+
),
|
|
277
|
+
client: null,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const codeChallenge = values.get("code_challenge") ?? "";
|
|
282
|
+
const method = values.get("code_challenge_method") ?? "S256";
|
|
283
|
+
if (!codeChallenge || method !== "S256") {
|
|
284
|
+
return {
|
|
285
|
+
response: reject("invalid_request", "PKCE with S256 is required."),
|
|
286
|
+
client: null,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
response: null,
|
|
292
|
+
client,
|
|
293
|
+
params: { clientId, redirectUri, codeChallenge, state },
|
|
294
|
+
};
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const controller = new Elysia()
|
|
298
|
+
.get("/.well-known/oauth-protected-resource", resourceMetadata)
|
|
299
|
+
.get("/.well-known/oauth-protected-resource/mcp", resourceMetadata)
|
|
300
|
+
.get("/.well-known/oauth-authorization-server", ({ request }) => {
|
|
301
|
+
const base = publicBase(request);
|
|
302
|
+
|
|
303
|
+
return json({
|
|
304
|
+
issuer: base,
|
|
305
|
+
authorization_endpoint: `${base}/oauth/authorize`,
|
|
306
|
+
token_endpoint: `${base}/oauth/token`,
|
|
307
|
+
registration_endpoint: `${base}/oauth/register`,
|
|
308
|
+
revocation_endpoint: `${base}/oauth/revoke`,
|
|
309
|
+
response_types_supported: ["code"],
|
|
310
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
311
|
+
code_challenge_methods_supported: ["S256"],
|
|
312
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
313
|
+
revocation_endpoint_auth_methods_supported: ["none"],
|
|
314
|
+
scopes_supported: ["offline_access"],
|
|
315
|
+
});
|
|
316
|
+
})
|
|
317
|
+
.post(
|
|
318
|
+
"/oauth/register",
|
|
319
|
+
async ({ request }) => {
|
|
320
|
+
let body: unknown;
|
|
321
|
+
try {
|
|
322
|
+
body = await request.json();
|
|
323
|
+
} catch {
|
|
324
|
+
return oauthError("invalid_client_metadata", "Body must be JSON.");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const { redirect_uris, client_name } = (body ?? {}) as {
|
|
328
|
+
redirect_uris?: unknown;
|
|
329
|
+
client_name?: unknown;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
if (
|
|
333
|
+
!Array.isArray(redirect_uris) ||
|
|
334
|
+
redirect_uris.length === 0 ||
|
|
335
|
+
redirect_uris.length > 10 ||
|
|
336
|
+
!redirect_uris.every(
|
|
337
|
+
(uri) => typeof uri === "string" && isAllowedRedirectUri(uri),
|
|
338
|
+
)
|
|
339
|
+
) {
|
|
340
|
+
return oauthError(
|
|
341
|
+
"invalid_redirect_uri",
|
|
342
|
+
"redirect_uris must be the Claude callback or a loopback address.",
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const clientName =
|
|
347
|
+
typeof client_name === "string" && client_name.trim()
|
|
348
|
+
? client_name.trim().slice(0, 100)
|
|
349
|
+
: "Unknown client";
|
|
350
|
+
|
|
351
|
+
await pruneExpired();
|
|
352
|
+
|
|
353
|
+
const [created] = await surreal()
|
|
354
|
+
.create<OAuthClient>(clientTable)
|
|
355
|
+
.content({
|
|
356
|
+
clientName,
|
|
357
|
+
redirectUris: redirect_uris as string[],
|
|
358
|
+
used: false,
|
|
359
|
+
createdAt: moment().toISOString(),
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
return json(
|
|
363
|
+
{
|
|
364
|
+
client_id: created!.id.id.toString(),
|
|
365
|
+
client_name: clientName,
|
|
366
|
+
redirect_uris,
|
|
367
|
+
token_endpoint_auth_method: "none",
|
|
368
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
369
|
+
response_types: ["code"],
|
|
370
|
+
client_id_issued_at: moment().unix(),
|
|
371
|
+
},
|
|
372
|
+
201,
|
|
373
|
+
);
|
|
374
|
+
},
|
|
375
|
+
{ parse: "none" },
|
|
376
|
+
)
|
|
377
|
+
.get("/oauth/authorize", async ({ request }) => {
|
|
378
|
+
const values = new URL(request.url).searchParams;
|
|
379
|
+
const resolved = await resolveAuthorize(values);
|
|
380
|
+
if (resolved.response) return resolved.response;
|
|
381
|
+
|
|
382
|
+
return consentPage(resolved.client, resolved.params);
|
|
383
|
+
})
|
|
384
|
+
.post(
|
|
385
|
+
"/oauth/authorize",
|
|
386
|
+
async ({ request }) => {
|
|
387
|
+
const values = await readForm(request);
|
|
388
|
+
const resolved = await resolveAuthorize(values);
|
|
389
|
+
if (resolved.response) return resolved.response;
|
|
390
|
+
|
|
391
|
+
const { client, params } = resolved;
|
|
392
|
+
|
|
393
|
+
if (values.get("action") === "deny") {
|
|
394
|
+
return redirectWith(params.redirectUri, {
|
|
395
|
+
error: "access_denied",
|
|
396
|
+
state: params.state,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const email = (values.get("email") ?? "").trim().toLowerCase();
|
|
401
|
+
const password = values.get("password") ?? "";
|
|
402
|
+
|
|
403
|
+
if (isLockedOut(email)) {
|
|
404
|
+
return consentPage(
|
|
405
|
+
client,
|
|
406
|
+
params,
|
|
407
|
+
"Too many failed attempts. Try again in a few minutes.",
|
|
408
|
+
429,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const { default: user } = await import("../models/user");
|
|
413
|
+
const [account] = await surreal()
|
|
414
|
+
.select<{ id: RecordId; password: string }>(user.table)
|
|
415
|
+
.where(eq("email", email));
|
|
416
|
+
|
|
417
|
+
const passwordOk = await Bun.password.verify(
|
|
418
|
+
password,
|
|
419
|
+
account?.password ?? DUMMY_HASH,
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
if (!account || !passwordOk) {
|
|
423
|
+
recordFailedSignIn(email);
|
|
424
|
+
return consentPage(
|
|
425
|
+
client,
|
|
426
|
+
params,
|
|
427
|
+
"Incorrect email or password.",
|
|
428
|
+
401,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
failedSignIns.delete(email);
|
|
433
|
+
|
|
434
|
+
const code = randomBytes(32).toString("base64url");
|
|
435
|
+
|
|
436
|
+
// The record id is the code's hash, so redeeming is a direct lookup and the plain
|
|
437
|
+
// code never touches the DB.
|
|
438
|
+
await surreal()
|
|
439
|
+
.create<OAuthCode>(new RecordId(codeTable, hashCode(code)))
|
|
440
|
+
.content({
|
|
441
|
+
clientId: params.clientId,
|
|
442
|
+
userId: account.id as unknown as OAuthCode["userId"],
|
|
443
|
+
redirectUri: params.redirectUri,
|
|
444
|
+
codeChallenge: params.codeChallenge,
|
|
445
|
+
expiresAt: moment()
|
|
446
|
+
.add(CODE_TTL_MINUTES, "minutes")
|
|
447
|
+
.toISOString(),
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
await surreal().update(client.id).merge({ used: true });
|
|
451
|
+
|
|
452
|
+
return redirectWith(params.redirectUri, {
|
|
453
|
+
code,
|
|
454
|
+
state: params.state,
|
|
455
|
+
});
|
|
456
|
+
},
|
|
457
|
+
{ parse: "none" },
|
|
458
|
+
)
|
|
459
|
+
.post(
|
|
460
|
+
"/oauth/token",
|
|
461
|
+
async ({ request }) => {
|
|
462
|
+
const values = await readForm(request);
|
|
463
|
+
const grantType = values.get("grant_type");
|
|
464
|
+
const clientId = values.get("client_id") ?? "";
|
|
465
|
+
|
|
466
|
+
const client = await lookupClient(clientId);
|
|
467
|
+
if (!client) {
|
|
468
|
+
return oauthError("invalid_client", "Unknown client.", 401);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (grantType === "refresh_token") {
|
|
472
|
+
const tokens = await rotateOAuthTokens(
|
|
473
|
+
values.get("refresh_token") ?? "",
|
|
474
|
+
clientId,
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
return tokens
|
|
478
|
+
? json(tokens)
|
|
479
|
+
: oauthError("invalid_grant", "Refresh token is not valid.");
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (grantType !== "authorization_code") {
|
|
483
|
+
return oauthError(
|
|
484
|
+
"unsupported_grant_type",
|
|
485
|
+
"Use authorization_code or refresh_token.",
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const code = values.get("code") ?? "";
|
|
490
|
+
const verifier = values.get("code_verifier") ?? "";
|
|
491
|
+
const id = new RecordId(codeTable, hashCode(code));
|
|
492
|
+
|
|
493
|
+
const record = code
|
|
494
|
+
? await surreal().select<OAuthCode>(id)
|
|
495
|
+
: undefined;
|
|
496
|
+
if (!record) {
|
|
497
|
+
return oauthError("invalid_grant", "Code is not valid.");
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Single use, burned before any other check: a code that fails PKCE or redirect
|
|
501
|
+
// verification is dead too, so it can't be retried with guessed verifiers.
|
|
502
|
+
await surreal().delete(id);
|
|
503
|
+
|
|
504
|
+
const challengeMatches =
|
|
505
|
+
verifier.length >= 43 &&
|
|
506
|
+
verifier.length <= 128 &&
|
|
507
|
+
pkceChallenge(verifier) === record.codeChallenge;
|
|
508
|
+
|
|
509
|
+
if (
|
|
510
|
+
moment().isAfter(record.expiresAt) ||
|
|
511
|
+
record.clientId !== clientId ||
|
|
512
|
+
record.redirectUri !== values.get("redirect_uri") ||
|
|
513
|
+
!challengeMatches
|
|
514
|
+
) {
|
|
515
|
+
return oauthError("invalid_grant", "Code is not valid.");
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return json(
|
|
519
|
+
await issueOAuthTokens({
|
|
520
|
+
name: client.clientName,
|
|
521
|
+
clientId,
|
|
522
|
+
userId: record.userId,
|
|
523
|
+
}),
|
|
524
|
+
);
|
|
525
|
+
},
|
|
526
|
+
{ parse: "none" },
|
|
527
|
+
)
|
|
528
|
+
.post(
|
|
529
|
+
"/oauth/revoke",
|
|
530
|
+
async ({ request }) => {
|
|
531
|
+
const values = await readForm(request);
|
|
532
|
+
const token = values.get("token");
|
|
533
|
+
|
|
534
|
+
if (token) await revokeOAuthToken(token);
|
|
535
|
+
|
|
536
|
+
// RFC 7009: always 200, so a caller can't probe which tokens exist.
|
|
537
|
+
return json({});
|
|
538
|
+
},
|
|
539
|
+
{ parse: "none" },
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
elysia().use(controller);
|
|
543
|
+
|
|
544
|
+
return {};
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
return fn;
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
export default oauth;
|
package/src/modules/setup.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { makeModule } from "../core/module";
|
|
2
1
|
import Elysia, { status } from "elysia";
|
|
3
2
|
import moment from "moment";
|
|
4
|
-
import { RecordId, Table, Uuid } from "surrealdb";
|
|
3
|
+
import { type RecordId, Table, Uuid } from "surrealdb";
|
|
5
4
|
import z from "zod";
|
|
5
|
+
import { makeModule } from "../core/module";
|
|
6
6
|
import elysia from "./elysia";
|
|
7
7
|
import surreal from "./surreal";
|
|
8
8
|
|
|
@@ -52,15 +52,23 @@ const setup = makeModule(() => {
|
|
|
52
52
|
const [token] = await surreal()
|
|
53
53
|
.create<Token>(tokenTable)
|
|
54
54
|
.content({
|
|
55
|
-
hash: new Bun.CryptoHasher("sha256")
|
|
55
|
+
hash: new Bun.CryptoHasher("sha256")
|
|
56
|
+
.update(plainToken)
|
|
57
|
+
.digest("utf8"),
|
|
56
58
|
user: created.id,
|
|
57
59
|
createdAt: moment().toISOString(),
|
|
58
60
|
updatedAt: moment().toISOString(),
|
|
59
61
|
});
|
|
60
62
|
|
|
61
|
-
await surreal().relate(
|
|
63
|
+
await surreal().relate(
|
|
64
|
+
token!.id,
|
|
65
|
+
new Table("user_token"),
|
|
66
|
+
created.id,
|
|
67
|
+
);
|
|
62
68
|
|
|
63
|
-
const { password: _, ...safeUser } = created as typeof created & {
|
|
69
|
+
const { password: _, ...safeUser } = created as typeof created & {
|
|
70
|
+
password: string;
|
|
71
|
+
};
|
|
64
72
|
|
|
65
73
|
return {
|
|
66
74
|
data: {
|