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
From c1e5cbdd1e373cbeec6f2083a0f9c500e5ca5006 Mon Sep 17 00:00:00 2001
From: avycado13 <108358183+avycado13@users.noreply.github.com>
Date: Tue, 6 Jan 2026 12:42:55 +0530
Subject: [PATCH] This update enhances LDAP integration by introducing group
verification, automated account cleanup for orphaned LDAP users, and improved
security practices.
---
.env.example | 6 +
.gitignore | 1 +
SECURITY.md | 28 ++-
scripts/audit-ldap-orphans.ts | 217 ++++++++++++++++++
src/client/admin-clients.ts | 6 +-
src/client/admin-invites.ts | 2 +-
src/client/docs.ts | 87 ++++---
src/client/login.ts | 2 +-
src/index.ts | 20 ++
src/ldap-cleanup.ts | 158 +++++++++++++
.../009_add_ldap_provisioned_flag.sql | 4 +
src/routes/auth.ts | 110 ++++++---
src/routes/clients.ts | 4 +-
src/routes/passkeys.ts | 12 +-
tunnel.conf | 11 +
15 files changed, 574 insertions(+), 94 deletions(-)
create mode 100644 scripts/audit-ldap-orphans.ts
create mode 100644 src/ldap-cleanup.ts
create mode 100644 src/migrations/009_add_ldap_provisioned_flag.sql
create mode 100644 tunnel.conf
diff --git a/.env.example b/.env.example
index 6a8f85b..23690b0 100644
--- a/.env.example
+++ b/.env.example
@@ -11,3 +11,9 @@ 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
+LDAP_ORPHAN_ACTION=false
+
+# LDAP Group verification (optional)
+LDAP_GROUP_DN=cn=allowed-users,ou=groups,dc=example,dc=com
+LDAP_GROUP_CLASS=groupOfUniqueNames
+LDAP_GROUP_MEMBER_ATTRIBUTE=uniqueMember
diff --git a/.gitignore b/.gitignore
index 3cb63f8..452fdb8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ data/
*.db
*.db-shm
*.db-wal
+.DS_Store
diff --git a/SECURITY.md b/SECURITY.md
index 7252530..d18692b 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -4,7 +4,7 @@
If you discover a security vulnerability in Indiko, please report it privately:
-- **Email:** security@dunkirk.sh
+- **Email:**
- **Do not** open public issues for security vulnerabilities
- You will receive a response within 48 hours
@@ -51,6 +51,28 @@ Indiko uses **WebAuthn/Passkeys** for passwordless authentication:
## Known Security Considerations
+### LDAP Account Provisioning ⚠️
+
+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.
+
+**Admin responsibilities:**
+
+- **Audit provisioned accounts:** Query the `provisioned_via_ldap` column to identify LDAP-provisioned users
+- **Manual deprovisioning:** Suspended or delete accounts in Indiko when users are removed from LDAP
+- **Document policy:** Establish clear procedures for account deletion when LDAP users are removed
+
+**Example audit query:**
+
+```sql
+SELECT username, created_at, status FROM users WHERE provisioned_via_ldap = 1;
+```
+
+To suspend an LDAP account:
+
+```sql
+UPDATE users SET status = 'suspended' WHERE username = 'username_here';
+```
+
### Rate Limiting ⚠️
Indiko does **not** currently implement rate limiting. This is acceptable for:
@@ -177,8 +199,8 @@ No tracking or analytics cookies are set by Indiko.
## Contact
-- **Security Issues:** security@dunkirk.sh
-- **General Support:** https://tangled.org/@dunkirk.sh/indiko
+- **Security Issues:**
+- **General Support:**
- **Maintainer:** Kieran Klukas (@taciturnaxolotl)
---
diff --git a/scripts/audit-ldap-orphans.ts b/scripts/audit-ldap-orphans.ts
new file mode 100644
index 0000000..11db732
--- /dev/null
+++ b/scripts/audit-ldap-orphans.ts
@@ -0,0 +1,217 @@
+/**
+ * LDAP Orphan Account Audit Script
+ *
+ * This script identifies Indiko accounts provisioned via LDAP that no longer exist in LDAP.
+ * Useful for detecting when users have been removed from LDAP but their Indiko accounts remain active.
+ *
+ * Usage: bun scripts/audit-ldap-orphans.ts [--suspend | --deactivate | --dry-run]
+ *
+ * Flags:
+ * --dry-run Show what would be done without making changes (default)
+ * --suspend Set status to 'suspended' for orphaned accounts
+ * --deactivate Set status to 'inactive' for orphaned accounts
+ */
+
+import { Database } from "bun:sqlite";
+import * as path from "node:path";
+import { authenticate } from "ldap-authentication";
+
+// Load database
+const dbPath = path.join(import.meta.dir, "..", "data", "indiko.db");
+const db = new Database(dbPath);
+
+// Configuration from environment
+const LDAP_URL = process.env.LDAP_URL || "ldap://localhost:389";
+const LDAP_ADMIN_DN = process.env.LDAP_ADMIN_DN;
+const LDAP_ADMIN_PASSWORD = process.env.LDAP_ADMIN_PASSWORD;
+const LDAP_USER_SEARCH_BASE =
+ process.env.LDAP_USER_SEARCH_BASE || "dc=example,dc=com";
+const LDAP_USERNAME_ATTRIBUTE = process.env.LDAP_USERNAME_ATTRIBUTE || "uid";
+
+interface LdapUser {
+ username: string;
+ id: number;
+ status: string;
+ created_at: number;
+}
+
+interface AuditResult {
+ total: number;
+ active: number;
+ orphaned: number;
+ errors: number;
+ orphanedUsers: Array<{
+ username: string;
+ id: number;
+ status: string;
+ createdDate: string | undefined;
+ }>;
+}
+
+async function checkLdapUser(username: string): Promise {
+ try {
+ const user = await authenticate({
+ ldapOpts: {
+ url: LDAP_URL,
+ },
+ adminDn: LDAP_ADMIN_DN,
+ adminPassword: LDAP_ADMIN_PASSWORD,
+ userSearchBase: LDAP_USER_SEARCH_BASE,
+ usernameAttribute: LDAP_USERNAME_ATTRIBUTE,
+ username: username,
+ verifyUserExists: true,
+ });
+ return !!user;
+ } catch (error) {
+ // User not found or invalid credentials (expected for non-existence check)
+ return false;
+ }
+}
+
+async function auditLdapAccounts(): Promise {
+ console.log("🔍 Starting LDAP orphan account audit...\n");
+
+ // Get all LDAP-provisioned users
+ const ldapUsers = db
+ .query(
+ "SELECT id, username, status, created_at FROM users WHERE provisioned_via_ldap = 1",
+ )
+ .all() as LdapUser[];
+
+ const result: AuditResult = {
+ total: ldapUsers.length,
+ active: 0,
+ orphaned: 0,
+ errors: 0,
+ orphanedUsers: [],
+ };
+
+ console.log(`Found ${result.total} LDAP-provisioned accounts\n`);
+
+ // Check each user against LDAP
+ for (const user of ldapUsers) {
+ process.stdout.write(`Checking ${user.username}... `);
+
+ try {
+ const existsInLdap = await checkLdapUser(user.username);
+
+ if (existsInLdap) {
+ console.log("✅ Found in LDAP");
+ result.active++;
+ } else {
+ console.log("❌ NOT FOUND in LDAP");
+ result.orphaned++;
+ result.orphanedUsers.push({
+ username: user.username,
+ id: user.id,
+ status: user.status,
+ createdDate: new Date(user.created_at * 1000)
+ .toISOString()
+ .split("T")[0],
+ });
+ }
+ } catch (error) {
+ console.log("⚠️ Error checking LDAP");
+ result.errors++;
+ console.error(
+ ` Error: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ }
+
+ return result;
+}
+
+function printReport(result: AuditResult): void {
+ console.log(`\n${"=".repeat(60)}`);
+ console.log("LDAP ORPHAN ACCOUNT AUDIT REPORT");
+ console.log(`${"=".repeat(60)}\n`);
+
+ console.log(`Total LDAP-provisioned accounts: ${result.total}`);
+ console.log(`Active in LDAP: ${result.active}`);
+ console.log(`Orphaned (missing from LDAP): ${result.orphaned}`);
+ console.log(`Check errors: ${result.errors}`);
+
+ if (result.orphaned === 0) {
+ console.log("\n✅ No orphaned accounts found!");
+ return;
+ }
+
+ console.log(`\n${"-".repeat(60)}`);
+ console.log("ORPHANED ACCOUNTS:");
+ console.log(`${"-".repeat(60)}\n`);
+
+ result.orphanedUsers.forEach((user, idx) => {
+ console.log(`${idx + 1}. ${user.username}`);
+ console.log(
+ ` ID: ${user.id} | Status: ${user.status} | Created: ${user.createdDate}`,
+ );
+ });
+}
+
+async function updateOrphanedAccounts(
+ result: AuditResult,
+ action: "suspend" | "deactivate",
+): Promise {
+ const newStatus = action === "suspend" ? "suspended" : "inactive";
+
+ console.log(
+ `\n📝 Updating ${result.orphaned} orphaned account(s) to status: '${newStatus}'`,
+ );
+
+ for (const user of result.orphanedUsers) {
+ db.query("UPDATE users SET status = ? WHERE id = ?").run(
+ newStatus,
+ user.id,
+ );
+ console.log(` Updated: ${user.username}`);
+ }
+
+ console.log(`\n✅ Updated ${result.orphaned} account(s)`);
+}
+
+async function main() {
+ // Validate LDAP configuration
+ if (!LDAP_ADMIN_DN || !LDAP_ADMIN_PASSWORD) {
+ console.error(
+ "❌ Error: LDAP_ADMIN_DN and LDAP_ADMIN_PASSWORD environment variables are required",
+ );
+ process.exit(1);
+ }
+
+ const args = process.argv.slice(2);
+ const dryRun = args.includes("--dry-run") || args.length === 0;
+ const shouldSuspend = args.includes("--suspend");
+ const shouldDeactivate = args.includes("--deactivate");
+
+ if (dryRun) {
+ console.log("🔄 Running in DRY-RUN mode (no changes will be made)\n");
+ }
+
+ try {
+ const result = await auditLdapAccounts();
+ printReport(result);
+
+ if (!dryRun && result.orphaned > 0) {
+ if (shouldSuspend) {
+ await updateOrphanedAccounts(result, "suspend");
+ } else if (shouldDeactivate) {
+ await updateOrphanedAccounts(result, "deactivate");
+ } else {
+ console.log(
+ "\n⚠️ No action specified. Use --suspend or --deactivate to update accounts.",
+ );
+ }
+ }
+
+ process.exit(0);
+ } catch (error) {
+ console.error(
+ "\n❌ Audit failed:",
+ error instanceof Error ? error.message : String(error),
+ );
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/src/client/admin-clients.ts b/src/client/admin-clients.ts
index 07d1a52..aa9c956 100644
--- a/src/client/admin-clients.ts
+++ b/src/client/admin-clients.ts
@@ -569,11 +569,7 @@ clientForm.addEventListener("submit", async (e) => {
// If creating a new client, show the credentials in modal
if (!isEdit) {
const result = await response.json();
- if (
- result.client &&
- result.client.clientId &&
- result.client.clientSecret
- ) {
+ if (result.client?.clientId && result.client.clientSecret) {
const secretModal = document.getElementById(
"secretModal",
) as HTMLElement;
diff --git a/src/client/admin-invites.ts b/src/client/admin-invites.ts
index 28ddb53..66acdd4 100644
--- a/src/client/admin-invites.ts
+++ b/src/client/admin-invites.ts
@@ -195,7 +195,7 @@ async function submitCreateInvite() {
) as HTMLSelectElement;
let role = "";
- if (roleSelect && roleSelect.value) {
+ if (roleSelect?.value) {
role = roleSelect.value;
}
diff --git a/src/client/docs.ts b/src/client/docs.ts
index fe4bd25..107e5d1 100644
--- a/src/client/docs.ts
+++ b/src/client/docs.ts
@@ -52,51 +52,50 @@ function highlightHTMLCSS(code: string): string {
return result;
},
);
- } else {
- // Process CSS (inside