From 0a966e32c7ce36ed8063df11c43723ea31546d1e Mon Sep 17 00:00:00 2001 From: avycado13 <108358183+avycado13@users.noreply.github.com> Date: Sun, 4 Jan 2026 19:47:02 +0530 Subject: [PATCH] feat: add ldap account syncing (if you delete in ldap will not update in indiko) --- .env.example | 8 + CRUSH.md | 6 + SPEC.md | 57 ++++ bun.lock | 27 ++ package.json | 1 + src/client/admin-invites.ts | 10 +- src/client/docs.ts | 14 +- src/client/index.ts | 47 ++- src/client/login.ts | 135 ++++++++- src/html/login.html | 33 +- src/index.ts | 30 +- .../007_add_username_to_authcodes.sql | 2 + .../008_add_ldap_username_to_invites.sql | 4 + src/routes/api.ts | 22 +- src/routes/auth.ts | 156 +++++++++- src/routes/clients.ts | 4 +- src/routes/indieauth.ts | 281 +++++++++++++----- src/routes/passkeys.ts | 8 +- src/styles.css | 1 + 19 files changed, 711 insertions(+), 135 deletions(-) create mode 100644 src/migrations/007_add_username_to_authcodes.sql create mode 100644 src/migrations/008_add_ldap_username_to_invites.sql diff --git a/.env.example b/.env.example index c9ddb78..6a8f85b 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,11 @@ RP_ID=indiko.dunkirk.sh PORT=3000 NODE_ENV="production" DATABASE_URL=data/indiko.db + +# LDAP Configuration (optional) +LDAP_ENABLED=false +LDAP_URL=ldap://localhost:389 +LDAP_ADMIN_DN=cn=admin,dc=example,dc=com +LDAP_ADMIN_PASSWORD=your_admin_password +LDAP_USER_SEARCH_BASE=dc=example,dc=com +LDAP_USERNAME_ATTRIBUTE=uid diff --git a/CRUSH.md b/CRUSH.md index 2f38f3e..9308ee1 100644 --- a/CRUSH.md +++ b/CRUSH.md @@ -9,6 +9,7 @@ ## Architecture Patterns ### Route Organization + - Use separate route files in `src/routes/` directory - Export handler functions that accept `Request` and return `Response` - Import handlers in `src/index.ts` and wire them in the `routes` object @@ -17,6 +18,7 @@ - IndieAuth/OAuth 2.0 endpoints in `src/routes/indieauth.ts` ### Project Structure + ``` src/ ├── db.ts # Database setup and exports @@ -40,6 +42,7 @@ src/ ``` ### Client-Side Code + - Extract JavaScript from HTML into separate TypeScript modules in `src/client/` - Import client modules into HTML with `` - Bun will bundle the imports automatically @@ -47,6 +50,7 @@ src/ - In HTML files: use paths relative to server root (e.g., `/logo.svg`, `/favicon.svg`) since Bun bundles HTML and resolves paths from server context ### IndieAuth/OAuth 2.0 Implementation + - Full IndieAuth server supporting OAuth 2.0 with PKCE - Authorization code flow with single-use, short-lived codes (60 seconds) - Auto-registration of client apps on first authorization @@ -59,6 +63,7 @@ src/ - **`me` parameter delegation**: When a client passes `me=https://example.com` in the authorization request and it matches the user's website URL, the token response returns that URL instead of the canonical `/u/{username}` URL ### Database Schema + - **users**: username, name, email, photo, url, status, role, tier, is_admin - **tier**: User access level - 'admin' (full access), 'developer' (can create apps), 'user' (can only authenticate with apps) - **is_admin**: Legacy flag, automatically synced with tier (1 if tier='admin', 0 otherwise) @@ -71,6 +76,7 @@ src/ - **invites**: admin-created invite codes ### WebAuthn/Passkey Settings + - **Registration**: residentKey="required", userVerification="required" - **Authentication**: omit allowCredentials to show all passkeys (discoverable credentials) - **Credential lookup**: credential_id stored as Buffer, compare using base64url string diff --git a/SPEC.md b/SPEC.md index 9812508..8a5a782 100644 --- a/SPEC.md +++ b/SPEC.md @@ -3,6 +3,7 @@ ## Overview **indiko** is a centralized authentication and user management system for personal projects. It provides: + - Passkey-based authentication (WebAuthn) - IndieAuth server implementation - User profile management @@ -12,12 +13,14 @@ ## Core Concepts ### Single Source of Truth + - Authentication via passkeys - User profiles (name, email, picture, URL) - Authorization with per-app scoping - User management (admin + invite system) ### Trust Model + - First user becomes admin - Admin can create invite links - Apps auto-register on first use @@ -30,6 +33,7 @@ Users are identified by: `https://indiko.yourdomain.com/u/{username}` ## Data Structures ### Users + ``` user:{username} -> { credential: { @@ -49,11 +53,13 @@ user:{username} -> { ``` ### Admin Marker + ``` admin:user -> username // marks first/admin user ``` ### Sessions + ``` session:{token} -> { username: string, @@ -63,6 +69,7 @@ session:{token} -> { ``` ### Apps (Auto-registered) + ``` app:{client_id} -> { client_id: string, // e.g. "https://blog.kierank.dev" @@ -74,6 +81,7 @@ app:{client_id} -> { ``` ### User Permissions (Per-App) + ``` permission:{username}:{client_id} -> { scopes: string[], // e.g. ["profile", "email"] @@ -83,6 +91,7 @@ permission:{username}:{client_id} -> { ``` ### Authorization Codes (Short-lived) + ``` authcode:{code} -> { username: string, @@ -98,6 +107,7 @@ authcode:{code} -> { ``` ### Invites + ``` invite:{code} -> { code: string, @@ -110,6 +120,7 @@ invite:{code} -> { ``` ### Challenges (WebAuthn) + ``` challenge:{challenge} -> { username: string, @@ -130,22 +141,26 @@ challenge:{challenge} -> { ### Authentication (WebAuthn/Passkey) #### `GET /login` + - Login/registration page - Shows passkey auth interface - First user: admin registration flow - With `?invite=CODE`: invite-based registration #### `GET /auth/can-register` + - Check if open registration allowed - Returns `{ canRegister: boolean }` #### `POST /auth/register/options` + - Generate WebAuthn registration options - Body: `{ username: string, inviteCode?: string }` - Validates invite code if not first user - Returns registration options #### `POST /auth/register/verify` + - Verify WebAuthn registration response - Body: `{ username: string, response: RegistrationResponseJSON, inviteCode?: string }` - Creates user, stores credential @@ -153,17 +168,20 @@ challenge:{challenge} -> { - Returns `{ token: string, username: string }` #### `POST /auth/login/options` + - Generate WebAuthn authentication options - Body: `{ username: string }` - Returns authentication options #### `POST /auth/login/verify` + - Verify WebAuthn authentication response - Body: `{ username: string, response: AuthenticationResponseJSON }` - Creates session - Returns `{ token: string, username: string }` #### `POST /auth/logout` + - Clear session - Requires: `Authorization: Bearer {token}` - Returns `{ success: true }` @@ -171,9 +189,11 @@ challenge:{challenge} -> { ### IndieAuth Endpoints #### `GET /auth/authorize` + Authorization request from client app **Query Parameters:** + - `response_type=code` (required) - `client_id` (required) - App's URL - `redirect_uri` (required) - Callback URL @@ -184,6 +204,7 @@ Authorization request from client app - `me` (optional) - User's URL (hint) **Flow:** + 1. Validate parameters 2. Auto-register app if not exists 3. If no session → redirect to `/login` @@ -193,14 +214,17 @@ Authorization request from client app - If no → show consent screen **Response:** + - HTML consent screen - Shows: app name, requested scopes - Buttons: "Allow" / "Deny" #### `POST /auth/authorize` + Consent form submission (CSRF protected) **Body:** + - `client_id` (required) - `redirect_uri` (required) - `state` (required) @@ -209,6 +233,7 @@ Consent form submission (CSRF protected) - `action` (required) - "allow" | "deny" **Flow:** + 1. Validate CSRF token 2. Validate session 3. If denied → redirect with error @@ -219,24 +244,29 @@ Consent form submission (CSRF protected) - Redirect to redirect_uri with code & state **Success Response:** + ``` HTTP/1.1 302 Found Location: {redirect_uri}?code={authcode}&state={state} ``` **Error Response:** + ``` HTTP/1.1 302 Found Location: {redirect_uri}?error=access_denied&state={state} ``` #### `POST /auth/token` + Exchange authorization code for user identity (NOT CSRF protected) **Headers:** + - `Content-Type: application/json` **Body:** + ```json { "grant_type": "authorization_code", @@ -248,6 +278,7 @@ Exchange authorization code for user identity (NOT CSRF protected) ``` **Flow:** + 1. Validate authorization code exists 2. Verify code not expired 3. Verify code not already used @@ -258,6 +289,7 @@ Exchange authorization code for user identity (NOT CSRF protected) 8. Return user identity + profile **Success Response:** + ```json { "me": "https://indiko.yourdomain.com/u/kieran", @@ -271,6 +303,7 @@ Exchange authorization code for user identity (NOT CSRF protected) ``` **Error Response:** + ```json { "error": "invalid_grant", @@ -279,12 +312,15 @@ Exchange authorization code for user identity (NOT CSRF protected) ``` #### `GET /auth/userinfo` (Optional) + Get current user profile with bearer token **Headers:** + - `Authorization: Bearer {access_token}` **Response:** + ```json { "sub": "https://indiko.yourdomain.com/u/kieran", @@ -298,18 +334,22 @@ Get current user profile with bearer token ### User Profile & Settings #### `GET /settings` + User settings page (requires session) **Shows:** + - Profile form (name, email, photo, URL) - Connected apps list - Revoke access buttons - (Admin only) Invite generation #### `POST /settings/profile` + Update user profile **Body:** + ```json { "name": "Kieran Klukas", @@ -320,6 +360,7 @@ Update user profile ``` **Response:** + ```json { "success": true, @@ -328,9 +369,11 @@ Update user profile ``` #### `POST /settings/apps/:client_id/revoke` + Revoke app access **Response:** + ```json { "success": true @@ -338,10 +381,12 @@ Revoke app access ``` #### `GET /u/:username` + Public user profile page (h-card) **Response:** HTML page with microformats h-card: + ```html
@@ -353,12 +398,15 @@ HTML page with microformats h-card: ### Admin Endpoints #### `POST /api/invites/create` + Create invite link (admin only) **Headers:** + - `Authorization: Bearer {token}` **Response:** + ```json { "inviteCode": "abc123xyz" @@ -370,9 +418,11 @@ Usage: `https://indiko.yourdomain.com/login?invite=abc123xyz` ### Dashboard #### `GET /` + Main dashboard (requires session) **Shows:** + - User info - Test API button - (Admin only) Admin controls section @@ -380,12 +430,15 @@ Main dashboard (requires session) - Invite display #### `GET /api/hello` + Test endpoint (requires session) **Headers:** + - `Authorization: Bearer {token}` **Response:** + ```json { "message": "Hello kieran! You're authenticated with passkeys.", @@ -397,6 +450,7 @@ Test endpoint (requires session) ## Session Behavior ### Single Sign-On + - Once logged into indiko (valid session), subsequent app authorization requests: - Skip passkey authentication - Show consent screen directly @@ -405,6 +459,7 @@ Test endpoint (requires session) - Passkey required only when session expires ### Security + - PKCE required for all authorization flows - Authorization codes: - Single-use only @@ -415,6 +470,7 @@ Test endpoint (requires session) ## Client Integration Example ### 1. Initiate Authorization + ```javascript const params = new URLSearchParams({ response_type: 'code', @@ -430,6 +486,7 @@ window.location.href = `https://indiko.yourdomain.com/auth/authorize?${params}`; ``` ### 2. Handle Callback + ```javascript // At https://blog.kierank.dev/auth/callback?code=...&state=... const code = new URLSearchParams(window.location.search).get('code'); diff --git a/bun.lock b/bun.lock index 9c06ffc..71ac94d 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/server": "^13.2.2", "bun-sqlite-migrations": "^1.0.2", + "ldap-authentication": "^3.3.6", "nanoid": "^5.1.6", }, "devDependencies": { @@ -54,24 +55,44 @@ "@simplewebauthn/types": ["@simplewebauthn/types@12.0.0", "", {}, "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA=="], + "@types/asn1": ["@types/asn1@0.2.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA=="], + "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], "@types/node": ["@types/node@25.0.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-rl78HwuZlaDIUSeUKkmogkhebA+8K1Hy7tddZuJ3D0xV8pZSfsYGTsliGUol1JPzu9EKnTxPC4L1fiWouStRew=="], + "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], + "asn1js": ["asn1js@3.0.7", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="], "bun-sqlite-migrations": ["bun-sqlite-migrations@1.0.2", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-WLw8q67KM+1RN7o4DqVVhmJASypuBp8fygrfA8QD5HZEjiP+E5hD1SV2dpyB7A4tFqLdUF8cdln7+Ptj5+Hz1Q=="], "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], + "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], + + "ldap-authentication": ["ldap-authentication@3.3.6", "", { "dependencies": { "ldapts": "^7.3.1" } }, "sha512-j8XxH5wGXhIQ3mMnoRCZTalSLmPhzEaGH4+5RIFP0Higc32fCKDRJBo+9wb5ysy9TnlaNtaf+rgdwYCD15OBpQ=="], + + "ldapts": ["ldapts@7.4.0", "", { "dependencies": { "@types/asn1": ">=0.2.4", "asn1": "0.2.6", "debug": "4.4.0", "strict-event-emitter-types": "2.0.0", "uuid": "11.1.0", "whatwg-url": "14.2.0" } }, "sha512-QLgx2pLvxMXY1nCc85Fx+cwVJDvC0sQ3l4CJZSl1FJ/iV8Ypfl6m+5xz4lm1lhoXcUlvhPqxEoyIj/8LR6ut+A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "strict-event-emitter-types": ["strict-event-emitter-types@2.0.0", "", {}, "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA=="], + + "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], @@ -80,6 +101,12 @@ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + + "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], + "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], } } diff --git a/package.json b/package.json index 5b560dc..ec991a8 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/server": "^13.2.2", "bun-sqlite-migrations": "^1.0.2", + "ldap-authentication": "^3.3.6", "nanoid": "^5.1.6" } } diff --git a/src/client/admin-invites.ts b/src/client/admin-invites.ts index 61aaeca..28ddb53 100644 --- a/src/client/admin-invites.ts +++ b/src/client/admin-invites.ts @@ -171,7 +171,9 @@ async function submitCreateInvite() { "submitInviteBtn", ) as HTMLButtonElement; - const maxUses = maxUsesInput.value ? parseInt(maxUsesInput.value, 10) : 1; + const maxUses = maxUsesInput.value + ? Number.parseInt(maxUsesInput.value, 10) + : 1; const expiresAt = expiresAtInput.value ? Math.floor(new Date(expiresAtInput.value).getTime() / 1000) : null; @@ -187,7 +189,7 @@ async function submitCreateInvite() { 'input[name="appRole"]:checked', ); checkedBoxes.forEach((checkbox) => { - const appId = parseInt((checkbox as HTMLInputElement).value, 10); + const appId = Number.parseInt((checkbox as HTMLInputElement).value, 10); const roleSelect = appRolesContainer.querySelector( `select.role-select[data-app-id="${appId}"]`, ) as HTMLSelectElement; @@ -507,7 +509,9 @@ let currentEditInviteId: number | null = null; "submitEditInviteBtn", ) as HTMLButtonElement; - const maxUses = maxUsesInput.value ? parseInt(maxUsesInput.value, 10) : null; + const maxUses = maxUsesInput.value + ? Number.parseInt(maxUsesInput.value, 10) + : null; const expiresAt = expiresAtInput.value ? Math.floor(new Date(expiresAtInput.value).getTime() / 1000) : null; diff --git a/src/client/docs.ts b/src/client/docs.ts index 99afdf9..fe4bd25 100644 --- a/src/client/docs.ts +++ b/src/client/docs.ts @@ -48,7 +48,7 @@ function highlightHTMLCSS(code: string): string { ); } - result += attrs + ">"; + result += `${attrs}>`; return result; }, ); @@ -462,16 +462,16 @@ function processElement(el: Element, lines: string[], indent = 0): void { const rows: string[][] = []; // Get headers - el.querySelectorAll("thead th").forEach((th) => { + for (const th of el.querySelectorAll("thead th")) { headers.push(th.textContent?.trim() || ""); - }); + } // Get rows el.querySelectorAll("tbody tr").forEach((tr) => { const row: string[] = []; - tr.querySelectorAll("td").forEach((td) => { + for (const td of tr.querySelectorAll("td")) { row.push(td.textContent?.trim() || ""); - }); + } rows.push(row); }); @@ -479,9 +479,9 @@ function processElement(el: Element, lines: string[], indent = 0): void { if (headers.length > 0) { lines.push(`| ${headers.join(" | ")} |`); lines.push(`|${headers.map(() => "-------").join("|")}|`); - rows.forEach((row) => { + for (const row of rows) { lines.push(`| ${row.join(" | ")} |`); - }); + } lines.push(""); } } diff --git a/src/client/index.ts b/src/client/index.ts index 8f0eb3d..3f82863 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,6 +1,4 @@ -import { - startRegistration, -} from "@simplewebauthn/browser"; +import { startRegistration } from "@simplewebauthn/browser"; const token = localStorage.getItem("indiko_session"); const footer = document.getElementById("footer") as HTMLElement; @@ -8,7 +6,9 @@ const welcome = document.getElementById("welcome") as HTMLElement; const subtitle = document.getElementById("subtitle") as HTMLElement; const recentApps = document.getElementById("recentApps") as HTMLElement; const passkeysList = document.getElementById("passkeysList") as HTMLElement; -const addPasskeyBtn = document.getElementById("addPasskeyBtn") as HTMLButtonElement; +const addPasskeyBtn = document.getElementById( + "addPasskeyBtn", +) as HTMLButtonElement; const toast = document.getElementById("toast") as HTMLElement; // Profile form elements @@ -320,13 +320,16 @@ async function loadPasskeys() { const passkeys = data.passkeys as Passkey[]; if (passkeys.length === 0) { - passkeysList.innerHTML = '
No passkeys registered
'; + passkeysList.innerHTML = + '
No passkeys registered
'; return; } passkeysList.innerHTML = passkeys .map((passkey) => { - const createdDate = new Date(passkey.created_at * 1000).toLocaleDateString(); + const createdDate = new Date( + passkey.created_at * 1000, + ).toLocaleDateString(); return `
@@ -336,7 +339,7 @@ async function loadPasskeys() {
- ${passkeys.length > 1 ? `` : ''} + ${passkeys.length > 1 ? `` : ""}
`; @@ -365,7 +368,9 @@ async function loadPasskeys() { } function showRenameForm(passkeyId: number) { - const passkeyItem = document.querySelector(`[data-passkey-id="${passkeyId}"]`); + const passkeyItem = document.querySelector( + `[data-passkey-id="${passkeyId}"]`, + ); if (!passkeyItem) return; const infoDiv = passkeyItem.querySelector(".passkey-info"); @@ -389,14 +394,18 @@ function showRenameForm(passkeyId: number) { input.select(); // Save button - infoDiv.querySelector(".save-rename-btn")?.addEventListener("click", async () => { - await renamePasskeyHandler(passkeyId, input.value); - }); + infoDiv + .querySelector(".save-rename-btn") + ?.addEventListener("click", async () => { + await renamePasskeyHandler(passkeyId, input.value); + }); // Cancel button - infoDiv.querySelector(".cancel-rename-btn")?.addEventListener("click", () => { - loadPasskeys(); - }); + infoDiv + .querySelector(".cancel-rename-btn") + ?.addEventListener("click", () => { + loadPasskeys(); + }); // Enter to save input.addEventListener("keypress", async (e) => { @@ -443,7 +452,11 @@ async function renamePasskeyHandler(passkeyId: number, newName: string) { } async function deletePasskeyHandler(passkeyId: number) { - if (!confirm("Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.")) { + if ( + !confirm( + "Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.", + ) + ) { return; } @@ -496,7 +509,9 @@ addPasskeyBtn.addEventListener("click", async () => { addPasskeyBtn.textContent = "verifying..."; // Ask for a name - const name = prompt("Give this passkey a name (e.g., 'iPhone', 'Work Laptop'):"); + const name = prompt( + "Give this passkey a name (e.g., 'iPhone', 'Work Laptop'):", + ); // Verify registration const verifyRes = await fetch("/api/passkeys/add/verify", { diff --git a/src/client/login.ts b/src/client/login.ts index f409dd4..c0c9906 100644 --- a/src/client/login.ts +++ b/src/client/login.ts @@ -5,8 +5,11 @@ import { const loginForm = document.getElementById("loginForm") as HTMLFormElement; const registerForm = document.getElementById("registerForm") as HTMLFormElement; +const ldapForm = document.getElementById("ldapForm") as HTMLFormElement; const message = document.getElementById("message") as HTMLDivElement; +let pendingLdapUsername: string | null = null; + // Check if registration is allowed on page load async function checkRegistrationAllowed() { try { @@ -15,12 +18,19 @@ async function checkRegistrationAllowed() { const inviteCode = urlParams.get("invite"); if (inviteCode) { + // Check if username is locked (from LDAP flow) + const lockedUsername = urlParams.get("username"); + const registerUsernameInput = document.getElementById( + "registerUsername", + ) as HTMLInputElement; + // Fetch invite details to show message try { + const testUsername = lockedUsername || "temp"; const response = await fetch("/auth/register/options", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ username: "temp", inviteCode }), + body: JSON.stringify({ username: testUsername, inviteCode }), }); if (response.ok) { @@ -38,9 +48,17 @@ async function checkRegistrationAllowed() { if (subtitleElement) { subtitleElement.textContent = "create your account"; } - ( - document.getElementById("registerUsername") as HTMLInputElement - ).placeholder = "choose username"; + + // If username is locked from LDAP, pre-fill and disable + if (lockedUsername) { + registerUsernameInput.value = lockedUsername; + registerUsernameInput.readOnly = true; + registerUsernameInput.style.opacity = "0.7"; + registerUsernameInput.style.cursor = "not-allowed"; + } else { + registerUsernameInput.placeholder = "choose username"; + } + ( document.getElementById("registerBtn") as HTMLButtonElement ).textContent = "create account"; @@ -112,6 +130,14 @@ loginForm.addEventListener("submit", async (e) => { const options = await optionsRes.json(); + // Check if LDAP verification is required (user exists in LDAP but not locally) + if (options.ldapVerificationRequired) { + showLdapPasswordPrompt(options.username); + loginBtn.disabled = false; + loginBtn.textContent = "sign in"; + return; + } + loginBtn.textContent = "use your passkey..."; // Start authentication @@ -212,8 +238,14 @@ registerForm.addEventListener("submit", async (e) => { showMessage("Registration successful!", "success"); - // Check for return URL parameter - const returnUrl = urlParams.get("return") || "/"; + // Check for return URL: first sessionStorage (from LDAP flow), then URL param, fallback to / + const storedRedirect = sessionStorage.getItem("postRegistrationRedirect"); + const returnUrl = storedRedirect || urlParams.get("return") || "/"; + + // Clear the stored redirect after use + if (storedRedirect) { + sessionStorage.removeItem("postRegistrationRedirect"); + } const redirectTimer = setTimeout(() => { window.location.href = returnUrl; @@ -225,3 +257,94 @@ registerForm.addEventListener("submit", async (e) => { registerBtn.textContent = "register passkey"; } }); + +// LDAP verification flow +function showLdapPasswordPrompt(username: string) { + pendingLdapUsername = username; + + // Update UI to show LDAP form + const subtitleElement = document.querySelector(".subtitle"); + if (subtitleElement) { + subtitleElement.textContent = "verify your LDAP password"; + } + + // Update LDAP form username display + const ldapUsernameSpan = document.getElementById("ldapUsername"); + if (ldapUsernameSpan) { + ldapUsernameSpan.textContent = username; + } + + // Show LDAP form, hide others + loginForm.style.display = "none"; + registerForm.style.display = "none"; + ldapForm.style.display = "block"; + + showMessage( + "This username exists in the linked LDAP directory. Enter your LDAP password to create your account.", + "success", + true, + ); +} + +ldapForm.addEventListener("submit", async (e) => { + e.preventDefault(); + + if (!pendingLdapUsername) { + showMessage("No username pending for LDAP verification"); + return; + } + + const password = (document.getElementById("ldapPassword") as HTMLInputElement) + .value; + const ldapBtn = document.getElementById("ldapBtn") as HTMLButtonElement; + + try { + ldapBtn.disabled = true; + ldapBtn.textContent = "verifying..."; + + // Get return URL for after registration + const urlParams = new URLSearchParams(window.location.search); + const returnUrl = urlParams.get("return") || "/"; + + // Verify LDAP credentials + const verifyRes = await fetch("/api/ldap-verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: pendingLdapUsername, + password: password, + returnUrl: returnUrl, + }), + }); + + if (!verifyRes.ok) { + const error = await verifyRes.json(); + throw new Error(error.error || "LDAP verification failed"); + } + + const result = await verifyRes.json(); + + if (result.success) { + showMessage( + "LDAP verification successful! Redirecting to setup...", + "success", + ); + + // Store return URL for after registration completes + if (result.returnUrl) { + sessionStorage.setItem("postRegistrationRedirect", result.returnUrl); + } + + // Redirect to registration with the invite code and locked username + const registerUrl = `/login?invite=${encodeURIComponent(result.inviteCode)}&username=${encodeURIComponent(result.username)}`; + + setTimeout(() => { + window.location.href = registerUrl; + }, 1000); + } + } catch (error) { + showMessage((error as Error).message || "LDAP verification failed"); + ldapBtn.disabled = false; + ldapBtn.textContent = "verify & continue"; + } +}); diff --git a/src/html/login.html b/src/html/login.html index f9cd6f5..530a066 100644 --- a/src/html/login.html +++ b/src/html/login.html @@ -49,10 +49,32 @@ margin-bottom: 1rem; } - input[type="text"] { + input[type="text"], + input[type="password"] { margin-bottom: 1rem; } + .ldap-user-display { + background: rgba(188, 141, 160, 0.1); + border-left: 3px solid var(--berry-crush); + padding: 0.75rem 1rem; + margin-bottom: 1rem; + text-align: left; + font-size: 0.875rem; + } + + .ldap-user-display .label { + color: var(--old-rose); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05rem; + } + + .ldap-user-display .username { + color: var(--lavender); + font-weight: 600; + } + button { width: 100%; padding: 1.25rem 2rem; @@ -95,6 +117,15 @@ autocomplete="username webauthn" /> + +
diff --git a/src/index.ts b/src/index.ts index d0ae032..9c3a541 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ import { env } from "bun"; import { db } from "./db"; -import adminHTML from "./html/admin.html"; import adminClientsHTML from "./html/admin-clients.html"; import adminInvitesHTML from "./html/admin-invites.html"; +import adminHTML from "./html/admin.html"; import appsHTML from "./html/apps.html"; import docsHTML from "./html/docs.html"; import indexHTML from "./html/index.html"; @@ -25,18 +25,12 @@ import { } from "./routes/api"; import { canRegister, + ldapVerify, loginOptions, loginVerify, registerOptions, registerVerify, } from "./routes/auth"; -import { - addPasskeyOptions, - addPasskeyVerify, - deletePasskey, - listPasskeys, - renamePasskey, -} from "./routes/passkeys"; import { createClient, deleteClient, @@ -61,6 +55,13 @@ import { userProfile, userinfo, } from "./routes/indieauth"; +import { + addPasskeyOptions, + addPasskeyVerify, + deletePasskey, + listPasskeys, + renamePasskey, +} from "./routes/passkeys"; (() => { const required = ["ORIGIN", "RP_ID"]; @@ -197,7 +198,7 @@ Policy: https://tangled.org/dunkirk.sh/indiko/blob/main/SECURITY.md if (req.method === "POST") { const url = new URL(req.url); const userId = url.pathname.split("/")[4]; - return disableUser(req, userId); + return disableUser(req, userId ? userId : ""); } return new Response("Method not allowed", { status: 405 }); }, @@ -205,7 +206,7 @@ Policy: https://tangled.org/dunkirk.sh/indiko/blob/main/SECURITY.md if (req.method === "POST") { const url = new URL(req.url); const userId = url.pathname.split("/")[4]; - return enableUser(req, userId); + return enableUser(req, userId ? userId : ""); } return new Response("Method not allowed", { status: 405 }); }, @@ -213,7 +214,7 @@ Policy: https://tangled.org/dunkirk.sh/indiko/blob/main/SECURITY.md if (req.method === "PUT") { const url = new URL(req.url); const userId = url.pathname.split("/")[4]; - return updateUserTier(req, userId); + return updateUserTier(req, userId ? userId : ""); } return new Response("Method not allowed", { status: 405 }); }, @@ -221,7 +222,7 @@ Policy: https://tangled.org/dunkirk.sh/indiko/blob/main/SECURITY.md if (req.method === "DELETE") { const url = new URL(req.url); const userId = url.pathname.split("/")[4]; - return deleteUser(req, userId); + return deleteUser(req, userId ? userId : ""); } return new Response("Method not allowed", { status: 405 }); }, @@ -253,6 +254,11 @@ Policy: https://tangled.org/dunkirk.sh/indiko/blob/main/SECURITY.md "/auth/register/verify": registerVerify, "/auth/login/options": loginOptions, "/auth/login/verify": loginVerify, + // LDAP verification endpoint + "/api/ldap-verify": (req: Request) => { + if (req.method === "POST") return ldapVerify(req); + return new Response("Method not allowed", { status: 405 }); + }, // Passkey management endpoints "/api/passkeys": (req: Request) => { if (req.method === "GET") return listPasskeys(req); diff --git a/src/migrations/007_add_username_to_authcodes.sql b/src/migrations/007_add_username_to_authcodes.sql new file mode 100644 index 0000000..7529693 --- /dev/null +++ b/src/migrations/007_add_username_to_authcodes.sql @@ -0,0 +1,2 @@ +-- Add username column to authcodes table for direct access without user_id lookup +ALTER TABLE authcodes ADD COLUMN username TEXT NOT NULL DEFAULT ''; diff --git a/src/migrations/008_add_ldap_username_to_invites.sql b/src/migrations/008_add_ldap_username_to_invites.sql new file mode 100644 index 0000000..32c542c --- /dev/null +++ b/src/migrations/008_add_ldap_username_to_invites.sql @@ -0,0 +1,4 @@ +-- Add ldap_username column to invites table +-- When set, the invite can only be used by a user with that exact username +-- Used for LDAP-verified user provisioning flow +ALTER TABLE invites ADD COLUMN ldap_username TEXT DEFAULT NULL; diff --git a/src/routes/api.ts b/src/routes/api.ts index e559aac..4753b9c 100644 --- a/src/routes/api.ts +++ b/src/routes/api.ts @@ -1,9 +1,11 @@ import { db } from "../db"; -import { verifyDomain, validateProfileURL } from "./indieauth"; +import { validateProfileURL, verifyDomain } from "./indieauth"; function getSessionUser( req: Request, -): { username: string; userId: number; is_admin: boolean; tier: string } | Response { +): + | { username: string; userId: number; is_admin: boolean; tier: string } + | Response { const authHeader = req.headers.get("Authorization"); if (!authHeader || !authHeader.startsWith("Bearer ")) { @@ -193,7 +195,10 @@ export async function updateProfile(req: Request): Promise { const origin = process.env.ORIGIN || "http://localhost:3000"; const indikoProfileUrl = `${origin}/u/${user.username}`; - const verification = await verifyDomain(validation.canonicalUrl!, indikoProfileUrl); + const verification = await verifyDomain( + validation.canonicalUrl!, + indikoProfileUrl, + ); if (!verification.success) { return Response.json( { error: verification.error || "Failed to verify domain" }, @@ -456,7 +461,7 @@ export function disableUser(req: Request, userId: string): Response { } // Prevent disabling self - if (targetUserId === user.id) { + if (targetUserId === user.userId) { return Response.json( { error: "Cannot disable your own account" }, { status: 400 }, @@ -508,7 +513,10 @@ export function enableUser(req: Request, userId: string): Response { return Response.json({ success: true }); } -export async function updateUserTier(req: Request, userId: string): Promise { +export async function updateUserTier( + req: Request, + userId: string, +): Promise { const user = getSessionUser(req); if (user instanceof Response) { return user; @@ -536,7 +544,9 @@ export async function updateUserTier(req: Request, userId: string): Promise { // Validate invite code const invite = db .query( - "SELECT id, max_uses, current_uses, expires_at, message FROM invites WHERE code = ?", + "SELECT id, max_uses, current_uses, expires_at, message, ldap_username FROM invites WHERE code = ?", ) .get(inviteCode) as | { @@ -75,6 +76,7 @@ export async function registerOptions(req: Request): Promise { current_uses: number; expires_at: number | null; message: string | null; + ldap_username: string | null; } | undefined; @@ -94,6 +96,14 @@ export async function registerOptions(req: Request): Promise { ); } + // If invite is locked to an LDAP username, enforce it + if (invite.ldap_username && invite.ldap_username !== username) { + return Response.json( + { error: "Username must match LDAP account" }, + { status: 400 }, + ); + } + // Store invite message to return with options inviteMessage = invite.message; } @@ -160,7 +170,10 @@ export async function registerVerify(req: Request): Promise { ); } - // Verify challenge exists and is valid + if (!expectedChallenge) { + return Response.json({ error: "Invalid challenge" }, { status: 400 }); + } + const challenge = db .query( "SELECT challenge, expires_at FROM challenges WHERE challenge = ? AND username = ? AND type = 'registration'", @@ -198,7 +211,7 @@ export async function registerVerify(req: Request): Promise { const invite = db .query( - "SELECT id, max_uses, current_uses, expires_at FROM invites WHERE code = ?", + "SELECT id, max_uses, current_uses, expires_at, ldap_username FROM invites WHERE code = ?", ) .get(inviteCode) as | { @@ -206,6 +219,7 @@ export async function registerVerify(req: Request): Promise { max_uses: number; current_uses: number; expires_at: number | null; + ldap_username: string | null; } | undefined; @@ -225,6 +239,14 @@ export async function registerVerify(req: Request): Promise { ); } + // If invite is locked to an LDAP username, enforce it + if (invite.ldap_username && invite.ldap_username !== username) { + return Response.json( + { error: "Username must match LDAP account" }, + { status: 400 }, + ); + } + inviteId = invite.id; // Get app role assignments for this invite @@ -239,8 +261,8 @@ export async function registerVerify(req: Request): Promise { verification = await verifyRegistrationResponse({ response, expectedChallenge: challenge.challenge, - expectedOrigin: process.env.ORIGIN!, - expectedRPID: process.env.RP_ID!, + expectedOrigin: process.env.ORIGIN ? process.env.ORIGIN : "", + expectedRPID: process.env.RP_ID ? process.env.RP_ID : "", }); } catch (error) { console.error("WebAuthn verification failed:", error); @@ -352,6 +374,13 @@ export async function loginOptions(req: Request): Promise { .get(username) as { id: number; status: string } | undefined; if (!user) { + // Check if LDAP is enabled - if so, user may exist in LDAP and need to register + if (process.env.LDAP_ENABLED === "true") { + return Response.json({ + ldapVerificationRequired: true, + username: username, + }); + } return Response.json({ error: "User not found" }, { status: 404 }); } @@ -471,8 +500,8 @@ export async function loginVerify(req: Request): Promise { expectedOrigin: process.env.ORIGIN!, expectedRPID: process.env.RP_ID!, credential: { - id: credential.credential_id, - publicKey: credential.public_key, + id: credential.credential_id.toString(), + publicKey: new Uint8Array(credential.public_key), counter: credential.counter, }, }); @@ -525,3 +554,112 @@ export async function loginVerify(req: Request): Promise { return Response.json({ error: "Internal server error" }, { status: 500 }); } } + +export async function ldapVerify(req: Request): Promise { + try { + const body = await req.json(); + const { username, password, returnUrl } = body as { + username: string; + password: string; + returnUrl?: string; + }; + + if (!username || !password) { + return Response.json( + { error: "Username and password required" }, + { status: 400 }, + ); + } + + // Verify user doesn't already exist locally (race condition check) + const existingUser = db + .query("SELECT id FROM users WHERE username = ?") + .get(username); + + if (existingUser) { + return Response.json( + { error: "Account already exists. Please use passkey login." }, + { status: 400 }, + ); + } + + // Attempt LDAP bind WITH password verification + let ldapUser: unknown; + try { + ldapUser = await authenticate({ + ldapOpts: { + url: process.env.LDAP_URL || "ldap://localhost:389", + }, + adminDn: process.env.LDAP_ADMIN_DN, + adminPassword: process.env.LDAP_ADMIN_PASSWORD, + userSearchBase: + process.env.LDAP_USER_SEARCH_BASE || "dc=example,dc=com", + usernameAttribute: process.env.LDAP_USERNAME_ATTRIBUTE || "uid", + username: username, + userPassword: password, + }); + } catch (ldapError) { + console.error("LDAP verification failed:", ldapError); + return Response.json({ error: "Invalid credentials" }, { status: 401 }); + } + + if (!ldapUser) { + return Response.json({ error: "Invalid credentials" }, { status: 401 }); + } + + // LDAP auth succeeded - create single-use invite locked to this username + const inviteCode = crypto.randomUUID(); + const expiresAt = Math.floor(Date.now() / 1000) + 600; // 10 minutes + + // Get an admin user to be the creator (required by NOT NULL constraint) + const adminUser = db + .query("SELECT id FROM users WHERE is_admin = 1 LIMIT 1") + .get() as { id: number } | undefined; + + if (!adminUser) { + return Response.json( + { error: "System not configured for LDAP provisioning" }, + { status: 500 }, + ); + } + + // Create the LDAP invite (max_uses=1, tied to username) + db.query( + "INSERT INTO invites (code, max_uses, current_uses, expires_at, created_by, message, ldap_username) VALUES (?, 1, 0, ?, ?, ?, ?)", + ).run(inviteCode, expiresAt, adminUser.id, "LDAP-verified account", username); + + const newInviteId = db + .query("SELECT id FROM invites WHERE code = ?") + .get(inviteCode) as { id: number }; + + // Copy roles from most recent admin-created invite if exists + const defaultInvite = db + .query( + "SELECT id FROM invites WHERE created_by IN (SELECT id FROM users WHERE is_admin = 1) ORDER BY created_at DESC LIMIT 1", + ) + .get() as { id: number } | undefined; + + if (defaultInvite) { + const inviteRoles = db + .query("SELECT app_id, role FROM invite_roles WHERE invite_id = ?") + .all(defaultInvite.id) as Array<{ app_id: number; role: string }>; + + const insertRole = db.query( + "INSERT INTO invite_roles (invite_id, app_id, role) VALUES (?, ?, ?)", + ); + for (const { app_id, role } of inviteRoles) { + insertRole.run(newInviteId.id, app_id, role); + } + } + + return Response.json({ + success: true, + inviteCode: inviteCode, + username: username, + returnUrl: returnUrl || null, + }); + } catch (error) { + console.error("LDAP verify error:", error); + return Response.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/routes/clients.ts b/src/routes/clients.ts index d752f92..e151484 100644 --- a/src/routes/clients.ts +++ b/src/routes/clients.ts @@ -16,7 +16,9 @@ function generateClientId(): string { function getSessionUser( req: Request, -): { username: string; userId: number; is_admin: boolean; tier: string } | Response { +): + | { username: string; userId: number; is_admin: boolean; tier: string } + | Response { const authHeader = req.headers.get("Authorization"); if (!authHeader || !authHeader.startsWith("Bearer ")) { diff --git a/src/routes/indieauth.ts b/src/routes/indieauth.ts index 033144e..06627f4 100644 --- a/src/routes/indieauth.ts +++ b/src/routes/indieauth.ts @@ -1,4 +1,4 @@ -import crypto from "crypto"; +import crypto from "node:crypto"; import { db } from "../db"; interface SessionUser { @@ -53,6 +53,7 @@ function getSessionUser(req: Request): SessionUser | Response { username: session.username, userId: session.id, isAdmin: session.is_admin === 1, + tier: session.tier, }; } @@ -68,7 +69,7 @@ function getUserFromCookie(req: Request): SessionUser | null { }), ); - const sessionToken = cookies["indiko_session"]; + const sessionToken = cookies.indiko_session; if (!sessionToken) return null; const session = db @@ -127,7 +128,11 @@ function canonicalizeURL(urlString: string): string { } // Validate profile URL per IndieAuth spec -export function validateProfileURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } { +export function validateProfileURL(urlString: string): { + valid: boolean; + error?: string; + canonicalUrl?: string; +} { let url: URL; try { url = new URL(urlString); @@ -152,7 +157,10 @@ export function validateProfileURL(urlString: string): { valid: boolean; error?: // MUST NOT contain username/password if (url.username || url.password) { - return { valid: false, error: "Profile URL must not contain username or password" }; + return { + valid: false, + error: "Profile URL must not contain username or password", + }; } // MUST NOT contain ports @@ -164,20 +172,30 @@ export function validateProfileURL(urlString: string): { valid: boolean; error?: const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; const ipv6Regex = /^\[?[0-9a-fA-F:]+\]?$/; if (ipv4Regex.test(url.hostname) || ipv6Regex.test(url.hostname)) { - return { valid: false, error: "Profile URL must use domain names, not IP addresses" }; + return { + valid: false, + error: "Profile URL must use domain names, not IP addresses", + }; } // MUST NOT contain single-dot or double-dot path segments const pathSegments = url.pathname.split("/"); if (pathSegments.includes(".") || pathSegments.includes("..")) { - return { valid: false, error: "Profile URL must not contain . or .. path segments" }; + return { + valid: false, + error: "Profile URL must not contain . or .. path segments", + }; } return { valid: true, canonicalUrl: canonicalizeURL(urlString) }; } // Validate client URL per IndieAuth spec -function validateClientURL(urlString: string): { valid: boolean; error?: string; canonicalUrl?: string } { +function validateClientURL(urlString: string): { + valid: boolean; + error?: string; + canonicalUrl?: string; +} { let url: URL; try { url = new URL(urlString); @@ -202,13 +220,19 @@ function validateClientURL(urlString: string): { valid: boolean; error?: string; // MUST NOT contain username/password if (url.username || url.password) { - return { valid: false, error: "Client URL must not contain username or password" }; + return { + valid: false, + error: "Client URL must not contain username or password", + }; } // MUST NOT contain single-dot or double-dot path segments const pathSegments = url.pathname.split("/"); if (pathSegments.includes(".") || pathSegments.includes("..")) { - return { valid: false, error: "Client URL must not contain . or .. path segments" }; + return { + valid: false, + error: "Client URL must not contain . or .. path segments", + }; } // MAY use loopback interface, but not other IP addresses @@ -217,13 +241,21 @@ function validateClientURL(urlString: string): { valid: boolean; error?: string; if (ipv4Regex.test(url.hostname)) { // Allow 127.0.0.1 (loopback), reject others if (!url.hostname.startsWith("127.")) { - return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" }; + return { + valid: false, + error: + "Client URL must use domain names, not IP addresses (except loopback)", + }; } } else if (ipv6Regex.test(url.hostname)) { // Allow ::1 (loopback), reject others const ipv6Match = url.hostname.match(ipv6Regex); if (ipv6Match && ipv6Match[1] !== "::1") { - return { valid: false, error: "Client URL must use domain names, not IP addresses (except loopback)" }; + return { + valid: false, + error: + "Client URL must use domain names, not IP addresses (except loopback)", + }; } } @@ -234,7 +266,12 @@ function validateClientURL(urlString: string): { valid: boolean; error?: string; function isLoopbackURL(urlString: string): boolean { try { const url = new URL(urlString); - return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname.startsWith("127."); + return ( + url.hostname === "localhost" || + url.hostname === "127.0.0.1" || + url.hostname === "[::1]" || + url.hostname.startsWith("127.") + ); } catch { return false; } @@ -254,7 +291,10 @@ async function fetchClientMetadata(clientId: string): Promise<{ }> { // MUST NOT fetch loopback addresses (security requirement) if (isLoopbackURL(clientId)) { - return { success: false, error: "Cannot fetch metadata from loopback addresses" }; + return { + success: false, + error: "Cannot fetch metadata from loopback addresses", + }; } try { @@ -273,7 +313,10 @@ async function fetchClientMetadata(clientId: string): Promise<{ clearTimeout(timeoutId); if (!response.ok) { - return { success: false, error: `Failed to fetch client metadata: HTTP ${response.status}` }; + return { + success: false, + error: `Failed to fetch client metadata: HTTP ${response.status}`, + }; } const contentType = response.headers.get("content-type") || ""; @@ -284,7 +327,10 @@ async function fetchClientMetadata(clientId: string): Promise<{ // Verify client_id matches if (metadata.client_id && metadata.client_id !== clientId) { - return { success: false, error: "client_id in metadata does not match URL" }; + return { + success: false, + error: "client_id in metadata does not match URL", + }; } return { success: true, metadata }; @@ -295,19 +341,21 @@ async function fetchClientMetadata(clientId: string): Promise<{ const html = await response.text(); // Extract redirect URIs from link tags - const redirectUriRegex = /]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi; + const redirectUriRegex = + /]*rel=["']redirect_uri["'][^>]*href=["']([^"']+)["'][^>]*>/gi; const redirectUris: string[] = []; let match: RegExpExecArray | null; while ((match = redirectUriRegex.exec(html)) !== null) { - redirectUris.push(match[1]); + redirectUris.push(match[1] ? match[1] : ""); } // Also try reverse order (href before rel) - const redirectUriRegex2 = /]*href=["']([^"']+)["'][^>]*rel=["']redirect_uri["'][^>]*>/gi; + const redirectUriRegex2 = + /]*href=["']([^"']+)["'][^>]*rel=["']redirect_uri["'][^>]*>/gi; while ((match = redirectUriRegex2.exec(html)) !== null) { - if (!redirectUris.includes(match[1])) { - redirectUris.push(match[1]); + if (!redirectUris.includes(match[1] ? match[1] : "")) { + redirectUris.push(match[1] ? match[1] : ""); } } @@ -321,7 +369,10 @@ async function fetchClientMetadata(clientId: string): Promise<{ }; } - return { success: false, error: "No client metadata or redirect_uri links found in HTML" }; + return { + success: false, + error: "No client metadata or redirect_uri links found in HTML", + }; } return { success: false, error: "Unsupported content type" }; @@ -330,14 +381,20 @@ async function fetchClientMetadata(clientId: string): Promise<{ if (error.name === "AbortError") { return { success: false, error: "Timeout fetching client metadata" }; } - return { success: false, error: `Failed to fetch client metadata: ${error.message}` }; + return { + success: false, + error: `Failed to fetch client metadata: ${error.message}`, + }; } return { success: false, error: "Failed to fetch client metadata" }; } } // Verify domain has rel="me" link back to user profile -export async function verifyDomain(domainUrl: string, indikoProfileUrl: string): Promise<{ +export async function verifyDomain( + domainUrl: string, + indikoProfileUrl: string, +): Promise<{ success: boolean; error?: string; }> { @@ -359,12 +416,18 @@ export async function verifyDomain(domainUrl: string, indikoProfileUrl: string): if (!response.ok) { const errorBody = await response.text(); - console.error(`[verifyDomain] Failed to fetch ${domainUrl}: HTTP ${response.status}`, { - status: response.status, - contentType: response.headers.get("content-type"), - bodyPreview: errorBody.substring(0, 200), - }); - return { success: false, error: `Failed to fetch domain: HTTP ${response.status}` }; + console.error( + `[verifyDomain] Failed to fetch ${domainUrl}: HTTP ${response.status}`, + { + status: response.status, + contentType: response.headers.get("content-type"), + bodyPreview: errorBody.substring(0, 200), + }, + ); + return { + success: false, + error: `Failed to fetch domain: HTTP ${response.status}`, + }; } const html = await response.text(); @@ -384,7 +447,7 @@ export async function verifyDomain(domainUrl: string, indikoProfileUrl: string): const relValue = relMatch[1]; // Check if "me" is a separate word in the rel attribute - if (!relValue.split(/\s+/).includes("me")) return null; + if (!relValue?.split(/\s+/).includes("me")) return null; // Extract href (handle quoted and unquoted attributes) const hrefMatch = tagHtml.match(/href=["']?([^"'\s>]+)["']?/i); @@ -413,7 +476,7 @@ export async function verifyDomain(domainUrl: string, indikoProfileUrl: string): // Check if any rel="me" link matches the indiko profile URL const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl); - const hasRelMe = relMeLinks.some(link => { + const hasRelMe = relMeLinks.some((link) => { try { const normalizedLink = canonicalizeURL(link); return normalizedLink === normalizedIndikoUrl; @@ -423,10 +486,13 @@ export async function verifyDomain(domainUrl: string, indikoProfileUrl: string): }); if (!hasRelMe) { - console.error(`[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`, { - foundLinks: relMeLinks, - normalizedTarget: normalizedIndikoUrl, - }); + console.error( + `[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`, + { + foundLinks: relMeLinks, + normalizedTarget: normalizedIndikoUrl, + }, + ); return { success: false, error: `Domain must have or ... to verify ownership`, @@ -440,13 +506,22 @@ export async function verifyDomain(domainUrl: string, indikoProfileUrl: string): console.error(`[verifyDomain] Timeout verifying ${domainUrl}`); return { success: false, error: "Timeout verifying domain" }; } - console.error(`[verifyDomain] Error verifying ${domainUrl}: ${error.message}`, { - name: error.name, - stack: error.stack, - }); - return { success: false, error: `Failed to verify domain: ${error.message}` }; + console.error( + `[verifyDomain] Error verifying ${domainUrl}: ${error.message}`, + { + name: error.name, + stack: error.stack, + }, + ); + return { + success: false, + error: `Failed to verify domain: ${error.message}`, + }; } - console.error(`[verifyDomain] Unknown error verifying ${domainUrl}:`, error); + console.error( + `[verifyDomain] Unknown error verifying ${domainUrl}:`, + error, + ); return { success: false, error: "Failed to verify domain" }; } } @@ -457,7 +532,11 @@ async function ensureApp( redirectUri: string, ): Promise<{ error?: string; - app?: { name: string | null; redirect_uris: string; logo_url?: string | null }; + app?: { + name: string | null; + redirect_uris: string; + logo_url?: string | null; + }; }> { const existing = db .query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?") @@ -550,8 +629,14 @@ async function ensureApp( // Fetch the newly created app const newApp = db - .query("SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?") - .get(canonicalClientId) as { name: string | null; redirect_uris: string; logo_url?: string | null }; + .query( + "SELECT name, redirect_uris, logo_url FROM apps WHERE client_id = ?", + ) + .get(canonicalClientId) as { + name: string | null; + redirect_uris: string; + logo_url?: string | null; + }; return { app: newApp }; } @@ -936,10 +1021,11 @@ export async function authorizeGet(req: Request): Promise { const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds db.query( - "INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO authcodes (code, user_id, username, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ).run( code, user.userId, + user.username, clientId, redirectUri, JSON.stringify(requestedScopes), @@ -954,7 +1040,9 @@ export async function authorizeGet(req: Request): Promise { ).run(Math.floor(Date.now() / 1000), user.userId, clientId); const origin = process.env.ORIGIN || "http://localhost:3000"; - return Response.redirect(`${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`); + return Response.redirect( + `${redirectUri}?code=${code}&state=${state}&iss=${encodeURIComponent(origin)}`, + ); } } @@ -1316,7 +1404,7 @@ function showConsentScreen( // POST /auth/authorize - Consent form submission export async function authorizePost(req: Request): Promise { const contentType = req.headers.get("Content-Type"); - + // Parse the request body let body: Record; let formData: FormData; @@ -1328,20 +1416,20 @@ export async function authorizePost(req: Request): Promise { body = await req.json(); // Create a fake FormData for JSON requests formData = new FormData(); - Object.entries(body).forEach(([key, value]) => { + for (const [key, value] of Object.entries(body)) { formData.append(key, value); - }); + } } const grantType = body.grant_type; - + // If grant_type is present, this is a token exchange request (IndieAuth profile scope only) if (grantType === "authorization_code") { // Create a mock request for token() function const mockReq = new Request(req.url, { method: "POST", headers: req.headers, - body: contentType?.includes("application/x-www-form-urlencoded") + body: contentType?.includes("application/x-www-form-urlencoded") ? new URLSearchParams(body).toString() : JSON.stringify(body), }); @@ -1373,7 +1461,9 @@ export async function authorizePost(req: Request): Promise { clientId = canonicalizeURL(rawClientId); redirectUri = canonicalizeURL(rawRedirectUri); } catch { - return new Response("Invalid client_id or redirect_uri URL format", { status: 400 }); + return new Response("Invalid client_id or redirect_uri URL format", { + status: 400, + }); } if (action === "deny") { @@ -1395,10 +1485,11 @@ export async function authorizePost(req: Request): Promise { const expiresAt = Math.floor(Date.now() / 1000) + 60; // 60 seconds db.query( - "INSERT INTO authcodes (code, user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO authcodes (code, user_id, username, client_id, redirect_uri, scopes, code_challenge, expires_at, me) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ).run( code, user.userId, + user.username, clientId, redirectUri, JSON.stringify(approvedScopes), @@ -1487,7 +1578,9 @@ export async function token(req: Request): Promise { let redirect_uri: string | undefined; try { client_id = raw_client_id ? canonicalizeURL(raw_client_id) : undefined; - redirect_uri = raw_redirect_uri ? canonicalizeURL(raw_redirect_uri) : undefined; + redirect_uri = raw_redirect_uri + ? canonicalizeURL(raw_redirect_uri) + : undefined; } catch { return Response.json( { @@ -1502,7 +1595,8 @@ export async function token(req: Request): Promise { return Response.json( { error: "unsupported_grant_type", - error_description: "Only authorization_code and refresh_token grant types are supported", + error_description: + "Only authorization_code and refresh_token grant types are supported", }, { status: 400 }, ); @@ -1577,9 +1671,11 @@ export async function token(req: Request): Promise { const expiresAt = now + expiresIn; // Update token (rotate access token, keep refresh token) - db.query( - "UPDATE tokens SET token = ?, expires_at = ? WHERE id = ?", - ).run(newAccessToken, expiresAt, tokenData.id); + db.query("UPDATE tokens SET token = ?, expires_at = ? WHERE id = ?").run( + newAccessToken, + expiresAt, + tokenData.id, + ); // Get user profile for me value const user = db @@ -1614,7 +1710,7 @@ export async function token(req: Request): Promise { headers: { "Content-Type": "application/json", "Cache-Control": "no-store", - "Pragma": "no-cache", + Pragma: "no-cache", }, }, ); @@ -1622,6 +1718,15 @@ export async function token(req: Request): Promise { // Handle authorization_code grant (existing flow) // Check if client is pre-registered and requires secret + if (!client_id) { + return Response.json( + { + error: "invalid_request", + error_description: "client_id is required", + }, + { status: 400 }, + ); + } const app = db .query( "SELECT is_preregistered, client_secret_hash FROM apps WHERE client_id = ?", @@ -1699,11 +1804,12 @@ export async function token(req: Request): Promise { // Look up authorization code const authcode = db .query( - "SELECT user_id, client_id, redirect_uri, scopes, code_challenge, expires_at, used, me FROM authcodes WHERE code = ?", + "SELECT user_id, username, client_id, redirect_uri, scopes, code_challenge, expires_at, used, me FROM authcodes WHERE code = ?", ) .get(code) as | { user_id: number; + username: string; client_id: string; redirect_uri: string; scopes: string; @@ -1727,7 +1833,9 @@ export async function token(req: Request): Promise { // Check if already used if (authcode.used) { - console.error("Token endpoint: authorization code already used", { code }); + console.error("Token endpoint: authorization code already used", { + code, + }); return Response.json( { error: "invalid_grant", @@ -1740,7 +1848,12 @@ export async function token(req: Request): Promise { // Check if expired const now = Math.floor(Date.now() / 1000); if (authcode.expires_at < now) { - console.error("Token endpoint: authorization code expired", { code, expires_at: authcode.expires_at, now, diff: now - authcode.expires_at }); + console.error("Token endpoint: authorization code expired", { + code, + expires_at: authcode.expires_at, + now, + diff: now - authcode.expires_at, + }); return Response.json( { error: "invalid_grant", @@ -1752,7 +1865,10 @@ export async function token(req: Request): Promise { // Verify client_id matches if (authcode.client_id !== client_id) { - console.error("Token endpoint: client_id mismatch", { stored: authcode.client_id, received: client_id }); + console.error("Token endpoint: client_id mismatch", { + stored: authcode.client_id, + received: client_id, + }); return Response.json( { error: "invalid_grant", @@ -1764,7 +1880,10 @@ export async function token(req: Request): Promise { // Verify redirect_uri matches if (authcode.redirect_uri !== redirect_uri) { - console.error("Token endpoint: redirect_uri mismatch", { stored: authcode.redirect_uri, received: redirect_uri }); + console.error("Token endpoint: redirect_uri mismatch", { + stored: authcode.redirect_uri, + received: redirect_uri, + }); return Response.json( { error: "invalid_grant", @@ -1776,7 +1895,10 @@ export async function token(req: Request): Promise { // Verify PKCE code_verifier (required for all clients per IndieAuth spec) if (!verifyPKCE(code_verifier, authcode.code_challenge)) { - console.error("Token endpoint: PKCE verification failed", { code_verifier, code_challenge: authcode.code_challenge }); + console.error("Token endpoint: PKCE verification failed", { + code_verifier, + code_challenge: authcode.code_challenge, + }); return Response.json( { error: "invalid_grant", @@ -1839,18 +1961,22 @@ export async function token(req: Request): Promise { // Validate that the user controls the requested me parameter if (authcode.me && authcode.me !== meValue) { - console.error("Token endpoint: me mismatch", { requested: authcode.me, actual: meValue }); + console.error("Token endpoint: me mismatch", { + requested: authcode.me, + actual: meValue, + }); return Response.json( { error: "invalid_grant", - error_description: "The requested identity does not match the user's verified domain", + error_description: + "The requested identity does not match the user's verified domain", }, { status: 400 }, ); } const origin = process.env.ORIGIN || "http://localhost:3000"; - + // Generate access token const accessToken = crypto.randomBytes(32).toString("base64url"); const expiresIn = 3600; // 1 hour @@ -1864,7 +1990,15 @@ export async function token(req: Request): Promise { // Store token in database with refresh token db.query( "INSERT INTO tokens (token, user_id, client_id, scope, expires_at, refresh_token, refresh_expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)", - ).run(accessToken, authcode.user_id, client_id, scopes.join(" "), expiresAt, refreshToken, refreshExpiresAt); + ).run( + accessToken, + authcode.user_id, + client_id, + scopes.join(" "), + expiresAt, + refreshToken, + refreshExpiresAt, + ); const response: Record = { access_token: accessToken, @@ -1882,13 +2016,16 @@ export async function token(req: Request): Promise { response.role = permission.role; } - console.log("Token endpoint: success", { me: meValue, scopes: scopes.join(" ") }); + console.log("Token endpoint: success", { + me: meValue, + scopes: scopes.join(" "), + }); return Response.json(response, { headers: { "Content-Type": "application/json", "Cache-Control": "no-store", - "Pragma": "no-cache", + Pragma: "no-cache", }, }); } catch (error) { @@ -2052,7 +2189,7 @@ export function userinfo(req: Request): Response { try { // Get access token from Authorization header const authHeader = req.headers.get("Authorization"); - + if (!authHeader || !authHeader.startsWith("Bearer ")) { return Response.json( { diff --git a/src/routes/passkeys.ts b/src/routes/passkeys.ts index 3edb498..471e238 100644 --- a/src/routes/passkeys.ts +++ b/src/routes/passkeys.ts @@ -1,7 +1,7 @@ import { type RegistrationResponseJSON, - generateRegistrationOptions, type VerifiedRegistrationResponse, + generateRegistrationOptions, verifyRegistrationResponse, } from "@simplewebauthn/server"; import { db } from "../db"; @@ -133,7 +133,11 @@ export async function addPasskeyVerify(req: Request): Promise { } const body = await req.json(); - const { response, challenge: expectedChallenge, name } = body as { + const { + response, + challenge: expectedChallenge, + name, + } = body as { response: RegistrationResponseJSON; challenge: string; name?: string; diff --git a/src/styles.css b/src/styles.css index b5ad762..8e6fbb6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -86,6 +86,7 @@ input[type="text"], input[type="email"], input[type="url"], input[type="number"], +input[type="password"], input[type="datetime-local"], textarea { width: 100%; -- 2.43.0