diff --git a/scripts/seed-dev.ts b/scripts/seed-dev.ts index 57b4cd2..6d9f72e 100644 --- a/scripts/seed-dev.ts +++ b/scripts/seed-dev.ts @@ -47,12 +47,48 @@ const now = Math.floor(Date.now() / 1000); // tacy is always the admin (index 0). Look up existing users by username // so we can seed on top of a live db without --reset. const users = [ - { username: "tacy", name: "Kieran Klukas", email: "kieran@dunkirk.sh", tier: "admin", status: "active" }, - { username: "alice", name: "Alice Carter", email: "alice@example.com", tier: "developer", status: "active" }, - { username: "bob", name: "Bob Nguyen", email: "bob@example.com", tier: "user", status: "active" }, - { username: "charlie", name: "Charlie Park", email: "charlie@example.com", tier: "user", status: "suspended" }, - { username: "dana", name: "Dana Williams", email: "dana@example.com", tier: "developer", status: "active" }, - { username: "eve", name: "Eve Martinez", email: null, tier: "user", status: "active" }, + { + username: "tacy", + name: "Kieran Klukas", + email: "kieran@dunkirk.sh", + tier: "admin", + status: "active", + }, + { + username: "alice", + name: "Alice Carter", + email: "alice@example.com", + tier: "developer", + status: "active", + }, + { + username: "bob", + name: "Bob Nguyen", + email: "bob@example.com", + tier: "user", + status: "active", + }, + { + username: "charlie", + name: "Charlie Park", + email: "charlie@example.com", + tier: "user", + status: "suspended", + }, + { + username: "dana", + name: "Dana Williams", + email: "dana@example.com", + tier: "developer", + status: "active", + }, + { + username: "eve", + name: "Eve Martinez", + email: null, + tier: "user", + status: "active", + }, ]; const userIds: number[] = []; @@ -85,7 +121,9 @@ for (const u of users) { insertedUsers++; } } -console.log(`${insertedUsers} new users inserted, ${users.length - insertedUsers} existing`); +console.log( + `${insertedUsers} new users inserted, ${users.length - insertedUsers} existing`, +); // --- Credentials (fake passkeys) --- // Only insert fake creds for newly-created users. Existing users keep their real passkeys. @@ -128,7 +166,10 @@ if (!existingSession) { console.log("inserted 1 dev session (token: dev-session-tacy)"); } else { // Refresh expiry on existing session - db.query("UPDATE sessions SET expires_at = ? WHERE token = ?").run(now + 86400, sessionToken); + db.query("UPDATE sessions SET expires_at = ? WHERE token = ?").run( + now + 86400, + sessionToken, + ); console.log("refreshed existing dev session (token: dev-session-tacy)"); } @@ -355,7 +396,9 @@ let insertedTokens = 0; for (const t of tokens) { // Check if this user+client combo already has an active token const existing = db - .query("SELECT id FROM tokens WHERE user_id = ? AND client_id = ? AND revoked = 0") + .query( + "SELECT id FROM tokens WHERE user_id = ? AND client_id = ? AND revoked = 0", + ) .get(userIds[t.user], t.client) as { id: number } | undefined; if (existing) continue; diff --git a/src/client/admin-clients.ts b/src/client/admin-clients.ts index 7a4bb00..bf67b5f 100644 --- a/src/client/admin-clients.ts +++ b/src/client/admin-clients.ts @@ -19,9 +19,9 @@ declare global { } import "./ds"; +import { apiFetch } from "./api"; import type IToast from "./ds/toast"; import { escapeHtml } from "./escape"; -import { apiFetch } from "./api"; const clientsList = document.getElementById("clientsList") as HTMLElement; const createClientBtn = document.getElementById( @@ -46,8 +46,7 @@ function showToast(message: string, type: "success" | "error" = "success") { async function checkAuth() { try { - const response = await apiFetch("/api/hello", { - }); + const response = await apiFetch("/api/hello", {}); if (response.status === 401 || response.status === 403) { window.location.href = "/login"; @@ -93,8 +92,7 @@ interface ClientUser { async function loadClients() { try { - const response = await apiFetch("/api/admin/clients", { - }); + const response = await apiFetch("/api/admin/clients", {}); if (!response.ok) { throw new Error("Failed to load clients"); @@ -217,8 +215,7 @@ window.toggleClient = async (clientId: string) => { const response = await apiFetch( `/api/admin/clients/${encodeURIComponent(clientId)}`, { - headers: { - }, + headers: {}, }, ); @@ -367,8 +364,7 @@ window.editClient = async (clientId: string) => { const response = await apiFetch( `/api/admin/clients/${encodeURIComponent(clientId)}`, { - headers: { - }, + headers: {}, }, ); @@ -426,8 +422,7 @@ window.deleteClient = async (clientId: string, event?: Event) => { `/api/admin/clients/${encodeURIComponent(clientId)}`, { method: "DELETE", - headers: { - }, + headers: {}, }, ); @@ -626,8 +621,7 @@ window.regenerateSecret = async (clientId: string, event?: Event) => { `/api/admin/clients/${encodeURIComponent(clientId)}/secret`, { method: "POST", - headers: { - }, + headers: {}, }, ); @@ -700,8 +694,7 @@ window.revokeUserPermission = async ( `/api/admin/apps/${encodeURIComponent(clientId)}/users/${encodeURIComponent(username)}`, { method: "DELETE", - headers: { - }, + headers: {}, }, ); diff --git a/src/client/admin-invites.ts b/src/client/admin-invites.ts index fb05f99..d6df8ea 100644 --- a/src/client/admin-invites.ts +++ b/src/client/admin-invites.ts @@ -1,6 +1,6 @@ import "./ds"; -import { escapeHtml } from "./escape"; import { apiFetch } from "./api"; +import { escapeHtml } from "./escape"; declare global { interface Window { @@ -20,8 +20,7 @@ const createInviteBtn = document.getElementById( // Check auth and display user async function checkAuth() { try { - const response = await apiFetch("/api/hello", { - }); + const response = await apiFetch("/api/hello", {}); if (response.status === 401 || response.status === 403) { window.location.href = "/login"; @@ -56,8 +55,7 @@ async function createInvite() { async function loadAppsForInvite() { try { - const response = await apiFetch("/api/admin/clients", { - }); + const response = await apiFetch("/api/admin/clients", {}); if (!response.ok) { throw new Error("Failed to load apps"); @@ -83,10 +81,13 @@ async function loadAppsForInvite() { name: string; roles: string[]; }) => { - const roleOptions = + const roleOptions = app.roles.length > 0 ? app.roles - .map((role) => ``) + .map( + (role) => + ``, + ) .join("") : ''; @@ -247,8 +248,7 @@ window.closeCreateInviteModal = closeCreateInviteModal; async function loadInvites() { try { - const response = await apiFetch("/api/invites", { - }); + const response = await apiFetch("/api/invites", {}); if (!response.ok) { throw new Error("Failed to load invites"); @@ -299,7 +299,7 @@ async function loadInvites() { ? `Expires: ${new Date(invite.expiresAt * 1000).toLocaleString()}` : "No expiry"; - const roleInfo = + const roleInfo = invite.appRoles.length > 0 ? `
app roles
@@ -448,8 +448,7 @@ let currentEditInviteId: number | null = null; // Make editInvite globally available for onclick handler window.editInvite = async (inviteId: number) => { try { - const response = await apiFetch("/api/invites", { - }); + const response = await apiFetch("/api/invites", {}); if (!response.ok) { throw new Error("Failed to load invite"); @@ -584,8 +583,7 @@ window.deleteInvite = async (inviteId: number, event?: Event) => { try { const response = await apiFetch(`/api/invites/${inviteId}`, { method: "DELETE", - headers: { - }, + headers: {}, }); if (!response.ok) { diff --git a/src/client/admin.ts b/src/client/admin.ts index 070c41b..2e5aaa2 100644 --- a/src/client/admin.ts +++ b/src/client/admin.ts @@ -1,6 +1,6 @@ import "./ds"; -import { escapeHtml } from "./escape"; import { apiFetch } from "./api"; +import { escapeHtml } from "./escape"; const usersList = document.getElementById("usersList") as HTMLElement; let currentUserId: number; @@ -8,8 +8,7 @@ let currentUserId: number; // Check auth and display user async function checkAuth() { try { - const response = await apiFetch("/api/hello", { - }); + const response = await apiFetch("/api/hello", {}); if (response.status === 401 || response.status === 403) { window.location.href = "/login"; @@ -35,8 +34,7 @@ async function checkAuth() { async function loadUsers() { try { - const response = await apiFetch("/api/users", { - }); + const response = await apiFetch("/api/users", {}); if (!response.ok) { throw new Error("Failed to load users"); @@ -141,8 +139,7 @@ async function handleUserAction(e: Event) { const response = await apiFetch(endpoint, { method, - headers: { - }, + headers: {}, }); if (!response.ok) { diff --git a/src/client/index.ts b/src/client/index.ts index 4048145..bf8905c 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,10 +1,9 @@ import { startRegistration } from "@simplewebauthn/browser"; import "./ds"; +import { apiFetch } from "./api"; import type IButton from "./ds/button"; import type IToast from "./ds/toast"; import { escapeHtml } from "./escape"; -import { apiFetch } from "./api"; - let welcome!: HTMLElement; let subtitle!: HTMLElement; @@ -26,7 +25,6 @@ let dangerZone!: HTMLElement; let isAdmin = false; - function $(id: string): HTMLElement { const el = document.getElementById(id); if (!el) { @@ -117,8 +115,7 @@ function updateAvatarPreview(photo: string | null, username: string) { // Check auth and display user async function checkAuth() { try { - const response = await apiFetch("/api/hello", { - }); + const response = await apiFetch("/api/hello", {}); if (response.status === 401 || response.status === 403) { window.location.href = "/login"; @@ -142,8 +139,7 @@ async function checkAuth() { async function loadProfile() { try { - const response = await apiFetch("/api/profile", { - }); + const response = await apiFetch("/api/profile", {}); if (!response.ok) { throw new Error("Failed to load profile"); @@ -178,8 +174,7 @@ async function loadProfile() { async function loadRecentApps() { try { - const response = await apiFetch("/api/apps", { - }); + const response = await apiFetch("/api/apps", {}); if (!response.ok) { throw new Error("Failed to load apps"); @@ -282,7 +277,7 @@ async function onDeleteAccount() { try { const response = await apiFetch("/api/profile", { - method: "DELETE" + method: "DELETE", }); if (!response.ok) { @@ -304,8 +299,7 @@ async function onDeleteAccount() { async function loadPasskeys() { try { - const response = await apiFetch("/api/passkeys", { - }); + const response = await apiFetch("/api/passkeys", {}); if (!response.ok) { throw new Error("Failed to load passkeys"); @@ -376,7 +370,7 @@ async function onPasskeyRemove(e: CustomEvent<{ id: string }>) { try { const response = await apiFetch(`/api/passkeys/${id}`, { - method: "DELETE" + method: "DELETE", }); if (!response.ok) { @@ -444,7 +438,7 @@ async function onAddPasskey() { try { // Get registration options const optionsRes = await apiFetch("/api/passkeys/add/options", { - method: "POST" + method: "POST", }); if (!optionsRes.ok) { diff --git a/src/index.ts b/src/index.ts index cb0d20d..19a1f2a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -106,7 +106,6 @@ import { console.log(`[Startup] Environment validated (${nodeEnv} mode)`); })(); - const server = Bun.serve({ port: env.PORT ? Number.parseInt(env.PORT, 10) : 3000, routes: { diff --git a/src/lib/oauth/pages.ts b/src/lib/oauth/pages.ts index fa1a2f0..caa97d2 100644 --- a/src/lib/oauth/pages.ts +++ b/src/lib/oauth/pages.ts @@ -418,7 +418,10 @@ export function consentPage(opts: ConsentPageOptions): Response {
` : ""; const resourceHidden = resources - .map((r) => ``) + .map( + (r) => + ``, + ) .join(""); const scopeItems = opts.scopes diff --git a/src/lib/oauth/resource.ts b/src/lib/oauth/resource.ts index da32f74..138aef6 100644 --- a/src/lib/oauth/resource.ts +++ b/src/lib/oauth/resource.ts @@ -21,7 +21,8 @@ export function validateResources(values: string[]): string[] | null { } catch { return null; } - if ((u.protocol !== "https:" && u.protocol !== "http:") || u.hash) return null; + if ((u.protocol !== "https:" && u.protocol !== "http:") || u.hash) + return null; const norm = u.origin + u.pathname.replace(/\/+$/, ""); if (!out.includes(norm)) out.push(norm); } @@ -51,7 +52,9 @@ export interface ResourceInfo { * an optional `logo_uri`). Best-effort + SSRF-safe: a resource with no PRM, an * unreachable one, or a bad logo URL just falls back to its hostname. */ -export async function resourceDisplay(resources: string[]): Promise { +export async function resourceDisplay( + resources: string[], +): Promise { return Promise.all(resources.map(fetchResourceInfo)); } @@ -65,11 +68,18 @@ async function fetchResourceInfo(id: string): Promise { const info: ResourceInfo = { id, name: host, host }; try { const prmUrl = `${id.replace(/\/+$/, "")}/.well-known/oauth-protected-resource`; - const res = await safeFetch(prmUrl, { timeout: 3000, headers: { accept: "application/json" } }); + const res = await safeFetch(prmUrl, { + timeout: 3000, + headers: { accept: "application/json" }, + }); if (!res.success) return info; - const prm = (await res.data.json()) as { resource_name?: string; logo_uri?: string }; + const prm = (await res.data.json()) as { + resource_name?: string; + logo_uri?: string; + }; if (prm.resource_name) info.name = prm.resource_name; - if (prm.logo_uri && validateExternalURL(prm.logo_uri).safe) info.logo = prm.logo_uri; + if (prm.logo_uri && validateExternalURL(prm.logo_uri).safe) + info.logo = prm.logo_uri; } catch { /* best-effort: hostname fallback already set */ } diff --git a/src/lib/ssrf-safe-fetch.ts b/src/lib/ssrf-safe-fetch.ts index 3ac0e15..7db3c68 100644 --- a/src/lib/ssrf-safe-fetch.ts +++ b/src/lib/ssrf-safe-fetch.ts @@ -79,7 +79,9 @@ function isPrivateIP(ip: string): boolean { } // IPv4-mapped IPv6 in hex form (::ffff:a01:101 = ::ffff:10.1.1.1) - const hexMappedMatch = ipv6.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i); + const hexMappedMatch = ipv6.match( + /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i, + ); if (hexMappedMatch?.[1] && hexMappedMatch[2]) { const hi = Number.parseInt(hexMappedMatch[1], 16); const lo = Number.parseInt(hexMappedMatch[2], 16); @@ -274,13 +276,19 @@ async function resolveAndValidateIPs(hostname: string): Promise<{ // Literal IPs don't need resolution — validate directly. if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) { return isPrivateIP(hostname) - ? { safe: false, error: "Cannot fetch from private/reserved IP addresses" } + ? { + safe: false, + error: "Cannot fetch from private/reserved IP addresses", + } : { safe: true }; } if (hostname.startsWith("[") && hostname.endsWith("]")) { return isPrivateIP(hostname) - ? { safe: false, error: "Cannot fetch from private/reserved IP addresses" } + ? { + safe: false, + error: "Cannot fetch from private/reserved IP addresses", + } : { safe: true }; } diff --git a/src/routes/auth.ts b/src/routes/auth.ts index e9eb3a8..c40fa76 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -46,10 +46,7 @@ export async function registerOptions(req: Request): Promise { return Response.json({ error: "Username required" }, { status: 400 }); } - if ( - username.length > 128 || - !/^[A-Za-z0-9._@-]+$/.test(username) - ) { + if (username.length > 128 || !/^[A-Za-z0-9._@-]+$/.test(username)) { return Response.json( { error: "Invalid username format" }, { status: 400 }, @@ -184,10 +181,7 @@ export async function registerVerify(req: Request): Promise { ); } - if ( - username.length > 128 || - !/^[A-Za-z0-9._@-]+$/.test(username) - ) { + if (username.length > 128 || !/^[A-Za-z0-9._@-]+$/.test(username)) { return Response.json( { error: "Invalid username format" }, { status: 400 }, @@ -697,11 +691,7 @@ export async function loginVerify(req: Request): Promise { // Update credential counter db.query( "UPDATE credentials SET counter = ? WHERE user_id = ? AND credential_id = ?", - ).run( - newCounter, - user.id, - credential.credential_id, - ); + ).run(newCounter, user.id, credential.credential_id); // Create session const token = crypto.randomUUID(); diff --git a/src/routes/oauth/authorize.ts b/src/routes/oauth/authorize.ts index 297a27b..34de084 100644 --- a/src/routes/oauth/authorize.ts +++ b/src/routes/oauth/authorize.ts @@ -2,13 +2,13 @@ import crypto from "node:crypto"; import { db } from "../../db"; import { ensureApp } from "../../lib/oauth/client-metadata"; import { consentPage, errorPage, escapeHtml } from "../../lib/oauth/pages"; -import { canonicalizeURL } from "../../lib/oauth/urls"; import { - resourceDisplay, type ResourceInfo, + resourceDisplay, resourcesToStored, validateResources, } from "../../lib/oauth/resource"; +import { canonicalizeURL } from "../../lib/oauth/urls"; import { getCsrfToken, getUserFromCookie } from "../../lib/session"; import { token } from "./token"; @@ -288,7 +288,9 @@ async function showConsentScreen( // Resolve the requested resources to friendly name + icon (via their PRM) so // the consent screen shows WHAT kloe is being granted access to, not a URL. - const resourceInfos: ResourceInfo[] = resources.length ? await resourceDisplay(resources) : []; + const resourceInfos: ResourceInfo[] = resources.length + ? await resourceDisplay(resources) + : []; return consentPage({ username: user.username, @@ -408,7 +410,9 @@ export async function authorizePost(req: Request): Promise { // Resource indicators carried through the consent form (hidden fields). // Re-validate — the POST body is attacker-controllable. - const approvedResources = validateResources(formData.getAll("resource") as string[]); + const approvedResources = validateResources( + formData.getAll("resource") as string[], + ); if (approvedResources === null) { return new Response("invalid_target", { status: 400 }); } diff --git a/src/routes/oauth/device.ts b/src/routes/oauth/device.ts index d0e9aef..118597e 100644 --- a/src/routes/oauth/device.ts +++ b/src/routes/oauth/device.ts @@ -75,7 +75,11 @@ export async function deviceAuthorization(req: Request): Promise { // RFC 8707 resource indicator (device flow carries it from the start). const resources = validateResources(rawResource ? [rawResource] : []); if (resources === null) { - return oauthError(400, "invalid_target", "resource must be an absolute URI without a fragment"); + return oauthError( + 400, + "invalid_target", + "resource must be an absolute URI without a fragment", + ); } const resourceStored = resourcesToStored(resources); diff --git a/src/routes/oauth/token.ts b/src/routes/oauth/token.ts index 636bf15..5c36368 100644 --- a/src/routes/oauth/token.ts +++ b/src/routes/oauth/token.ts @@ -354,7 +354,9 @@ async function handleDeviceCodeGrant( db.query("DELETE FROM device_codes WHERE id = ?").run(deviceCode.id); const user = db - .query("SELECT username, name, email, photo, url, status FROM users WHERE id = ?") + .query( + "SELECT username, name, email, photo, url, status FROM users WHERE id = ?", + ) .get(deviceCode.user_id) as | { username: string; @@ -590,7 +592,9 @@ async function handleAuthorizationCodeGrant( } const user = db - .query("SELECT username, name, email, photo, url, status FROM users WHERE id = ?") + .query( + "SELECT username, name, email, photo, url, status FROM users WHERE id = ?", + ) .get(authcode.user_id) as | { username: string; @@ -788,8 +792,15 @@ export async function tokenIntrospect(req: Request): Promise { // RFC 8707 / 7662: echo the token's audience so a resource server can // confirm the token was minted for it. One resource → a string, several → // an array; omitted entirely when the token is unscoped. - const resources = tokenData.resource ? tokenData.resource.split(" ").filter(Boolean) : []; - const aud = resources.length === 1 ? resources[0] : resources.length > 1 ? resources : undefined; + const resources = tokenData.resource + ? tokenData.resource.split(" ").filter(Boolean) + : []; + const aud = + resources.length === 1 + ? resources[0] + : resources.length > 1 + ? resources + : undefined; return Response.json({ active: true, diff --git a/test/cleanup.test.ts b/test/cleanup.test.ts index f5b50da..5166705 100644 --- a/test/cleanup.test.ts +++ b/test/cleanup.test.ts @@ -127,7 +127,15 @@ describe("sweepExpiredRecords", () => { ).run("sess-dead", userId, now - 10); db.query( "INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, used) VALUES (?, ?, ?, ?, ?, ?, ?, 0)", - ).run("code-dead", userId, CLIENT_ID, `${CLIENT_ID}callback`, "[]", "c", now - 10); + ).run( + "code-dead", + userId, + CLIENT_ID, + `${CLIENT_ID}callback`, + "[]", + "c", + now - 10, + ); db.query( "INSERT INTO device_codes (device_code, user_code, client_id, scope, expires_at) VALUES (?, ?, ?, ?, ?)", ).run("dc-dead", "UC-DEAD", CLIENT_ID, "profile", now - 10);