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