Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitbfdb5e7unknown_key

wip(BLOCK-B6): OAuth 2.0 provider schema + developer app management

wip(BLOCK-B6): OAuth 2.0 provider schema + developer app management

Scaffolding for Block B6 (OAuth 2.0 provider) — the wire protocol
endpoints (/oauth/authorize, /oauth/token, /oauth/revoke) and bearer
token auth path land in the next commit.

- drizzle/0007_oauth_provider.sql: oauth_apps, oauth_authorizations,
  oauth_access_tokens (all secrets stored as SHA-256 hashes)
- src/db/schema.ts: matching Drizzle schema + row types
- src/lib/oauth.ts: client_id/secret/code/token generators, PKCE S256
  verification, scope parsing, redirect-URI validation (exact match,
  https-only except localhost), constant-time compare
- src/routes/developer-apps.tsx: /settings/applications UI — list,
  create, edit, rotate secret, delete. Plaintext secret shown once
  at creation and after rotation; audit()' on every write.
Claude committed on April 15, 2026Parent: 2df1f8c
4 files changed+9290bfdb5e7e79b7a5628275e3f2feaf852911ef5597
4 changed files+929−0
Addeddrizzle/0007_oauth_provider.sql+78−0View fileUnifiedSplit
1-- Gluecron migration 0007: Block B6 — OAuth 2.0 provider.
2--
3-- Tables:
4-- oauth_apps — third-party apps registered by developers
5-- oauth_authorizations — short-lived authorization codes (single-use)
6-- oauth_access_tokens — long-lived bearer tokens + refresh tokens
7--
8-- All secrets / code / token values are stored as SHA-256 hex hashes.
9-- Only the plaintext `clientSecret` is shown to the developer once at
10-- creation; the plaintext auth code and tokens are returned to the client
11-- in the OAuth response and never persisted.
12
13--> statement-breakpoint
14CREATE TABLE IF NOT EXISTS "oauth_apps" (
15 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
16 "owner_id" uuid NOT NULL,
17 "name" text NOT NULL,
18 "client_id" text NOT NULL UNIQUE,
19 "client_secret_hash" text NOT NULL,
20 "client_secret_prefix" text NOT NULL,
21 "redirect_uris" text NOT NULL,
22 "homepage_url" text,
23 "description" text,
24 "confidential" boolean DEFAULT true NOT NULL,
25 "revoked_at" timestamp,
26 "created_at" timestamp DEFAULT now() NOT NULL,
27 "updated_at" timestamp DEFAULT now() NOT NULL,
28 CONSTRAINT "oauth_apps_owner_fk" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE cascade
29);
30
31--> statement-breakpoint
32CREATE INDEX IF NOT EXISTS "oauth_apps_owner" ON "oauth_apps" ("owner_id");
33
34--> statement-breakpoint
35CREATE TABLE IF NOT EXISTS "oauth_authorizations" (
36 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
37 "app_id" uuid NOT NULL,
38 "user_id" uuid NOT NULL,
39 "code_hash" text NOT NULL UNIQUE,
40 "redirect_uri" text NOT NULL,
41 "scopes" text NOT NULL DEFAULT '',
42 "code_challenge" text,
43 "code_challenge_method" text,
44 "expires_at" timestamp NOT NULL,
45 "used_at" timestamp,
46 "created_at" timestamp DEFAULT now() NOT NULL,
47 CONSTRAINT "oauth_authorizations_app_fk" FOREIGN KEY ("app_id") REFERENCES "oauth_apps"("id") ON DELETE cascade,
48 CONSTRAINT "oauth_authorizations_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
49);
50
51--> statement-breakpoint
52CREATE INDEX IF NOT EXISTS "oauth_authorizations_expires" ON "oauth_authorizations" ("expires_at");
53
54--> statement-breakpoint
55CREATE TABLE IF NOT EXISTS "oauth_access_tokens" (
56 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
57 "app_id" uuid NOT NULL,
58 "user_id" uuid NOT NULL,
59 "access_token_hash" text NOT NULL UNIQUE,
60 "refresh_token_hash" text UNIQUE,
61 "scopes" text NOT NULL DEFAULT '',
62 "expires_at" timestamp NOT NULL,
63 "refresh_expires_at" timestamp,
64 "revoked_at" timestamp,
65 "last_used_at" timestamp,
66 "created_at" timestamp DEFAULT now() NOT NULL,
67 CONSTRAINT "oauth_access_tokens_app_fk" FOREIGN KEY ("app_id") REFERENCES "oauth_apps"("id") ON DELETE cascade,
68 CONSTRAINT "oauth_access_tokens_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
69);
70
71--> statement-breakpoint
72CREATE INDEX IF NOT EXISTS "oauth_access_tokens_user" ON "oauth_access_tokens" ("user_id");
73
74--> statement-breakpoint
75CREATE INDEX IF NOT EXISTS "oauth_access_tokens_app" ON "oauth_access_tokens" ("app_id");
76
77--> statement-breakpoint
78CREATE INDEX IF NOT EXISTS "oauth_access_tokens_expires" ON "oauth_access_tokens" ("expires_at");
Modifiedsrc/db/schema.ts+96−0View fileUnifiedSplit
877877
878878export type UserPasskey = typeof userPasskeys.$inferSelect;
879879export type WebauthnChallenge = typeof webauthnChallenges.$inferSelect;
880
881/**
882 * OAuth 2.0 provider (Block B6).
883 *
884 * `oauthApps` is a third-party app registered by a developer. Each app has
885 * a public `client_id`, a hashed `client_secret`, and one or more allowed
886 * `redirect_uris` (newline-separated). The plaintext secret is shown to the
887 * developer exactly once at creation and cannot be recovered; they can
888 * rotate it instead.
889 *
890 * `oauthAuthorizations` is a short-lived authorization code issued after
891 * the user consents at /oauth/authorize. Single-use: `usedAt` is set on
892 * redemption so a replay after-the-fact fails.
893 *
894 * `oauthAccessTokens` is a long-lived bearer token plus an optional
895 * refresh token. Both are stored as SHA-256 hashes; the plaintext values
896 * are only returned to the client once in the /oauth/token response.
897 */
898export const oauthApps = pgTable(
899 "oauth_apps",
900 {
901 id: uuid("id").primaryKey().defaultRandom(),
902 ownerId: uuid("owner_id")
903 .notNull()
904 .references(() => users.id, { onDelete: "cascade" }),
905 name: text("name").notNull(),
906 clientId: text("client_id").notNull().unique(),
907 clientSecretHash: text("client_secret_hash").notNull(),
908 clientSecretPrefix: text("client_secret_prefix").notNull(), // first 8 chars for display
909 /** Newline-separated list of allowed redirect URIs. */
910 redirectUris: text("redirect_uris").notNull(),
911 homepageUrl: text("homepage_url"),
912 description: text("description"),
913 /**
914 * If `true`, the app must present its client_secret at /oauth/token.
915 * Public SPA/mobile apps should set this to `false` and use PKCE.
916 */
917 confidential: boolean("confidential").default(true).notNull(),
918 revokedAt: timestamp("revoked_at"),
919 createdAt: timestamp("created_at").defaultNow().notNull(),
920 updatedAt: timestamp("updated_at").defaultNow().notNull(),
921 },
922 (table) => [index("oauth_apps_owner").on(table.ownerId)]
923);
924
925export const oauthAuthorizations = pgTable(
926 "oauth_authorizations",
927 {
928 id: uuid("id").primaryKey().defaultRandom(),
929 appId: uuid("app_id")
930 .notNull()
931 .references(() => oauthApps.id, { onDelete: "cascade" }),
932 userId: uuid("user_id")
933 .notNull()
934 .references(() => users.id, { onDelete: "cascade" }),
935 codeHash: text("code_hash").notNull().unique(),
936 redirectUri: text("redirect_uri").notNull(),
937 scopes: text("scopes").notNull().default(""),
938 codeChallenge: text("code_challenge"),
939 codeChallengeMethod: text("code_challenge_method"), // "S256" | "plain"
940 expiresAt: timestamp("expires_at").notNull(),
941 usedAt: timestamp("used_at"),
942 createdAt: timestamp("created_at").defaultNow().notNull(),
943 },
944 (table) => [index("oauth_authorizations_expires").on(table.expiresAt)]
945);
946
947export const oauthAccessTokens = pgTable(
948 "oauth_access_tokens",
949 {
950 id: uuid("id").primaryKey().defaultRandom(),
951 appId: uuid("app_id")
952 .notNull()
953 .references(() => oauthApps.id, { onDelete: "cascade" }),
954 userId: uuid("user_id")
955 .notNull()
956 .references(() => users.id, { onDelete: "cascade" }),
957 accessTokenHash: text("access_token_hash").notNull().unique(),
958 refreshTokenHash: text("refresh_token_hash").unique(),
959 scopes: text("scopes").notNull().default(""),
960 expiresAt: timestamp("expires_at").notNull(),
961 refreshExpiresAt: timestamp("refresh_expires_at"),
962 revokedAt: timestamp("revoked_at"),
963 lastUsedAt: timestamp("last_used_at"),
964 createdAt: timestamp("created_at").defaultNow().notNull(),
965 },
966 (table) => [
967 index("oauth_access_tokens_user").on(table.userId),
968 index("oauth_access_tokens_app").on(table.appId),
969 index("oauth_access_tokens_expires").on(table.expiresAt),
970 ]
971);
972
973export type OauthApp = typeof oauthApps.$inferSelect;
974export type OauthAuthorization = typeof oauthAuthorizations.$inferSelect;
975export type OauthAccessToken = typeof oauthAccessTokens.$inferSelect;
Addedsrc/lib/oauth.ts+197−0View fileUnifiedSplit
1/**
2 * OAuth 2.0 helpers (Block B6).
3 *
4 * Stateless utilities for the OAuth provider implemented in
5 * `src/routes/oauth.tsx`:
6 *
7 * - token / code / secret generation
8 * - constant-time SHA-256 hashing (matches how we store PATs)
9 * - PKCE (RFC 7636) code_challenge verification
10 * - scope parsing + validation
11 * - redirect-URI matching (exact match, no wildcards)
12 *
13 * All outputs that end up in URLs or Authorization headers are prefix-tagged
14 * so they're greppable in logs without leaking the secret portion.
15 */
16
17/** Supported OAuth scopes. Add new ones here + document on the consent screen. */
18export const SUPPORTED_SCOPES = [
19 "read:user",
20 "read:repo",
21 "write:repo",
22 "read:org",
23 "write:org",
24 "read:issue",
25 "write:issue",
26 "read:pr",
27 "write:pr",
28] as const;
29
30export type OauthScope = (typeof SUPPORTED_SCOPES)[number];
31
32/** Default access token TTL (1 hour). */
33export const ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000;
34/** Refresh token TTL (30 days). */
35export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
36/** Authorization code TTL (10 min — well within RFC 6749's 10-minute max). */
37export const AUTH_CODE_TTL_MS = 10 * 60 * 1000;
38
39function randomHex(byteLen: number): string {
40 const bytes = crypto.getRandomValues(new Uint8Array(byteLen));
41 let s = "";
42 for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, "0");
43 return s;
44}
45
46/** Returns a 20-char client_id like `glc_app_<12 hex>`. */
47export function generateClientId(): string {
48 return "glc_app_" + randomHex(12);
49}
50
51/** Returns a 40-char client secret like `glcs_<32 hex>`. */
52export function generateClientSecret(): string {
53 return "glcs_" + randomHex(32);
54}
55
56export function generateAuthCode(): string {
57 return "glca_" + randomHex(24);
58}
59
60export function generateAccessToken(): string {
61 return "glct_" + randomHex(32);
62}
63
64export function generateRefreshToken(): string {
65 return "glcr_" + randomHex(32);
66}
67
68/** SHA-256 hex digest. Same algorithm as src/routes/tokens.ts. */
69export async function sha256Hex(input: string): Promise<string> {
70 const data = new TextEncoder().encode(input);
71 const digest = await crypto.subtle.digest("SHA-256", data);
72 const bytes = new Uint8Array(digest);
73 let s = "";
74 for (let i = 0; i < bytes.length; i++) s += bytes[i].toString(16).padStart(2, "0");
75 return s;
76}
77
78/** Base64url (no padding) — used by PKCE. */
79export function b64urlFromBytes(bytes: Uint8Array): string {
80 let bin = "";
81 for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
82 return btoa(bin)
83 .replace(/\+/g, "-")
84 .replace(/\//g, "_")
85 .replace(/=+$/, "");
86}
87
88/**
89 * PKCE verification (RFC 7636).
90 * For "S256": base64url(SHA-256(verifier)) must equal the challenge.
91 * For "plain": the verifier must equal the challenge literally.
92 * Returns true when the method is unrecognized but `challenge` is empty —
93 * callers should refuse before calling this if PKCE is required.
94 */
95export async function verifyPkce(opts: {
96 challenge: string | null | undefined;
97 method: string | null | undefined;
98 verifier: string;
99}): Promise<boolean> {
100 const challenge = (opts.challenge || "").trim();
101 if (!challenge) return false;
102 const method = (opts.method || "plain").toLowerCase();
103
104 if (method === "s256") {
105 const data = new TextEncoder().encode(opts.verifier);
106 const digest = await crypto.subtle.digest("SHA-256", data);
107 const produced = b64urlFromBytes(new Uint8Array(digest));
108 return timingSafeEqual(produced, challenge);
109 }
110 if (method === "plain") {
111 return timingSafeEqual(opts.verifier, challenge);
112 }
113 return false;
114}
115
116/** Constant-time string comparison. */
117export function timingSafeEqual(a: string, b: string): boolean {
118 if (a.length !== b.length) return false;
119 let diff = 0;
120 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
121 return diff === 0;
122}
123
124/**
125 * Parse a scope string (space-separated per RFC 6749 or comma-separated for
126 * convenience). Unknown scopes are dropped silently; duplicates collapsed.
127 */
128export function parseScopes(input: string | null | undefined): OauthScope[] {
129 if (!input) return [];
130 const parts = input.split(/[\s,]+/).filter(Boolean);
131 const seen = new Set<string>();
132 const out: OauthScope[] = [];
133 for (const p of parts) {
134 const s = p.trim().toLowerCase();
135 if (!s || seen.has(s)) continue;
136 if ((SUPPORTED_SCOPES as readonly string[]).includes(s)) {
137 out.push(s as OauthScope);
138 seen.add(s);
139 }
140 }
141 return out;
142}
143
144/** Serialize scopes back to a space-separated string. */
145export function serializeScopes(scopes: readonly OauthScope[]): string {
146 return scopes.join(" ");
147}
148
149/**
150 * Parse an app's stored `redirectUris` column (newline-separated).
151 * Empty / whitespace-only lines are ignored.
152 */
153export function parseRedirectUris(stored: string): string[] {
154 return stored
155 .split(/\r?\n/)
156 .map((s) => s.trim())
157 .filter(Boolean);
158}
159
160/**
161 * Validate a single redirect URI for storage:
162 * - must be absolute http(s):// (http only for localhost)
163 * - no fragment (`#...`)
164 * - no wildcards
165 */
166export function isValidRedirectUri(uri: string): boolean {
167 try {
168 const u = new URL(uri);
169 if (u.protocol !== "http:" && u.protocol !== "https:") return false;
170 if (u.protocol === "http:") {
171 if (!["localhost", "127.0.0.1", "[::1]"].includes(u.hostname)) {
172 return false;
173 }
174 }
175 if (u.hash) return false;
176 if (uri.includes("*")) return false;
177 return true;
178 } catch {
179 return false;
180 }
181}
182
183/** Exact-match check against the app's registered list. */
184export function redirectUriAllowed(
185 candidate: string,
186 registered: readonly string[]
187): boolean {
188 if (!candidate) return false;
189 for (const r of registered) {
190 if (timingSafeEqual(candidate, r)) return true;
191 }
192 return false;
193}
194
195export const __test = {
196 randomHex,
197};
Addedsrc/routes/developer-apps.tsx+558−0View fileUnifiedSplit
1/**
2 * Developer Apps UI (Block B6).
3 *
4 * Lets authenticated users register + manage their OAuth 2.0 apps:
5 * GET /settings/applications list + new button
6 * GET /settings/applications/new form
7 * POST /settings/applications/new create (returns client_secret once)
8 * GET /settings/applications/:id edit / rotate secret / delete
9 * POST /settings/applications/:id update
10 * POST /settings/applications/:id/rotate generate a new client secret
11 * POST /settings/applications/:id/delete remove app + all tokens
12 *
13 * All writes audit()' the action. Read-only responses are HTML (SSR JSX).
14 */
15
16import { Hono } from "hono";
17import { and, eq } from "drizzle-orm";
18import { db } from "../db";
19import { oauthApps } from "../db/schema";
20import { Layout } from "../views/layout";
21import { requireAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import {
24 generateClientId,
25 generateClientSecret,
26 sha256Hex,
27 isValidRedirectUri,
28 parseRedirectUris,
29} from "../lib/oauth";
30import { audit } from "../lib/notify";
31
32const apps = new Hono<AuthEnv>();
33
34apps.use("/settings/applications", requireAuth);
35apps.use("/settings/applications/*", requireAuth);
36
37function normaliseRedirectUris(raw: string): {
38 ok: boolean;
39 value?: string;
40 error?: string;
41} {
42 const lines = raw
43 .split(/\r?\n/)
44 .map((s) => s.trim())
45 .filter(Boolean);
46 if (lines.length === 0) {
47 return { ok: false, error: "At least one redirect URI is required" };
48 }
49 if (lines.length > 10) {
50 return { ok: false, error: "At most 10 redirect URIs allowed" };
51 }
52 for (const u of lines) {
53 if (!isValidRedirectUri(u)) {
54 return { ok: false, error: `Invalid redirect URI: ${u}` };
55 }
56 }
57 return { ok: true, value: lines.join("\n") };
58}
59
60apps.get("/settings/applications", async (c) => {
61 const user = c.get("user")!;
62 const error = c.req.query("error");
63 const success = c.req.query("success");
64
65 let rows: (typeof oauthApps.$inferSelect)[] = [];
66 try {
67 rows = await db
68 .select()
69 .from(oauthApps)
70 .where(eq(oauthApps.ownerId, user.id));
71 } catch (err) {
72 console.error("[oauth-apps] list:", err);
73 }
74
75 return c.html(
76 <Layout title="OAuth applications" user={user}>
77 <div class="settings-container">
78 <div class="breadcrumb">
79 <a href="/settings">settings</a>
80 <span>/</span>
81 <span>applications</span>
82 </div>
83 <h2>OAuth applications</h2>
84 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
85 {success && (
86 <div class="auth-success">{decodeURIComponent(success)}</div>
87 )}
88 <p style="color: var(--text-muted); font-size: 13px">
89 Register third-party apps that can request access to gluecron on
90 behalf of users via the OAuth 2.0 authorization code flow.
91 </p>
92 <div style="margin: 16px 0">
93 <a href="/settings/applications/new" class="btn btn-primary">
94 New OAuth app
95 </a>
96 </div>
97 <div
98 style="border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden"
99 >
100 {rows.length === 0 ? (
101 <div
102 style="padding: 16px; color: var(--text-muted); font-size: 13px; background: var(--bg-secondary)"
103 >
104 No OAuth apps registered yet.
105 </div>
106 ) : (
107 rows.map((app) => (
108 <div
109 style="padding: 12px 16px; border-bottom: 1px solid var(--border); background: var(--bg-secondary)"
110 >
111 <div style="display: flex; justify-content: space-between; align-items: center">
112 <div>
113 <strong>
114 <a href={`/settings/applications/${app.id}`}>{app.name}</a>
115 </strong>
116 {app.revokedAt && (
117 <span style="color: var(--red); font-size: 12px; margin-left: 8px">
118 revoked
119 </span>
120 )}
121 <div
122 style="color: var(--text-muted); font-size: 12px; margin-top: 2px"
123 >
124 <code>{app.clientId}</code>
125 {" · "}added {new Date(app.createdAt).toLocaleDateString()}
126 </div>
127 </div>
128 <a
129 href={`/settings/applications/${app.id}`}
130 class="btn btn-sm"
131 >
132 manage
133 </a>
134 </div>
135 </div>
136 ))
137 )}
138 </div>
139 </div>
140 </Layout>
141 );
142});
143
144apps.get("/settings/applications/new", async (c) => {
145 const user = c.get("user")!;
146 const error = c.req.query("error");
147 return c.html(
148 <Layout title="New OAuth app" user={user}>
149 <div class="settings-container">
150 <div class="breadcrumb">
151 <a href="/settings/applications">applications</a>
152 <span>/</span>
153 <span>new</span>
154 </div>
155 <h2>Register a new OAuth app</h2>
156 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
157 <form method="POST" action="/settings/applications/new">
158 <div class="form-group">
159 <label for="name">Application name</label>
160 <input
161 type="text"
162 id="name"
163 name="name"
164 required
165 maxLength={80}
166 placeholder="My Awesome Integration"
167 />
168 </div>
169 <div class="form-group">
170 <label for="homepage_url">Homepage URL</label>
171 <input
172 type="url"
173 id="homepage_url"
174 name="homepage_url"
175 placeholder="https://example.com"
176 />
177 </div>
178 <div class="form-group">
179 <label for="description">Description</label>
180 <textarea
181 id="description"
182 name="description"
183 rows={3}
184 maxLength={500}
185 />
186 </div>
187 <div class="form-group">
188 <label for="redirect_uris">Authorization callback URLs</label>
189 <textarea
190 id="redirect_uris"
191 name="redirect_uris"
192 rows={4}
193 required
194 placeholder="https://example.com/oauth/callback"
195 />
196 <small style="color: var(--text-muted)">
197 One URL per line. HTTPS required (HTTP allowed for localhost).
198 Exact match; no wildcards.
199 </small>
200 </div>
201 <div class="form-group">
202 <label>
203 <input
204 type="checkbox"
205 name="confidential"
206 value="on"
207 checked
208 />
209 {" "}Confidential client (server-side app)
210 </label>
211 <br />
212 <small style="color: var(--text-muted)">
213 Uncheck for public SPA / mobile apps — they must use PKCE
214 instead of a client secret.
215 </small>
216 </div>
217 <button type="submit" class="btn btn-primary">
218 Register app
219 </button>
220 <a
221 href="/settings/applications"
222 class="btn"
223 style="margin-left: 8px"
224 >
225 Cancel
226 </a>
227 </form>
228 </div>
229 </Layout>
230 );
231});
232
233apps.post("/settings/applications/new", async (c) => {
234 const user = c.get("user")!;
235 const body = await c.req.parseBody();
236 const name = String(body.name || "").trim().slice(0, 80);
237 const homepageUrl = String(body.homepage_url || "").trim().slice(0, 200);
238 const description = String(body.description || "").trim().slice(0, 500);
239 const confidential = String(body.confidential || "") === "on";
240 const redirectRaw = String(body.redirect_uris || "");
241
242 if (!name) {
243 return c.redirect("/settings/applications/new?error=Name+is+required");
244 }
245 const parsed = normaliseRedirectUris(redirectRaw);
246 if (!parsed.ok) {
247 return c.redirect(
248 `/settings/applications/new?error=${encodeURIComponent(parsed.error || "Invalid redirect URIs")}`
249 );
250 }
251
252 const clientId = generateClientId();
253 const clientSecret = generateClientSecret();
254 const clientSecretHash = await sha256Hex(clientSecret);
255
256 try {
257 const [row] = await db
258 .insert(oauthApps)
259 .values({
260 ownerId: user.id,
261 name,
262 clientId,
263 clientSecretHash,
264 clientSecretPrefix: clientSecret.slice(0, 8),
265 redirectUris: parsed.value!,
266 homepageUrl: homepageUrl || null,
267 description: description || null,
268 confidential,
269 })
270 .returning();
271 await audit({
272 userId: user.id,
273 action: "oauth_app.create",
274 targetType: "oauth_app",
275 targetId: row.id,
276 metadata: { clientId },
277 });
278 // Redirect to the manage page with the plaintext secret appended once.
279 return c.redirect(
280 `/settings/applications/${row.id}?secret=${encodeURIComponent(clientSecret)}&success=App+created`
281 );
282 } catch (err) {
283 console.error("[oauth-apps] create:", err);
284 return c.redirect(
285 "/settings/applications/new?error=Service+unavailable"
286 );
287 }
288});
289
290apps.get("/settings/applications/:id", async (c) => {
291 const user = c.get("user")!;
292 const id = c.req.param("id");
293 const error = c.req.query("error");
294 const success = c.req.query("success");
295 const secret = c.req.query("secret");
296
297 let app: typeof oauthApps.$inferSelect | undefined;
298 try {
299 const [row] = await db
300 .select()
301 .from(oauthApps)
302 .where(and(eq(oauthApps.id, id), eq(oauthApps.ownerId, user.id)))
303 .limit(1);
304 app = row;
305 } catch (err) {
306 console.error("[oauth-apps] get:", err);
307 }
308 if (!app) {
309 return c.redirect("/settings/applications?error=Not+found");
310 }
311
312 return c.html(
313 <Layout title={app.name} user={user}>
314 <div class="settings-container">
315 <div class="breadcrumb">
316 <a href="/settings/applications">applications</a>
317 <span>/</span>
318 <span>{app.name}</span>
319 </div>
320 <h2>{app.name}</h2>
321 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
322 {success && (
323 <div class="auth-success">{decodeURIComponent(success)}</div>
324 )}
325
326 {secret && (
327 <div
328 style="padding: 12px; border: 1px solid var(--yellow); background: rgba(255,193,7,0.1); border-radius: var(--radius); margin-bottom: 16px"
329 >
330 <strong>Save this client secret — it will not be shown again:</strong>
331 <pre
332 style="margin-top: 8px; padding: 8px; background: var(--bg); border-radius: 4px; overflow-x: auto; user-select: all"
333 >
334 {secret}
335 </pre>
336 </div>
337 )}
338
339 <dl style="display: grid; grid-template-columns: 200px 1fr; gap: 8px 16px; margin-bottom: 16px">
340 <dt style="color: var(--text-muted)">Client ID</dt>
341 <dd>
342 <code style="user-select: all">{app.clientId}</code>
343 </dd>
344 <dt style="color: var(--text-muted)">Client secret prefix</dt>
345 <dd>
346 <code>{app.clientSecretPrefix}…</code>
347 </dd>
348 <dt style="color: var(--text-muted)">Type</dt>
349 <dd>{app.confidential ? "Confidential" : "Public (PKCE)"}</dd>
350 <dt style="color: var(--text-muted)">Created</dt>
351 <dd>{new Date(app.createdAt).toLocaleString()}</dd>
352 </dl>
353
354 <form method="POST" action={`/settings/applications/${app.id}`}>
355 <div class="form-group">
356 <label for="name">Application name</label>
357 <input
358 type="text"
359 id="name"
360 name="name"
361 required
362 maxLength={80}
363 defaultValue={app.name}
364 />
365 </div>
366 <div class="form-group">
367 <label for="homepage_url">Homepage URL</label>
368 <input
369 type="url"
370 id="homepage_url"
371 name="homepage_url"
372 defaultValue={app.homepageUrl || ""}
373 />
374 </div>
375 <div class="form-group">
376 <label for="description">Description</label>
377 <textarea
378 id="description"
379 name="description"
380 rows={3}
381 maxLength={500}
382 >
383 {app.description || ""}
384 </textarea>
385 </div>
386 <div class="form-group">
387 <label for="redirect_uris">Authorization callback URLs</label>
388 <textarea
389 id="redirect_uris"
390 name="redirect_uris"
391 rows={4}
392 required
393 >
394 {app.redirectUris}
395 </textarea>
396 </div>
397 <button type="submit" class="btn btn-primary">
398 Save changes
399 </button>
400 </form>
401
402 <hr style="margin: 24px 0; border-color: var(--border)" />
403
404 <h3>Rotate client secret</h3>
405 <p style="color: var(--text-muted); font-size: 13px">
406 Generate a new secret. The old one is invalidated immediately —
407 existing access tokens keep working, but token exchange with the
408 old secret will fail.
409 </p>
410 <form
411 method="POST"
412 action={`/settings/applications/${app.id}/rotate`}
413 onsubmit="return confirm('Rotate the client secret? The old one will stop working immediately.')"
414 >
415 <button type="submit" class="btn">
416 Rotate secret
417 </button>
418 </form>
419
420 <hr style="margin: 24px 0; border-color: var(--border)" />
421
422 <h3 style="color: var(--red)">Danger zone</h3>
423 <form
424 method="POST"
425 action={`/settings/applications/${app.id}/delete`}
426 onsubmit="return confirm('Delete this OAuth app? All issued access tokens will be revoked.')"
427 >
428 <button type="submit" class="btn btn-danger">
429 Delete app
430 </button>
431 </form>
432 </div>
433 </Layout>
434 );
435});
436
437apps.post("/settings/applications/:id", async (c) => {
438 const user = c.get("user")!;
439 const id = c.req.param("id");
440 const body = await c.req.parseBody();
441 const name = String(body.name || "").trim().slice(0, 80);
442 const homepageUrl = String(body.homepage_url || "").trim().slice(0, 200);
443 const description = String(body.description || "").trim().slice(0, 500);
444 const redirectRaw = String(body.redirect_uris || "");
445
446 if (!name) {
447 return c.redirect(
448 `/settings/applications/${id}?error=Name+is+required`
449 );
450 }
451 const parsed = normaliseRedirectUris(redirectRaw);
452 if (!parsed.ok) {
453 return c.redirect(
454 `/settings/applications/${id}?error=${encodeURIComponent(parsed.error || "Invalid redirect URIs")}`
455 );
456 }
457 try {
458 const [existing] = await db
459 .select({ id: oauthApps.id, ownerId: oauthApps.ownerId })
460 .from(oauthApps)
461 .where(eq(oauthApps.id, id))
462 .limit(1);
463 if (!existing || existing.ownerId !== user.id) {
464 return c.redirect("/settings/applications?error=Not+found");
465 }
466 await db
467 .update(oauthApps)
468 .set({
469 name,
470 homepageUrl: homepageUrl || null,
471 description: description || null,
472 redirectUris: parsed.value!,
473 updatedAt: new Date(),
474 })
475 .where(eq(oauthApps.id, id));
476 await audit({
477 userId: user.id,
478 action: "oauth_app.update",
479 targetType: "oauth_app",
480 targetId: id,
481 });
482 return c.redirect(`/settings/applications/${id}?success=Saved`);
483 } catch (err) {
484 console.error("[oauth-apps] update:", err);
485 return c.redirect(
486 `/settings/applications/${id}?error=Service+unavailable`
487 );
488 }
489});
490
491apps.post("/settings/applications/:id/rotate", async (c) => {
492 const user = c.get("user")!;
493 const id = c.req.param("id");
494 try {
495 const [existing] = await db
496 .select({ id: oauthApps.id, ownerId: oauthApps.ownerId })
497 .from(oauthApps)
498 .where(eq(oauthApps.id, id))
499 .limit(1);
500 if (!existing || existing.ownerId !== user.id) {
501 return c.redirect("/settings/applications?error=Not+found");
502 }
503 const newSecret = generateClientSecret();
504 const newHash = await sha256Hex(newSecret);
505 await db
506 .update(oauthApps)
507 .set({
508 clientSecretHash: newHash,
509 clientSecretPrefix: newSecret.slice(0, 8),
510 updatedAt: new Date(),
511 })
512 .where(eq(oauthApps.id, id));
513 await audit({
514 userId: user.id,
515 action: "oauth_app.rotate_secret",
516 targetType: "oauth_app",
517 targetId: id,
518 });
519 return c.redirect(
520 `/settings/applications/${id}?secret=${encodeURIComponent(newSecret)}&success=Secret+rotated`
521 );
522 } catch (err) {
523 console.error("[oauth-apps] rotate:", err);
524 return c.redirect(
525 `/settings/applications/${id}?error=Service+unavailable`
526 );
527 }
528});
529
530apps.post("/settings/applications/:id/delete", async (c) => {
531 const user = c.get("user")!;
532 const id = c.req.param("id");
533 try {
534 const [existing] = await db
535 .select({ id: oauthApps.id, ownerId: oauthApps.ownerId })
536 .from(oauthApps)
537 .where(eq(oauthApps.id, id))
538 .limit(1);
539 if (!existing || existing.ownerId !== user.id) {
540 return c.redirect("/settings/applications?error=Not+found");
541 }
542 await db.delete(oauthApps).where(eq(oauthApps.id, id));
543 await audit({
544 userId: user.id,
545 action: "oauth_app.delete",
546 targetType: "oauth_app",
547 targetId: id,
548 });
549 return c.redirect("/settings/applications?success=App+deleted");
550 } catch (err) {
551 console.error("[oauth-apps] delete:", err);
552 return c.redirect(
553 `/settings/applications/${id}?error=Service+unavailable`
554 );
555 }
556});
557
558export default apps;
0559