@madauth/server 0.1.0

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.
@@ -0,0 +1,498 @@
1
+ import {
2
+ SESSION_COOKIE,
3
+ SESSION_TYP,
4
+ randomString,
5
+ readToken,
6
+ signSession,
7
+ signToken,
8
+ userFromClaims
9
+ } from "./chunk-PRRKLEW7.js";
10
+ import {
11
+ MAX_PASSWORD_LENGTH,
12
+ Users,
13
+ checkPasswordPolicy,
14
+ deriveCodeKey,
15
+ hashPassword,
16
+ isValidEmail,
17
+ normalizeEmail,
18
+ verifyAgainstDummy,
19
+ verifyPassword
20
+ } from "./chunk-V2XKGETK.js";
21
+ import {
22
+ importSigningKeys
23
+ } from "./chunk-JDQLSL36.js";
24
+ import {
25
+ createWebhookClient
26
+ } from "./chunk-AVZE6FFR.js";
27
+
28
+ // src/routes/google.ts
29
+ import { deleteCookie, getCookie, setCookie } from "hono/cookie";
30
+ import { base64url } from "jose";
31
+
32
+ // src/google.ts
33
+ import { createRemoteJWKSet, jwtVerify } from "jose";
34
+ var GOOGLE_ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
35
+ var googleJwks;
36
+ function googleKeys() {
37
+ googleJwks ??= createRemoteJWKSet(new URL("https://www.googleapis.com/oauth2/v3/certs"));
38
+ return googleJwks;
39
+ }
40
+ async function verifyGoogleIdToken(idToken, opts) {
41
+ let payload;
42
+ try {
43
+ ({ payload } = await jwtVerify(idToken, opts.keys ?? googleKeys(), {
44
+ issuer: GOOGLE_ISSUERS,
45
+ audience: opts.clientId,
46
+ algorithms: ["RS256", "ES256"]
47
+ }));
48
+ } catch (e) {
49
+ return { ok: false, error: "verification_failed", reason: e.message };
50
+ }
51
+ if (typeof payload.sub !== "string" || !payload.sub) {
52
+ return { ok: false, error: "verification_failed", reason: "missing sub" };
53
+ }
54
+ if (payload.nonce !== opts.nonce) {
55
+ return { ok: false, error: "verification_failed", reason: "nonce mismatch" };
56
+ }
57
+ if (payload.email_verified !== true) {
58
+ return { ok: false, error: "email_unverified", reason: "e-mail address not verified" };
59
+ }
60
+ const user = { id: `google:${payload.sub}` };
61
+ if (typeof payload.email === "string") user.email = payload.email;
62
+ if (typeof payload.name === "string") user.name = payload.name;
63
+ if (typeof payload.picture === "string") user.picture = payload.picture;
64
+ return { ok: true, user };
65
+ }
66
+
67
+ // src/routes/google.ts
68
+ var NONCE_COOKIE = "madauth_nonce";
69
+ var NONCE_TYP = "madauth-nonce+jwt";
70
+ var NONCE_TTL = 5 * 60;
71
+ var OAUTH_COOKIE = "madauth_oauth";
72
+ var OAUTH_TYP = "madauth-oauth-state+jwt";
73
+ var OAUTH_TTL = 10 * 60;
74
+ var GOOGLE_AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
75
+ var GOOGLE_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
76
+ var REDIRECT_ERROR_PARAM = "madauth_error";
77
+ async function pkceChallenge(verifier) {
78
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
79
+ return base64url.encode(new Uint8Array(digest));
80
+ }
81
+ function googleRoutes(app, ctx, google) {
82
+ const { config, keys, secure } = ctx;
83
+ const { issuer } = config;
84
+ const callbackUrl = `${issuer}/auth/google/callback`;
85
+ app.post("/auth/google/nonce", async (c) => {
86
+ const nonce = randomString();
87
+ const token = await signToken(await keys, issuer, NONCE_TYP, { nonce }, NONCE_TTL);
88
+ setCookie(c, NONCE_COOKIE, token, {
89
+ path: "/auth/google",
90
+ httpOnly: true,
91
+ secure,
92
+ sameSite: "Strict",
93
+ maxAge: NONCE_TTL
94
+ });
95
+ return c.json({ nonce });
96
+ });
97
+ app.post("/auth/google/verify", async (c) => {
98
+ const body = await c.req.json().catch(() => ({}));
99
+ if (typeof body.credential !== "string" || !body.credential) {
100
+ return c.json({ error: "verification_failed", message: "credential is missing" }, 400);
101
+ }
102
+ const nonceClaims = await readToken(await keys, issuer, NONCE_TYP, getCookie(c, NONCE_COOKIE));
103
+ if (!nonceClaims) {
104
+ return c.json({ error: "verification_failed", message: "nonce cookie missing or expired" }, 401);
105
+ }
106
+ const result = await verifyGoogleIdToken(body.credential, {
107
+ clientId: google.clientId,
108
+ nonce: nonceClaims.nonce,
109
+ keys: config.jwksResolver
110
+ });
111
+ if (!result.ok) {
112
+ return c.json({ error: result.error, message: result.reason }, result.error === "email_unverified" ? 403 : 401);
113
+ }
114
+ deleteCookie(c, NONCE_COOKIE, { path: "/auth/google", secure });
115
+ await ctx.startSession(c, result.user, ["google"]);
116
+ await ctx.emit("user.signed_in", { user: result.user, method: "google" });
117
+ return c.json({ user: result.user });
118
+ });
119
+ const requireCodeFlow = async (c, next) => {
120
+ if (!google.clientSecret) return c.json({ error: "not_found" }, 404);
121
+ await next();
122
+ };
123
+ app.get("/auth/google/start", requireCodeFlow, async (c) => {
124
+ const returnUrl = ctx.allowedUrl(c.req.query("return_to"));
125
+ if (!returnUrl) return c.text("return_to must be an absolute URL whose origin is in ALLOWED_ORIGINS", 400);
126
+ const oauth = {
127
+ state: randomString(),
128
+ nonce: randomString(),
129
+ verifier: randomString(),
130
+ returnTo: returnUrl.href
131
+ };
132
+ const token = await signToken(await keys, issuer, OAUTH_TYP, { ...oauth }, OAUTH_TTL);
133
+ setCookie(c, OAUTH_COOKIE, token, {
134
+ path: "/auth/google",
135
+ httpOnly: true,
136
+ secure,
137
+ sameSite: "Lax",
138
+ maxAge: OAUTH_TTL
139
+ });
140
+ const url = new URL(GOOGLE_AUTH_ENDPOINT);
141
+ url.search = new URLSearchParams({
142
+ client_id: google.clientId,
143
+ redirect_uri: callbackUrl,
144
+ response_type: "code",
145
+ scope: "openid email profile",
146
+ state: oauth.state,
147
+ nonce: oauth.nonce,
148
+ code_challenge: await pkceChallenge(oauth.verifier),
149
+ code_challenge_method: "S256"
150
+ }).toString();
151
+ return c.redirect(url.href, 302);
152
+ });
153
+ app.get("/auth/google/callback", requireCodeFlow, async (c) => {
154
+ const oauth = await readToken(await keys, issuer, OAUTH_TYP, getCookie(c, OAUTH_COOKIE));
155
+ if (!oauth) return c.text("Sign-in expired or was started in another browser. Please try again.", 400);
156
+ deleteCookie(c, OAUTH_COOKIE, { path: "/auth/google", secure });
157
+ const back = (error) => {
158
+ const url = new URL(oauth.returnTo);
159
+ if (error) url.hash = `${REDIRECT_ERROR_PARAM}=${error}`;
160
+ return c.redirect(url.href, 302);
161
+ };
162
+ if (c.req.query("error")) return back(c.req.query("error") === "access_denied" ? "cancelled" : "verification_failed");
163
+ const code = c.req.query("code");
164
+ if (!code || c.req.query("state") !== oauth.state) return back("verification_failed");
165
+ let idToken;
166
+ try {
167
+ const res = await fetch(GOOGLE_TOKEN_ENDPOINT, {
168
+ method: "POST",
169
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
170
+ body: new URLSearchParams({
171
+ code,
172
+ client_id: google.clientId,
173
+ client_secret: google.clientSecret,
174
+ redirect_uri: callbackUrl,
175
+ grant_type: "authorization_code",
176
+ code_verifier: oauth.verifier
177
+ })
178
+ });
179
+ if (!res.ok) return back("verification_failed");
180
+ ({ id_token: idToken } = await res.json());
181
+ } catch {
182
+ return back("verification_failed");
183
+ }
184
+ if (typeof idToken !== "string") return back("verification_failed");
185
+ const result = await verifyGoogleIdToken(idToken, {
186
+ clientId: google.clientId,
187
+ nonce: oauth.nonce,
188
+ keys: config.jwksResolver
189
+ });
190
+ if (!result.ok) return back(result.error);
191
+ await ctx.startSession(c, result.user, ["google"]);
192
+ await ctx.emit("user.signed_in", { user: result.user, method: "google" });
193
+ return back();
194
+ });
195
+ }
196
+
197
+ // src/app.ts
198
+ import { Hono } from "hono";
199
+ import { deleteCookie as deleteCookie2, getCookie as getCookie2, setCookie as setCookie2 } from "hono/cookie";
200
+ import { cors } from "hono/cors";
201
+
202
+ // src/routes/password.ts
203
+ var FREE_ATTEMPTS = 5;
204
+ var MAX_LOCK_MS = 15 * 60 * 1e3;
205
+ var MAIL_INTERVAL_MS = 60 * 1e3;
206
+ var VERIFY_TTL_MS = 24 * 60 * 60 * 1e3;
207
+ var RESET_TTL_MS = 30 * 60 * 1e3;
208
+ var WEBHOOK_TIMEOUT_MS = 1e4;
209
+ var VERIFY_LINK_PARAM = "madauth_verify";
210
+ var RESET_LINK_PARAM = "madauth_reset";
211
+ var MAX_NAME_LENGTH = 100;
212
+ var MAX_LOCALE_LENGTH = 35;
213
+ function toMadauthUser(user) {
214
+ const result = { id: user.id, email: user.email };
215
+ if (user.name) result.name = user.name;
216
+ return result;
217
+ }
218
+ function passwordRoutes(app, ctx, password, users, webhook) {
219
+ const { minLength } = password;
220
+ const body = async (c) => {
221
+ const data = await c.req.json().catch(() => null);
222
+ return data && typeof data === "object" ? data : {};
223
+ };
224
+ const error = (c, status, code, message) => c.json({ error: code, message }, status);
225
+ const unavailable = (c) => error(c, 503, "temporarily_unavailable", "E-mails can not be sent right now. Please try again later.");
226
+ const accepted = (c) => c.json({}, 202);
227
+ const redirectTarget = (c, value) => {
228
+ const url = ctx.allowedUrl(value);
229
+ return url ?? error(c, 400, "invalid_options", "redirectTo must be an absolute URL whose origin is in ALLOWED_ORIGINS.");
230
+ };
231
+ const sendEmail = async (user, type, data) => {
232
+ if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
233
+ const result = await webhook.call(type, { to: user.email, user: { id: user.id, name: user.name ?? void 0 }, ...data }, WEBHOOK_TIMEOUT_MS);
234
+ if (!result.ok) {
235
+ console.error(`[madauth] Webhook "${type}" for ${user.email} failed: ${result.reason}`);
236
+ return false;
237
+ }
238
+ await users.updateUser(user.id, { lastMailAt: Date.now() });
239
+ return true;
240
+ };
241
+ const sendLinkEmail = async (user, purpose, target, locale) => {
242
+ if (Date.now() - user.lastMailAt < MAIL_INTERVAL_MS) return true;
243
+ const ttl = purpose === "verify" ? VERIFY_TTL_MS : RESET_TTL_MS;
244
+ const { token, code } = await users.issueVerification(user.id, purpose, ttl);
245
+ const link = new URL(target);
246
+ link.hash = `${purpose === "verify" ? VERIFY_LINK_PARAM : RESET_LINK_PARAM}=${token}`;
247
+ return sendEmail(user, purpose === "verify" ? "email.verify" : "email.reset", {
248
+ link: link.href,
249
+ code,
250
+ expiresAt: Date.now() + ttl,
251
+ site: target.host,
252
+ locale
253
+ });
254
+ };
255
+ const localeOf = (data) => typeof data.locale === "string" && data.locale.length <= MAX_LOCALE_LENGTH ? data.locale : void 0;
256
+ const consume = async (data, purpose) => {
257
+ if (typeof data.token === "string" && data.token) {
258
+ return { userId: await users.consumeLinkToken(data.token, purpose), via: "link" };
259
+ }
260
+ if (isValidEmail(data.email) && typeof data.code === "string") {
261
+ const user = await users.findByEmail(normalizeEmail(data.email));
262
+ const code = data.code.replace(/\s/g, "");
263
+ return { userId: user ? await users.consumeCode(user.id, purpose, code) : null, via: "code" };
264
+ }
265
+ return { userId: null, via: "code" };
266
+ };
267
+ const invalidVerification = (c, via) => via === "link" ? error(c, 400, "link_invalid", "The link is invalid, expired or was already used.") : error(c, 400, "code_invalid", "The code is wrong or expired.");
268
+ app.post("/auth/password/signin", async (c) => {
269
+ const data = await body(c);
270
+ const wrong = () => error(c, 401, "invalid_credentials", "E-mail or password is wrong.");
271
+ const pw = typeof data.password === "string" ? data.password : "";
272
+ if (!isValidEmail(data.email) || !pw || pw.length > MAX_PASSWORD_LENGTH * 4) return wrong();
273
+ const user = await users.findByEmail(normalizeEmail(data.email));
274
+ const account = user ? await users.passwordAccount(user.id) : null;
275
+ if (!user || !account?.secret) {
276
+ await verifyAgainstDummy(pw);
277
+ return wrong();
278
+ }
279
+ const now = Date.now();
280
+ if (account.lockedUntil > now) {
281
+ const seconds = Math.ceil((account.lockedUntil - now) / 1e3);
282
+ return error(c, 429, "too_many_attempts", `Too many failed attempts. Try again in ${seconds} seconds.`);
283
+ }
284
+ const failed = account.failedAttempts + 1;
285
+ const lockedUntil = failed >= FREE_ATTEMPTS ? now + Math.min(2 ** (failed - FREE_ATTEMPTS) * 1e3, MAX_LOCK_MS) : 0;
286
+ if (!await users.countAttempt(account, { failedAttempts: failed, lockedUntil })) {
287
+ return error(c, 429, "too_many_attempts", "Too many attempts at once. Please try again.");
288
+ }
289
+ const { ok, needsRehash } = await verifyPassword(pw, account.secret);
290
+ if (!ok) return wrong();
291
+ await users.updateAccount(account.id, {
292
+ failedAttempts: 0,
293
+ lockedUntil: 0,
294
+ ...needsRehash ? { secret: await hashPassword(pw) } : {}
295
+ });
296
+ if (!user.emailVerified) {
297
+ return error(c, 403, "email_unverified", "Please confirm your e-mail address first. We can send the e-mail again.");
298
+ }
299
+ const result = toMadauthUser(user);
300
+ await ctx.startSession(c, result, ["pwd"], { sv: user.sessionVersion });
301
+ await ctx.emit("user.signed_in", { user: result, method: "password" });
302
+ return c.json({ user: result });
303
+ });
304
+ app.post("/auth/password/signup", async (c) => {
305
+ const data = await body(c);
306
+ const target = redirectTarget(c, data.redirectTo);
307
+ if (target instanceof Response) return target;
308
+ if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
309
+ const policy = checkPasswordPolicy(data.password, minLength);
310
+ if (policy) return error(c, 400, "weak_password", policy);
311
+ const name = typeof data.name === "string" && data.name.trim() ? data.name.trim().slice(0, MAX_NAME_LENGTH) : null;
312
+ const email = data.email.trim();
313
+ const locale = localeOf(data);
314
+ if (webhook.wants("signup.before")) {
315
+ const check = await webhook.call("signup.before", { email, name: name ?? void 0, locale }, WEBHOOK_TIMEOUT_MS);
316
+ if (!check.ok) {
317
+ console.error(`[madauth] Webhook "signup.before" failed: ${check.reason}`);
318
+ return unavailable(c);
319
+ }
320
+ const answer = check.body;
321
+ if (answer?.allow === false) {
322
+ const message = typeof answer.message === "string" && answer.message ? answer.message : "Sign-up is not possible with this e-mail address.";
323
+ return error(c, 403, "signup_rejected", message);
324
+ }
325
+ }
326
+ const passwordHash = await hashPassword(data.password);
327
+ const existing = await users.findByEmail(normalizeEmail(email));
328
+ if (existing?.emailVerified) {
329
+ const sent = await sendEmail(existing, "email.already_registered", { link: target.href, site: target.host, locale });
330
+ return sent ? accepted(c) : unavailable(c);
331
+ }
332
+ if (existing) {
333
+ if (Date.now() - existing.lastMailAt < MAIL_INTERVAL_MS) return accepted(c);
334
+ const account = await users.passwordAccount(existing.id);
335
+ if (account) await users.updateAccount(account.id, { secret: passwordHash, failedAttempts: 0, lockedUntil: 0 });
336
+ await users.updateUser(existing.id, { name });
337
+ return await sendLinkEmail({ ...existing, name }, "verify", target, locale) ? accepted(c) : unavailable(c);
338
+ }
339
+ const user = await users.createPasswordUser({
340
+ email,
341
+ emailNormalized: normalizeEmail(email),
342
+ name,
343
+ passwordHash,
344
+ emailVerified: false
345
+ });
346
+ if (!user) return accepted(c);
347
+ await ctx.emit("user.created", { user: toMadauthUser(user) });
348
+ return await sendLinkEmail(user, "verify", target, locale) ? accepted(c) : unavailable(c);
349
+ });
350
+ app.post("/auth/password/send-verification", async (c) => {
351
+ const data = await body(c);
352
+ const target = redirectTarget(c, data.redirectTo);
353
+ if (target instanceof Response) return target;
354
+ if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
355
+ const user = await users.findByEmail(normalizeEmail(data.email));
356
+ if (user && !user.emailVerified && await users.passwordAccount(user.id)) {
357
+ if (!await sendLinkEmail(user, "verify", target, localeOf(data))) return unavailable(c);
358
+ }
359
+ return accepted(c);
360
+ });
361
+ app.post("/auth/password/verify-email", async (c) => {
362
+ const { userId, via } = await consume(await body(c), "verify");
363
+ const user = userId ? await users.findById(userId) : null;
364
+ if (!user) return invalidVerification(c, via);
365
+ await users.updateUser(user.id, { emailVerified: true });
366
+ const result = toMadauthUser(user);
367
+ await ctx.startSession(c, result, ["pwd"], { sv: user.sessionVersion });
368
+ await ctx.emit("email.verified", { user: result, via });
369
+ await ctx.emit("user.signed_in", { user: result, method: "password" });
370
+ return c.json({ user: result });
371
+ });
372
+ app.post("/auth/password/send-reset", async (c) => {
373
+ const data = await body(c);
374
+ const target = redirectTarget(c, data.redirectTo);
375
+ if (target instanceof Response) return target;
376
+ if (!isValidEmail(data.email)) return error(c, 400, "invalid_email", "This is not a valid e-mail address.");
377
+ const user = await users.findByEmail(normalizeEmail(data.email));
378
+ if (user && await users.passwordAccount(user.id)) {
379
+ if (!await sendLinkEmail(user, "reset", target, localeOf(data))) return unavailable(c);
380
+ }
381
+ return accepted(c);
382
+ });
383
+ app.post("/auth/password/reset", async (c) => {
384
+ const data = await body(c);
385
+ const policy = checkPasswordPolicy(data.password, minLength);
386
+ if (policy) return error(c, 400, "weak_password", policy);
387
+ const { userId, via } = await consume(data, "reset");
388
+ const user = userId ? await users.findById(userId) : null;
389
+ const account = user ? await users.passwordAccount(user.id) : null;
390
+ if (!user || !account) return invalidVerification(c, via);
391
+ await users.updateAccount(account.id, { secret: await hashPassword(data.password), failedAttempts: 0, lockedUntil: 0 });
392
+ const sessionVersion = user.sessionVersion + 1;
393
+ await users.updateUser(user.id, { emailVerified: true, sessionVersion });
394
+ await users.clearVerifications(user.id);
395
+ const result = toMadauthUser(user);
396
+ await ctx.startSession(c, result, ["pwd"], { sv: sessionVersion });
397
+ await ctx.emit("password.reset", { user: result });
398
+ await ctx.emit("user.signed_in", { user: result, method: "password" });
399
+ return c.json({ user: result });
400
+ });
401
+ }
402
+
403
+ // src/app.ts
404
+ var EVENT_TIMEOUT_MS = 5e3;
405
+ function createApp(config) {
406
+ const { issuer, allowedOrigins, sessionTtlSeconds, cookieDomain } = config;
407
+ const keys = importSigningKeys(config.signingKey);
408
+ const secure = issuer.startsWith("https:");
409
+ const isAllowedOrigin = (origin) => !!origin && allowedOrigins.includes(origin);
410
+ const ctx = {
411
+ config,
412
+ keys,
413
+ secure,
414
+ isAllowedOrigin,
415
+ allowedUrl(value) {
416
+ let url;
417
+ try {
418
+ url = new URL(typeof value === "string" ? value : "");
419
+ } catch {
420
+ return null;
421
+ }
422
+ if (!isAllowedOrigin(url.origin)) return null;
423
+ url.hash = "";
424
+ return url;
425
+ },
426
+ async startSession(c, user, amr, extra) {
427
+ const token = await signSession(await keys, issuer, sessionTtlSeconds, user, amr, extra);
428
+ setCookie2(c, SESSION_COOKIE, token, {
429
+ path: "/",
430
+ domain: cookieDomain,
431
+ httpOnly: true,
432
+ secure,
433
+ sameSite: "Lax",
434
+ maxAge: sessionTtlSeconds
435
+ });
436
+ },
437
+ users: config.password ? new Users(config.password.store, deriveCodeKey(config.signingKey.d)) : void 0,
438
+ webhook: config.webhook ? createWebhookClient(config.webhook, config.webhookFetch) : void 0,
439
+ async emit(type, data) {
440
+ if (!this.webhook?.wants(type)) return;
441
+ const result = await this.webhook.call(type, data, EVENT_TIMEOUT_MS);
442
+ if (!result.ok) console.error(`[madauth] Webhook "${type}" failed: ${result.reason}`);
443
+ }
444
+ };
445
+ const app = new Hono();
446
+ app.use(
447
+ "/auth/*",
448
+ cors({
449
+ origin: (origin) => isAllowedOrigin(origin) ? origin : null,
450
+ credentials: true,
451
+ allowMethods: ["GET", "POST"],
452
+ allowHeaders: ["Content-Type"],
453
+ maxAge: 600
454
+ })
455
+ );
456
+ app.use("/auth/*", async (c, next) => {
457
+ if (c.req.method === "POST" && !isAllowedOrigin(c.req.header("origin"))) {
458
+ return c.json({ error: "forbidden_origin" }, 403);
459
+ }
460
+ await next();
461
+ });
462
+ app.get("/health", (c) => c.text("ok"));
463
+ app.get("/.well-known/jwks.json", async (c) => {
464
+ const { publicJwk } = await keys;
465
+ c.header("Cache-Control", "public, max-age=3600");
466
+ return c.json({ keys: [publicJwk] });
467
+ });
468
+ app.get(
469
+ "/auth/config",
470
+ (c) => c.json({
471
+ google: config.google ? { clientId: config.google.clientId, codeFlow: !!config.google.clientSecret } : null,
472
+ password: config.password ? { minLength: config.password.minLength } : null
473
+ })
474
+ );
475
+ if (config.google) googleRoutes(app, ctx, config.google);
476
+ if (config.password && ctx.users && ctx.webhook) passwordRoutes(app, ctx, config.password, ctx.users, ctx.webhook);
477
+ app.get("/auth/session", async (c) => {
478
+ const claims = await readToken(await keys, issuer, SESSION_TYP, getCookie2(c, SESSION_COOKIE));
479
+ if (!claims) return c.json({ error: "no_session" }, 401);
480
+ if (claims.sub.startsWith("usr_")) {
481
+ const stored = await ctx.users?.findById(claims.sub);
482
+ if (!stored || stored.sessionVersion !== claims.sv) return c.json({ error: "no_session" }, 401);
483
+ }
484
+ const user = userFromClaims(claims);
485
+ if (Date.now() / 1e3 - claims.iat > sessionTtlSeconds / 2) await ctx.startSession(c, user, claims.amr, { sv: claims.sv });
486
+ return c.json({ user });
487
+ });
488
+ app.post("/auth/logout", (c) => {
489
+ deleteCookie2(c, SESSION_COOKIE, { path: "/", domain: cookieDomain, secure });
490
+ return c.body(null, 204);
491
+ });
492
+ return app;
493
+ }
494
+
495
+ export {
496
+ REDIRECT_ERROR_PARAM,
497
+ createApp
498
+ };