Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

oauth.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

oauth.tsxBlame782 lines · 1 contributor
058d752Claude1/**
2 * OAuth 2.0 provider endpoints (Block B6).
3 *
4 * GET /oauth/authorize consent screen (authed)
5 * POST /oauth/authorize/decision approve/deny → redirect with code (authed)
6 * POST /oauth/token code or refresh → access+refresh tokens
7 * POST /oauth/revoke revoke access or refresh token
8 * GET /settings/authorizations list apps the user has granted (authed)
9 * POST /settings/authorizations/:appId/revoke user-initiated revoke
10 *
11 * Developer-facing app management lives in `src/routes/developer-apps.tsx`.
12 */
13
14import { Hono } from "hono";
15import { and, eq, gt, isNull } from "drizzle-orm";
16import { db } from "../db";
17import {
18 oauthApps,
19 oauthAuthorizations,
20 oauthAccessTokens,
21 users,
22} from "../db/schema";
36cc17aClaude23import type { User } from "../db/schema";
058d752Claude24import { Layout } from "../views/layout";
25import { requireAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
27import {
28 generateAuthCode,
29 generateAccessToken,
30 generateRefreshToken,
31 sha256Hex,
32 verifyPkce,
33 parseScopes,
34 parseRedirectUris,
35 redirectUriAllowed,
36 timingSafeEqual,
37 ACCESS_TOKEN_TTL_MS,
38 REFRESH_TOKEN_TTL_MS,
39 AUTH_CODE_TTL_MS,
40 SUPPORTED_SCOPES,
41} from "../lib/oauth";
42import { audit } from "../lib/notify";
43
44const oauth = new Hono<AuthEnv>();
45
46oauth.use("/oauth/authorize", requireAuth);
47oauth.use("/oauth/authorize/decision", requireAuth);
48oauth.use("/settings/authorizations", requireAuth);
49oauth.use("/settings/authorizations/*", requireAuth);
50
51// --- helpers ----------------------------------------------------------------
52
53function appendQuery(url: string, params: Record<string, string | undefined>) {
54 const sep = url.includes("?") ? "&" : "?";
55 const parts: string[] = [];
56 for (const [k, v] of Object.entries(params)) {
57 if (v === undefined || v === null) continue;
58 parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
59 }
60 if (parts.length === 0) return url;
61 return url + sep + parts.join("&");
62}
63
36cc17aClaude64function errorPage(title: string, message: string, user: User | null) {
058d752Claude65 return (
36cc17aClaude66 <Layout title={title} user={user}>
058d752Claude67 <div class="empty-state">
68 <h2>{title}</h2>
69 <p>{message}</p>
70 <a href="/" style="margin-top: 12px; display: inline-block">
71 Go home
72 </a>
73 </div>
74 </Layout>
75 );
76}
77
78type OauthApp = typeof oauthApps.$inferSelect;
79
80async function loadAppByClientId(clientId: string): Promise<OauthApp | null> {
81 try {
82 const [row] = await db
83 .select()
84 .from(oauthApps)
85 .where(eq(oauthApps.clientId, clientId))
86 .limit(1);
87 return row || null;
88 } catch (err) {
89 console.error("[oauth] loadApp:", err);
90 return null;
91 }
92}
93
94/**
95 * Extracts client_id + client_secret from either the request body or an
96 * `Authorization: Basic` header. Returns `null` if neither is present.
97 */
98function extractClientCreds(
99 authHeader: string | undefined,
100 body: Record<string, unknown>
101): { clientId?: string; clientSecret?: string } {
102 if (authHeader && authHeader.toLowerCase().startsWith("basic ")) {
103 try {
104 const decoded = atob(authHeader.slice(6).trim());
105 const idx = decoded.indexOf(":");
106 if (idx > 0) {
107 return {
108 clientId: decoded.slice(0, idx),
109 clientSecret: decoded.slice(idx + 1),
110 };
111 }
112 } catch {
113 /* fall through */
114 }
115 }
116 const cid = body.client_id ? String(body.client_id) : undefined;
117 const csec = body.client_secret ? String(body.client_secret) : undefined;
118 return { clientId: cid, clientSecret: csec };
119}
120
121async function authenticateClient(
122 app: OauthApp,
123 providedSecret: string | undefined
124): Promise<boolean> {
125 if (!app.confidential) return true; // public clients auth via PKCE
126 if (!providedSecret) return false;
127 const hash = await sha256Hex(providedSecret);
128 return timingSafeEqual(hash, app.clientSecretHash);
129}
130
131// --- GET /oauth/authorize ---------------------------------------------------
132
133oauth.get("/oauth/authorize", async (c) => {
134 const user = c.get("user")!;
135 const q = c.req.query();
136 const clientId = q.client_id || "";
137 const redirectUri = q.redirect_uri || "";
138 const responseType = q.response_type || "";
139 const scopeParam = q.scope || "";
140 const state = q.state || "";
141 const codeChallenge = q.code_challenge || "";
142 const codeChallengeMethod = q.code_challenge_method || "";
143
144 if (!clientId) {
36cc17aClaude145 return c.html(errorPage("OAuth error", "Missing client_id.", user), 400);
058d752Claude146 }
147 const app = await loadAppByClientId(clientId);
148 if (!app) {
36cc17aClaude149 return c.html(errorPage("OAuth error", "Unknown client.", user), 400);
058d752Claude150 }
151 if (app.revokedAt) {
36cc17aClaude152 return c.html(errorPage("OAuth error", "This application has been revoked.", user), 400);
058d752Claude153 }
154
155 const registered = parseRedirectUris(app.redirectUris);
156 if (!redirectUriAllowed(redirectUri, registered)) {
157 return c.html(
158 errorPage(
159 "OAuth error",
36cc17aClaude160 "redirect_uri does not match any registered callback for this app.",
161 user
058d752Claude162 ),
163 400
164 );
165 }
166
167 // Beyond this point errors redirect back to redirect_uri with ?error=...
168 if (responseType !== "code") {
169 return c.redirect(
170 appendQuery(redirectUri, {
171 error: "unsupported_response_type",
172 error_description: "response_type must be 'code'",
173 state: state || undefined,
174 })
175 );
176 }
177 if (!app.confidential && !codeChallenge) {
178 return c.redirect(
179 appendQuery(redirectUri, {
180 error: "invalid_request",
181 error_description: "PKCE code_challenge is required for public clients",
182 state: state || undefined,
183 })
184 );
185 }
186
187 const scopes = parseScopes(scopeParam);
188
189 // Look up the app owner for the consent screen.
190 let ownerName = "unknown";
191 try {
192 const [ownerRow] = await db
193 .select({ username: users.username })
194 .from(users)
195 .where(eq(users.id, app.ownerId))
196 .limit(1);
197 if (ownerRow) ownerName = ownerRow.username;
198 } catch {
199 /* non-fatal */
200 }
201
202 return c.html(
203 <Layout title="Authorize application" user={user}>
204 <div class="auth-container">
205 <h2>Authorize {app.name}</h2>
206 <p style="color: var(--text-muted); font-size: 13px">
207 <strong>{app.name}</strong> by <code>{ownerName}</code> wants
208 access to your gluecron account as <strong>{user.username}</strong>.
209 </p>
210 {app.description && (
211 <p style="color: var(--text-muted); font-size: 13px">
212 {app.description}
213 </p>
214 )}
215 <div
216 style="border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; margin: 16px 0; background: var(--bg-secondary)"
217 >
218 <strong>Requested scopes</strong>
219 {scopes.length === 0 ? (
220 <p style="color: var(--text-muted); font-size: 12px; margin: 8px 0 0">
221 No scopes — this app will only be able to identify you.
222 </p>
223 ) : (
224 <ul style="margin: 8px 0 0 16px; font-size: 13px">
225 {scopes.map((s) => (
226 <li>
227 <code>{s}</code>
228 </li>
229 ))}
230 </ul>
231 )}
232 </div>
233 <p style="color: var(--text-muted); font-size: 12px">
234 You can revoke access at any time from{" "}
235 <a href="/settings/authorizations">Authorized applications</a>.
236 </p>
e7e240eClaude237 <form method="post" action="/oauth/authorize/decision">
058d752Claude238 <input type="hidden" name="client_id" value={clientId} />
239 <input type="hidden" name="redirect_uri" value={redirectUri} />
240 <input type="hidden" name="response_type" value={responseType} />
241 <input type="hidden" name="scope" value={scopes.join(" ")} />
242 <input type="hidden" name="state" value={state} />
243 <input type="hidden" name="code_challenge" value={codeChallenge} />
244 <input
245 type="hidden"
246 name="code_challenge_method"
247 value={codeChallengeMethod}
248 />
249 <div style="display: flex; gap: 8px">
250 <button type="submit" name="decision" value="approve" class="btn btn-primary">
251 Authorize
252 </button>
253 <button type="submit" name="decision" value="deny" class="btn">
254 Cancel
255 </button>
256 </div>
257 </form>
258 </div>
259 </Layout>
260 );
261});
262
263// --- POST /oauth/authorize/decision -----------------------------------------
264
265oauth.post("/oauth/authorize/decision", async (c) => {
266 const user = c.get("user")!;
267 const body = await c.req.parseBody();
268 const clientId = String(body.client_id || "");
269 const redirectUri = String(body.redirect_uri || "");
270 const scopeParam = String(body.scope || "");
271 const state = String(body.state || "");
272 const decision = String(body.decision || "");
273 const codeChallenge = String(body.code_challenge || "");
274 const codeChallengeMethod = String(body.code_challenge_method || "");
275
276 const app = await loadAppByClientId(clientId);
277 if (!app || app.revokedAt) {
36cc17aClaude278 return c.html(errorPage("OAuth error", "Unknown or revoked client.", user), 400);
058d752Claude279 }
280 const registered = parseRedirectUris(app.redirectUris);
281 if (!redirectUriAllowed(redirectUri, registered)) {
36cc17aClaude282 return c.html(errorPage("OAuth error", "Invalid redirect_uri.", user), 400);
058d752Claude283 }
284
285 if (decision !== "approve") {
286 return c.redirect(
287 appendQuery(redirectUri, {
288 error: "access_denied",
289 error_description: "User denied the request",
290 state: state || undefined,
291 })
292 );
293 }
294
295 const scopes = parseScopes(scopeParam);
296 const code = generateAuthCode();
297 const codeHash = await sha256Hex(code);
298
299 try {
300 await db.insert(oauthAuthorizations).values({
301 appId: app.id,
302 userId: user.id,
303 codeHash,
304 redirectUri,
305 scopes: scopes.join(" "),
306 codeChallenge: codeChallenge || null,
307 codeChallengeMethod: codeChallengeMethod || null,
308 expiresAt: new Date(Date.now() + AUTH_CODE_TTL_MS),
309 });
310 await audit({
311 userId: user.id,
312 action: "oauth.authorize",
313 targetType: "oauth_app",
314 targetId: app.id,
315 metadata: { scopes: scopes.join(" ") },
316 });
317 return c.redirect(
318 appendQuery(redirectUri, {
319 code,
320 state: state || undefined,
321 })
322 );
323 } catch (err) {
324 console.error("[oauth] authorize/decision:", err);
325 return c.redirect(
326 appendQuery(redirectUri, {
327 error: "server_error",
328 error_description: "Service unavailable",
329 state: state || undefined,
330 })
331 );
332 }
333});
334
335// --- POST /oauth/token ------------------------------------------------------
336
337oauth.post("/oauth/token", async (c) => {
338 // Accept either form-encoded or JSON bodies.
339 let body: Record<string, unknown> = {};
340 const contentType = (c.req.header("content-type") || "").toLowerCase();
341 try {
342 if (contentType.includes("application/json")) {
343 body = (await c.req.json()) as Record<string, unknown>;
344 } else {
345 body = (await c.req.parseBody()) as Record<string, unknown>;
346 }
347 } catch {
348 return c.json(
349 { error: "invalid_request", error_description: "Malformed body" },
350 400
351 );
352 }
353
354 const grantType = body.grant_type ? String(body.grant_type) : "";
355 const authHeader = c.req.header("authorization");
356 const creds = extractClientCreds(authHeader, body);
357
358 if (!creds.clientId) {
359 return c.json(
360 { error: "invalid_client", error_description: "Missing client_id" },
361 401
362 );
363 }
364 const app = await loadAppByClientId(creds.clientId);
365 if (!app || app.revokedAt) {
366 return c.json(
367 { error: "invalid_client", error_description: "Unknown client" },
368 401
369 );
370 }
371 const clientAuthOk = await authenticateClient(app, creds.clientSecret);
372 if (!clientAuthOk) {
373 return c.json(
374 { error: "invalid_client", error_description: "Client authentication failed" },
375 401
376 );
377 }
378
379 try {
380 if (grantType === "authorization_code") {
381 const code = body.code ? String(body.code) : "";
382 const redirectUri = body.redirect_uri ? String(body.redirect_uri) : "";
383 const codeVerifier = body.code_verifier ? String(body.code_verifier) : "";
384 if (!code || !redirectUri) {
385 return c.json(
386 { error: "invalid_request", error_description: "code and redirect_uri required" },
387 400
388 );
389 }
390 const codeHash = await sha256Hex(code);
391 const [authRow] = await db
392 .select()
393 .from(oauthAuthorizations)
394 .where(eq(oauthAuthorizations.codeHash, codeHash))
395 .limit(1);
396 if (!authRow) {
397 return c.json({ error: "invalid_grant", error_description: "Unknown code" }, 400);
398 }
399 if (authRow.usedAt) {
400 return c.json(
401 { error: "invalid_grant", error_description: "Code already used" },
402 400
403 );
404 }
405 if (new Date(authRow.expiresAt) < new Date()) {
406 return c.json({ error: "invalid_grant", error_description: "Code expired" }, 400);
407 }
408 if (authRow.appId !== app.id) {
409 return c.json(
410 { error: "invalid_grant", error_description: "Code does not belong to client" },
411 400
412 );
413 }
414 if (!timingSafeEqual(authRow.redirectUri, redirectUri)) {
415 return c.json(
416 { error: "invalid_grant", error_description: "redirect_uri mismatch" },
417 400
418 );
419 }
420 if (authRow.codeChallenge) {
421 const ok = await verifyPkce({
422 challenge: authRow.codeChallenge,
423 method: authRow.codeChallengeMethod,
424 verifier: codeVerifier,
425 });
426 if (!ok) {
427 return c.json(
428 { error: "invalid_grant", error_description: "PKCE verification failed" },
429 400
430 );
431 }
432 }
433
434 // Single-use: mark used immediately.
435 await db
436 .update(oauthAuthorizations)
437 .set({ usedAt: new Date() })
438 .where(eq(oauthAuthorizations.id, authRow.id));
439
440 const accessToken = generateAccessToken();
441 const refreshToken = generateRefreshToken();
442 const accessHash = await sha256Hex(accessToken);
443 const refreshHash = await sha256Hex(refreshToken);
444
445 await db.insert(oauthAccessTokens).values({
446 appId: app.id,
447 userId: authRow.userId,
448 accessTokenHash: accessHash,
449 refreshTokenHash: refreshHash,
450 scopes: authRow.scopes,
451 expiresAt: new Date(Date.now() + ACCESS_TOKEN_TTL_MS),
452 refreshExpiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
453 });
454 await audit({
455 userId: authRow.userId,
456 action: "oauth.token.issue",
457 targetType: "oauth_app",
458 targetId: app.id,
459 });
460 return c.json({
461 access_token: accessToken,
462 token_type: "bearer",
463 expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1000),
464 refresh_token: refreshToken,
465 scope: authRow.scopes,
466 });
467 }
468
469 if (grantType === "refresh_token") {
470 const refreshToken = body.refresh_token ? String(body.refresh_token) : "";
471 if (!refreshToken) {
472 return c.json(
473 { error: "invalid_request", error_description: "refresh_token required" },
474 400
475 );
476 }
477 const refreshHash = await sha256Hex(refreshToken);
478 const [tokenRow] = await db
479 .select()
480 .from(oauthAccessTokens)
481 .where(eq(oauthAccessTokens.refreshTokenHash, refreshHash))
482 .limit(1);
483 if (!tokenRow || tokenRow.revokedAt) {
484 return c.json(
485 { error: "invalid_grant", error_description: "Unknown refresh_token" },
486 400
487 );
488 }
489 if (tokenRow.appId !== app.id) {
490 return c.json(
491 { error: "invalid_grant", error_description: "Token does not belong to client" },
492 400
493 );
494 }
495 if (
496 tokenRow.refreshExpiresAt &&
497 new Date(tokenRow.refreshExpiresAt) < new Date()
498 ) {
499 return c.json(
500 { error: "invalid_grant", error_description: "refresh_token expired" },
501 400
502 );
503 }
504
505 // Narrow scopes if the client explicitly requested a subset.
506 let newScopes = tokenRow.scopes;
507 if (body.scope) {
508 const requested = parseScopes(String(body.scope));
509 const originalSet = new Set(
510 tokenRow.scopes.split(/\s+/).filter(Boolean)
511 );
512 const narrowed = requested.filter((s) => originalSet.has(s));
513 newScopes = narrowed.join(" ");
514 }
515
516 // Rotate: revoke old, issue new.
517 await db
518 .update(oauthAccessTokens)
519 .set({ revokedAt: new Date() })
520 .where(eq(oauthAccessTokens.id, tokenRow.id));
521
522 const accessToken = generateAccessToken();
523 const newRefresh = generateRefreshToken();
524 await db.insert(oauthAccessTokens).values({
525 appId: app.id,
526 userId: tokenRow.userId,
527 accessTokenHash: await sha256Hex(accessToken),
528 refreshTokenHash: await sha256Hex(newRefresh),
529 scopes: newScopes,
530 expiresAt: new Date(Date.now() + ACCESS_TOKEN_TTL_MS),
531 refreshExpiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
532 });
533 await audit({
534 userId: tokenRow.userId,
535 action: "oauth.token.refresh",
536 targetType: "oauth_app",
537 targetId: app.id,
538 });
539 return c.json({
540 access_token: accessToken,
541 token_type: "bearer",
542 expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1000),
543 refresh_token: newRefresh,
544 scope: newScopes,
545 });
546 }
547
548 return c.json(
549 {
550 error: "unsupported_grant_type",
551 error_description: `grant_type '${grantType}' not supported`,
552 },
553 400
554 );
555 } catch (err) {
556 console.error("[oauth] token:", err);
557 return c.json(
558 { error: "server_error", error_description: "Service unavailable" },
559 503
560 );
561 }
562});
563
564// --- POST /oauth/revoke (RFC 7009) ------------------------------------------
565
566oauth.post("/oauth/revoke", async (c) => {
567 let body: Record<string, unknown> = {};
568 const contentType = (c.req.header("content-type") || "").toLowerCase();
569 try {
570 if (contentType.includes("application/json")) {
571 body = (await c.req.json()) as Record<string, unknown>;
572 } else {
573 body = (await c.req.parseBody()) as Record<string, unknown>;
574 }
575 } catch {
576 // Per RFC 7009 we still respond 200 to unknown tokens — but a malformed
577 // body indicates a misbehaving client, so 400 is acceptable here.
578 return c.json({ error: "invalid_request" }, 400);
579 }
580
581 const token = body.token ? String(body.token) : "";
582 const authHeader = c.req.header("authorization");
583 const creds = extractClientCreds(authHeader, body);
584
585 if (!creds.clientId) {
586 return c.json({ error: "invalid_client" }, 401);
587 }
588 const app = await loadAppByClientId(creds.clientId);
589 if (!app) {
590 return c.json({ error: "invalid_client" }, 401);
591 }
592 const clientAuthOk = await authenticateClient(app, creds.clientSecret);
593 if (!clientAuthOk) {
594 return c.json({ error: "invalid_client" }, 401);
595 }
596
597 if (!token) {
598 // RFC 7009: server responds as if successful.
599 return c.body(null, 200);
600 }
601
602 try {
603 const hash = await sha256Hex(token);
604 // Try access token first, then refresh token.
605 const [asAccess] = await db
606 .select()
607 .from(oauthAccessTokens)
608 .where(eq(oauthAccessTokens.accessTokenHash, hash))
609 .limit(1);
610 const [asRefresh] = asAccess
611 ? []
612 : await db
613 .select()
614 .from(oauthAccessTokens)
615 .where(eq(oauthAccessTokens.refreshTokenHash, hash))
616 .limit(1);
617 const row = asAccess || asRefresh;
618 if (row && row.appId === app.id && !row.revokedAt) {
619 await db
620 .update(oauthAccessTokens)
621 .set({ revokedAt: new Date() })
622 .where(eq(oauthAccessTokens.id, row.id));
623 await audit({
624 userId: row.userId,
625 action: "oauth.token.revoke",
626 targetType: "oauth_app",
627 targetId: app.id,
628 });
629 }
630 } catch (err) {
631 console.error("[oauth] revoke:", err);
632 // Still 200 per RFC 7009 — we don't want to leak whether the token existed.
633 }
634 return c.body(null, 200);
635});
636
637// --- GET /settings/authorizations -------------------------------------------
638
639oauth.get("/settings/authorizations", async (c) => {
640 const user = c.get("user")!;
641 const success = c.req.query("success");
642 const error = c.req.query("error");
643
644 type Row = {
645 app: typeof oauthApps.$inferSelect | null;
646 token: typeof oauthAccessTokens.$inferSelect;
647 };
648 let rows: Row[] = [];
649 try {
650 const raw = await db
651 .select()
652 .from(oauthAccessTokens)
653 .leftJoin(oauthApps, eq(oauthAccessTokens.appId, oauthApps.id))
654 .where(
655 and(
656 eq(oauthAccessTokens.userId, user.id),
657 isNull(oauthAccessTokens.revokedAt),
658 gt(oauthAccessTokens.expiresAt, new Date())
659 )
660 );
661 rows = raw.map((r: any) => ({
662 app: r.oauth_apps,
663 token: r.oauth_access_tokens,
664 }));
665 } catch (err) {
666 console.error("[oauth] authorizations list:", err);
667 }
668
669 // Group by appId — show each app once with the most recent token's data.
670 const byApp = new Map<string, Row>();
671 for (const r of rows) {
672 const existing = byApp.get(r.token.appId);
673 if (
674 !existing ||
675 new Date(r.token.createdAt) > new Date(existing.token.createdAt)
676 ) {
677 byApp.set(r.token.appId, r);
678 }
679 }
680 const grouped = Array.from(byApp.values());
681
682 return c.html(
683 <Layout title="Authorized applications" user={user}>
684 <div class="settings-container">
685 <div class="breadcrumb">
686 <a href="/settings">settings</a>
687 <span>/</span>
688 <span>authorized applications</span>
689 </div>
690 <h2>Authorized applications</h2>
691 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
692 {success && (
693 <div class="auth-success">{decodeURIComponent(success)}</div>
694 )}
695 <p style="color: var(--text-muted); font-size: 13px">
696 Apps that have been granted access to your gluecron account.
697 Revoking immediately invalidates every access + refresh token
698 issued to that app for your user.
699 </p>
700 <div
701 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; margin-top: 16px"
702 >
703 {grouped.length === 0 ? (
704 <div
705 style="padding: 16px; color: var(--text-muted); font-size: 13px; background: var(--bg-secondary)"
706 >
707 No authorized applications.
708 </div>
709 ) : (
710 grouped.map(({ app, token }) => (
711 <div
712 style="padding: 12px 16px; border-bottom: 1px solid var(--border); background: var(--bg-secondary); display: flex; justify-content: space-between; align-items: center"
713 >
714 <div>
715 <strong>{app?.name || "Unknown app"}</strong>
716 <div
717 style="color: var(--text-muted); font-size: 12px; margin-top: 2px"
718 >
719 scopes: <code>{token.scopes || "(none)"}</code>
720 {" · "}authorised{" "}
721 {new Date(token.createdAt).toLocaleDateString()}
722 {token.lastUsedAt &&
723 ` · last used ${new Date(token.lastUsedAt).toLocaleDateString()}`}
724 </div>
725 </div>
726 <form
e7e240eClaude727 method="post"
058d752Claude728 action={`/settings/authorizations/${token.appId}/revoke`}
729 onsubmit="return confirm('Revoke access for this application?')"
730 >
731 <button type="submit" class="btn btn-sm btn-danger">
732 Revoke
733 </button>
734 </form>
735 </div>
736 ))
737 )}
738 </div>
739 <p style="margin-top: 16px; font-size: 12px; color: var(--text-muted)">
740 Building an OAuth app?{" "}
741 <a href="/settings/applications">Register one</a>.
742 </p>
743 </div>
744 </Layout>
745 );
746});
747
748// --- POST /settings/authorizations/:appId/revoke ----------------------------
749
750oauth.post("/settings/authorizations/:appId/revoke", async (c) => {
751 const user = c.get("user")!;
752 const appId = c.req.param("appId");
753 try {
754 await db
755 .update(oauthAccessTokens)
756 .set({ revokedAt: new Date() })
757 .where(
758 and(
759 eq(oauthAccessTokens.userId, user.id),
760 eq(oauthAccessTokens.appId, appId),
761 isNull(oauthAccessTokens.revokedAt)
762 )
763 );
764 await audit({
765 userId: user.id,
766 action: "oauth.user_revoke",
767 targetType: "oauth_app",
768 targetId: appId,
769 });
770 return c.redirect("/settings/authorizations?success=Revoked");
771 } catch (err) {
772 console.error("[oauth] user revoke:", err);
773 return c.redirect(
774 "/settings/authorizations?error=Service+unavailable"
775 );
776 }
777});
778
779export default oauth;
780
781// re-export for test visibility
782export { SUPPORTED_SCOPES };