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

feat: add LDAP sync with grace period and on-login verification

Implements robust LDAP account deletion handling with:
- On-login verification for LDAP-provisioned users (with 24h cache)
- Grace period before suspending orphaned accounts (7 days default)
- Hourly background cleanup job (down from 12 hours)
- Consolidated migration file (no blank lines between statements)
- Updated CRUSH.md with migration documentation

Configuration:
- LDAP_ORPHAN_ACTION=suspend (default, enabled)
- LDAP_ORPHAN_GRACE_PERIOD=604800 (7 days)
- LDAP_CHECK_INTERVAL=86400 (24 hours)

💘 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 d6f846b0 9c392669

verified
+126 -34
+5 -1
.env.example
··· 11 11 LDAP_ADMIN_PASSWORD=your_admin_password 12 12 LDAP_USER_SEARCH_BASE=dc=example,dc=com 13 13 LDAP_USERNAME_ATTRIBUTE=uid 14 - LDAP_ORPHAN_ACTION=false 14 + 15 + # LDAP account sync configuration 16 + LDAP_ORPHAN_ACTION=suspend 17 + LDAP_ORPHAN_GRACE_PERIOD=604800 18 + LDAP_CHECK_INTERVAL=86400 15 19 16 20 # LDAP Group verification (optional) 17 21 LDAP_GROUP_DN=cn=allowed-users,ou=groups,dc=example,dc=com
+53 -7
CRUSH.md
··· 33 33 │ ├── login.html 34 34 │ ├── index.html 35 35 │ └── profile.html 36 - └── migrations/ # SQL migrations 37 - ├── 001_init.sql 38 - ├── 002_add_user_status_role.sql 39 - └── 003_add_indieauth_tables.sql 36 + ├── migrations/ # SQL migrations 37 + │ ├── 001_init.sql 38 + │ ├── 002_add_user_status_role.sql 39 + │ ├── 003_add_indieauth_tables.sql 40 + │ └── 007_add_ldap_support.sql 40 41 ``` 41 42 43 + ### Database Migrations 44 + 45 + **Migration Versioning:** 46 + - SQLite uses `PRAGMA user_version` to track migration state 47 + - Version starts at 0, increments by 1 for each migration 48 + - The `bun-sqlite-migrations` package handles version tracking 49 + - Migrations are stored in `src/migrations/` directory 50 + 51 + **Creating a New Migration:** 52 + 53 + 1. **Name the file**: Use 3-digit prefix (e.g., `008_add_feature.sql`) 54 + - Next available number = highest existing number + 1 55 + - Use descriptive name (e.g., `008_add_auth_tokens.sql`) 56 + 57 + 2. **Write SQL statements**: Add schema changes in the file 58 + ```sql 59 + -- Add new column to users table 60 + ALTER TABLE users ADD COLUMN new_field TEXT DEFAULT ''; 61 + 62 + -- Create new table 63 + CREATE TABLE IF NOT EXISTS new_table ( 64 + id INTEGER PRIMARY KEY AUTOINCREMENT, 65 + name TEXT NOT NULL 66 + ); 67 + ``` 68 + 69 + 3. **Migration execution**: 70 + - Migrations run automatically when server starts (`src/db.ts`) 71 + - Only new migrations (version > current) are executed 72 + - Each migration increments `user_version` by 1 73 + 74 + **Version Tracking:** 75 + - Check current version: `sqlite3 data/indiko.db "PRAGMA user_version;"` 76 + - The migration system compares `user_version` against migration files 77 + - No manual version updates needed - handled by `bun-sqlite-migrations` 78 + 79 + **Best Practices:** 80 + - Use `ALTER TABLE` for adding columns to existing tables 81 + - Use `CREATE TABLE IF NOT EXISTS` for new tables 82 + - Use `DEFAULT` values when adding non-null columns 83 + - Add comments with `--` to explain changes 84 + - Test migrations locally before committing 85 + 42 86 ### Client-Side Code 43 87 - Extract JavaScript from HTML into separate TypeScript modules in `src/client/` 44 88 - Import client modules into HTML with `<script type="module" src="../client/file.ts"></script>` ··· 59 103 - **`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 60 104 61 105 ### Database Schema 62 - - **users**: username, name, email, photo, url, status, role, tier, is_admin 106 + - **users**: username, name, email, photo, url, status, role, tier, is_admin, provisioned_via_ldap, last_ldap_verified_at 63 107 - **tier**: User access level - 'admin' (full access), 'developer' (can create apps), 'user' (can only authenticate with apps) 64 108 - **is_admin**: Legacy flag, automatically synced with tier (1 if tier='admin', 0 otherwise) 109 + - **provisioned_via_ldap**: Flag tracking if user was created via LDAP authentication (0 = local, 1 = LDAP) 110 + - **last_ldap_verified_at**: Timestamp of last successful LDAP existence check (NULL if never checked) 65 111 - **credentials**: passkey credentials (credential_id, public_key, counter) 66 112 - **sessions**: user sessions with 24-hour expiry 67 113 - **challenges**: WebAuthn challenges (5-minute expiry) 68 114 - **apps**: auto-registered OAuth clients 69 115 - **permissions**: per-user, per-app granted scopes 70 - - **authcodes**: short-lived authorization codes (60-second expiry, single-use), includes `me` parameter for delegation 71 - - **invites**: admin-created invite codes 116 + - **authcodes**: short-lived authorization codes (60-second expiry, single-use), includes username and `me` parameter for delegation 117 + - **invites**: admin-created invite codes, includes `ldap_username` for LDAP-provisioned accounts 72 118 73 119 ### WebAuthn/Passkey Settings 74 120 - **Registration**: residentKey="required", userVerification="required"
+3 -5
scripts/audit-ldap-orphans.ts
··· 44 44 username: string; 45 45 id: number; 46 46 status: string; 47 - createdDate: string | undefined; 47 + createdAt: number; 48 48 }>; 49 49 } 50 50 ··· 105 105 username: user.username, 106 106 id: user.id, 107 107 status: user.status, 108 - createdDate: new Date(user.created_at * 1000) 109 - .toISOString() 110 - .split("T")[0], 108 + createdAt: user.created_at, 111 109 }); 112 110 } 113 111 } catch (error) { ··· 144 142 result.orphanedUsers.forEach((user, idx) => { 145 143 console.log(`${idx + 1}. ${user.username}`); 146 144 console.log( 147 - ` ID: ${user.id} | Status: ${user.status} | Created: ${user.createdDate}`, 145 + ` ID: ${user.id} | Status: ${user.status} | Created: ${new Date(user.createdAt * 1000).toISOString().split("T")[0]}`, 148 146 ); 149 147 }); 150 148 }
+28 -9
src/index.ts
··· 351 351 ? setInterval(async () => { 352 352 const result = await getLdapAccounts(); 353 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"); 354 + const gracePeriod = Number.parseInt( 355 + process.env.LDAP_ORPHAN_GRACE_PERIOD || "604800", 356 + 10, 357 + ); // 7 days default 358 + const now = Math.floor(Date.now() / 1000); 359 + 360 + // Only take action on accounts orphaned longer than grace period 361 + if (result.orphaned > 0) { 362 + const expiredOrphans = result.orphanedUsers.filter( 363 + (user) => now - user.createdAt > gracePeriod, 364 + ); 365 + 366 + if (expiredOrphans.length > 0) { 367 + if (action === "suspend") { 368 + await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "suspend"); 369 + } else if (action === "deactivate") { 370 + await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "deactivate"); 371 + } else if (action === "remove") { 372 + await updateOrphanedAccounts({ ...result, orphanedUsers: expiredOrphans }, "remove"); 373 + } 374 + console.log( 375 + `[LDAP Cleanup] ${action === "remove" ? "Removed" : action === "suspend" ? "Suspended" : "Deactivated"} ${expiredOrphans.length} LDAP orphan accounts (grace period: ${gracePeriod}s)`, 376 + ); 377 + } 360 378 } 379 + 361 380 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.`, 381 + `[LDAP Cleanup] Check completed: ${result.total} total, ${result.active} active, ${result.orphaned} orphaned, ${result.errors} errors.`, 363 382 ); 364 - }, 43200000) 365 - : null; // 12 hours in milliseconds 383 + }, 3600000) 384 + : null; // 1 hour in milliseconds 366 385 367 386 let is_shutting_down = false; 368 387 function shutdown(sig: string) {
+2 -4
src/ldap-cleanup.ts
··· 17 17 username: string; 18 18 id: number; 19 19 status: string; 20 - createdDate: string | undefined; 20 + createdAt: number; 21 21 }>; 22 22 } 23 23 ··· 114 114 username: user.username, 115 115 id: user.id, 116 116 status: user.status, 117 - createdDate: new Date(user.created_at * 1000) 118 - .toISOString() 119 - .split("T")[0], 117 + createdAt: user.created_at, 120 118 }); 121 119 } 122 120 } catch (error) {
+5 -6
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 1 -- Add username column to authcodes table for direct access without user_id lookup 5 2 ALTER TABLE authcodes ADD COLUMN username TEXT NOT NULL DEFAULT ''; 6 - 7 3 -- Add ldap_username column to invites table 8 4 -- When set, the invite can only be used by a user with that exact username 9 5 -- Used for LDAP-verified user provisioning flow 10 6 ALTER TABLE invites ADD COLUMN ldap_username TEXT DEFAULT NULL; 11 - 12 7 -- Add provisioned_via_ldap flag for audit purposes 13 8 -- 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 9 + -- Important: If a user is deleted from LDAP, their account remains active but this flag tracks its origin 15 10 ALTER TABLE users ADD COLUMN provisioned_via_ldap INTEGER NOT NULL DEFAULT 0; 11 + -- Add last_ldap_verified_at timestamp for LDAP account sync with grace period 12 + -- Tracks when we last verified the user exists in LDAP 13 + -- Used to implement caching and grace periods for orphaned account detection 14 + ALTER TABLE users ADD COLUMN last_ldap_verified_at INTEGER DEFAULT NULL;
+30 -2
src/routes/auth.ts
··· 381 381 382 382 // Check if user exists and is active 383 383 const user = db 384 - .query("SELECT id, status FROM users WHERE username = ?") 385 - .get(username) as { id: number; status: string } | undefined; 384 + .query("SELECT id, status, provisioned_via_ldap, last_ldap_verified_at FROM users WHERE username = ?") 385 + .get(username) as { id: number; status: string; provisioned_via_ldap: number; last_ldap_verified_at: number | null } | undefined; 386 386 387 387 if (!user) { 388 388 return Response.json({ error: "Invalid credentials" }, { status: 401 }); ··· 390 390 391 391 if (user.status !== "active") { 392 392 return Response.json({ error: "Invalid credentials" }, { status: 401 }); 393 + } 394 + 395 + // Check if LDAP-provisioned user still exists in LDAP (with caching) 396 + if (user.provisioned_via_ldap === 1) { 397 + const checkInterval = 398 + Number.parseInt(process.env.LDAP_CHECK_INTERVAL || "86400", 10) * 1000; 399 + const now = Date.now(); 400 + const shouldCheck = 401 + !user.last_ldap_verified_at || 402 + now - user.last_ldap_verified_at > checkInterval; 403 + 404 + if (shouldCheck) { 405 + const existsInLdap = await checkLdapUser(username); 406 + if (!existsInLdap) { 407 + // User no longer exists in LDAP - suspend the account 408 + db.query("UPDATE users SET status = 'suspended' WHERE id = ?").run(user.id); 409 + return Response.json( 410 + { error: "Invalid credentials" }, 411 + { status: 401 }, 412 + ); 413 + } 414 + 415 + // Update last verification timestamp 416 + db.query("UPDATE users SET last_ldap_verified_at = ? WHERE id = ?").run( 417 + Math.floor(now / 1000), 418 + user.id, 419 + ); 420 + } 393 421 } 394 422 395 423 // Get user's credentials (just to verify they exist)