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