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.tsxBlame1368 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`.
f0b5874Claude12 *
13 * Visual polish (2026): gradient hairline + orb hero + app card + scope chips
14 * + gradient Authorize CTA. Scoped under `.oauth-*` so it can't bleed into
15 * other pages. Every OAuth flow, redirect URI, state/nonce, and token
16 * issuance path is preserved EXACTLY — this is security-critical.
058d752Claude17 */
18
19import { Hono } from "hono";
20import { and, eq, gt, isNull } from "drizzle-orm";
21import { db } from "../db";
22import {
23 oauthApps,
24 oauthAuthorizations,
25 oauthAccessTokens,
26 users,
27} from "../db/schema";
36cc17aClaude28import type { User } from "../db/schema";
058d752Claude29import { Layout } from "../views/layout";
30import { requireAuth } from "../middleware/auth";
31import type { AuthEnv } from "../middleware/auth";
32import {
33 generateAuthCode,
34 generateAccessToken,
35 generateRefreshToken,
36 sha256Hex,
37 verifyPkce,
38 parseScopes,
39 parseRedirectUris,
40 redirectUriAllowed,
41 timingSafeEqual,
42 ACCESS_TOKEN_TTL_MS,
43 REFRESH_TOKEN_TTL_MS,
44 AUTH_CODE_TTL_MS,
45 SUPPORTED_SCOPES,
46} from "../lib/oauth";
47import { audit } from "../lib/notify";
48
49const oauth = new Hono<AuthEnv>();
50
51oauth.use("/oauth/authorize", requireAuth);
52oauth.use("/oauth/authorize/decision", requireAuth);
53oauth.use("/settings/authorizations", requireAuth);
54oauth.use("/settings/authorizations/*", requireAuth);
55
f0b5874Claude56// --- scope explainer copy (human-readable consent text) --------------------
57//
58// Plain-English description for each scope chip on the consent screen. The
59// keys mirror SUPPORTED_SCOPES; unknown scopes fall back to a generic line.
60const SCOPE_DESCRIPTIONS: Record<string, { label: string; explain: string }> = {
61 "read:user": {
62 label: "Read your profile",
63 explain: "username, avatar, public email — never your password",
64 },
65 "read:repo": {
66 label: "Read your repositories",
67 explain: "code, branches, commits, file contents",
68 },
69 "write:repo": {
70 label: "Write to your repositories",
71 explain: "push commits, create branches, edit files",
72 },
73 "read:org": {
74 label: "Read your organisations",
75 explain: "membership, teams, org-owned repo list",
76 },
77 "write:org": {
78 label: "Manage organisations",
79 explain: "invite members, change settings, edit teams",
80 },
81 "read:issue": {
82 label: "Read issues",
83 explain: "issue threads, comments, labels, assignees",
84 },
85 "write:issue": {
86 label: "Create + edit issues",
87 explain: "open, comment, close, label, assign",
88 },
89 "read:pr": {
90 label: "Read pull requests",
91 explain: "PR diffs, comments, reviews, status",
92 },
93 "write:pr": {
94 label: "Create + edit pull requests",
95 explain: "open, review, comment, merge, close",
96 },
97};
98
99function describeScope(s: string): { label: string; explain: string } {
100 return (
101 SCOPE_DESCRIPTIONS[s] ?? {
102 label: s,
103 explain: "custom scope — see the app's documentation",
104 }
105 );
106}
107
108// --- scoped styles ----------------------------------------------------------
109//
110// Every class prefixed `.oauth-` so the consent screen / authorizations list
111// can't bleed into the wider app polish. Mirrors the gradient-hairline hero +
112// orb pattern from admin-integrations.tsx and error-page.tsx.
113const oauthStyles = `
eed4684Claude114 .oauth-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
f0b5874Claude115
116 /* ─── Hero ─── */
117 .oauth-hero {
118 position: relative;
119 margin-bottom: var(--space-5);
120 padding: var(--space-5) var(--space-6);
121 background: var(--bg-elevated);
122 border: 1px solid var(--border);
123 border-radius: 16px;
124 overflow: hidden;
125 }
126 .oauth-hero::before {
127 content: '';
128 position: absolute;
129 top: 0; left: 0; right: 0;
130 height: 2px;
131 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
132 opacity: 0.7;
133 pointer-events: none;
134 }
135 .oauth-hero-orb {
136 position: absolute;
137 inset: -20% -10% auto auto;
138 width: 380px; height: 380px;
139 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
140 filter: blur(80px);
141 opacity: 0.7;
142 pointer-events: none;
143 z-index: 0;
144 }
145 .oauth-hero-inner { position: relative; z-index: 1; max-width: 680px; }
146 .oauth-eyebrow {
147 font-size: 11px;
148 text-transform: uppercase;
149 letter-spacing: 0.18em;
150 color: var(--text-muted);
151 margin-bottom: var(--space-2);
152 display: inline-flex;
153 align-items: center;
154 gap: 8px;
155 font-family: var(--font-mono);
156 font-weight: 600;
157 }
158 .oauth-eyebrow-dot {
159 width: 8px; height: 8px;
160 border-radius: 9999px;
161 background: linear-gradient(135deg, #8c6dff, #36c5d6);
162 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
163 }
164 .oauth-title {
165 font-size: clamp(26px, 3.5vw, 36px);
166 font-family: var(--font-display);
167 font-weight: 800;
168 letter-spacing: -0.026em;
169 line-height: 1.08;
170 margin: 0 0 var(--space-2);
171 color: var(--text-strong);
172 }
173 .oauth-title-grad {
174 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
175 -webkit-background-clip: text;
176 background-clip: text;
177 -webkit-text-fill-color: transparent;
178 color: transparent;
179 }
180 .oauth-sub {
181 font-size: 14.5px;
182 color: var(--text-muted);
183 margin: 0;
184 line-height: 1.55;
185 }
186 .oauth-sub code {
187 font-family: var(--font-mono);
188 font-size: 12.5px;
189 background: var(--bg-tertiary);
190 padding: 1px 5px;
191 border-radius: 4px;
192 color: var(--text);
193 }
194
195 /* ─── Banners ─── */
196 .oauth-banner {
197 margin-bottom: var(--space-4);
198 padding: 10px 14px;
199 border-radius: 10px;
200 font-size: 13.5px;
201 border: 1px solid var(--border);
202 background: rgba(255,255,255,0.025);
203 color: var(--text);
204 }
205 .oauth-banner.is-ok {
206 border-color: rgba(52,211,153,0.40);
207 background: rgba(52,211,153,0.08);
208 color: #bbf7d0;
209 }
210 .oauth-banner.is-error {
211 border-color: rgba(248,113,113,0.40);
212 background: rgba(248,113,113,0.08);
213 color: #fecaca;
214 }
215
216 /* ─── App identity card (consent) ─── */
217 .oauth-appcard {
218 display: flex;
219 gap: var(--space-3);
220 align-items: flex-start;
221 padding: var(--space-4) var(--space-5);
222 background: var(--bg-elevated);
223 border: 1px solid var(--border);
224 border-radius: 14px;
225 margin-bottom: var(--space-4);
226 }
227 .oauth-applogo {
228 flex-shrink: 0;
229 width: 56px;
230 height: 56px;
231 border-radius: 12px;
232 background: linear-gradient(135deg, rgba(140,109,255,0.22), rgba(54,197,214,0.16));
233 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
234 display: flex;
235 align-items: center;
236 justify-content: center;
237 font-family: var(--font-display);
238 font-size: 22px;
239 font-weight: 800;
240 color: #e9d5ff;
241 letter-spacing: -0.02em;
242 text-transform: uppercase;
243 }
244 .oauth-appmeta { flex: 1; min-width: 0; }
245 .oauth-appname {
246 margin: 0 0 4px;
247 font-family: var(--font-display);
248 font-size: 18px;
249 font-weight: 700;
250 letter-spacing: -0.018em;
251 color: var(--text-strong);
252 word-break: break-word;
253 }
254 .oauth-appname code {
255 font-family: var(--font-mono);
256 font-size: 12.5px;
257 color: var(--accent);
258 background: rgba(140,109,255,0.08);
259 padding: 1px 6px;
260 border-radius: 4px;
261 margin-left: 6px;
262 font-weight: 500;
263 }
264 .oauth-appdesc {
265 margin: 0;
266 font-size: 13px;
267 color: var(--text-muted);
268 line-height: 1.5;
269 }
270
271 /* ─── Section card ─── */
272 .oauth-section {
273 margin-bottom: var(--space-4);
274 background: var(--bg-elevated);
275 border: 1px solid var(--border);
276 border-radius: 14px;
277 overflow: hidden;
278 }
279 .oauth-section-head {
280 padding: var(--space-3) var(--space-5);
281 border-bottom: 1px solid var(--border);
282 display: flex;
283 align-items: center;
284 justify-content: space-between;
285 gap: var(--space-2);
286 flex-wrap: wrap;
287 }
288 .oauth-section-title {
289 margin: 0;
290 font-family: var(--font-display);
291 font-size: 14px;
292 font-weight: 700;
293 letter-spacing: 0.04em;
294 text-transform: uppercase;
295 color: var(--text-strong);
296 }
297 .oauth-section-count {
298 font-family: var(--font-mono);
299 font-size: 12px;
300 color: var(--text-muted);
301 }
302 .oauth-section-body { padding: var(--space-4) var(--space-5); }
303
304 /* ─── Scope chips ─── */
305 .oauth-scopes {
306 list-style: none;
307 padding: 0;
308 margin: 0;
309 display: flex;
310 flex-direction: column;
311 gap: var(--space-2);
312 }
313 .oauth-scope {
314 display: flex;
315 align-items: flex-start;
316 gap: 12px;
317 padding: 10px 12px;
318 border: 1px solid var(--border);
319 border-radius: 10px;
320 background: rgba(255,255,255,0.02);
321 }
322 .oauth-scope-chip {
323 display: inline-flex;
324 align-items: center;
325 gap: 6px;
326 padding: 3px 10px;
327 border-radius: 9999px;
328 font-family: var(--font-mono);
329 font-size: 11.5px;
330 font-weight: 600;
331 color: #e9d5ff;
332 background: linear-gradient(135deg, rgba(140,109,255,0.18), rgba(54,197,214,0.10));
333 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
334 flex-shrink: 0;
335 margin-top: 2px;
336 white-space: nowrap;
337 }
338 .oauth-scope-chip[data-write="1"] {
339 color: #fde68a;
340 background: linear-gradient(135deg, rgba(251,191,36,0.18), rgba(248,113,113,0.10));
341 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.36);
342 }
343 .oauth-scope-text { flex: 1; min-width: 0; }
344 .oauth-scope-label {
345 font-size: 13.5px;
346 font-weight: 600;
347 color: var(--text-strong);
348 line-height: 1.3;
349 }
350 .oauth-scope-explain {
351 margin-top: 2px;
352 font-size: 12.5px;
353 color: var(--text-muted);
354 line-height: 1.45;
355 }
356 .oauth-scope-empty {
357 margin: 0;
358 font-size: 13px;
359 color: var(--text-muted);
360 font-style: italic;
361 }
362
363 /* ─── Actions row ─── */
364 .oauth-actions {
365 display: flex;
366 gap: 10px;
367 flex-wrap: wrap;
368 align-items: center;
369 margin-top: var(--space-4);
370 }
371 .oauth-actions form { margin: 0; }
372 .oauth-btn {
373 display: inline-flex;
374 align-items: center;
375 justify-content: center;
376 gap: 8px;
377 padding: 13px 24px;
378 border-radius: 12px;
379 font-size: 14.5px;
380 font-weight: 700;
381 text-decoration: none;
382 border: 1px solid transparent;
383 cursor: pointer;
384 font: inherit;
385 line-height: 1;
386 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
387 }
388 .oauth-btn-primary {
389 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
390 color: #ffffff;
391 box-shadow: 0 8px 22px -6px rgba(140,109,255,0.55), inset 0 1px 0 rgba(255,255,255,0.18);
392 min-width: 180px;
393 }
394 .oauth-btn-primary:hover {
395 transform: translateY(-1px);
396 box-shadow: 0 12px 28px -8px rgba(140,109,255,0.65), inset 0 1px 0 rgba(255,255,255,0.22);
397 color: #ffffff;
398 text-decoration: none;
399 }
400 .oauth-btn-ghost {
401 background: transparent;
402 color: var(--text);
403 border-color: var(--border-strong);
404 padding: 13px 20px;
405 }
406 .oauth-btn-ghost:hover {
407 background: rgba(255,255,255,0.04);
408 border-color: rgba(140,109,255,0.45);
409 color: var(--text-strong);
410 text-decoration: none;
411 }
412 .oauth-btn-danger-sm {
413 display: inline-flex;
414 align-items: center;
415 gap: 6px;
416 padding: 6px 12px;
417 border-radius: 8px;
418 font-size: 12.5px;
419 font-weight: 600;
420 border: 1px solid rgba(248,113,113,0.35);
421 background: transparent;
422 color: #fca5a5;
423 cursor: pointer;
424 font: inherit;
425 line-height: 1;
426 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
427 }
428 .oauth-btn-danger-sm:hover {
429 border-color: rgba(248,113,113,0.70);
430 background: rgba(248,113,113,0.08);
431 color: #fecaca;
432 }
433
434 /* ─── Explainer ─── */
435 .oauth-explainer {
436 margin-top: var(--space-4);
437 padding: var(--space-3) var(--space-4);
438 background: rgba(140,109,255,0.04);
439 border: 1px dashed rgba(140,109,255,0.22);
440 border-radius: 12px;
441 font-size: 12.5px;
442 color: var(--text-muted);
443 line-height: 1.55;
444 }
445 .oauth-explainer strong { color: var(--text-strong); display: block; margin-bottom: 4px; font-size: 13px; }
446 .oauth-explainer a { color: var(--accent); text-decoration: none; }
447 .oauth-explainer a:hover { text-decoration: underline; }
448 .oauth-explainer ul { margin: 6px 0 0; padding-left: 20px; }
449 .oauth-explainer li { margin: 2px 0; }
450
451 /* ─── Authorizations list ─── */
452 .oauth-breadcrumb {
453 font-size: 13px;
454 color: var(--text-muted);
455 margin-bottom: var(--space-3);
456 display: flex;
457 gap: 8px;
458 align-items: center;
459 flex-wrap: wrap;
460 }
461 .oauth-breadcrumb a { color: var(--accent); text-decoration: none; }
462 .oauth-breadcrumb a:hover { text-decoration: underline; }
463 .oauth-breadcrumb span.sep { color: var(--text-muted); }
464
465 .oauth-grant-list {
466 list-style: none;
467 padding: 0;
468 margin: 0;
469 display: flex;
470 flex-direction: column;
471 gap: var(--space-2);
472 }
473 .oauth-grant {
474 display: flex;
475 align-items: center;
476 justify-content: space-between;
477 gap: var(--space-3);
478 padding: 14px 16px;
479 background: var(--bg-elevated);
480 border: 1px solid var(--border);
481 border-radius: 12px;
482 flex-wrap: wrap;
483 }
484 .oauth-grant-info { flex: 1; min-width: 240px; }
485 .oauth-grant-name {
486 font-family: var(--font-display);
487 font-size: 15px;
488 font-weight: 700;
489 color: var(--text-strong);
490 margin: 0 0 4px;
491 letter-spacing: -0.01em;
492 }
493 .oauth-grant-meta {
494 font-size: 12px;
495 color: var(--text-muted);
496 line-height: 1.5;
497 }
498 .oauth-grant-meta code {
499 font-family: var(--font-mono);
500 font-size: 11.5px;
501 background: rgba(255,255,255,0.04);
502 border: 1px solid var(--border);
503 padding: 1px 6px;
504 border-radius: 4px;
505 color: var(--text);
506 }
507 .oauth-empty {
508 padding: 22px 18px;
509 text-align: center;
510 color: var(--text-muted);
511 font-size: 13.5px;
512 background: var(--bg-elevated);
513 border: 1px dashed var(--border);
514 border-radius: 12px;
515 }
516
517 /* ─── Error sub-page ─── */
518 .oauth-error-page {
519 max-width: 540px;
520 margin: var(--space-12) auto;
521 padding: var(--space-6);
522 text-align: center;
523 background: var(--bg-elevated);
524 border: 1px solid var(--border);
525 border-radius: 16px;
526 }
527 .oauth-error-page h2 {
528 font-family: var(--font-display);
529 font-size: 22px;
530 margin: 0 0 8px;
531 color: var(--text-strong);
532 }
533 .oauth-error-page p { color: var(--text-muted); margin: 0 0 16px; font-size: 14px; }
534`;
535
058d752Claude536// --- helpers ----------------------------------------------------------------
537
538function appendQuery(url: string, params: Record<string, string | undefined>) {
539 const sep = url.includes("?") ? "&" : "?";
540 const parts: string[] = [];
541 for (const [k, v] of Object.entries(params)) {
542 if (v === undefined || v === null) continue;
543 parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
544 }
545 if (parts.length === 0) return url;
546 return url + sep + parts.join("&");
547}
548
36cc17aClaude549function errorPage(title: string, message: string, user: User | null) {
058d752Claude550 return (
36cc17aClaude551 <Layout title={title} user={user}>
f0b5874Claude552 <div class="oauth-wrap">
553 <div class="oauth-error-page">
554 <h2>{title}</h2>
555 <p>{message}</p>
556 <a href="/" class="oauth-btn oauth-btn-ghost" style="padding: 10px 18px">
557 Go home
558 </a>
559 </div>
058d752Claude560 </div>
f0b5874Claude561 <style dangerouslySetInnerHTML={{ __html: oauthStyles }} />
058d752Claude562 </Layout>
563 );
564}
565
566type OauthApp = typeof oauthApps.$inferSelect;
567
568async function loadAppByClientId(clientId: string): Promise<OauthApp | null> {
569 try {
570 const [row] = await db
571 .select()
572 .from(oauthApps)
573 .where(eq(oauthApps.clientId, clientId))
574 .limit(1);
575 return row || null;
576 } catch (err) {
577 console.error("[oauth] loadApp:", err);
578 return null;
579 }
580}
581
582/**
583 * Extracts client_id + client_secret from either the request body or an
584 * `Authorization: Basic` header. Returns `null` if neither is present.
585 */
586function extractClientCreds(
587 authHeader: string | undefined,
588 body: Record<string, unknown>
589): { clientId?: string; clientSecret?: string } {
590 if (authHeader && authHeader.toLowerCase().startsWith("basic ")) {
591 try {
592 const decoded = atob(authHeader.slice(6).trim());
593 const idx = decoded.indexOf(":");
594 if (idx > 0) {
595 return {
596 clientId: decoded.slice(0, idx),
597 clientSecret: decoded.slice(idx + 1),
598 };
599 }
600 } catch {
601 /* fall through */
602 }
603 }
604 const cid = body.client_id ? String(body.client_id) : undefined;
605 const csec = body.client_secret ? String(body.client_secret) : undefined;
606 return { clientId: cid, clientSecret: csec };
607}
608
609async function authenticateClient(
610 app: OauthApp,
611 providedSecret: string | undefined
612): Promise<boolean> {
613 if (!app.confidential) return true; // public clients auth via PKCE
614 if (!providedSecret) return false;
615 const hash = await sha256Hex(providedSecret);
616 return timingSafeEqual(hash, app.clientSecretHash);
617}
618
f0b5874Claude619function isWriteScope(s: string): boolean {
620 return s.startsWith("write:");
621}
622
058d752Claude623// --- GET /oauth/authorize ---------------------------------------------------
624
625oauth.get("/oauth/authorize", async (c) => {
626 const user = c.get("user")!;
627 const q = c.req.query();
628 const clientId = q.client_id || "";
629 const redirectUri = q.redirect_uri || "";
630 const responseType = q.response_type || "";
631 const scopeParam = q.scope || "";
632 const state = q.state || "";
633 const codeChallenge = q.code_challenge || "";
634 const codeChallengeMethod = q.code_challenge_method || "";
635
636 if (!clientId) {
36cc17aClaude637 return c.html(errorPage("OAuth error", "Missing client_id.", user), 400);
058d752Claude638 }
639 const app = await loadAppByClientId(clientId);
640 if (!app) {
36cc17aClaude641 return c.html(errorPage("OAuth error", "Unknown client.", user), 400);
058d752Claude642 }
643 if (app.revokedAt) {
36cc17aClaude644 return c.html(errorPage("OAuth error", "This application has been revoked.", user), 400);
058d752Claude645 }
646
647 const registered = parseRedirectUris(app.redirectUris);
648 if (!redirectUriAllowed(redirectUri, registered)) {
649 return c.html(
650 errorPage(
651 "OAuth error",
36cc17aClaude652 "redirect_uri does not match any registered callback for this app.",
653 user
058d752Claude654 ),
655 400
656 );
657 }
658
659 // Beyond this point errors redirect back to redirect_uri with ?error=...
660 if (responseType !== "code") {
661 return c.redirect(
662 appendQuery(redirectUri, {
663 error: "unsupported_response_type",
664 error_description: "response_type must be 'code'",
665 state: state || undefined,
666 })
667 );
668 }
669 if (!app.confidential && !codeChallenge) {
670 return c.redirect(
671 appendQuery(redirectUri, {
672 error: "invalid_request",
673 error_description: "PKCE code_challenge is required for public clients",
674 state: state || undefined,
675 })
676 );
677 }
678
679 const scopes = parseScopes(scopeParam);
680
681 // Look up the app owner for the consent screen.
682 let ownerName = "unknown";
683 try {
684 const [ownerRow] = await db
685 .select({ username: users.username })
686 .from(users)
687 .where(eq(users.id, app.ownerId))
688 .limit(1);
689 if (ownerRow) ownerName = ownerRow.username;
690 } catch {
691 /* non-fatal */
692 }
693
f0b5874Claude694 const initial = (app.name || "?").trim().charAt(0).toUpperCase() || "?";
695
058d752Claude696 return c.html(
697 <Layout title="Authorize application" user={user}>
f0b5874Claude698 <div class="oauth-wrap">
699 <section class="oauth-hero">
700 <div class="oauth-hero-orb" aria-hidden="true" />
701 <div class="oauth-hero-inner">
702 <div class="oauth-eyebrow">
703 <span class="oauth-eyebrow-dot" aria-hidden="true" />
704 OAuth · Authorize an application
705 </div>
706 <h1 class="oauth-title">
707 <span class="oauth-title-grad">Authorize {app.name}?</span>
708 </h1>
709 <p class="oauth-sub">
710 Signed in as <code>{user.username}</code>. Review what this app is
711 asking for, then approve or cancel.
058d752Claude712 </p>
f0b5874Claude713 </div>
714 </section>
715
716 <section class="oauth-appcard" aria-label="Application identity">
717 <div class="oauth-applogo" aria-hidden="true">{initial}</div>
718 <div class="oauth-appmeta">
719 <h2 class="oauth-appname">
720 {app.name}
721 <code>by {ownerName}</code>
722 </h2>
723 {app.description ? (
724 <p class="oauth-appdesc">{app.description}</p>
725 ) : (
726 <p class="oauth-appdesc" style="font-style: italic">
727 No description provided by the developer.
728 </p>
729 )}
730 </div>
731 </section>
732
733 <section class="oauth-section" aria-label="Requested scopes">
734 <header class="oauth-section-head">
735 <h3 class="oauth-section-title">Requested permissions</h3>
736 <span class="oauth-section-count">
737 {scopes.length} {scopes.length === 1 ? "scope" : "scopes"}
738 </span>
739 </header>
740 <div class="oauth-section-body">
741 {scopes.length === 0 ? (
742 <p class="oauth-scope-empty">
743 No scopes — this app will only be able to identify you (your
744 username + public profile).
745 </p>
746 ) : (
747 <ul class="oauth-scopes">
748 {scopes.map((s) => {
749 const meta = describeScope(s);
750 return (
751 <li class="oauth-scope">
752 <span
753 class="oauth-scope-chip"
754 data-write={isWriteScope(s) ? "1" : "0"}
755 >
756 <code>{s}</code>
757 </span>
758 <div class="oauth-scope-text">
759 <div class="oauth-scope-label">{meta.label}</div>
760 <div class="oauth-scope-explain">{meta.explain}</div>
761 </div>
762 </li>
763 );
764 })}
765 </ul>
766 )}
767 </div>
768 </section>
769
e7e240eClaude770 <form method="post" action="/oauth/authorize/decision">
058d752Claude771 <input type="hidden" name="client_id" value={clientId} />
772 <input type="hidden" name="redirect_uri" value={redirectUri} />
773 <input type="hidden" name="response_type" value={responseType} />
774 <input type="hidden" name="scope" value={scopes.join(" ")} />
775 <input type="hidden" name="state" value={state} />
776 <input type="hidden" name="code_challenge" value={codeChallenge} />
777 <input
778 type="hidden"
779 name="code_challenge_method"
780 value={codeChallengeMethod}
781 />
f0b5874Claude782 <div class="oauth-actions">
783 <button
784 type="submit"
785 name="decision"
786 value="approve"
787 class="oauth-btn oauth-btn-primary"
788 >
789 Authorize {app.name}
058d752Claude790 </button>
f0b5874Claude791 <button
792 type="submit"
793 name="decision"
794 value="deny"
795 class="oauth-btn oauth-btn-ghost"
796 >
797 Deny
058d752Claude798 </button>
799 </div>
800 </form>
f0b5874Claude801
802 <div class="oauth-explainer">
803 <strong>What does {app.name} get?</strong>
804 <ul>
805 <li>
806 A scoped access token that expires in{" "}
807 {Math.round(ACCESS_TOKEN_TTL_MS / 60000)} minutes (auto-refreshed for{" "}
808 up to {Math.round(REFRESH_TOKEN_TTL_MS / (24 * 60 * 60 * 1000))} days).
809 </li>
810 <li>
811 The scopes listed above — nothing else. It never sees your password
812 or your other personal access tokens.
813 </li>
814 <li>
815 Revoke at any time from{" "}
816 <a href="/settings/authorizations">Authorized applications</a> —
817 every access + refresh token for this app is invalidated immediately.
818 </li>
819 </ul>
820 </div>
058d752Claude821 </div>
f0b5874Claude822 <style dangerouslySetInnerHTML={{ __html: oauthStyles }} />
058d752Claude823 </Layout>
824 );
825});
826
827// --- POST /oauth/authorize/decision -----------------------------------------
828
829oauth.post("/oauth/authorize/decision", async (c) => {
830 const user = c.get("user")!;
831 const body = await c.req.parseBody();
832 const clientId = String(body.client_id || "");
833 const redirectUri = String(body.redirect_uri || "");
834 const scopeParam = String(body.scope || "");
835 const state = String(body.state || "");
836 const decision = String(body.decision || "");
837 const codeChallenge = String(body.code_challenge || "");
838 const codeChallengeMethod = String(body.code_challenge_method || "");
839
840 const app = await loadAppByClientId(clientId);
841 if (!app || app.revokedAt) {
36cc17aClaude842 return c.html(errorPage("OAuth error", "Unknown or revoked client.", user), 400);
058d752Claude843 }
844 const registered = parseRedirectUris(app.redirectUris);
845 if (!redirectUriAllowed(redirectUri, registered)) {
36cc17aClaude846 return c.html(errorPage("OAuth error", "Invalid redirect_uri.", user), 400);
058d752Claude847 }
848
849 if (decision !== "approve") {
850 return c.redirect(
851 appendQuery(redirectUri, {
852 error: "access_denied",
853 error_description: "User denied the request",
854 state: state || undefined,
855 })
856 );
857 }
858
859 const scopes = parseScopes(scopeParam);
860 const code = generateAuthCode();
861 const codeHash = await sha256Hex(code);
862
863 try {
864 await db.insert(oauthAuthorizations).values({
865 appId: app.id,
866 userId: user.id,
867 codeHash,
868 redirectUri,
869 scopes: scopes.join(" "),
870 codeChallenge: codeChallenge || null,
871 codeChallengeMethod: codeChallengeMethod || null,
872 expiresAt: new Date(Date.now() + AUTH_CODE_TTL_MS),
873 });
874 await audit({
875 userId: user.id,
876 action: "oauth.authorize",
877 targetType: "oauth_app",
878 targetId: app.id,
879 metadata: { scopes: scopes.join(" ") },
880 });
881 return c.redirect(
882 appendQuery(redirectUri, {
883 code,
884 state: state || undefined,
885 })
886 );
887 } catch (err) {
888 console.error("[oauth] authorize/decision:", err);
889 return c.redirect(
890 appendQuery(redirectUri, {
891 error: "server_error",
892 error_description: "Service unavailable",
893 state: state || undefined,
894 })
895 );
896 }
897});
898
899// --- POST /oauth/token ------------------------------------------------------
900
901oauth.post("/oauth/token", async (c) => {
902 // Accept either form-encoded or JSON bodies.
903 let body: Record<string, unknown> = {};
904 const contentType = (c.req.header("content-type") || "").toLowerCase();
905 try {
906 if (contentType.includes("application/json")) {
907 body = (await c.req.json()) as Record<string, unknown>;
908 } else {
909 body = (await c.req.parseBody()) as Record<string, unknown>;
910 }
911 } catch {
912 return c.json(
913 { error: "invalid_request", error_description: "Malformed body" },
914 400
915 );
916 }
917
918 const grantType = body.grant_type ? String(body.grant_type) : "";
919 const authHeader = c.req.header("authorization");
920 const creds = extractClientCreds(authHeader, body);
921
922 if (!creds.clientId) {
923 return c.json(
924 { error: "invalid_client", error_description: "Missing client_id" },
925 401
926 );
927 }
928 const app = await loadAppByClientId(creds.clientId);
929 if (!app || app.revokedAt) {
930 return c.json(
931 { error: "invalid_client", error_description: "Unknown client" },
932 401
933 );
934 }
935 const clientAuthOk = await authenticateClient(app, creds.clientSecret);
936 if (!clientAuthOk) {
937 return c.json(
938 { error: "invalid_client", error_description: "Client authentication failed" },
939 401
940 );
941 }
942
943 try {
944 if (grantType === "authorization_code") {
945 const code = body.code ? String(body.code) : "";
946 const redirectUri = body.redirect_uri ? String(body.redirect_uri) : "";
947 const codeVerifier = body.code_verifier ? String(body.code_verifier) : "";
948 if (!code || !redirectUri) {
949 return c.json(
950 { error: "invalid_request", error_description: "code and redirect_uri required" },
951 400
952 );
953 }
954 const codeHash = await sha256Hex(code);
955 const [authRow] = await db
956 .select()
957 .from(oauthAuthorizations)
958 .where(eq(oauthAuthorizations.codeHash, codeHash))
959 .limit(1);
960 if (!authRow) {
961 return c.json({ error: "invalid_grant", error_description: "Unknown code" }, 400);
962 }
963 if (authRow.usedAt) {
964 return c.json(
965 { error: "invalid_grant", error_description: "Code already used" },
966 400
967 );
968 }
969 if (new Date(authRow.expiresAt) < new Date()) {
970 return c.json({ error: "invalid_grant", error_description: "Code expired" }, 400);
971 }
972 if (authRow.appId !== app.id) {
973 return c.json(
974 { error: "invalid_grant", error_description: "Code does not belong to client" },
975 400
976 );
977 }
978 if (!timingSafeEqual(authRow.redirectUri, redirectUri)) {
979 return c.json(
980 { error: "invalid_grant", error_description: "redirect_uri mismatch" },
981 400
982 );
983 }
984 if (authRow.codeChallenge) {
985 const ok = await verifyPkce({
986 challenge: authRow.codeChallenge,
987 method: authRow.codeChallengeMethod,
988 verifier: codeVerifier,
989 });
990 if (!ok) {
991 return c.json(
992 { error: "invalid_grant", error_description: "PKCE verification failed" },
993 400
994 );
995 }
996 }
997
998 // Single-use: mark used immediately.
999 await db
1000 .update(oauthAuthorizations)
1001 .set({ usedAt: new Date() })
1002 .where(eq(oauthAuthorizations.id, authRow.id));
1003
1004 const accessToken = generateAccessToken();
1005 const refreshToken = generateRefreshToken();
1006 const accessHash = await sha256Hex(accessToken);
1007 const refreshHash = await sha256Hex(refreshToken);
1008
1009 await db.insert(oauthAccessTokens).values({
1010 appId: app.id,
1011 userId: authRow.userId,
1012 accessTokenHash: accessHash,
1013 refreshTokenHash: refreshHash,
1014 scopes: authRow.scopes,
1015 expiresAt: new Date(Date.now() + ACCESS_TOKEN_TTL_MS),
1016 refreshExpiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
1017 });
1018 await audit({
1019 userId: authRow.userId,
1020 action: "oauth.token.issue",
1021 targetType: "oauth_app",
1022 targetId: app.id,
1023 });
1024 return c.json({
1025 access_token: accessToken,
1026 token_type: "bearer",
1027 expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1000),
1028 refresh_token: refreshToken,
1029 scope: authRow.scopes,
1030 });
1031 }
1032
1033 if (grantType === "refresh_token") {
1034 const refreshToken = body.refresh_token ? String(body.refresh_token) : "";
1035 if (!refreshToken) {
1036 return c.json(
1037 { error: "invalid_request", error_description: "refresh_token required" },
1038 400
1039 );
1040 }
1041 const refreshHash = await sha256Hex(refreshToken);
1042 const [tokenRow] = await db
1043 .select()
1044 .from(oauthAccessTokens)
1045 .where(eq(oauthAccessTokens.refreshTokenHash, refreshHash))
1046 .limit(1);
1047 if (!tokenRow || tokenRow.revokedAt) {
1048 return c.json(
1049 { error: "invalid_grant", error_description: "Unknown refresh_token" },
1050 400
1051 );
1052 }
1053 if (tokenRow.appId !== app.id) {
1054 return c.json(
1055 { error: "invalid_grant", error_description: "Token does not belong to client" },
1056 400
1057 );
1058 }
1059 if (
1060 tokenRow.refreshExpiresAt &&
1061 new Date(tokenRow.refreshExpiresAt) < new Date()
1062 ) {
1063 return c.json(
1064 { error: "invalid_grant", error_description: "refresh_token expired" },
1065 400
1066 );
1067 }
1068
1069 // Narrow scopes if the client explicitly requested a subset.
1070 let newScopes = tokenRow.scopes;
1071 if (body.scope) {
1072 const requested = parseScopes(String(body.scope));
1073 const originalSet = new Set(
1074 tokenRow.scopes.split(/\s+/).filter(Boolean)
1075 );
1076 const narrowed = requested.filter((s) => originalSet.has(s));
1077 newScopes = narrowed.join(" ");
1078 }
1079
1080 // Rotate: revoke old, issue new.
1081 await db
1082 .update(oauthAccessTokens)
1083 .set({ revokedAt: new Date() })
1084 .where(eq(oauthAccessTokens.id, tokenRow.id));
1085
1086 const accessToken = generateAccessToken();
1087 const newRefresh = generateRefreshToken();
1088 await db.insert(oauthAccessTokens).values({
1089 appId: app.id,
1090 userId: tokenRow.userId,
1091 accessTokenHash: await sha256Hex(accessToken),
1092 refreshTokenHash: await sha256Hex(newRefresh),
1093 scopes: newScopes,
1094 expiresAt: new Date(Date.now() + ACCESS_TOKEN_TTL_MS),
1095 refreshExpiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
1096 });
1097 await audit({
1098 userId: tokenRow.userId,
1099 action: "oauth.token.refresh",
1100 targetType: "oauth_app",
1101 targetId: app.id,
1102 });
1103 return c.json({
1104 access_token: accessToken,
1105 token_type: "bearer",
1106 expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1000),
1107 refresh_token: newRefresh,
1108 scope: newScopes,
1109 });
1110 }
1111
1112 return c.json(
1113 {
1114 error: "unsupported_grant_type",
1115 error_description: `grant_type '${grantType}' not supported`,
1116 },
1117 400
1118 );
1119 } catch (err) {
1120 console.error("[oauth] token:", err);
1121 return c.json(
1122 { error: "server_error", error_description: "Service unavailable" },
1123 503
1124 );
1125 }
1126});
1127
1128// --- POST /oauth/revoke (RFC 7009) ------------------------------------------
1129
1130oauth.post("/oauth/revoke", async (c) => {
1131 let body: Record<string, unknown> = {};
1132 const contentType = (c.req.header("content-type") || "").toLowerCase();
1133 try {
1134 if (contentType.includes("application/json")) {
1135 body = (await c.req.json()) as Record<string, unknown>;
1136 } else {
1137 body = (await c.req.parseBody()) as Record<string, unknown>;
1138 }
1139 } catch {
1140 // Per RFC 7009 we still respond 200 to unknown tokens — but a malformed
1141 // body indicates a misbehaving client, so 400 is acceptable here.
1142 return c.json({ error: "invalid_request" }, 400);
1143 }
1144
1145 const token = body.token ? String(body.token) : "";
1146 const authHeader = c.req.header("authorization");
1147 const creds = extractClientCreds(authHeader, body);
1148
1149 if (!creds.clientId) {
1150 return c.json({ error: "invalid_client" }, 401);
1151 }
1152 const app = await loadAppByClientId(creds.clientId);
1153 if (!app) {
1154 return c.json({ error: "invalid_client" }, 401);
1155 }
1156 const clientAuthOk = await authenticateClient(app, creds.clientSecret);
1157 if (!clientAuthOk) {
1158 return c.json({ error: "invalid_client" }, 401);
1159 }
1160
1161 if (!token) {
1162 // RFC 7009: server responds as if successful.
1163 return c.body(null, 200);
1164 }
1165
1166 try {
1167 const hash = await sha256Hex(token);
1168 // Try access token first, then refresh token.
1169 const [asAccess] = await db
1170 .select()
1171 .from(oauthAccessTokens)
1172 .where(eq(oauthAccessTokens.accessTokenHash, hash))
1173 .limit(1);
1174 const [asRefresh] = asAccess
1175 ? []
1176 : await db
1177 .select()
1178 .from(oauthAccessTokens)
1179 .where(eq(oauthAccessTokens.refreshTokenHash, hash))
1180 .limit(1);
1181 const row = asAccess || asRefresh;
1182 if (row && row.appId === app.id && !row.revokedAt) {
1183 await db
1184 .update(oauthAccessTokens)
1185 .set({ revokedAt: new Date() })
1186 .where(eq(oauthAccessTokens.id, row.id));
1187 await audit({
1188 userId: row.userId,
1189 action: "oauth.token.revoke",
1190 targetType: "oauth_app",
1191 targetId: app.id,
1192 });
1193 }
1194 } catch (err) {
1195 console.error("[oauth] revoke:", err);
1196 // Still 200 per RFC 7009 — we don't want to leak whether the token existed.
1197 }
1198 return c.body(null, 200);
1199});
1200
1201// --- GET /settings/authorizations -------------------------------------------
1202
1203oauth.get("/settings/authorizations", async (c) => {
1204 const user = c.get("user")!;
1205 const success = c.req.query("success");
1206 const error = c.req.query("error");
1207
1208 type Row = {
1209 app: typeof oauthApps.$inferSelect | null;
1210 token: typeof oauthAccessTokens.$inferSelect;
1211 };
1212 let rows: Row[] = [];
1213 try {
1214 const raw = await db
1215 .select()
1216 .from(oauthAccessTokens)
1217 .leftJoin(oauthApps, eq(oauthAccessTokens.appId, oauthApps.id))
1218 .where(
1219 and(
1220 eq(oauthAccessTokens.userId, user.id),
1221 isNull(oauthAccessTokens.revokedAt),
1222 gt(oauthAccessTokens.expiresAt, new Date())
1223 )
1224 );
1225 rows = raw.map((r: any) => ({
1226 app: r.oauth_apps,
1227 token: r.oauth_access_tokens,
1228 }));
1229 } catch (err) {
1230 console.error("[oauth] authorizations list:", err);
1231 }
1232
1233 // Group by appId — show each app once with the most recent token's data.
1234 const byApp = new Map<string, Row>();
1235 for (const r of rows) {
1236 const existing = byApp.get(r.token.appId);
1237 if (
1238 !existing ||
1239 new Date(r.token.createdAt) > new Date(existing.token.createdAt)
1240 ) {
1241 byApp.set(r.token.appId, r);
1242 }
1243 }
1244 const grouped = Array.from(byApp.values());
1245
1246 return c.html(
1247 <Layout title="Authorized applications" user={user}>
f0b5874Claude1248 <div class="oauth-wrap">
1249 <div class="oauth-breadcrumb">
058d752Claude1250 <a href="/settings">settings</a>
f0b5874Claude1251 <span class="sep">/</span>
058d752Claude1252 <span>authorized applications</span>
1253 </div>
f0b5874Claude1254
1255 <section class="oauth-hero">
1256 <div class="oauth-hero-orb" aria-hidden="true" />
1257 <div class="oauth-hero-inner">
1258 <div class="oauth-eyebrow">
1259 <span class="oauth-eyebrow-dot" aria-hidden="true" />
1260 OAuth · {user.username}
058d752Claude1261 </div>
f0b5874Claude1262 <h1 class="oauth-title">
1263 <span class="oauth-title-grad">Authorized applications.</span>
1264 </h1>
1265 <p class="oauth-sub">
1266 Apps you've granted access to your Gluecron account. Revoking
1267 immediately invalidates every access + refresh token issued to
1268 that app for you.
1269 </p>
1270 </div>
1271 </section>
1272
1273 {error && <div class="oauth-banner is-error">{decodeURIComponent(error)}</div>}
1274 {success && <div class="oauth-banner is-ok">{decodeURIComponent(success)}</div>}
1275
1276 {grouped.length === 0 ? (
1277 <div class="oauth-empty">
1278 No authorized applications. Apps you approve via the OAuth consent
1279 screen will appear here.
1280 </div>
1281 ) : (
1282 <ul class="oauth-grant-list">
1283 {grouped.map(({ app, token }) => {
1284 const initial = ((app?.name || "?").trim().charAt(0) || "?").toUpperCase();
1285 return (
1286 <li class="oauth-grant">
058d752Claude1287 <div
f0b5874Claude1288 class="oauth-applogo"
1289 aria-hidden="true"
1290 style="width:42px;height:42px;font-size:17px;border-radius:10px"
058d752Claude1291 >
f0b5874Claude1292 {initial}
058d752Claude1293 </div>
f0b5874Claude1294 <div class="oauth-grant-info">
1295 <h3 class="oauth-grant-name">{app?.name || "Unknown app"}</h3>
1296 <div class="oauth-grant-meta">
1297 scopes: <code>{token.scopes || "(none)"}</code>
1298 {" · "}authorised{" "}
1299 {new Date(token.createdAt).toLocaleDateString()}
1300 {token.lastUsedAt &&
1301 ` · last used ${new Date(token.lastUsedAt).toLocaleDateString()}`}
1302 </div>
1303 </div>
1304 <form
1305 method="post"
1306 action={`/settings/authorizations/${token.appId}/revoke`}
1307 onsubmit="return confirm('Revoke access for this application?')"
1308 style="margin:0"
1309 >
1310 <button type="submit" class="oauth-btn-danger-sm">
1311 Revoke
1312 </button>
1313 </form>
1314 </li>
1315 );
1316 })}
1317 </ul>
1318 )}
1319
1320 <div class="oauth-explainer" style="margin-top:var(--space-5)">
1321 <strong>Building an OAuth app?</strong>
1322 Register one at <a href="/settings/applications">Developer applications</a>.
1323 You'll get a <code>client_id</code> + <code>client_secret</code> and
1324 can point users at <code>/oauth/authorize?client_id=…</code>.
1325 The full spec — supported scopes ({SUPPORTED_SCOPES.length}), PKCE
1326 rules, token TTLs — is at <a href="/api-docs">/api-docs</a>.
058d752Claude1327 </div>
1328 </div>
f0b5874Claude1329 <style dangerouslySetInnerHTML={{ __html: oauthStyles }} />
058d752Claude1330 </Layout>
1331 );
1332});
1333
1334// --- POST /settings/authorizations/:appId/revoke ----------------------------
1335
1336oauth.post("/settings/authorizations/:appId/revoke", async (c) => {
1337 const user = c.get("user")!;
1338 const appId = c.req.param("appId");
1339 try {
1340 await db
1341 .update(oauthAccessTokens)
1342 .set({ revokedAt: new Date() })
1343 .where(
1344 and(
1345 eq(oauthAccessTokens.userId, user.id),
1346 eq(oauthAccessTokens.appId, appId),
1347 isNull(oauthAccessTokens.revokedAt)
1348 )
1349 );
1350 await audit({
1351 userId: user.id,
1352 action: "oauth.user_revoke",
1353 targetType: "oauth_app",
1354 targetId: appId,
1355 });
1356 return c.redirect("/settings/authorizations?success=Revoked");
1357 } catch (err) {
1358 console.error("[oauth] user revoke:", err);
1359 return c.redirect(
1360 "/settings/authorizations?error=Service+unavailable"
1361 );
1362 }
1363});
1364
1365export default oauth;
1366
1367// re-export for test visibility
1368export { SUPPORTED_SCOPES };