my own indieAuth provider! indiko.dunkirk.sh/docs
indieauth oauth2-server

feat: add LDAP account syncing with group verification and orphan cleanup

This update enhances LDAP integration by introducing:
- LDAP authentication with auto-provisioning on first login
- Group membership verification support
- Automated orphan account cleanup (configurable: suspend/deactivate/remove)
- Security improvements (no username enumeration, atomic invite usage)

Key features:
- Users authenticate with LDAP password on first login, then register passkey
- LDAP-provisioned accounts tracked with provisioned_via_ldap flag
- Admin audit script to identify orphaned accounts
- Background cleanup job runs every 12 hours
- Consolidated migration for all LDAP schema changes

💘 Generated with Crush

Assisted-by: Claude Sonnet 4.5 via Crush <crush@charm.land>
Co-authored-by: avycado13 <108358183+avycado13@users.noreply.github.com>

authored by

avycado13
avycado13
and committed by dunkirk.sh 9c392669 d82a4bf8

verified
+856 -43
+14
.env.example
··· 3 3 PORT=3000 4 4 NODE_ENV="production" 5 5 DATABASE_URL=data/indiko.db 6 + 7 + # LDAP Configuration (optional) 8 + LDAP_ENABLED=false 9 + LDAP_URL=ldap://localhost:389 10 + LDAP_ADMIN_DN=cn=admin,dc=example,dc=com 11 + LDAP_ADMIN_PASSWORD=your_admin_password 12 + LDAP_USER_SEARCH_BASE=dc=example,dc=com 13 + LDAP_USERNAME_ATTRIBUTE=uid 14 + LDAP_ORPHAN_ACTION=false 15 + 16 + # LDAP Group verification (optional) 17 + LDAP_GROUP_DN=cn=allowed-users,ou=groups,dc=example,dc=com 18 + LDAP_GROUP_CLASS=groupOfUniqueNames 19 + LDAP_GROUP_MEMBER_ATTRIBUTE=uniqueMember
+1
.gitignore
··· 8 8 *.db 9 9 *.db-shm 10 10 *.db-wal 11 + .DS_Store
+24 -2
SECURITY.md
··· 51 51 52 52 ## Known Security Considerations 53 53 54 + ### LDAP Account Provisioning ⚠️ 55 + 56 + When using LDAP authentication, accounts are provisioned on first successful LDAP login. **Important:** If a user is subsequently deleted from LDAP, their Indiko account **remains active**. This is by design—account lifecycle is managed independently from LDAP. 57 + 58 + **Admin responsibilities:** 59 + 60 + - **Audit provisioned accounts:** Query the `provisioned_via_ldap` column to identify LDAP-provisioned users 61 + - **Manual deprovisioning:** Suspended or delete accounts in Indiko when users are removed from LDAP 62 + - **Document policy:** Establish clear procedures for account deletion when LDAP users are removed 63 + 64 + **Example audit query:** 65 + 66 + ```sql 67 + SELECT username, created_at, status FROM users WHERE provisioned_via_ldap = 1; 68 + ``` 69 + 70 + To suspend an LDAP account: 71 + 72 + ```sql 73 + UPDATE users SET status = 'suspended' WHERE username = 'username_here'; 74 + ``` 75 + 54 76 ### Rate Limiting ⚠️ 55 77 56 78 Indiko does **not** currently implement rate limiting. This is acceptable for: ··· 177 199 178 200 ## Contact 179 201 180 - - **Security Issues:** security@dunkirk.sh 181 - - **General Support:** https://tangled.org/@dunkirk.sh/indiko 202 + - **Security Issues:** <security@dunkirk.sh> 203 + - **General Support:** <https://tangled.org/@dunkirk.sh/indiko> 182 204 - **Maintainer:** Kieran Klukas (@taciturnaxolotl) 183 205 184 206 ---
+27
bun.lock
··· 8 8 "@simplewebauthn/browser": "^13.2.2", 9 9 "@simplewebauthn/server": "^13.2.2", 10 10 "bun-sqlite-migrations": "^1.0.2", 11 + "ldap-authentication": "^3.3.6", 11 12 "nanoid": "^5.1.6", 12 13 }, 13 14 "devDependencies": { ··· 54 55 55 56 "@simplewebauthn/types": ["@simplewebauthn/types@12.0.0", "", {}, "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA=="], 56 57 58 + "@types/asn1": ["@types/asn1@0.2.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA=="], 59 + 57 60 "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], 58 61 59 62 "@types/node": ["@types/node@25.0.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-rl78HwuZlaDIUSeUKkmogkhebA+8K1Hy7tddZuJ3D0xV8pZSfsYGTsliGUol1JPzu9EKnTxPC4L1fiWouStRew=="], 63 + 64 + "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], 60 65 61 66 "asn1js": ["asn1js@3.0.7", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="], 62 67 ··· 64 69 65 70 "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], 66 71 72 + "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], 73 + 74 + "ldap-authentication": ["ldap-authentication@3.3.6", "", { "dependencies": { "ldapts": "^7.3.1" } }, "sha512-j8XxH5wGXhIQ3mMnoRCZTalSLmPhzEaGH4+5RIFP0Higc32fCKDRJBo+9wb5ysy9TnlaNtaf+rgdwYCD15OBpQ=="], 75 + 76 + "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=="], 77 + 78 + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 79 + 67 80 "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], 68 81 82 + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], 83 + 69 84 "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], 70 85 71 86 "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], 72 87 73 88 "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], 89 + 90 + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], 91 + 92 + "strict-event-emitter-types": ["strict-event-emitter-types@2.0.0", "", {}, "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA=="], 93 + 94 + "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], 74 95 75 96 "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 76 97 ··· 79 100 "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 80 101 81 102 "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 103 + 104 + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], 105 + 106 + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], 107 + 108 + "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], 82 109 83 110 "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], 84 111 }
+1
package.json
··· 19 19 "@simplewebauthn/browser": "^13.2.2", 20 20 "@simplewebauthn/server": "^13.2.2", 21 21 "bun-sqlite-migrations": "^1.0.2", 22 + "ldap-authentication": "^3.3.6", 22 23 "nanoid": "^5.1.6" 23 24 } 24 25 }
+217
scripts/audit-ldap-orphans.ts
··· 1 + /** 2 + * LDAP Orphan Account Audit Script 3 + * 4 + * This script identifies Indiko accounts provisioned via LDAP that no longer exist in LDAP. 5 + * Useful for detecting when users have been removed from LDAP but their Indiko accounts remain active. 6 + * 7 + * Usage: bun scripts/audit-ldap-orphans.ts [--suspend | --deactivate | --dry-run] 8 + * 9 + * Flags: 10 + * --dry-run Show what would be done without making changes (default) 11 + * --suspend Set status to 'suspended' for orphaned accounts 12 + * --deactivate Set status to 'inactive' for orphaned accounts 13 + */ 14 + 15 + import { Database } from "bun:sqlite"; 16 + import * as path from "node:path"; 17 + import { authenticate } from "ldap-authentication"; 18 + 19 + // Load database 20 + const dbPath = path.join(import.meta.dir, "..", "data", "indiko.db"); 21 + const db = new Database(dbPath); 22 + 23 + // Configuration from environment 24 + const LDAP_URL = process.env.LDAP_URL || "ldap://localhost:389"; 25 + const LDAP_ADMIN_DN = process.env.LDAP_ADMIN_DN; 26 + const LDAP_ADMIN_PASSWORD = process.env.LDAP_ADMIN_PASSWORD; 27 + const LDAP_USER_SEARCH_BASE = 28 + process.env.LDAP_USER_SEARCH_BASE || "dc=example,dc=com"; 29 + const LDAP_USERNAME_ATTRIBUTE = process.env.LDAP_USERNAME_ATTRIBUTE || "uid"; 30 + 31 + interface LdapUser { 32 + username: string; 33 + id: number; 34 + status: string; 35 + created_at: number; 36 + } 37 + 38 + interface AuditResult { 39 + total: number; 40 + active: number; 41 + orphaned: number; 42 + errors: number; 43 + orphanedUsers: Array<{ 44 + username: string; 45 + id: number; 46 + status: string; 47 + createdDate: string | undefined; 48 + }>; 49 + } 50 + 51 + async function checkLdapUser(username: string): Promise<boolean> { 52 + try { 53 + const user = await authenticate({ 54 + ldapOpts: { 55 + url: LDAP_URL, 56 + }, 57 + adminDn: LDAP_ADMIN_DN, 58 + adminPassword: LDAP_ADMIN_PASSWORD, 59 + userSearchBase: LDAP_USER_SEARCH_BASE, 60 + usernameAttribute: LDAP_USERNAME_ATTRIBUTE, 61 + username: username, 62 + verifyUserExists: true, 63 + }); 64 + return !!user; 65 + } catch (error) { 66 + // User not found or invalid credentials (expected for non-existence check) 67 + return false; 68 + } 69 + } 70 + 71 + async function auditLdapAccounts(): Promise<AuditResult> { 72 + console.log("🔍 Starting LDAP orphan account audit...\n"); 73 + 74 + // Get all LDAP-provisioned users 75 + const ldapUsers = db 76 + .query( 77 + "SELECT id, username, status, created_at FROM users WHERE provisioned_via_ldap = 1", 78 + ) 79 + .all() as LdapUser[]; 80 + 81 + const result: AuditResult = { 82 + total: ldapUsers.length, 83 + active: 0, 84 + orphaned: 0, 85 + errors: 0, 86 + orphanedUsers: [], 87 + }; 88 + 89 + console.log(`Found ${result.total} LDAP-provisioned accounts\n`); 90 + 91 + // Check each user against LDAP 92 + for (const user of ldapUsers) { 93 + process.stdout.write(`Checking ${user.username}... `); 94 + 95 + try { 96 + const existsInLdap = await checkLdapUser(user.username); 97 + 98 + if (existsInLdap) { 99 + console.log("✅ Found in LDAP"); 100 + result.active++; 101 + } else { 102 + console.log("❌ NOT FOUND in LDAP"); 103 + result.orphaned++; 104 + result.orphanedUsers.push({ 105 + username: user.username, 106 + id: user.id, 107 + status: user.status, 108 + createdDate: new Date(user.created_at * 1000) 109 + .toISOString() 110 + .split("T")[0], 111 + }); 112 + } 113 + } catch (error) { 114 + console.log("⚠️ Error checking LDAP"); 115 + result.errors++; 116 + console.error( 117 + ` Error: ${error instanceof Error ? error.message : String(error)}`, 118 + ); 119 + } 120 + } 121 + 122 + return result; 123 + } 124 + 125 + function printReport(result: AuditResult): void { 126 + console.log(`\n${"=".repeat(60)}`); 127 + console.log("LDAP ORPHAN ACCOUNT AUDIT REPORT"); 128 + console.log(`${"=".repeat(60)}\n`); 129 + 130 + console.log(`Total LDAP-provisioned accounts: ${result.total}`); 131 + console.log(`Active in LDAP: ${result.active}`); 132 + console.log(`Orphaned (missing from LDAP): ${result.orphaned}`); 133 + console.log(`Check errors: ${result.errors}`); 134 + 135 + if (result.orphaned === 0) { 136 + console.log("\n✅ No orphaned accounts found!"); 137 + return; 138 + } 139 + 140 + console.log(`\n${"-".repeat(60)}`); 141 + console.log("ORPHANED ACCOUNTS:"); 142 + console.log(`${"-".repeat(60)}\n`); 143 + 144 + result.orphanedUsers.forEach((user, idx) => { 145 + console.log(`${idx + 1}. ${user.username}`); 146 + console.log( 147 + ` ID: ${user.id} | Status: ${user.status} | Created: ${user.createdDate}`, 148 + ); 149 + }); 150 + } 151 + 152 + async function updateOrphanedAccounts( 153 + result: AuditResult, 154 + action: "suspend" | "deactivate", 155 + ): Promise<void> { 156 + const newStatus = action === "suspend" ? "suspended" : "inactive"; 157 + 158 + console.log( 159 + `\n📝 Updating ${result.orphaned} orphaned account(s) to status: '${newStatus}'`, 160 + ); 161 + 162 + for (const user of result.orphanedUsers) { 163 + db.query("UPDATE users SET status = ? WHERE id = ?").run( 164 + newStatus, 165 + user.id, 166 + ); 167 + console.log(` Updated: ${user.username}`); 168 + } 169 + 170 + console.log(`\n✅ Updated ${result.orphaned} account(s)`); 171 + } 172 + 173 + async function main() { 174 + // Validate LDAP configuration 175 + if (!LDAP_ADMIN_DN || !LDAP_ADMIN_PASSWORD) { 176 + console.error( 177 + "❌ Error: LDAP_ADMIN_DN and LDAP_ADMIN_PASSWORD environment variables are required", 178 + ); 179 + process.exit(1); 180 + } 181 + 182 + const args = process.argv.slice(2); 183 + const dryRun = args.includes("--dry-run") || args.length === 0; 184 + const shouldSuspend = args.includes("--suspend"); 185 + const shouldDeactivate = args.includes("--deactivate"); 186 + 187 + if (dryRun) { 188 + console.log("🔄 Running in DRY-RUN mode (no changes will be made)\n"); 189 + } 190 + 191 + try { 192 + const result = await auditLdapAccounts(); 193 + printReport(result); 194 + 195 + if (!dryRun && result.orphaned > 0) { 196 + if (shouldSuspend) { 197 + await updateOrphanedAccounts(result, "suspend"); 198 + } else if (shouldDeactivate) { 199 + await updateOrphanedAccounts(result, "deactivate"); 200 + } else { 201 + console.log( 202 + "\n⚠️ No action specified. Use --suspend or --deactivate to update accounts.", 203 + ); 204 + } 205 + } 206 + 207 + process.exit(0); 208 + } catch (error) { 209 + console.error( 210 + "\n❌ Audit failed:", 211 + error instanceof Error ? error.message : String(error), 212 + ); 213 + process.exit(1); 214 + } 215 + } 216 + 217 + main();
+129 -6
src/client/login.ts
··· 5 5 6 6 const loginForm = document.getElementById("loginForm") as HTMLFormElement; 7 7 const registerForm = document.getElementById("registerForm") as HTMLFormElement; 8 + const ldapForm = document.getElementById("ldapForm") as HTMLFormElement; 8 9 const message = document.getElementById("message") as HTMLDivElement; 10 + 11 + let pendingLdapUsername: string | null = null; 9 12 10 13 // Check if registration is allowed on page load 11 14 async function checkRegistrationAllowed() { ··· 15 18 const inviteCode = urlParams.get("invite"); 16 19 17 20 if (inviteCode) { 21 + // Check if username is locked (from LDAP flow) 22 + const lockedUsername = urlParams.get("username"); 23 + const registerUsernameInput = document.getElementById( 24 + "registerUsername", 25 + ) as HTMLInputElement; 26 + 18 27 // Fetch invite details to show message 19 28 try { 29 + const testUsername = lockedUsername || "temp"; 20 30 const response = await fetch("/auth/register/options", { 21 31 method: "POST", 22 32 headers: { "Content-Type": "application/json" }, 23 - body: JSON.stringify({ username: "temp", inviteCode }), 33 + body: JSON.stringify({ username: testUsername, inviteCode }), 24 34 }); 25 35 26 36 if (response.ok) { ··· 38 48 if (subtitleElement) { 39 49 subtitleElement.textContent = "create your account"; 40 50 } 41 - ( 42 - document.getElementById("registerUsername") as HTMLInputElement 43 - ).placeholder = "choose username"; 51 + 52 + // If username is locked from LDAP, pre-fill and disable 53 + if (lockedUsername) { 54 + registerUsernameInput.value = lockedUsername; 55 + registerUsernameInput.readOnly = true; 56 + registerUsernameInput.style.opacity = "0.7"; 57 + registerUsernameInput.style.cursor = "not-allowed"; 58 + } else { 59 + registerUsernameInput.placeholder = "choose username"; 60 + } 61 + 44 62 ( 45 63 document.getElementById("registerBtn") as HTMLButtonElement 46 64 ).textContent = "create account"; ··· 111 129 } 112 130 113 131 const options = await optionsRes.json(); 132 + 133 + // Check if LDAP verification is required (user exists in LDAP but not locally) 134 + if (options.ldapVerificationRequired) { 135 + showLdapPasswordPrompt(options.username); 136 + loginBtn.disabled = false; 137 + loginBtn.textContent = "sign in"; 138 + return; 139 + } 114 140 115 141 loginBtn.textContent = "use your passkey..."; 116 142 ··· 212 238 213 239 showMessage("Registration successful!", "success"); 214 240 215 - // Check for return URL parameter 216 - const returnUrl = urlParams.get("return") || "/"; 241 + // Check for return URL: first sessionStorage (from LDAP flow), then URL param, fallback to / 242 + const storedRedirect = sessionStorage.getItem("postRegistrationRedirect"); 243 + const returnUrl = storedRedirect || urlParams.get("return") || "/"; 244 + 245 + // Clear the stored redirect after use 246 + if (storedRedirect) { 247 + sessionStorage.removeItem("postRegistrationRedirect"); 248 + } 217 249 218 250 const redirectTimer = setTimeout(() => { 219 251 window.location.href = returnUrl; ··· 225 257 registerBtn.textContent = "register passkey"; 226 258 } 227 259 }); 260 + 261 + // LDAP verification flow 262 + function showLdapPasswordPrompt(username: string) { 263 + pendingLdapUsername = username; 264 + 265 + // Update UI to show LDAP form 266 + const subtitleElement = document.querySelector(".subtitle"); 267 + if (subtitleElement) { 268 + subtitleElement.textContent = "verify your LDAP password"; 269 + } 270 + 271 + // Update LDAP form username display 272 + const ldapUsernameSpan = document.getElementById("ldapUsername"); 273 + if (ldapUsernameSpan) { 274 + ldapUsernameSpan.textContent = username; 275 + } 276 + 277 + // Show LDAP form, hide others 278 + loginForm.style.display = "none"; 279 + registerForm.style.display = "none"; 280 + ldapForm.style.display = "block"; 281 + 282 + showMessage( 283 + "This username exists in the linked LDAP directory. Enter your LDAP password to create your account.", 284 + "success", 285 + true, 286 + ); 287 + } 288 + 289 + ldapForm.addEventListener("submit", async (e) => { 290 + e.preventDefault(); 291 + 292 + if (!pendingLdapUsername) { 293 + showMessage("No username pending for LDAP verification"); 294 + return; 295 + } 296 + 297 + const password = (document.getElementById("ldapPassword") as HTMLInputElement) 298 + .value; 299 + const ldapBtn = document.getElementById("ldapBtn") as HTMLButtonElement; 300 + 301 + try { 302 + ldapBtn.disabled = true; 303 + ldapBtn.textContent = "verifying..."; 304 + 305 + // Get return URL for after registration 306 + const urlParams = new URLSearchParams(window.location.search); 307 + const returnUrl = urlParams.get("return") || "/"; 308 + 309 + // Verify LDAP credentials 310 + const verifyRes = await fetch("/api/ldap-verify", { 311 + method: "POST", 312 + headers: { "Content-Type": "application/json" }, 313 + body: JSON.stringify({ 314 + username: pendingLdapUsername, 315 + password: password, 316 + returnUrl: returnUrl, 317 + }), 318 + }); 319 + 320 + if (!verifyRes.ok) { 321 + const error = await verifyRes.json(); 322 + throw new Error(error.error || "LDAP verification failed"); 323 + } 324 + 325 + const result = await verifyRes.json(); 326 + 327 + if (result.success) { 328 + showMessage( 329 + "LDAP verification successful! Redirecting to setup...", 330 + "success", 331 + ); 332 + 333 + // Store return URL for after registration completes 334 + if (result.returnUrl) { 335 + sessionStorage.setItem("postRegistrationRedirect", result.returnUrl); 336 + } 337 + 338 + // Redirect to registration with the invite code and locked username 339 + const registerUrl = `/login?invite=${encodeURIComponent(result.inviteCode)}&username=${encodeURIComponent(result.username)}`; 340 + 341 + setTimeout(() => { 342 + window.location.href = registerUrl; 343 + }, 1000); 344 + } 345 + } catch (error) { 346 + showMessage((error as Error).message || "LDAP verification failed"); 347 + ldapBtn.disabled = false; 348 + ldapBtn.textContent = "verify & continue"; 349 + } 350 + });
+32 -1
src/html/login.html
··· 49 49 margin-bottom: 1rem; 50 50 } 51 51 52 - input[type="text"] { 52 + input[type="text"], 53 + input[type="password"] { 53 54 margin-bottom: 1rem; 55 + } 56 + 57 + .ldap-user-display { 58 + background: rgba(188, 141, 160, 0.1); 59 + border-left: 3px solid var(--berry-crush); 60 + padding: 0.75rem 1rem; 61 + margin-bottom: 1rem; 62 + text-align: left; 63 + font-size: 0.875rem; 64 + } 65 + 66 + .ldap-user-display .label { 67 + color: var(--old-rose); 68 + font-size: 0.75rem; 69 + text-transform: uppercase; 70 + letter-spacing: 0.05rem; 71 + } 72 + 73 + .ldap-user-display .username { 74 + color: var(--lavender); 75 + font-weight: 600; 54 76 } 55 77 56 78 button { ··· 94 116 <input type="text" id="registerUsername" placeholder="create username" required 95 117 autocomplete="username webauthn" /> 96 118 <button type="submit" class="secondary-btn" id="registerBtn">create passkey</button> 119 + </form> 120 + 121 + <form id="ldapForm" style="display: none;"> 122 + <div class="ldap-user-display"> 123 + <div class="label">username</div> 124 + <div class="username" id="ldapUsername"></div> 125 + </div> 126 + <input type="password" id="ldapPassword" placeholder="LDAP password" required autocomplete="current-password" /> 127 + <button type="submit" id="ldapBtn">verify & continue</button> 97 128 </form> 98 129 </div> 99 130
+26
src/index.ts
··· 7 7 import docsHTML from "./html/docs.html"; 8 8 import indexHTML from "./html/index.html"; 9 9 import loginHTML from "./html/login.html"; 10 + import { getLdapAccounts, updateOrphanedAccounts } from "./ldap-cleanup"; 10 11 import { 11 12 deleteSelfAccount, 12 13 deleteUser, ··· 25 26 } from "./routes/api"; 26 27 import { 27 28 canRegister, 29 + ldapVerify, 28 30 loginOptions, 29 31 loginVerify, 30 32 registerOptions, ··· 253 255 "/auth/register/verify": registerVerify, 254 256 "/auth/login/options": loginOptions, 255 257 "/auth/login/verify": loginVerify, 258 + // LDAP verification endpoint 259 + "/api/ldap-verify": (req: Request) => { 260 + if (req.method === "POST") return ldapVerify(req); 261 + return new Response("Method not allowed", { status: 405 }); 262 + }, 256 263 // Passkey management endpoints 257 264 "/api/passkeys": (req: Request) => { 258 265 if (req.method === "GET") return listPasskeys(req); ··· 339 346 } 340 347 }, 3600000); // 1 hour in milliseconds 341 348 349 + const ldapCleanupJob = 350 + process.env.LDAP_ADMIN_DN && process.env.LDAP_ADMIN_PASSWORD 351 + ? setInterval(async () => { 352 + const result = await getLdapAccounts(); 353 + const action = process.env.LDAP_ORPHAN_ACTION || "deactivate"; 354 + if (action === "suspend") { 355 + await updateOrphanedAccounts(result, "suspend"); 356 + } else if (action === "deactivate") { 357 + await updateOrphanedAccounts(result, "deactivate"); 358 + } else if (action === "remove") { 359 + await updateOrphanedAccounts(result, "remove"); 360 + } 361 + console.log( 362 + `[LDAP Cleanup] ${action === "remove" ? "Removed" : action === "suspend" ? "Suspended" : "Deactivated"} LDAP orphan accounts: ${result.total} total, ${result.active} active, ${result.orphaned} orphaned, ${result.errors} errors.`, 363 + ); 364 + }, 43200000) 365 + : null; // 12 hours in milliseconds 366 + 342 367 let is_shutting_down = false; 343 368 function shutdown(sig: string) { 344 369 if (is_shutting_down) return; ··· 347 372 console.log(`[Shutdown] triggering shutdown due to ${sig}`); 348 373 349 374 clearInterval(cleanupJob); 375 + if (ldapCleanupJob) clearInterval(ldapCleanupJob); 350 376 console.log("[Shutdown] stopped cleanup job"); 351 377 352 378 server.stop();
+158
src/ldap-cleanup.ts
··· 1 + import { authenticate } from "ldap-authentication"; 2 + import { db } from "./db"; 3 + 4 + interface LdapUser { 5 + username: string; 6 + id: number; 7 + status: string; 8 + created_at: number; 9 + } 10 + 11 + interface AuditResult { 12 + total: number; 13 + active: number; 14 + orphaned: number; 15 + errors: number; 16 + orphanedUsers: Array<{ 17 + username: string; 18 + id: number; 19 + status: string; 20 + createdDate: string | undefined; 21 + }>; 22 + } 23 + 24 + export async function checkLdapUser(username: string): Promise<boolean> { 25 + try { 26 + const user = await authenticate({ 27 + ldapOpts: { 28 + url: process.env.LDAP_URL || "ldap://localhost:389", 29 + }, 30 + adminDn: process.env.LDAP_ADMIN_DN || "", 31 + adminPassword: process.env.LDAP_ADMIN_PASSWORD || "", 32 + userSearchBase: process.env.LDAP_USER_SEARCH_BASE || "dc=example,dc=com", 33 + usernameAttribute: process.env.LDAP_USERNAME_ATTRIBUTE || "uid", 34 + username: username, 35 + verifyUserExists: true, 36 + }); 37 + return !!user; 38 + } catch (error) { 39 + // User not found or invalid credentials (expected for non-existence check) 40 + return false; 41 + } 42 + } 43 + 44 + export async function checkLdapGroupMembership( 45 + username: string, 46 + userDn: string, 47 + ): Promise<boolean> { 48 + if (!process.env.LDAP_GROUP_DN) { 49 + return true; // No group restriction configured 50 + } 51 + 52 + try { 53 + const groupDn = process.env.LDAP_GROUP_DN; 54 + const groupClass = process.env.LDAP_GROUP_CLASS || "groupOfUniqueNames"; 55 + const memberAttribute = 56 + process.env.LDAP_GROUP_MEMBER_ATTRIBUTE || "uniqueMember"; 57 + 58 + const user = await authenticate({ 59 + ldapOpts: { 60 + url: process.env.LDAP_URL || "ldap://localhost:389", 61 + }, 62 + userDn: userDn, 63 + userSearchBase: process.env.LDAP_USER_SEARCH_BASE || "dc=example,dc=com", 64 + usernameAttribute: process.env.LDAP_USERNAME_ATTRIBUTE || "uid", 65 + username: username, 66 + verifyUserExists: true, 67 + groupsSearchBase: groupDn, 68 + groupClass: groupClass, 69 + groupMemberAttribute: memberAttribute, 70 + }); 71 + 72 + // If user was found and authenticate returns it, groups are available 73 + return !!user; 74 + } catch (error) { 75 + console.error("LDAP group membership check failed:", error); 76 + return false; 77 + } 78 + } 79 + 80 + export async function getLdapAccounts(): Promise<AuditResult> { 81 + console.log("🔍 Starting LDAP orphan account audit...\n"); 82 + 83 + // Get all LDAP-provisioned users 84 + const ldapUsers = db 85 + .query( 86 + "SELECT id, username, status, created_at FROM users WHERE provisioned_via_ldap = 1", 87 + ) 88 + .all() as LdapUser[]; 89 + 90 + const result: AuditResult = { 91 + total: ldapUsers.length, 92 + active: 0, 93 + orphaned: 0, 94 + errors: 0, 95 + orphanedUsers: [], 96 + }; 97 + 98 + console.log(`Found ${result.total} LDAP-provisioned accounts\n`); 99 + 100 + // Check each user against LDAP 101 + for (const user of ldapUsers) { 102 + process.stdout.write(`Checking ${user.username}... `); 103 + 104 + try { 105 + const existsInLdap = await checkLdapUser(user.username); 106 + 107 + if (existsInLdap) { 108 + console.log("✅ Found in LDAP"); 109 + result.active++; 110 + } else { 111 + console.log("❌ NOT FOUND in LDAP"); 112 + result.orphaned++; 113 + result.orphanedUsers.push({ 114 + username: user.username, 115 + id: user.id, 116 + status: user.status, 117 + createdDate: new Date(user.created_at * 1000) 118 + .toISOString() 119 + .split("T")[0], 120 + }); 121 + } 122 + } catch (error) { 123 + console.log("⚠️ Error checking LDAP"); 124 + result.errors++; 125 + console.error( 126 + ` Error: ${error instanceof Error ? error.message : String(error)}`, 127 + ); 128 + } 129 + } 130 + 131 + return result; 132 + } 133 + 134 + export async function updateOrphanedAccounts( 135 + result: AuditResult, 136 + action: "suspend" | "deactivate" | "remove", 137 + ): Promise<void> { 138 + const newStatus = action === "suspend" ? "suspended" : "inactive"; 139 + 140 + console.log( 141 + `\n📝 Updating ${result.orphaned} orphaned account(s) to status: '${newStatus}'`, 142 + ); 143 + 144 + for (const user of result.orphanedUsers) { 145 + if (action === "remove") { 146 + db.query("DELETE FROM users WHERE id = ?").run(user.id); 147 + console.log(` Removed: ${user.username}`); 148 + continue; 149 + } 150 + db.query("UPDATE users SET status = ? WHERE id = ?").run( 151 + newStatus, 152 + user.id, 153 + ); 154 + console.log(` Updated: ${user.username}`); 155 + } 156 + 157 + console.log(`\n✅ Updated ${result.orphaned} account(s)`); 158 + }
+15
src/migrations/007_add_ldap_support.sql
··· 1 + -- LDAP Integration Support 2 + -- This migration adds columns needed for LDAP authentication and account provisioning 3 + 4 + -- Add username column to authcodes table for direct access without user_id lookup 5 + ALTER TABLE authcodes ADD COLUMN username TEXT NOT NULL DEFAULT ''; 6 + 7 + -- Add ldap_username column to invites table 8 + -- When set, the invite can only be used by a user with that exact username 9 + -- Used for LDAP-verified user provisioning flow 10 + ALTER TABLE invites ADD COLUMN ldap_username TEXT DEFAULT NULL; 11 + 12 + -- Add provisioned_via_ldap flag for audit purposes 13 + -- Allows admins to identify LDAP-provisioned accounts 14 + -- Important: If user is deleted from LDAP, the account remains active but this flag tracks its origin 15 + ALTER TABLE users ADD COLUMN provisioned_via_ldap INTEGER NOT NULL DEFAULT 0;
+211 -34
src/routes/auth.ts
··· 1 1 import { 2 2 type AuthenticationResponseJSON, 3 - generateAuthenticationOptions, 4 - generateRegistrationOptions, 5 3 type PublicKeyCredentialCreationOptionsJSON, 6 4 type PublicKeyCredentialRequestOptionsJSON, 7 5 type RegistrationResponseJSON, 8 6 type VerifiedAuthenticationResponse, 9 7 type VerifiedRegistrationResponse, 8 + generateAuthenticationOptions, 9 + generateRegistrationOptions, 10 10 verifyAuthenticationResponse, 11 11 verifyRegistrationResponse, 12 12 } from "@simplewebauthn/server"; 13 + import { authenticate } from "ldap-authentication"; 13 14 import { db } from "../db"; 15 + import { checkLdapGroupMembership } from "../ldap-cleanup"; 14 16 15 17 const RP_NAME = "Indiko"; 16 18 17 - export function canRegister(req: Request): Response { 19 + export function canRegister(_req: Request): Response { 18 20 const userCount = db.query("SELECT COUNT(*) as count FROM users").get() as { 19 21 count: number; 20 22 }; ··· 66 68 // Validate invite code 67 69 const invite = db 68 70 .query( 69 - "SELECT id, max_uses, current_uses, expires_at, message FROM invites WHERE code = ?", 71 + "SELECT id, max_uses, current_uses, expires_at, message, ldap_username FROM invites WHERE code = ?", 70 72 ) 71 73 .get(inviteCode) as 72 74 | { ··· 75 77 current_uses: number; 76 78 expires_at: number | null; 77 79 message: string | null; 80 + ldap_username: string | null; 78 81 } 79 82 | undefined; 80 83 ··· 87 90 return Response.json({ error: "Invite code expired" }, { status: 403 }); 88 91 } 89 92 90 - if (invite.current_uses >= invite.max_uses) { 93 + // Will check usage limit atomically during update 94 + 95 + // If invite is locked to an LDAP username, enforce it 96 + if (invite.ldap_username && invite.ldap_username !== username) { 91 97 return Response.json( 92 - { error: "Invite code fully used" }, 93 - { status: 403 }, 98 + { error: "Username must match LDAP account" }, 99 + { status: 400 }, 94 100 ); 95 101 } 96 102 ··· 102 108 const options: PublicKeyCredentialCreationOptionsJSON = 103 109 await generateRegistrationOptions({ 104 110 rpName: RP_NAME, 105 - rpID: process.env.RP_ID!, 111 + rpID: process.env.RP_ID || "", 106 112 userName: username, 107 113 userDisplayName: username, 108 114 attestationType: "none", ··· 160 166 ); 161 167 } 162 168 169 + if (!expectedChallenge) { 170 + return Response.json({ error: "Invalid challenge" }, { status: 400 }); 171 + } 172 + 163 173 // Verify challenge exists and is valid 164 174 const challenge = db 165 175 .query( ··· 198 208 199 209 const invite = db 200 210 .query( 201 - "SELECT id, max_uses, current_uses, expires_at FROM invites WHERE code = ?", 211 + "SELECT id, max_uses, current_uses, expires_at, ldap_username FROM invites WHERE code = ?", 202 212 ) 203 213 .get(inviteCode) as 204 214 | { ··· 206 216 max_uses: number; 207 217 current_uses: number; 208 218 expires_at: number | null; 219 + ldap_username: string | null; 209 220 } 210 221 | undefined; 211 222 ··· 218 229 return Response.json({ error: "Invite code expired" }, { status: 403 }); 219 230 } 220 231 221 - if (invite.current_uses >= invite.max_uses) { 232 + // If invite is locked to an LDAP username, enforce it 233 + if (invite.ldap_username && invite.ldap_username !== username) { 222 234 return Response.json( 223 - { error: "Invite code fully used" }, 224 - { status: 403 }, 235 + { error: "Username must match LDAP account" }, 236 + { status: 400 }, 225 237 ); 226 238 } 227 239 ··· 239 251 verification = await verifyRegistrationResponse({ 240 252 response, 241 253 expectedChallenge: challenge.challenge, 242 - expectedOrigin: process.env.ORIGIN!, 243 - expectedRPID: process.env.RP_ID!, 254 + expectedOrigin: process.env.ORIGIN || "", 255 + expectedRPID: process.env.RP_ID || "", 244 256 }); 245 257 } catch (error) { 246 258 console.error("WebAuthn verification failed:", error); ··· 253 265 254 266 const { credential } = verification.registrationInfo; 255 267 268 + // Check if this user is being provisioned via LDAP 269 + let isLdapProvisioned = false; 270 + if (inviteId) { 271 + const invite = db 272 + .query("SELECT ldap_username FROM invites WHERE id = ?") 273 + .get(inviteId) as { ldap_username: string | null } | undefined; 274 + isLdapProvisioned = 275 + invite?.ldap_username !== null && invite?.ldap_username !== undefined; 276 + } 277 + 256 278 // Create user (bootstrap is always admin, invited users are regular users) 257 279 const insertUser = db.query( 258 - "INSERT INTO users (username, name, is_admin, tier, role) VALUES (?, ?, ?, ?, ?) RETURNING id", 280 + "INSERT INTO users (username, name, is_admin, tier, role, provisioned_via_ldap) VALUES (?, ?, ?, ?, ?, ?) RETURNING id", 259 281 ); 260 282 const user = insertUser.get( 261 283 username, ··· 263 285 isBootstrap ? 1 : 0, 264 286 isBootstrap ? "admin" : "user", 265 287 isBootstrap ? "admin" : "user", 288 + isLdapProvisioned ? 1 : 0, 266 289 ) as { 267 290 id: number; 268 291 }; ··· 283 306 if (inviteId) { 284 307 const usedAt = Math.floor(Date.now() / 1000); 285 308 286 - // Increment invite usage counter 287 - db.query( 288 - "UPDATE invites SET current_uses = current_uses + 1 WHERE id = ?", 289 - ).run(inviteId); 309 + // Atomically increment invite usage counter while checking max_uses limit 310 + const result = db 311 + .query( 312 + "UPDATE invites SET current_uses = current_uses + 1 WHERE id = ? AND current_uses < max_uses", 313 + ) 314 + .run(inviteId); 315 + 316 + // Check if update was successful (0 rows affected means invite was already fully used) 317 + if (result.changes === 0) { 318 + return Response.json( 319 + { error: "Invite code fully used" }, 320 + { status: 403 }, 321 + ); 322 + } 290 323 291 324 // Record this invite use 292 325 db.query( ··· 352 385 .get(username) as { id: number; status: string } | undefined; 353 386 354 387 if (!user) { 355 - return Response.json({ error: "User not found" }, { status: 404 }); 388 + return Response.json({ error: "Invalid credentials" }, { status: 401 }); 356 389 } 357 390 358 391 if (user.status !== "active") { 359 - return Response.json({ error: "Account is suspended" }, { status: 403 }); 392 + return Response.json({ error: "Invalid credentials" }, { status: 401 }); 360 393 } 361 394 362 395 // Get user's credentials (just to verify they exist) ··· 365 398 .all(user.id) as { credential_id: Buffer }[]; 366 399 367 400 if (credentials.length === 0) { 368 - return Response.json({ error: "No credentials found" }, { status: 404 }); 401 + return Response.json({ error: "Invalid credentials" }, { status: 401 }); 369 402 } 370 403 371 404 // Generate authentication options 372 405 // Use discoverable credentials (no allowCredentials) for better UX 373 406 const options: PublicKeyCredentialRequestOptionsJSON = 374 407 await generateAuthenticationOptions({ 375 - rpID: process.env.RP_ID!, 408 + rpID: process.env.RP_ID || "", 376 409 userVerification: "required", 377 410 }); 378 411 ··· 382 415 "INSERT INTO challenges (challenge, username, type, expires_at) VALUES (?, ?, 'authentication', ?)", 383 416 ).run(options.challenge, username, expiresAt); 384 417 385 - return Response.json(options); 418 + // Local user always uses passkey login, no LDAP verification needed 419 + return Response.json({ 420 + ...options, 421 + ldapVerificationRequired: false, 422 + }); 386 423 } catch (error) { 387 424 console.error("Login options error:", error); 388 425 return Response.json({ error: "Internal server error" }, { status: 500 }); ··· 423 460 | undefined; 424 461 425 462 if (!credentialWithUser) { 426 - return Response.json({ error: "Credential not found" }, { status: 404 }); 463 + return Response.json({ error: "Invalid credentials" }, { status: 401 }); 427 464 } 428 465 429 466 // Check if user account is active 430 467 if (credentialWithUser.status !== "active") { 431 - return Response.json({ error: "Account is suspended" }, { status: 403 }); 468 + return Response.json({ error: "Invalid credentials" }, { status: 401 }); 432 469 } 433 470 434 471 // Verify the username matches 435 472 if (credentialWithUser.username !== username) { 436 - return Response.json( 437 - { error: "Credential does not belong to this user" }, 438 - { status: 403 }, 439 - ); 473 + return Response.json({ error: "Invalid credentials" }, { status: 401 }); 440 474 } 441 475 442 476 const credential = { ··· 468 502 verification = await verifyAuthenticationResponse({ 469 503 response, 470 504 expectedChallenge: challenge.challenge, 471 - expectedOrigin: process.env.ORIGIN!, 472 - expectedRPID: process.env.RP_ID!, 505 + expectedOrigin: process.env.ORIGIN || "", 506 + expectedRPID: process.env.RP_ID || "", 473 507 credential: { 474 - id: credential.credential_id, 475 - publicKey: credential.public_key, 508 + id: credential.credential_id.toString(), 509 + publicKey: new Uint8Array(credential.public_key), 476 510 counter: credential.counter, 477 511 }, 478 512 }); ··· 525 559 return Response.json({ error: "Internal server error" }, { status: 500 }); 526 560 } 527 561 } 562 + 563 + export async function ldapVerify(req: Request): Promise<Response> { 564 + try { 565 + const body = await req.json(); 566 + const { username, password, returnUrl } = body as { 567 + username: string; 568 + password: string; 569 + returnUrl?: string; 570 + }; 571 + 572 + // Check if LDAP is configured 573 + if (!process.env.LDAP_ADMIN_DN || !process.env.LDAP_ADMIN_PASSWORD) { 574 + return Response.json( 575 + { error: "LDAP is not configured" }, 576 + { status: 400 }, 577 + ); 578 + } 579 + 580 + if ( 581 + !username || 582 + username.length > 128 || 583 + !/^[A-Za-z0-9._@-]+$/.test(username) 584 + ) { 585 + return Response.json( 586 + { error: "Invalid username format" }, 587 + { status: 400 }, 588 + ); 589 + } 590 + 591 + // Verify user doesn't already exist locally (race condition check) 592 + const existingUser = db 593 + .query("SELECT id FROM users WHERE username = ?") 594 + .get(username); 595 + 596 + if (existingUser) { 597 + return Response.json( 598 + { error: "Account already exists. Please use passkey login." }, 599 + { status: 400 }, 600 + ); 601 + } 602 + 603 + // Attempt LDAP bind WITH password verification 604 + let ldapUser: unknown; 605 + let userDn: string | null = null; 606 + try { 607 + ldapUser = await authenticate({ 608 + ldapOpts: { 609 + url: process.env.LDAP_URL || "ldap://localhost:389", 610 + }, 611 + adminDn: process.env.LDAP_ADMIN_DN || "", 612 + adminPassword: process.env.LDAP_ADMIN_PASSWORD || "", 613 + userSearchBase: 614 + process.env.LDAP_USER_SEARCH_BASE || "dc=example,dc=com", 615 + usernameAttribute: process.env.LDAP_USERNAME_ATTRIBUTE || "uid", 616 + username: username, 617 + userPassword: password, 618 + }); 619 + 620 + // Extract userDn from the returned user object 621 + if (ldapUser && typeof ldapUser === "object" && "dn" in ldapUser) { 622 + userDn = (ldapUser as { dn: string }).dn; 623 + } 624 + } catch (_ldapError) { 625 + return Response.json({ error: "Invalid credentials" }, { status: 401 }); 626 + } 627 + 628 + if (!ldapUser) { 629 + return Response.json({ error: "Invalid credentials" }, { status: 401 }); 630 + } 631 + 632 + // Check group membership if configured 633 + if (userDn) { 634 + const isInGroup = await checkLdapGroupMembership(username, userDn); 635 + if (!isInGroup) { 636 + return Response.json( 637 + { error: "User is not a member of the required group" }, 638 + { status: 403 }, 639 + ); 640 + } 641 + } 642 + 643 + // LDAP auth succeeded - create single-use invite locked to this username 644 + const inviteCode = crypto.randomUUID(); 645 + const expiresAt = Math.floor(Date.now() / 1000) + 600; // 10 minutes 646 + 647 + // Get an admin user to be the creator (required by NOT NULL constraint) 648 + const adminUser = db 649 + .query("SELECT id FROM users WHERE is_admin = 1 LIMIT 1") 650 + .get() as { id: number } | undefined; 651 + 652 + if (!adminUser) { 653 + return Response.json( 654 + { error: "System not configured for LDAP provisioning" }, 655 + { status: 500 }, 656 + ); 657 + } 658 + 659 + // Create the LDAP invite (max_uses=1, tied to username) 660 + db.query( 661 + "INSERT INTO invites (code, max_uses, current_uses, expires_at, created_by, message, ldap_username) VALUES (?, 1, 0, ?, ?, ?, ?)", 662 + ).run( 663 + inviteCode, 664 + expiresAt, 665 + adminUser.id, 666 + "LDAP-verified account", 667 + username, 668 + ); 669 + 670 + const newInviteId = db 671 + .query("SELECT id FROM invites WHERE code = ?") 672 + .get(inviteCode) as { id: number }; 673 + 674 + // Copy roles from most recent admin-created invite if exists 675 + const defaultInvite = db 676 + .query( 677 + "SELECT id FROM invites WHERE created_by IN (SELECT id FROM users WHERE is_admin = 1) ORDER BY created_at DESC LIMIT 1", 678 + ) 679 + .get() as { id: number } | undefined; 680 + 681 + if (defaultInvite) { 682 + const inviteRoles = db 683 + .query("SELECT app_id, role FROM invite_roles WHERE invite_id = ?") 684 + .all(defaultInvite.id) as Array<{ app_id: number; role: string }>; 685 + 686 + const insertRole = db.query( 687 + "INSERT INTO invite_roles (invite_id, app_id, role) VALUES (?, ?, ?)", 688 + ); 689 + for (const { app_id, role } of inviteRoles) { 690 + insertRole.run(newInviteId.id, app_id, role); 691 + } 692 + } 693 + 694 + return Response.json({ 695 + success: true, 696 + inviteCode: inviteCode, 697 + username: username, 698 + returnUrl: returnUrl || null, 699 + }); 700 + } catch (error) { 701 + console.error("LDAP verify error:", error); 702 + return Response.json({ error: "Internal server error" }, { status: 500 }); 703 + } 704 + }
+1
src/styles.css
··· 86 86 input[type="email"], 87 87 input[type="url"], 88 88 input[type="number"], 89 + input[type="password"], 89 90 input[type="datetime-local"], 90 91 textarea { 91 92 width: 100%;