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