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.tsxBlame1550 lines · 3 contributors
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";
8ed88f2ccantynz-alt20import type { Context } from "hono";
058d752Claude21import { and, eq, gt, isNull } from "drizzle-orm";
22import { db } from "../db";
4c27627ccanty labs23import { config } from "../lib/config";
058d752Claude24import {
25 oauthApps,
26 oauthAuthorizations,
27 oauthAccessTokens,
28 users,
29} from "../db/schema";
36cc17aClaude30import type { User } from "../db/schema";
058d752Claude31import { Layout } from "../views/layout";
32import { requireAuth } from "../middleware/auth";
33import type { AuthEnv } from "../middleware/auth";
34import {
35 generateAuthCode,
36 generateAccessToken,
37 generateRefreshToken,
4c27627ccanty labs38 generateClientId,
39 generateClientSecret,
40 isValidRedirectUri,
058d752Claude41 sha256Hex,
42 verifyPkce,
43 parseScopes,
44 parseRedirectUris,
45 redirectUriAllowed,
4c27627ccanty labs46 serializeScopes,
058d752Claude47 timingSafeEqual,
48 ACCESS_TOKEN_TTL_MS,
49 REFRESH_TOKEN_TTL_MS,
50 AUTH_CODE_TTL_MS,
51 SUPPORTED_SCOPES,
52} from "../lib/oauth";
4c27627ccanty labs53import { authRateLimit } from "../middleware/rate-limit";
058d752Claude54import { audit } from "../lib/notify";
55
56const oauth = new Hono<AuthEnv>();
57
58oauth.use("/oauth/authorize", requireAuth);
59oauth.use("/oauth/authorize/decision", requireAuth);
60oauth.use("/settings/authorizations", requireAuth);
61oauth.use("/settings/authorizations/*", requireAuth);
4c27627ccanty labs62// DCR is unauthenticated (that's the point — clients self-register), so it
63// MUST be rate-limited to prevent registration spam. Reuse the strict auth
64// limiter (10/min per IP).
65oauth.use("/oauth/register", authRateLimit);
058d752Claude66
f0b5874Claude67// --- scope explainer copy (human-readable consent text) --------------------
68//
69// Plain-English description for each scope chip on the consent screen. The
70// keys mirror SUPPORTED_SCOPES; unknown scopes fall back to a generic line.
71const SCOPE_DESCRIPTIONS: Record<string, { label: string; explain: string }> = {
72 "read:user": {
73 label: "Read your profile",
74 explain: "username, avatar, public email — never your password",
75 },
76 "read:repo": {
77 label: "Read your repositories",
78 explain: "code, branches, commits, file contents",
79 },
80 "write:repo": {
81 label: "Write to your repositories",
82 explain: "push commits, create branches, edit files",
83 },
84 "read:org": {
85 label: "Read your organisations",
86 explain: "membership, teams, org-owned repo list",
87 },
88 "write:org": {
89 label: "Manage organisations",
90 explain: "invite members, change settings, edit teams",
91 },
92 "read:issue": {
93 label: "Read issues",
94 explain: "issue threads, comments, labels, assignees",
95 },
96 "write:issue": {
97 label: "Create + edit issues",
98 explain: "open, comment, close, label, assign",
99 },
100 "read:pr": {
101 label: "Read pull requests",
102 explain: "PR diffs, comments, reviews, status",
103 },
104 "write:pr": {
105 label: "Create + edit pull requests",
106 explain: "open, review, comment, merge, close",
107 },
108};
109
110function describeScope(s: string): { label: string; explain: string } {
111 return (
112 SCOPE_DESCRIPTIONS[s] ?? {
113 label: s,
114 explain: "custom scope — see the app's documentation",
115 }
116 );
117}
118
119// --- scoped styles ----------------------------------------------------------
120//
121// Every class prefixed `.oauth-` so the consent screen / authorizations list
122// can't bleed into the wider app polish. Mirrors the gradient-hairline hero +
123// orb pattern from admin-integrations.tsx and error-page.tsx.
124const oauthStyles = `
eed4684Claude125 .oauth-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
f0b5874Claude126
127 /* ─── Hero ─── */
128 .oauth-hero {
129 position: relative;
130 margin-bottom: var(--space-5);
131 padding: var(--space-5) var(--space-6);
132 background: var(--bg-elevated);
133 border: 1px solid var(--border);
134 border-radius: 16px;
135 overflow: hidden;
136 }
137 .oauth-hero::before {
138 content: '';
139 position: absolute;
140 top: 0; left: 0; right: 0;
141 height: 2px;
6fd5915Claude142 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
f0b5874Claude143 opacity: 0.7;
144 pointer-events: none;
145 }
146 .oauth-hero-orb {
147 position: absolute;
148 inset: -20% -10% auto auto;
149 width: 380px; height: 380px;
6fd5915Claude150 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
f0b5874Claude151 filter: blur(80px);
152 opacity: 0.7;
153 pointer-events: none;
154 z-index: 0;
155 }
156 .oauth-hero-inner { position: relative; z-index: 1; max-width: 680px; }
157 .oauth-eyebrow {
158 font-size: 11px;
159 text-transform: uppercase;
160 letter-spacing: 0.18em;
161 color: var(--text-muted);
162 margin-bottom: var(--space-2);
163 display: inline-flex;
164 align-items: center;
165 gap: 8px;
166 font-family: var(--font-mono);
167 font-weight: 600;
168 }
169 .oauth-eyebrow-dot {
170 width: 8px; height: 8px;
171 border-radius: 9999px;
6fd5915Claude172 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
173 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
f0b5874Claude174 }
175 .oauth-title {
176 font-size: clamp(26px, 3.5vw, 36px);
177 font-family: var(--font-display);
178 font-weight: 800;
179 letter-spacing: -0.026em;
180 line-height: 1.08;
181 margin: 0 0 var(--space-2);
182 color: var(--text-strong);
183 }
184 .oauth-title-grad {
6fd5915Claude185 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
f0b5874Claude186 -webkit-background-clip: text;
187 background-clip: text;
188 -webkit-text-fill-color: transparent;
189 color: transparent;
190 }
191 .oauth-sub {
192 font-size: 14.5px;
193 color: var(--text-muted);
194 margin: 0;
195 line-height: 1.55;
196 }
197 .oauth-sub code {
198 font-family: var(--font-mono);
199 font-size: 12.5px;
200 background: var(--bg-tertiary);
201 padding: 1px 5px;
202 border-radius: 4px;
203 color: var(--text);
204 }
205
206 /* ─── Banners ─── */
207 .oauth-banner {
208 margin-bottom: var(--space-4);
209 padding: 10px 14px;
210 border-radius: 10px;
211 font-size: 13.5px;
212 border: 1px solid var(--border);
213 background: rgba(255,255,255,0.025);
214 color: var(--text);
215 }
216 .oauth-banner.is-ok {
217 border-color: rgba(52,211,153,0.40);
218 background: rgba(52,211,153,0.08);
219 color: #bbf7d0;
220 }
221 .oauth-banner.is-error {
222 border-color: rgba(248,113,113,0.40);
223 background: rgba(248,113,113,0.08);
224 color: #fecaca;
225 }
226
227 /* ─── App identity card (consent) ─── */
228 .oauth-appcard {
229 display: flex;
230 gap: var(--space-3);
231 align-items: flex-start;
232 padding: var(--space-4) var(--space-5);
233 background: var(--bg-elevated);
234 border: 1px solid var(--border);
235 border-radius: 14px;
236 margin-bottom: var(--space-4);
237 }
238 .oauth-applogo {
239 flex-shrink: 0;
240 width: 56px;
241 height: 56px;
242 border-radius: 12px;
6fd5915Claude243 background: linear-gradient(135deg, rgba(91,110,232,0.22), rgba(95,143,160,0.16));
244 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.32);
f0b5874Claude245 display: flex;
246 align-items: center;
247 justify-content: center;
248 font-family: var(--font-display);
249 font-size: 22px;
250 font-weight: 800;
251 color: #e9d5ff;
252 letter-spacing: -0.02em;
253 text-transform: uppercase;
254 }
255 .oauth-appmeta { flex: 1; min-width: 0; }
256 .oauth-appname {
257 margin: 0 0 4px;
258 font-family: var(--font-display);
259 font-size: 18px;
260 font-weight: 700;
261 letter-spacing: -0.018em;
262 color: var(--text-strong);
263 word-break: break-word;
264 }
265 .oauth-appname code {
266 font-family: var(--font-mono);
267 font-size: 12.5px;
268 color: var(--accent);
6fd5915Claude269 background: rgba(91,110,232,0.08);
f0b5874Claude270 padding: 1px 6px;
271 border-radius: 4px;
272 margin-left: 6px;
273 font-weight: 500;
274 }
275 .oauth-appdesc {
276 margin: 0;
277 font-size: 13px;
278 color: var(--text-muted);
279 line-height: 1.5;
280 }
281
282 /* ─── Section card ─── */
283 .oauth-section {
284 margin-bottom: var(--space-4);
285 background: var(--bg-elevated);
286 border: 1px solid var(--border);
287 border-radius: 14px;
288 overflow: hidden;
289 }
290 .oauth-section-head {
291 padding: var(--space-3) var(--space-5);
292 border-bottom: 1px solid var(--border);
293 display: flex;
294 align-items: center;
295 justify-content: space-between;
296 gap: var(--space-2);
297 flex-wrap: wrap;
298 }
299 .oauth-section-title {
300 margin: 0;
301 font-family: var(--font-display);
302 font-size: 14px;
303 font-weight: 700;
304 letter-spacing: 0.04em;
305 text-transform: uppercase;
306 color: var(--text-strong);
307 }
308 .oauth-section-count {
309 font-family: var(--font-mono);
310 font-size: 12px;
311 color: var(--text-muted);
312 }
313 .oauth-section-body { padding: var(--space-4) var(--space-5); }
314
315 /* ─── Scope chips ─── */
316 .oauth-scopes {
317 list-style: none;
318 padding: 0;
319 margin: 0;
320 display: flex;
321 flex-direction: column;
322 gap: var(--space-2);
323 }
324 .oauth-scope {
325 display: flex;
326 align-items: flex-start;
327 gap: 12px;
328 padding: 10px 12px;
329 border: 1px solid var(--border);
330 border-radius: 10px;
331 background: rgba(255,255,255,0.02);
332 }
333 .oauth-scope-chip {
334 display: inline-flex;
335 align-items: center;
336 gap: 6px;
337 padding: 3px 10px;
338 border-radius: 9999px;
339 font-family: var(--font-mono);
340 font-size: 11.5px;
341 font-weight: 600;
342 color: #e9d5ff;
6fd5915Claude343 background: linear-gradient(135deg, rgba(91,110,232,0.18), rgba(95,143,160,0.10));
344 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.32);
f0b5874Claude345 flex-shrink: 0;
346 margin-top: 2px;
347 white-space: nowrap;
348 }
349 .oauth-scope-chip[data-write="1"] {
350 color: #fde68a;
351 background: linear-gradient(135deg, rgba(251,191,36,0.18), rgba(248,113,113,0.10));
352 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.36);
353 }
354 .oauth-scope-text { flex: 1; min-width: 0; }
355 .oauth-scope-label {
356 font-size: 13.5px;
357 font-weight: 600;
358 color: var(--text-strong);
359 line-height: 1.3;
360 }
361 .oauth-scope-explain {
362 margin-top: 2px;
363 font-size: 12.5px;
364 color: var(--text-muted);
365 line-height: 1.45;
366 }
367 .oauth-scope-empty {
368 margin: 0;
369 font-size: 13px;
370 color: var(--text-muted);
371 font-style: italic;
372 }
373
374 /* ─── Actions row ─── */
375 .oauth-actions {
376 display: flex;
377 gap: 10px;
378 flex-wrap: wrap;
379 align-items: center;
380 margin-top: var(--space-4);
381 }
382 .oauth-actions form { margin: 0; }
383 .oauth-btn {
384 display: inline-flex;
385 align-items: center;
386 justify-content: center;
387 gap: 8px;
388 padding: 13px 24px;
389 border-radius: 12px;
390 font-size: 14.5px;
391 font-weight: 700;
392 text-decoration: none;
393 border: 1px solid transparent;
394 cursor: pointer;
395 font: inherit;
396 line-height: 1;
397 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease, color 120ms ease;
398 }
399 .oauth-btn-primary {
6fd5915Claude400 background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%);
f0b5874Claude401 color: #ffffff;
6fd5915Claude402 box-shadow: 0 8px 22px -6px rgba(91,110,232,0.55), inset 0 1px 0 rgba(255,255,255,0.18);
f0b5874Claude403 min-width: 180px;
404 }
405 .oauth-btn-primary:hover {
406 transform: translateY(-1px);
6fd5915Claude407 box-shadow: 0 12px 28px -8px rgba(91,110,232,0.65), inset 0 1px 0 rgba(255,255,255,0.22);
f0b5874Claude408 color: #ffffff;
409 text-decoration: none;
410 }
411 .oauth-btn-ghost {
412 background: transparent;
413 color: var(--text);
414 border-color: var(--border-strong);
415 padding: 13px 20px;
416 }
417 .oauth-btn-ghost:hover {
418 background: rgba(255,255,255,0.04);
6fd5915Claude419 border-color: rgba(91,110,232,0.45);
f0b5874Claude420 color: var(--text-strong);
421 text-decoration: none;
422 }
423 .oauth-btn-danger-sm {
424 display: inline-flex;
425 align-items: center;
426 gap: 6px;
427 padding: 6px 12px;
428 border-radius: 8px;
429 font-size: 12.5px;
430 font-weight: 600;
431 border: 1px solid rgba(248,113,113,0.35);
432 background: transparent;
433 color: #fca5a5;
434 cursor: pointer;
435 font: inherit;
436 line-height: 1;
437 transition: border-color 120ms ease, background 120ms ease, color 120ms ease;
438 }
439 .oauth-btn-danger-sm:hover {
440 border-color: rgba(248,113,113,0.70);
441 background: rgba(248,113,113,0.08);
442 color: #fecaca;
443 }
444
445 /* ─── Explainer ─── */
446 .oauth-explainer {
447 margin-top: var(--space-4);
448 padding: var(--space-3) var(--space-4);
6fd5915Claude449 background: rgba(91,110,232,0.04);
450 border: 1px dashed rgba(91,110,232,0.22);
f0b5874Claude451 border-radius: 12px;
452 font-size: 12.5px;
453 color: var(--text-muted);
454 line-height: 1.55;
455 }
456 .oauth-explainer strong { color: var(--text-strong); display: block; margin-bottom: 4px; font-size: 13px; }
457 .oauth-explainer a { color: var(--accent); text-decoration: none; }
458 .oauth-explainer a:hover { text-decoration: underline; }
459 .oauth-explainer ul { margin: 6px 0 0; padding-left: 20px; }
460 .oauth-explainer li { margin: 2px 0; }
461
462 /* ─── Authorizations list ─── */
463 .oauth-breadcrumb {
464 font-size: 13px;
465 color: var(--text-muted);
466 margin-bottom: var(--space-3);
467 display: flex;
468 gap: 8px;
469 align-items: center;
470 flex-wrap: wrap;
471 }
472 .oauth-breadcrumb a { color: var(--accent); text-decoration: none; }
473 .oauth-breadcrumb a:hover { text-decoration: underline; }
474 .oauth-breadcrumb span.sep { color: var(--text-muted); }
475
476 .oauth-grant-list {
477 list-style: none;
478 padding: 0;
479 margin: 0;
480 display: flex;
481 flex-direction: column;
482 gap: var(--space-2);
483 }
484 .oauth-grant {
485 display: flex;
486 align-items: center;
487 justify-content: space-between;
488 gap: var(--space-3);
489 padding: 14px 16px;
490 background: var(--bg-elevated);
491 border: 1px solid var(--border);
492 border-radius: 12px;
493 flex-wrap: wrap;
494 }
495 .oauth-grant-info { flex: 1; min-width: 240px; }
496 .oauth-grant-name {
497 font-family: var(--font-display);
498 font-size: 15px;
499 font-weight: 700;
500 color: var(--text-strong);
501 margin: 0 0 4px;
502 letter-spacing: -0.01em;
503 }
504 .oauth-grant-meta {
505 font-size: 12px;
506 color: var(--text-muted);
507 line-height: 1.5;
508 }
509 .oauth-grant-meta code {
510 font-family: var(--font-mono);
511 font-size: 11.5px;
512 background: rgba(255,255,255,0.04);
513 border: 1px solid var(--border);
514 padding: 1px 6px;
515 border-radius: 4px;
516 color: var(--text);
517 }
518 .oauth-empty {
519 padding: 22px 18px;
520 text-align: center;
521 color: var(--text-muted);
522 font-size: 13.5px;
523 background: var(--bg-elevated);
524 border: 1px dashed var(--border);
525 border-radius: 12px;
526 }
527
528 /* ─── Error sub-page ─── */
529 .oauth-error-page {
530 max-width: 540px;
531 margin: var(--space-12) auto;
532 padding: var(--space-6);
533 text-align: center;
534 background: var(--bg-elevated);
535 border: 1px solid var(--border);
536 border-radius: 16px;
537 }
538 .oauth-error-page h2 {
539 font-family: var(--font-display);
540 font-size: 22px;
541 margin: 0 0 8px;
542 color: var(--text-strong);
543 }
544 .oauth-error-page p { color: var(--text-muted); margin: 0 0 16px; font-size: 14px; }
545`;
546
058d752Claude547// --- helpers ----------------------------------------------------------------
548
549function appendQuery(url: string, params: Record<string, string | undefined>) {
550 const sep = url.includes("?") ? "&" : "?";
551 const parts: string[] = [];
552 for (const [k, v] of Object.entries(params)) {
553 if (v === undefined || v === null) continue;
554 parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v)}`);
555 }
556 if (parts.length === 0) return url;
557 return url + sep + parts.join("&");
558}
559
36cc17aClaude560function errorPage(title: string, message: string, user: User | null) {
058d752Claude561 return (
36cc17aClaude562 <Layout title={title} user={user}>
f0b5874Claude563 <div class="oauth-wrap">
564 <div class="oauth-error-page">
565 <h2>{title}</h2>
566 <p>{message}</p>
567 <a href="/" class="oauth-btn oauth-btn-ghost" style="padding: 10px 18px">
568 Go home
569 </a>
570 </div>
058d752Claude571 </div>
f0b5874Claude572 <style dangerouslySetInnerHTML={{ __html: oauthStyles }} />
058d752Claude573 </Layout>
574 );
575}
576
577type OauthApp = typeof oauthApps.$inferSelect;
578
579async function loadAppByClientId(clientId: string): Promise<OauthApp | null> {
580 try {
581 const [row] = await db
582 .select()
583 .from(oauthApps)
584 .where(eq(oauthApps.clientId, clientId))
585 .limit(1);
586 return row || null;
587 } catch (err) {
588 console.error("[oauth] loadApp:", err);
589 return null;
590 }
591}
592
593/**
594 * Extracts client_id + client_secret from either the request body or an
595 * `Authorization: Basic` header. Returns `null` if neither is present.
596 */
597function extractClientCreds(
598 authHeader: string | undefined,
599 body: Record<string, unknown>
600): { clientId?: string; clientSecret?: string } {
601 if (authHeader && authHeader.toLowerCase().startsWith("basic ")) {
602 try {
603 const decoded = atob(authHeader.slice(6).trim());
604 const idx = decoded.indexOf(":");
605 if (idx > 0) {
606 return {
607 clientId: decoded.slice(0, idx),
608 clientSecret: decoded.slice(idx + 1),
609 };
610 }
611 } catch {
612 /* fall through */
613 }
614 }
615 const cid = body.client_id ? String(body.client_id) : undefined;
616 const csec = body.client_secret ? String(body.client_secret) : undefined;
617 return { clientId: cid, clientSecret: csec };
618}
619
620async function authenticateClient(
621 app: OauthApp,
622 providedSecret: string | undefined
623): Promise<boolean> {
624 if (!app.confidential) return true; // public clients auth via PKCE
625 if (!providedSecret) return false;
626 const hash = await sha256Hex(providedSecret);
627 return timingSafeEqual(hash, app.clientSecretHash);
628}
629
f0b5874Claude630function isWriteScope(s: string): boolean {
631 return s.startsWith("write:");
632}
633
058d752Claude634// --- GET /oauth/authorize ---------------------------------------------------
635
636oauth.get("/oauth/authorize", async (c) => {
637 const user = c.get("user")!;
638 const q = c.req.query();
639 const clientId = q.client_id || "";
640 const redirectUri = q.redirect_uri || "";
641 const responseType = q.response_type || "";
642 const scopeParam = q.scope || "";
643 const state = q.state || "";
644 const codeChallenge = q.code_challenge || "";
645 const codeChallengeMethod = q.code_challenge_method || "";
646
647 if (!clientId) {
36cc17aClaude648 return c.html(errorPage("OAuth error", "Missing client_id.", user), 400);
058d752Claude649 }
650 const app = await loadAppByClientId(clientId);
651 if (!app) {
36cc17aClaude652 return c.html(errorPage("OAuth error", "Unknown client.", user), 400);
058d752Claude653 }
654 if (app.revokedAt) {
36cc17aClaude655 return c.html(errorPage("OAuth error", "This application has been revoked.", user), 400);
058d752Claude656 }
657
658 const registered = parseRedirectUris(app.redirectUris);
659 if (!redirectUriAllowed(redirectUri, registered)) {
660 return c.html(
661 errorPage(
662 "OAuth error",
36cc17aClaude663 "redirect_uri does not match any registered callback for this app.",
664 user
058d752Claude665 ),
666 400
667 );
668 }
669
670 // Beyond this point errors redirect back to redirect_uri with ?error=...
671 if (responseType !== "code") {
672 return c.redirect(
673 appendQuery(redirectUri, {
674 error: "unsupported_response_type",
675 error_description: "response_type must be 'code'",
676 state: state || undefined,
677 })
678 );
679 }
680 if (!app.confidential && !codeChallenge) {
681 return c.redirect(
682 appendQuery(redirectUri, {
683 error: "invalid_request",
684 error_description: "PKCE code_challenge is required for public clients",
685 state: state || undefined,
686 })
687 );
688 }
689
690 const scopes = parseScopes(scopeParam);
691
692 // Look up the app owner for the consent screen.
693 let ownerName = "unknown";
694 try {
8ed88f2ccantynz-alt695 if (app.ownerId) {
696 const [ownerRow] = await db
697 .select({ username: users.username })
698 .from(users)
699 .where(eq(users.id, app.ownerId))
700 .limit(1);
701 if (ownerRow) ownerName = ownerRow.username;
702 }
058d752Claude703 } catch {
704 /* non-fatal */
705 }
706
f0b5874Claude707 const initial = (app.name || "?").trim().charAt(0).toUpperCase() || "?";
708
058d752Claude709 return c.html(
710 <Layout title="Authorize application" user={user}>
f0b5874Claude711 <div class="oauth-wrap">
712 <section class="oauth-hero">
713 <div class="oauth-hero-orb" aria-hidden="true" />
714 <div class="oauth-hero-inner">
715 <div class="oauth-eyebrow">
716 <span class="oauth-eyebrow-dot" aria-hidden="true" />
717 OAuth · Authorize an application
718 </div>
719 <h1 class="oauth-title">
720 <span class="oauth-title-grad">Authorize {app.name}?</span>
721 </h1>
722 <p class="oauth-sub">
723 Signed in as <code>{user.username}</code>. Review what this app is
724 asking for, then approve or cancel.
058d752Claude725 </p>
f0b5874Claude726 </div>
727 </section>
728
729 <section class="oauth-appcard" aria-label="Application identity">
730 <div class="oauth-applogo" aria-hidden="true">{initial}</div>
731 <div class="oauth-appmeta">
732 <h2 class="oauth-appname">
733 {app.name}
734 <code>by {ownerName}</code>
735 </h2>
736 {app.description ? (
737 <p class="oauth-appdesc">{app.description}</p>
738 ) : (
739 <p class="oauth-appdesc" style="font-style: italic">
740 No description provided by the developer.
741 </p>
742 )}
743 </div>
744 </section>
745
746 <section class="oauth-section" aria-label="Requested scopes">
747 <header class="oauth-section-head">
748 <h3 class="oauth-section-title">Requested permissions</h3>
749 <span class="oauth-section-count">
750 {scopes.length} {scopes.length === 1 ? "scope" : "scopes"}
751 </span>
752 </header>
753 <div class="oauth-section-body">
754 {scopes.length === 0 ? (
755 <p class="oauth-scope-empty">
756 No scopes — this app will only be able to identify you (your
757 username + public profile).
758 </p>
759 ) : (
760 <ul class="oauth-scopes">
761 {scopes.map((s) => {
762 const meta = describeScope(s);
763 return (
764 <li class="oauth-scope">
765 <span
766 class="oauth-scope-chip"
767 data-write={isWriteScope(s) ? "1" : "0"}
768 >
769 <code>{s}</code>
770 </span>
771 <div class="oauth-scope-text">
772 <div class="oauth-scope-label">{meta.label}</div>
773 <div class="oauth-scope-explain">{meta.explain}</div>
774 </div>
775 </li>
776 );
777 })}
778 </ul>
779 )}
780 </div>
781 </section>
782
e7e240eClaude783 <form method="post" action="/oauth/authorize/decision">
058d752Claude784 <input type="hidden" name="client_id" value={clientId} />
785 <input type="hidden" name="redirect_uri" value={redirectUri} />
786 <input type="hidden" name="response_type" value={responseType} />
787 <input type="hidden" name="scope" value={scopes.join(" ")} />
788 <input type="hidden" name="state" value={state} />
789 <input type="hidden" name="code_challenge" value={codeChallenge} />
790 <input
791 type="hidden"
792 name="code_challenge_method"
793 value={codeChallengeMethod}
794 />
f0b5874Claude795 <div class="oauth-actions">
796 <button
797 type="submit"
798 name="decision"
799 value="approve"
800 class="oauth-btn oauth-btn-primary"
801 >
802 Authorize {app.name}
058d752Claude803 </button>
f0b5874Claude804 <button
805 type="submit"
806 name="decision"
807 value="deny"
808 class="oauth-btn oauth-btn-ghost"
809 >
810 Deny
058d752Claude811 </button>
812 </div>
813 </form>
f0b5874Claude814
815 <div class="oauth-explainer">
816 <strong>What does {app.name} get?</strong>
817 <ul>
818 <li>
819 A scoped access token that expires in{" "}
820 {Math.round(ACCESS_TOKEN_TTL_MS / 60000)} minutes (auto-refreshed for{" "}
821 up to {Math.round(REFRESH_TOKEN_TTL_MS / (24 * 60 * 60 * 1000))} days).
822 </li>
823 <li>
824 The scopes listed above — nothing else. It never sees your password
825 or your other personal access tokens.
826 </li>
827 <li>
828 Revoke at any time from{" "}
829 <a href="/settings/authorizations">Authorized applications</a> —
830 every access + refresh token for this app is invalidated immediately.
831 </li>
832 </ul>
833 </div>
058d752Claude834 </div>
f0b5874Claude835 <style dangerouslySetInnerHTML={{ __html: oauthStyles }} />
058d752Claude836 </Layout>
837 );
838});
839
840// --- POST /oauth/authorize/decision -----------------------------------------
841
842oauth.post("/oauth/authorize/decision", async (c) => {
843 const user = c.get("user")!;
844 const body = await c.req.parseBody();
845 const clientId = String(body.client_id || "");
846 const redirectUri = String(body.redirect_uri || "");
847 const scopeParam = String(body.scope || "");
848 const state = String(body.state || "");
849 const decision = String(body.decision || "");
850 const codeChallenge = String(body.code_challenge || "");
851 const codeChallengeMethod = String(body.code_challenge_method || "");
852
853 const app = await loadAppByClientId(clientId);
854 if (!app || app.revokedAt) {
36cc17aClaude855 return c.html(errorPage("OAuth error", "Unknown or revoked client.", user), 400);
058d752Claude856 }
857 const registered = parseRedirectUris(app.redirectUris);
858 if (!redirectUriAllowed(redirectUri, registered)) {
36cc17aClaude859 return c.html(errorPage("OAuth error", "Invalid redirect_uri.", user), 400);
058d752Claude860 }
861
862 if (decision !== "approve") {
863 return c.redirect(
864 appendQuery(redirectUri, {
865 error: "access_denied",
866 error_description: "User denied the request",
867 state: state || undefined,
868 })
869 );
870 }
871
872 const scopes = parseScopes(scopeParam);
873 const code = generateAuthCode();
874 const codeHash = await sha256Hex(code);
875
876 try {
877 await db.insert(oauthAuthorizations).values({
878 appId: app.id,
879 userId: user.id,
880 codeHash,
881 redirectUri,
882 scopes: scopes.join(" "),
883 codeChallenge: codeChallenge || null,
884 codeChallengeMethod: codeChallengeMethod || null,
885 expiresAt: new Date(Date.now() + AUTH_CODE_TTL_MS),
886 });
887 await audit({
888 userId: user.id,
889 action: "oauth.authorize",
890 targetType: "oauth_app",
891 targetId: app.id,
892 metadata: { scopes: scopes.join(" ") },
893 });
894 return c.redirect(
895 appendQuery(redirectUri, {
896 code,
897 state: state || undefined,
898 })
899 );
900 } catch (err) {
901 console.error("[oauth] authorize/decision:", err);
902 return c.redirect(
903 appendQuery(redirectUri, {
904 error: "server_error",
905 error_description: "Service unavailable",
906 state: state || undefined,
907 })
908 );
909 }
910});
911
912// --- POST /oauth/token ------------------------------------------------------
913
914oauth.post("/oauth/token", async (c) => {
915 // Accept either form-encoded or JSON bodies.
916 let body: Record<string, unknown> = {};
917 const contentType = (c.req.header("content-type") || "").toLowerCase();
918 try {
919 if (contentType.includes("application/json")) {
920 body = (await c.req.json()) as Record<string, unknown>;
921 } else {
922 body = (await c.req.parseBody()) as Record<string, unknown>;
923 }
924 } catch {
925 return c.json(
926 { error: "invalid_request", error_description: "Malformed body" },
927 400
928 );
929 }
930
931 const grantType = body.grant_type ? String(body.grant_type) : "";
932 const authHeader = c.req.header("authorization");
933 const creds = extractClientCreds(authHeader, body);
934
935 if (!creds.clientId) {
936 return c.json(
937 { error: "invalid_client", error_description: "Missing client_id" },
938 401
939 );
940 }
941 const app = await loadAppByClientId(creds.clientId);
942 if (!app || app.revokedAt) {
943 return c.json(
944 { error: "invalid_client", error_description: "Unknown client" },
945 401
946 );
947 }
948 const clientAuthOk = await authenticateClient(app, creds.clientSecret);
949 if (!clientAuthOk) {
950 return c.json(
951 { error: "invalid_client", error_description: "Client authentication failed" },
952 401
953 );
954 }
955
956 try {
957 if (grantType === "authorization_code") {
958 const code = body.code ? String(body.code) : "";
959 const redirectUri = body.redirect_uri ? String(body.redirect_uri) : "";
960 const codeVerifier = body.code_verifier ? String(body.code_verifier) : "";
961 if (!code || !redirectUri) {
962 return c.json(
963 { error: "invalid_request", error_description: "code and redirect_uri required" },
964 400
965 );
966 }
967 const codeHash = await sha256Hex(code);
968 const [authRow] = await db
969 .select()
970 .from(oauthAuthorizations)
971 .where(eq(oauthAuthorizations.codeHash, codeHash))
972 .limit(1);
973 if (!authRow) {
974 return c.json({ error: "invalid_grant", error_description: "Unknown code" }, 400);
975 }
976 if (authRow.usedAt) {
977 return c.json(
978 { error: "invalid_grant", error_description: "Code already used" },
979 400
980 );
981 }
982 if (new Date(authRow.expiresAt) < new Date()) {
983 return c.json({ error: "invalid_grant", error_description: "Code expired" }, 400);
984 }
985 if (authRow.appId !== app.id) {
986 return c.json(
987 { error: "invalid_grant", error_description: "Code does not belong to client" },
988 400
989 );
990 }
991 if (!timingSafeEqual(authRow.redirectUri, redirectUri)) {
992 return c.json(
993 { error: "invalid_grant", error_description: "redirect_uri mismatch" },
994 400
995 );
996 }
997 if (authRow.codeChallenge) {
998 const ok = await verifyPkce({
999 challenge: authRow.codeChallenge,
1000 method: authRow.codeChallengeMethod,
1001 verifier: codeVerifier,
1002 });
1003 if (!ok) {
1004 return c.json(
1005 { error: "invalid_grant", error_description: "PKCE verification failed" },
1006 400
1007 );
1008 }
1009 }
1010
1011 // Single-use: mark used immediately.
1012 await db
1013 .update(oauthAuthorizations)
1014 .set({ usedAt: new Date() })
1015 .where(eq(oauthAuthorizations.id, authRow.id));
1016
1017 const accessToken = generateAccessToken();
1018 const refreshToken = generateRefreshToken();
1019 const accessHash = await sha256Hex(accessToken);
1020 const refreshHash = await sha256Hex(refreshToken);
1021
1022 await db.insert(oauthAccessTokens).values({
1023 appId: app.id,
1024 userId: authRow.userId,
1025 accessTokenHash: accessHash,
1026 refreshTokenHash: refreshHash,
1027 scopes: authRow.scopes,
1028 expiresAt: new Date(Date.now() + ACCESS_TOKEN_TTL_MS),
1029 refreshExpiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
1030 });
1031 await audit({
1032 userId: authRow.userId,
1033 action: "oauth.token.issue",
1034 targetType: "oauth_app",
1035 targetId: app.id,
1036 });
1037 return c.json({
1038 access_token: accessToken,
1039 token_type: "bearer",
1040 expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1000),
1041 refresh_token: refreshToken,
1042 scope: authRow.scopes,
1043 });
1044 }
1045
1046 if (grantType === "refresh_token") {
1047 const refreshToken = body.refresh_token ? String(body.refresh_token) : "";
1048 if (!refreshToken) {
1049 return c.json(
1050 { error: "invalid_request", error_description: "refresh_token required" },
1051 400
1052 );
1053 }
1054 const refreshHash = await sha256Hex(refreshToken);
1055 const [tokenRow] = await db
1056 .select()
1057 .from(oauthAccessTokens)
1058 .where(eq(oauthAccessTokens.refreshTokenHash, refreshHash))
1059 .limit(1);
1060 if (!tokenRow || tokenRow.revokedAt) {
1061 return c.json(
1062 { error: "invalid_grant", error_description: "Unknown refresh_token" },
1063 400
1064 );
1065 }
1066 if (tokenRow.appId !== app.id) {
1067 return c.json(
1068 { error: "invalid_grant", error_description: "Token does not belong to client" },
1069 400
1070 );
1071 }
1072 if (
1073 tokenRow.refreshExpiresAt &&
1074 new Date(tokenRow.refreshExpiresAt) < new Date()
1075 ) {
1076 return c.json(
1077 { error: "invalid_grant", error_description: "refresh_token expired" },
1078 400
1079 );
1080 }
1081
1082 // Narrow scopes if the client explicitly requested a subset.
1083 let newScopes = tokenRow.scopes;
1084 if (body.scope) {
1085 const requested = parseScopes(String(body.scope));
1086 const originalSet = new Set(
1087 tokenRow.scopes.split(/\s+/).filter(Boolean)
1088 );
1089 const narrowed = requested.filter((s) => originalSet.has(s));
1090 newScopes = narrowed.join(" ");
1091 }
1092
1093 // Rotate: revoke old, issue new.
1094 await db
1095 .update(oauthAccessTokens)
1096 .set({ revokedAt: new Date() })
1097 .where(eq(oauthAccessTokens.id, tokenRow.id));
1098
1099 const accessToken = generateAccessToken();
1100 const newRefresh = generateRefreshToken();
1101 await db.insert(oauthAccessTokens).values({
1102 appId: app.id,
1103 userId: tokenRow.userId,
1104 accessTokenHash: await sha256Hex(accessToken),
1105 refreshTokenHash: await sha256Hex(newRefresh),
1106 scopes: newScopes,
1107 expiresAt: new Date(Date.now() + ACCESS_TOKEN_TTL_MS),
1108 refreshExpiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
1109 });
1110 await audit({
1111 userId: tokenRow.userId,
1112 action: "oauth.token.refresh",
1113 targetType: "oauth_app",
1114 targetId: app.id,
1115 });
1116 return c.json({
1117 access_token: accessToken,
1118 token_type: "bearer",
1119 expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1000),
1120 refresh_token: newRefresh,
1121 scope: newScopes,
1122 });
1123 }
1124
1125 return c.json(
1126 {
1127 error: "unsupported_grant_type",
1128 error_description: `grant_type '${grantType}' not supported`,
1129 },
1130 400
1131 );
1132 } catch (err) {
1133 console.error("[oauth] token:", err);
1134 return c.json(
1135 { error: "server_error", error_description: "Service unavailable" },
1136 503
1137 );
1138 }
1139});
1140
1141// --- POST /oauth/revoke (RFC 7009) ------------------------------------------
1142
1143oauth.post("/oauth/revoke", async (c) => {
1144 let body: Record<string, unknown> = {};
1145 const contentType = (c.req.header("content-type") || "").toLowerCase();
1146 try {
1147 if (contentType.includes("application/json")) {
1148 body = (await c.req.json()) as Record<string, unknown>;
1149 } else {
1150 body = (await c.req.parseBody()) as Record<string, unknown>;
1151 }
1152 } catch {
1153 // Per RFC 7009 we still respond 200 to unknown tokens — but a malformed
1154 // body indicates a misbehaving client, so 400 is acceptable here.
1155 return c.json({ error: "invalid_request" }, 400);
1156 }
1157
1158 const token = body.token ? String(body.token) : "";
1159 const authHeader = c.req.header("authorization");
1160 const creds = extractClientCreds(authHeader, body);
1161
1162 if (!creds.clientId) {
1163 return c.json({ error: "invalid_client" }, 401);
1164 }
1165 const app = await loadAppByClientId(creds.clientId);
1166 if (!app) {
1167 return c.json({ error: "invalid_client" }, 401);
1168 }
1169 const clientAuthOk = await authenticateClient(app, creds.clientSecret);
1170 if (!clientAuthOk) {
1171 return c.json({ error: "invalid_client" }, 401);
1172 }
1173
1174 if (!token) {
1175 // RFC 7009: server responds as if successful.
1176 return c.body(null, 200);
1177 }
1178
1179 try {
1180 const hash = await sha256Hex(token);
1181 // Try access token first, then refresh token.
1182 const [asAccess] = await db
1183 .select()
1184 .from(oauthAccessTokens)
1185 .where(eq(oauthAccessTokens.accessTokenHash, hash))
1186 .limit(1);
1187 const [asRefresh] = asAccess
1188 ? []
1189 : await db
1190 .select()
1191 .from(oauthAccessTokens)
1192 .where(eq(oauthAccessTokens.refreshTokenHash, hash))
1193 .limit(1);
1194 const row = asAccess || asRefresh;
1195 if (row && row.appId === app.id && !row.revokedAt) {
1196 await db
1197 .update(oauthAccessTokens)
1198 .set({ revokedAt: new Date() })
1199 .where(eq(oauthAccessTokens.id, row.id));
1200 await audit({
1201 userId: row.userId,
1202 action: "oauth.token.revoke",
1203 targetType: "oauth_app",
1204 targetId: app.id,
1205 });
1206 }
1207 } catch (err) {
1208 console.error("[oauth] revoke:", err);
1209 // Still 200 per RFC 7009 — we don't want to leak whether the token existed.
1210 }
1211 return c.body(null, 200);
1212});
1213
1214// --- GET /settings/authorizations -------------------------------------------
1215
1216oauth.get("/settings/authorizations", async (c) => {
1217 const user = c.get("user")!;
1218 const success = c.req.query("success");
1219 const error = c.req.query("error");
1220
1221 type Row = {
1222 app: typeof oauthApps.$inferSelect | null;
1223 token: typeof oauthAccessTokens.$inferSelect;
1224 };
1225 let rows: Row[] = [];
1226 try {
1227 const raw = await db
1228 .select()
1229 .from(oauthAccessTokens)
1230 .leftJoin(oauthApps, eq(oauthAccessTokens.appId, oauthApps.id))
1231 .where(
1232 and(
1233 eq(oauthAccessTokens.userId, user.id),
1234 isNull(oauthAccessTokens.revokedAt),
1235 gt(oauthAccessTokens.expiresAt, new Date())
1236 )
1237 );
1238 rows = raw.map((r: any) => ({
1239 app: r.oauth_apps,
1240 token: r.oauth_access_tokens,
1241 }));
1242 } catch (err) {
1243 console.error("[oauth] authorizations list:", err);
1244 }
1245
1246 // Group by appId — show each app once with the most recent token's data.
1247 const byApp = new Map<string, Row>();
1248 for (const r of rows) {
1249 const existing = byApp.get(r.token.appId);
1250 if (
1251 !existing ||
1252 new Date(r.token.createdAt) > new Date(existing.token.createdAt)
1253 ) {
1254 byApp.set(r.token.appId, r);
1255 }
1256 }
1257 const grouped = Array.from(byApp.values());
1258
1259 return c.html(
1260 <Layout title="Authorized applications" user={user}>
f0b5874Claude1261 <div class="oauth-wrap">
1262 <div class="oauth-breadcrumb">
058d752Claude1263 <a href="/settings">settings</a>
f0b5874Claude1264 <span class="sep">/</span>
058d752Claude1265 <span>authorized applications</span>
1266 </div>
f0b5874Claude1267
1268 <section class="oauth-hero">
1269 <div class="oauth-hero-orb" aria-hidden="true" />
1270 <div class="oauth-hero-inner">
1271 <div class="oauth-eyebrow">
1272 <span class="oauth-eyebrow-dot" aria-hidden="true" />
1273 OAuth · {user.username}
058d752Claude1274 </div>
f0b5874Claude1275 <h1 class="oauth-title">
1276 <span class="oauth-title-grad">Authorized applications.</span>
1277 </h1>
1278 <p class="oauth-sub">
1279 Apps you've granted access to your Gluecron account. Revoking
1280 immediately invalidates every access + refresh token issued to
1281 that app for you.
1282 </p>
1283 </div>
1284 </section>
1285
1286 {error && <div class="oauth-banner is-error">{decodeURIComponent(error)}</div>}
1287 {success && <div class="oauth-banner is-ok">{decodeURIComponent(success)}</div>}
1288
1289 {grouped.length === 0 ? (
1290 <div class="oauth-empty">
1291 No authorized applications. Apps you approve via the OAuth consent
1292 screen will appear here.
1293 </div>
1294 ) : (
1295 <ul class="oauth-grant-list">
1296 {grouped.map(({ app, token }) => {
1297 const initial = ((app?.name || "?").trim().charAt(0) || "?").toUpperCase();
1298 return (
1299 <li class="oauth-grant">
058d752Claude1300 <div
f0b5874Claude1301 class="oauth-applogo"
1302 aria-hidden="true"
1303 style="width:42px;height:42px;font-size:17px;border-radius:10px"
058d752Claude1304 >
f0b5874Claude1305 {initial}
058d752Claude1306 </div>
f0b5874Claude1307 <div class="oauth-grant-info">
1308 <h3 class="oauth-grant-name">{app?.name || "Unknown app"}</h3>
1309 <div class="oauth-grant-meta">
1310 scopes: <code>{token.scopes || "(none)"}</code>
1311 {" · "}authorised{" "}
1312 {new Date(token.createdAt).toLocaleDateString()}
1313 {token.lastUsedAt &&
1314 ` · last used ${new Date(token.lastUsedAt).toLocaleDateString()}`}
1315 </div>
1316 </div>
1317 <form
1318 method="post"
1319 action={`/settings/authorizations/${token.appId}/revoke`}
1320 onsubmit="return confirm('Revoke access for this application?')"
1321 style="margin:0"
1322 >
1323 <button type="submit" class="oauth-btn-danger-sm">
1324 Revoke
1325 </button>
1326 </form>
1327 </li>
1328 );
1329 })}
1330 </ul>
1331 )}
1332
1333 <div class="oauth-explainer" style="margin-top:var(--space-5)">
1334 <strong>Building an OAuth app?</strong>
1335 Register one at <a href="/settings/applications">Developer applications</a>.
1336 You'll get a <code>client_id</code> + <code>client_secret</code> and
1337 can point users at <code>/oauth/authorize?client_id=…</code>.
1338 The full spec — supported scopes ({SUPPORTED_SCOPES.length}), PKCE
1339 rules, token TTLs — is at <a href="/api-docs">/api-docs</a>.
058d752Claude1340 </div>
1341 </div>
f0b5874Claude1342 <style dangerouslySetInnerHTML={{ __html: oauthStyles }} />
058d752Claude1343 </Layout>
1344 );
1345});
1346
1347// --- POST /settings/authorizations/:appId/revoke ----------------------------
1348
1349oauth.post("/settings/authorizations/:appId/revoke", async (c) => {
1350 const user = c.get("user")!;
1351 const appId = c.req.param("appId");
1352 try {
1353 await db
1354 .update(oauthAccessTokens)
1355 .set({ revokedAt: new Date() })
1356 .where(
1357 and(
1358 eq(oauthAccessTokens.userId, user.id),
1359 eq(oauthAccessTokens.appId, appId),
1360 isNull(oauthAccessTokens.revokedAt)
1361 )
1362 );
1363 await audit({
1364 userId: user.id,
1365 action: "oauth.user_revoke",
1366 targetType: "oauth_app",
1367 targetId: appId,
1368 });
1369 return c.redirect("/settings/authorizations?success=Revoked");
1370 } catch (err) {
1371 console.error("[oauth] user revoke:", err);
1372 return c.redirect(
1373 "/settings/authorizations?error=Service+unavailable"
1374 );
1375 }
1376});
1377
4c27627ccanty labs1378// ---------------------------------------------------------------------------
1379// RFC 7591 — OAuth 2.0 Dynamic Client Registration
1380//
1381// POST /oauth/register
1382//
1383// Lets an agent (claude.ai remote connectors, Cursor, Copilot, …) register
1384// itself as an OAuth client with no human in the loop, then run the normal
1385// authorization-code + PKCE flow. Rate-limited (see the middleware above).
1386//
1387// Request (application/json), RFC 7591 §2:
1388// { "client_name": "...", "redirect_uris": ["https://.../callback"],
1389// "token_endpoint_auth_method": "none" | "client_secret_basic" | "client_secret_post",
1390// "scope": "read:repo write:pr", "client_uri": "https://..." }
1391//
1392// Response 201 (RFC 7591 §3.2.1): client_id, [client_secret], issued_at,
1393// client_secret_expires_at (0 = never), the echoed metadata, plus a
1394// registration_access_token + registration_client_uri for self-management.
1395// ---------------------------------------------------------------------------
1396
1397/** Cap on how many redirect URIs a single dynamic registration may declare. */
1398const DCR_MAX_REDIRECT_URIS = 8;
1399
1400function dcrError(
8ed88f2ccantynz-alt1401 c: Context<AuthEnv>,
4c27627ccanty labs1402 error: string,
1403 description: string,
1404 status: 400 | 401 | 403 = 400
1405) {
1406 return c.json({ error, error_description: description }, status);
1407}
1408
1409oauth.post("/oauth/register", async (c) => {
1410 let body: Record<string, unknown> = {};
1411 try {
1412 body = (await c.req.json()) as Record<string, unknown>;
1413 } catch {
1414 return dcrError(c, "invalid_client_metadata", "Body must be JSON");
1415 }
1416
1417 // redirect_uris — required, non-empty, each a valid https (or http-localhost)
1418 // absolute URI with no wildcard/fragment (isValidRedirectUri enforces this).
1419 const rawUris = body.redirect_uris;
1420 if (!Array.isArray(rawUris) || rawUris.length === 0) {
1421 return dcrError(
1422 c,
1423 "invalid_redirect_uri",
1424 "redirect_uris is required and must be a non-empty array"
1425 );
1426 }
1427 if (rawUris.length > DCR_MAX_REDIRECT_URIS) {
1428 return dcrError(
1429 c,
1430 "invalid_redirect_uri",
1431 `At most ${DCR_MAX_REDIRECT_URIS} redirect URIs are allowed`
1432 );
1433 }
1434 const redirectUris: string[] = [];
1435 for (const u of rawUris) {
1436 if (typeof u !== "string" || !isValidRedirectUri(u)) {
1437 return dcrError(
1438 c,
1439 "invalid_redirect_uri",
1440 `Invalid redirect URI: ${typeof u === "string" ? u : "(non-string)"}`
1441 );
1442 }
1443 redirectUris.push(u);
1444 }
1445
1446 // token_endpoint_auth_method — "none" ⇒ public client (PKCE, no secret);
1447 // anything else ⇒ confidential (issued a client_secret). Default per RFC
1448 // 7591 §2 is client_secret_basic (confidential).
1449 const authMethod = body.token_endpoint_auth_method
1450 ? String(body.token_endpoint_auth_method)
1451 : "client_secret_basic";
1452 const ALLOWED_AUTH_METHODS = [
1453 "none",
1454 "client_secret_basic",
1455 "client_secret_post",
1456 ];
1457 if (!ALLOWED_AUTH_METHODS.includes(authMethod)) {
1458 return dcrError(
1459 c,
1460 "invalid_client_metadata",
1461 `Unsupported token_endpoint_auth_method: ${authMethod}`
1462 );
1463 }
1464 const isPublic = authMethod === "none";
1465
1466 // scope — optional; intersect with SUPPORTED_SCOPES (unknown scopes dropped).
1467 const requestedScopes = parseScopes(
1468 typeof body.scope === "string" ? body.scope : ""
1469 );
1470
1471 const clientName =
1472 typeof body.client_name === "string" && body.client_name.trim()
1473 ? body.client_name.trim().slice(0, 120)
1474 : "Dynamically Registered Client";
1475 const clientUri =
1476 typeof body.client_uri === "string" ? body.client_uri.slice(0, 500) : null;
1477
1478 // Secrets. Public clients still get a stored (unused) secret hash to satisfy
1479 // NOT NULL — authenticateClient() short-circuits for non-confidential apps,
1480 // so it is never checked. Only confidential clients get a returned secret.
1481 const clientId = generateClientId();
1482 const clientSecret = generateClientSecret();
1483 const clientSecretHash = await sha256Hex(clientSecret);
1484 // A registration_access_token lets the client later manage its own record
1485 // (RFC 7591 §4). We hash it; the plaintext is returned once.
1486 const regAccessToken = generateAccessToken();
1487 const regAccessTokenHash = await sha256Hex(regAccessToken);
1488
1489 try {
1490 const [row] = await db
1491 .insert(oauthApps)
1492 .values({
1493 ownerId: null, // self-registered — no human owner
1494 name: clientName,
1495 clientId,
1496 clientSecretHash,
1497 clientSecretPrefix: clientSecret.slice(0, 8),
1498 redirectUris: redirectUris.join("\n"),
1499 homepageUrl: clientUri,
1500 description: "Registered via OAuth Dynamic Client Registration (RFC 7591).",
1501 confidential: !isPublic,
1502 dynamicallyRegistered: true,
1503 registrationAccessTokenHash: regAccessTokenHash,
1504 })
1505 .returning({ id: oauthApps.id });
1506
1507 // Best-effort audit — never block registration on it.
1508 audit({
1509 userId: null,
1510 action: "oauth.client.dynamically_registered",
1511 targetType: "oauth_app",
1512 targetId: row?.id,
1513 metadata: { clientId, clientName, isPublic, redirectUris },
1514 }).catch(() => {});
1515
1516 const base = config.appBaseUrl;
1517 const responseBody: Record<string, unknown> = {
1518 client_id: clientId,
1519 client_id_issued_at: Math.floor(Date.now() / 1000),
1520 client_secret_expires_at: 0, // never expires
1521 client_name: clientName,
1522 redirect_uris: redirectUris,
1523 grant_types: ["authorization_code", "refresh_token"],
1524 response_types: ["code"],
1525 token_endpoint_auth_method: authMethod,
1526 scope: serializeScopes(requestedScopes),
1527 registration_access_token: regAccessToken,
1528 registration_client_uri: `${base}/oauth/register/${clientId}`,
1529 };
1530 if (!isPublic) {
1531 responseBody.client_secret = clientSecret;
1532 }
1533 return c.json(responseBody, 201);
1534 } catch {
1535 // Validation already passed, so a failure here is server-side (e.g. the
1536 // DB is unreachable) — 503, not a client-metadata error.
1537 return c.json(
1538 {
1539 error: "temporarily_unavailable",
1540 error_description: "Registration could not be completed right now",
1541 },
1542 503
1543 );
1544 }
1545});
1546
058d752Claude1547export default oauth;
1548
1549// re-export for test visibility
1550export { SUPPORTED_SCOPES };