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

feat: init

dunkirk.sh b574c7f0 57b762cf

verified
+7
.gitignore
··· 1 + node_modules/ 2 + .wrangler/ 3 + dist/ 4 + .dev.vars 5 + .env 6 + bun.lockb 7 + data/
+47
CRUSH.md
··· 1 + # Crush Memory - Indiko Project 2 + 3 + ## User Preferences 4 + 5 + - **DO NOT** run the server - user will always run it themselves 6 + - **DO NOT** test the server by starting it 7 + - Use Bun's `routes` object in server config, not manual fetch handler routing 8 + 9 + ## Architecture Patterns 10 + 11 + ### Route Organization 12 + - Use separate route files in `src/routes/` directory 13 + - Export handler functions that accept `Request` and return `Response` 14 + - Import handlers in `src/index.ts` and wire them in the `routes` object 15 + - Use Bun's built-in routing: `routes: { "/path": handler }` 16 + - Example: `src/routes/auth.ts` contains authentication-related routes 17 + 18 + ### Project Structure 19 + ``` 20 + src/ 21 + ├── db.ts # Database setup and exports 22 + ├── index.ts # Main server entry point 23 + ├── routes/ # Route handlers (server-side) 24 + │ └── auth.ts # Authentication routes 25 + ├── client/ # Client-side TypeScript modules 26 + │ └── login.ts # Login page logic 27 + ├── html/ # HTML templates (Bun bundles them with script imports) 28 + └── migrations/ # SQL migrations 29 + ``` 30 + 31 + ### Client-Side Code 32 + - Extract JavaScript from HTML into separate TypeScript modules in `src/client/` 33 + - Import client modules into HTML with `<script type="module" src="../client/file.ts"></script>` 34 + - Bun will bundle the imports automatically 35 + - Static assets (images, favicons) in `public/` are served at root path 36 + - 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 37 + 38 + ## Commands 39 + 40 + (Add test/lint/build commands here as discovered) 41 + 42 + ## Code Style 43 + 44 + - Use tabs for indentation 45 + - TypeScript with Bun runtime 46 + - Use SQLite with WAL mode 47 + - Route handlers: `(req: Request) => Response`
+1 -1
README.md
··· 1 1 # Indiko 2 2 3 - No that was not a typo the project's name actually is `indiko`! This is a small implementation of [IndieAuth](https://indieweb.org/How_to_set_up_web_sign-in_on_your_own_domain) running on cloudflare workers and serving as the authentication provider for my homelab / side projects. 3 + No that was not a typo the project's name actually is `indiko`! This is a small implementation of [IndieAuth](https://indieweb.org/How_to_set_up_web_sign-in_on_your_own_domain) running on bun with sqlite and lit web components and serving as the authentication provider for my homelab / side projects. 4 4 5 5 The canonical repo for this is hosted on tangled over at [`dunkirk.sh/indiko`](https://tangled.org/@dunkirk.sh/indiko) 6 6
+478
SPEC.md
··· 1 + # indiko - IndieAuth Server Specification 2 + 3 + ## Overview 4 + 5 + **indiko** is a centralized authentication and user management system for personal projects. It provides: 6 + - Passkey-based authentication (WebAuthn) 7 + - IndieAuth server implementation 8 + - User profile management 9 + - Per-app access control 10 + - Invite-based user registration 11 + 12 + ## Core Concepts 13 + 14 + ### Single Source of Truth 15 + - Authentication via passkeys 16 + - User profiles (name, email, picture, URL) 17 + - Authorization with per-app scoping 18 + - User management (admin + invite system) 19 + 20 + ### Trust Model 21 + - First user becomes admin 22 + - Admin can create invite links 23 + - Apps auto-register on first use 24 + - Users grant/revoke app access via consent 25 + 26 + ## User Identifier Format 27 + 28 + Users are identified by: `https://indiko.yourdomain.com/u/{username}` 29 + 30 + ## Data Structures 31 + 32 + ### Users 33 + ``` 34 + user:{username} -> { 35 + credential: { 36 + credentialID: Uint8Array, 37 + publicKey: Uint8Array, 38 + counter: number 39 + }, 40 + isAdmin: boolean, 41 + profile: { 42 + name: string, 43 + email: string, 44 + photo: string, // URL 45 + url: string // personal website 46 + }, 47 + createdAt: timestamp 48 + } 49 + ``` 50 + 51 + ### Admin Marker 52 + ``` 53 + admin:user -> username // marks first/admin user 54 + ``` 55 + 56 + ### Sessions 57 + ``` 58 + session:{token} -> { 59 + username: string, 60 + expiresAt: timestamp 61 + } 62 + // TTL: 24 hours 63 + ``` 64 + 65 + ### Apps (Auto-registered) 66 + ``` 67 + app:{client_id} -> { 68 + client_id: string, // e.g. "https://blog.kierank.dev" 69 + redirect_uris: string[], 70 + first_seen: timestamp, 71 + last_used: timestamp, 72 + name?: string // optional, from client metadata 73 + } 74 + ``` 75 + 76 + ### User Permissions (Per-App) 77 + ``` 78 + permission:{username}:{client_id} -> { 79 + scopes: string[], // e.g. ["profile", "email"] 80 + granted_at: timestamp, 81 + last_used: timestamp 82 + } 83 + ``` 84 + 85 + ### Authorization Codes (Short-lived) 86 + ``` 87 + authcode:{code} -> { 88 + username: string, 89 + client_id: string, 90 + redirect_uri: string, 91 + scopes: string[], 92 + code_challenge: string, // PKCE 93 + expires_at: timestamp, 94 + used: boolean 95 + } 96 + // TTL: 60 seconds 97 + // Single-use only 98 + ``` 99 + 100 + ### Invites 101 + ``` 102 + invite:{code} -> { 103 + code: string, 104 + created_by: string, // admin username 105 + created_at: timestamp, 106 + used: boolean, 107 + used_by?: string, 108 + used_at?: timestamp 109 + } 110 + ``` 111 + 112 + ### Challenges (WebAuthn) 113 + ``` 114 + challenge:{challenge} -> { 115 + username: string, 116 + type: "registration" | "authentication", 117 + expires_at: timestamp 118 + } 119 + // TTL: 5 minutes 120 + ``` 121 + 122 + ## Supported Scopes 123 + 124 + - `profile` - Name, photo, URL 125 + - `email` - Email address 126 + - (Future: custom scopes as needed) 127 + 128 + ## Routes 129 + 130 + ### Authentication (WebAuthn/Passkey) 131 + 132 + #### `GET /login` 133 + - Login/registration page 134 + - Shows passkey auth interface 135 + - First user: admin registration flow 136 + - With `?invite=CODE`: invite-based registration 137 + 138 + #### `GET /auth/can-register` 139 + - Check if open registration allowed 140 + - Returns `{ canRegister: boolean }` 141 + 142 + #### `POST /auth/register/options` 143 + - Generate WebAuthn registration options 144 + - Body: `{ username: string, inviteCode?: string }` 145 + - Validates invite code if not first user 146 + - Returns registration options 147 + 148 + #### `POST /auth/register/verify` 149 + - Verify WebAuthn registration response 150 + - Body: `{ username: string, response: RegistrationResponseJSON, inviteCode?: string }` 151 + - Creates user, stores credential 152 + - First user marked as admin 153 + - Returns `{ token: string, username: string }` 154 + 155 + #### `POST /auth/login/options` 156 + - Generate WebAuthn authentication options 157 + - Body: `{ username: string }` 158 + - Returns authentication options 159 + 160 + #### `POST /auth/login/verify` 161 + - Verify WebAuthn authentication response 162 + - Body: `{ username: string, response: AuthenticationResponseJSON }` 163 + - Creates session 164 + - Returns `{ token: string, username: string }` 165 + 166 + #### `POST /auth/logout` 167 + - Clear session 168 + - Requires: `Authorization: Bearer {token}` 169 + - Returns `{ success: true }` 170 + 171 + ### IndieAuth Endpoints 172 + 173 + #### `GET /auth/authorize` 174 + Authorization request from client app 175 + 176 + **Query Parameters:** 177 + - `response_type=code` (required) 178 + - `client_id` (required) - App's URL 179 + - `redirect_uri` (required) - Callback URL 180 + - `state` (required) - CSRF protection 181 + - `code_challenge` (required) - PKCE challenge 182 + - `code_challenge_method=S256` (required) 183 + - `scope` (optional) - Space-separated scopes (default: "profile") 184 + - `me` (optional) - User's URL (hint) 185 + 186 + **Flow:** 187 + 1. Validate parameters 188 + 2. Auto-register app if not exists 189 + 3. If no session → redirect to `/login` 190 + 4. If session exists → show consent screen 191 + 5. Check if user previously approved this app 192 + - If yes → auto-approve (skip consent) 193 + - If no → show consent screen 194 + 195 + **Response:** 196 + - HTML consent screen 197 + - Shows: app name, requested scopes 198 + - Buttons: "Allow" / "Deny" 199 + 200 + #### `POST /auth/authorize` 201 + Consent form submission (CSRF protected) 202 + 203 + **Body:** 204 + - `client_id` (required) 205 + - `redirect_uri` (required) 206 + - `state` (required) 207 + - `code_challenge` (required) 208 + - `scopes` (required) 209 + - `action` (required) - "allow" | "deny" 210 + 211 + **Flow:** 212 + 1. Validate CSRF token 213 + 2. Validate session 214 + 3. If denied → redirect with error 215 + 4. If allowed: 216 + - Create authorization code 217 + - Store permission grant 218 + - Update app last_used 219 + - Redirect to redirect_uri with code & state 220 + 221 + **Success Response:** 222 + ``` 223 + HTTP/1.1 302 Found 224 + Location: {redirect_uri}?code={authcode}&state={state} 225 + ``` 226 + 227 + **Error Response:** 228 + ``` 229 + HTTP/1.1 302 Found 230 + Location: {redirect_uri}?error=access_denied&state={state} 231 + ``` 232 + 233 + #### `POST /auth/token` 234 + Exchange authorization code for user identity (NOT CSRF protected) 235 + 236 + **Headers:** 237 + - `Content-Type: application/json` 238 + 239 + **Body:** 240 + ```json 241 + { 242 + "grant_type": "authorization_code", 243 + "code": "authcode123", 244 + "client_id": "https://blog.kierank.dev", 245 + "redirect_uri": "https://blog.kierank.dev/auth/callback", 246 + "code_verifier": "pkce_verifier_string" 247 + } 248 + ``` 249 + 250 + **Flow:** 251 + 1. Validate authorization code exists 252 + 2. Verify code not expired 253 + 3. Verify code not already used 254 + 4. Verify client_id matches 255 + 5. Verify redirect_uri matches 256 + 6. Verify PKCE code_verifier 257 + 7. Mark code as used 258 + 8. Return user identity + profile 259 + 260 + **Success Response:** 261 + ```json 262 + { 263 + "me": "https://indiko.yourdomain.com/u/kieran", 264 + "profile": { 265 + "name": "Kieran Klukas", 266 + "email": "kieran@example.com", 267 + "photo": "https://...", 268 + "url": "https://kierank.dev" 269 + } 270 + } 271 + ``` 272 + 273 + **Error Response:** 274 + ```json 275 + { 276 + "error": "invalid_grant", 277 + "error_description": "Authorization code expired" 278 + } 279 + ``` 280 + 281 + #### `GET /auth/userinfo` (Optional) 282 + Get current user profile with bearer token 283 + 284 + **Headers:** 285 + - `Authorization: Bearer {access_token}` 286 + 287 + **Response:** 288 + ```json 289 + { 290 + "sub": "https://indiko.yourdomain.com/u/kieran", 291 + "name": "Kieran Klukas", 292 + "email": "kieran@example.com", 293 + "picture": "https://...", 294 + "website": "https://kierank.dev" 295 + } 296 + ``` 297 + 298 + ### User Profile & Settings 299 + 300 + #### `GET /settings` 301 + User settings page (requires session) 302 + 303 + **Shows:** 304 + - Profile form (name, email, photo, URL) 305 + - Connected apps list 306 + - Revoke access buttons 307 + - (Admin only) Invite generation 308 + 309 + #### `POST /settings/profile` 310 + Update user profile 311 + 312 + **Body:** 313 + ```json 314 + { 315 + "name": "Kieran Klukas", 316 + "email": "kieran@example.com", 317 + "photo": "https://...", 318 + "url": "https://kierank.dev" 319 + } 320 + ``` 321 + 322 + **Response:** 323 + ```json 324 + { 325 + "success": true, 326 + "profile": { ... } 327 + } 328 + ``` 329 + 330 + #### `POST /settings/apps/:client_id/revoke` 331 + Revoke app access 332 + 333 + **Response:** 334 + ```json 335 + { 336 + "success": true 337 + } 338 + ``` 339 + 340 + #### `GET /u/:username` 341 + Public user profile page (h-card) 342 + 343 + **Response:** 344 + HTML page with microformats h-card: 345 + ```html 346 + <div class="h-card"> 347 + <img class="u-photo" src="..."> 348 + <a class="p-name u-url" href="...">Kieran Klukas</a> 349 + <a class="u-email" href="mailto:...">email</a> 350 + </div> 351 + ``` 352 + 353 + ### Admin Endpoints 354 + 355 + #### `POST /api/invites/create` 356 + Create invite link (admin only) 357 + 358 + **Headers:** 359 + - `Authorization: Bearer {token}` 360 + 361 + **Response:** 362 + ```json 363 + { 364 + "inviteCode": "abc123xyz" 365 + } 366 + ``` 367 + 368 + Usage: `https://indiko.yourdomain.com/login?invite=abc123xyz` 369 + 370 + ### Dashboard 371 + 372 + #### `GET /` 373 + Main dashboard (requires session) 374 + 375 + **Shows:** 376 + - User info 377 + - Test API button 378 + - (Admin only) Admin controls section 379 + - Generate invite link button 380 + - Invite display 381 + 382 + #### `GET /api/hello` 383 + Test endpoint (requires session) 384 + 385 + **Headers:** 386 + - `Authorization: Bearer {token}` 387 + 388 + **Response:** 389 + ```json 390 + { 391 + "message": "Hello kieran! You're authenticated with passkeys.", 392 + "username": "kieran", 393 + "isAdmin": true 394 + } 395 + ``` 396 + 397 + ## Session Behavior 398 + 399 + ### Single Sign-On 400 + - Once logged into indiko (valid session), subsequent app authorization requests: 401 + - Skip passkey authentication 402 + - Show consent screen directly 403 + - If app previously approved, auto-approve 404 + - Session duration: 24 hours 405 + - Passkey required only when session expires 406 + 407 + ### Security 408 + - PKCE required for all authorization flows 409 + - Authorization codes: 410 + - Single-use only 411 + - 60-second expiration 412 + - Bound to client_id and redirect_uri 413 + - State parameter required for CSRF protection 414 + 415 + ## Client Integration Example 416 + 417 + ### 1. Initiate Authorization 418 + ```javascript 419 + const params = new URLSearchParams({ 420 + response_type: 'code', 421 + client_id: 'https://blog.kierank.dev', 422 + redirect_uri: 'https://blog.kierank.dev/auth/callback', 423 + state: generateRandomState(), 424 + code_challenge: generatePKCEChallenge(verifier), 425 + code_challenge_method: 'S256', 426 + scope: 'profile email' 427 + }); 428 + 429 + window.location.href = `https://indiko.yourdomain.com/auth/authorize?${params}`; 430 + ``` 431 + 432 + ### 2. Handle Callback 433 + ```javascript 434 + // At https://blog.kierank.dev/auth/callback?code=...&state=... 435 + const code = new URLSearchParams(window.location.search).get('code'); 436 + const state = new URLSearchParams(window.location.search).get('state'); 437 + 438 + // Verify state matches 439 + 440 + // Exchange code for profile 441 + const response = await fetch('https://indiko.yourdomain.com/auth/token', { 442 + method: 'POST', 443 + headers: { 'Content-Type': 'application/json' }, 444 + body: JSON.stringify({ 445 + grant_type: 'authorization_code', 446 + code, 447 + client_id: 'https://blog.kierank.dev', 448 + redirect_uri: 'https://blog.kierank.dev/auth/callback', 449 + code_verifier: storedVerifier 450 + }) 451 + }); 452 + 453 + const { me, profile } = await response.json(); 454 + // me: "https://indiko.yourdomain.com/u/kieran" 455 + // profile: { name, email, photo, url } 456 + 457 + // Create session for user 458 + ``` 459 + 460 + ## Future Enhancements 461 + 462 + - Token endpoint for longer-lived access tokens 463 + - Refresh tokens 464 + - Client metadata endpoint discovery 465 + - Micropub support 466 + - WebSub notifications 467 + - Multiple passkey support per user 468 + - Email notifications for new logins 469 + - Audit log for admin 470 + - Rate limiting 471 + - Account recovery flow 472 + 473 + ## Standards Compliance 474 + 475 + - [IndieAuth Specification](https://indieauth.spec.indieweb.org/) 476 + - [WebAuthn/FIDO2](https://www.w3.org/TR/webauthn-2/) 477 + - [OAuth 2.0 PKCE](https://tools.ietf.org/html/rfc7636) 478 + - [Microformats h-card](http://microformats.org/wiki/h-card)
+82
bun.lock
··· 1 + { 2 + "lockfileVersion": 1, 3 + "configVersion": 1, 4 + "workspaces": { 5 + "": { 6 + "name": "indiko", 7 + "dependencies": { 8 + "@simplewebauthn/browser": "^13.2.2", 9 + "@simplewebauthn/server": "^13.2.2", 10 + "bun-sqlite-migrations": "^1.0.2", 11 + }, 12 + "devDependencies": { 13 + "@simplewebauthn/types": "^12.0.0", 14 + "@types/bun": "latest", 15 + }, 16 + "peerDependencies": { 17 + "typescript": "^5", 18 + }, 19 + }, 20 + }, 21 + "packages": { 22 + "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], 23 + 24 + "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], 25 + 26 + "@peculiar/asn1-android": ["@peculiar/asn1-android@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ=="], 27 + 28 + "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "@peculiar/asn1-x509-attr": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA=="], 29 + 30 + "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ=="], 31 + 32 + "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw=="], 33 + 34 + "@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.6.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-pkcs8": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ=="], 35 + 36 + "@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA=="], 37 + 38 + "@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.6.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-pfx": "^2.6.0", "@peculiar/asn1-pkcs8": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "@peculiar/asn1-x509-attr": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw=="], 39 + 40 + "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w=="], 41 + 42 + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.6.0", "", { "dependencies": { "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg=="], 43 + 44 + "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA=="], 45 + 46 + "@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA=="], 47 + 48 + "@peculiar/x509": ["@peculiar/x509@1.14.2", "", { "dependencies": { "@peculiar/asn1-cms": "^2.6.0", "@peculiar/asn1-csr": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.0", "@peculiar/asn1-pkcs9": "^2.6.0", "@peculiar/asn1-rsa": "^2.6.0", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-r2w1Hg6pODDs0zfAKHkSS5HLkOLSeburtcgwvlLLWWCixw+MmW3U6kD5ddyvc2Y2YdbGuVwCF2S2ASoU1cFAag=="], 49 + 50 + "@simplewebauthn/browser": ["@simplewebauthn/browser@13.2.2", "", {}, "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA=="], 51 + 52 + "@simplewebauthn/server": ["@simplewebauthn/server@13.2.2", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.3.10", "@peculiar/asn1-ecc": "^2.3.8", "@peculiar/asn1-rsa": "^2.3.8", "@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-x509": "^2.3.8", "@peculiar/x509": "^1.13.0" } }, "sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA=="], 53 + 54 + "@simplewebauthn/types": ["@simplewebauthn/types@12.0.0", "", {}, "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA=="], 55 + 56 + "@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="], 57 + 58 + "@types/node": ["@types/node@25.0.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-rl78HwuZlaDIUSeUKkmogkhebA+8K1Hy7tddZuJ3D0xV8pZSfsYGTsliGUol1JPzu9EKnTxPC4L1fiWouStRew=="], 59 + 60 + "asn1js": ["asn1js@3.0.7", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="], 61 + 62 + "bun-sqlite-migrations": ["bun-sqlite-migrations@1.0.2", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-WLw8q67KM+1RN7o4DqVVhmJASypuBp8fygrfA8QD5HZEjiP+E5hD1SV2dpyB7A4tFqLdUF8cdln7+Ptj5+Hz1Q=="], 63 + 64 + "bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="], 65 + 66 + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], 67 + 68 + "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], 69 + 70 + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], 71 + 72 + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 73 + 74 + "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], 75 + 76 + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 77 + 78 + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 79 + 80 + "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], 81 + } 82 + }
+13
crush.json
··· 1 + { 2 + "$schema": "https://charm.land/crush.json", 3 + "lsp": { 4 + "biome": { 5 + "command": "bunx", 6 + "args": ["biome", "lsp-proxy"] 7 + }, 8 + "typescript": { 9 + "command": "bunx", 10 + "args": ["typescript-language-server", "--stdio"] 11 + } 12 + } 13 + }
+22
package.json
··· 1 + { 2 + "name": "indiko", 3 + "module": "index.ts", 4 + "type": "module", 5 + "private": true, 6 + "scripts": { 7 + "dev": "bun run --hot src/index.ts", 8 + "start": "bun run src/index.ts" 9 + }, 10 + "devDependencies": { 11 + "@simplewebauthn/types": "^12.0.0", 12 + "@types/bun": "latest" 13 + }, 14 + "peerDependencies": { 15 + "typescript": "^5" 16 + }, 17 + "dependencies": { 18 + "@simplewebauthn/browser": "^13.2.2", 19 + "@simplewebauthn/server": "^13.2.2", 20 + "bun-sqlite-migrations": "^1.0.2" 21 + } 22 + }
+7
public/favicon.svg
··· 1 + <svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 + <rect width="256" height="256" fill="#26242B"/> 3 + <path d="M28.175 207L25.1959 81.425H39.0209L42 207H28.175Z" fill="#D9D9D9"/> 4 + <path d="M69.875 196.5L68 53.5H81.825L83.7 146.1H86.15L123.566 81.425H142.816L97.175 152.575L146 196.5H126.925L86.15 159.225H83.7V196.5H69.875Z" fill="#D9D9D9"/> 5 + <path d="M183.75 185.475C175.117 185.475 167.475 183.667 160.825 180.05C154.292 176.433 149.158 171.358 145.425 164.825C141.808 158.175 140 150.358 140 141.375V139.1C140 130.233 141.808 122.475 145.425 115.825C149.158 109.175 154.292 104.042 160.825 100.425C167.475 96.8083 175.117 95 183.75 95C192.383 95 199.967 96.8083 206.5 100.425C213.15 104.042 218.283 109.175 221.9 115.825C225.633 122.475 227.5 130.233 227.5 139.1V141.375C227.5 150.358 225.633 158.175 221.9 164.825C218.283 171.358 213.15 176.433 206.5 180.05C199.967 183.667 192.383 185.475 183.75 185.475Z" fill="#D9D9D9"/> 6 + <path d="M37 19C50.8071 19 62 30.1929 62 44C62 57.8071 50.8071 69 37 69C23.1929 69 12 57.8071 12 44C12 30.1929 23.1929 19 37 19ZM36.5 33C30.701 33 26 37.701 26 43.5C26 49.299 30.701 54 36.5 54C42.299 54 47 49.299 47 43.5C47 37.701 42.299 33 36.5 33Z" fill="#D9D9D9"/> 7 + </svg>
+20
public/logo.svg
··· 1 + <svg width="512" height="166" viewBox="0 0 512 166" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 + <mask id="mask0_1_2" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="18" y="8" width="479" height="151"> 3 + <path d="M18 156V33.5L37.5 33.5L37 156.5L18 156Z" fill="#D9D9D9"/> 4 + <path d="M61.2496 156V70.425H74.7246V84.95H77.1746C79.0413 80.8667 82.1913 77.1917 86.6246 73.925C91.1746 70.5417 97.8246 68.85 106.575 68.85C112.991 68.85 118.708 70.1917 123.725 72.875C128.858 75.5583 132.941 79.525 135.975 84.775C139.008 90.025 140.525 96.5 140.525 104.2V156L124 156V105.25C124 96.7333 121.842 90.55 117.525 86.7C113.325 82.85 110.308 80.925 103.075 80.925C94.7913 80.925 91.75 83.6333 86.5 89C81.3667 94.3667 78.8 102.358 78.8 112.975V156.025L61.2496 156Z" fill="#D9D9D9"/> 5 + <path d="M204.799 158.45C197.449 158.45 190.682 156.7 184.499 153.2C178.315 149.7 173.415 144.625 169.799 137.975C166.182 131.325 164.374 123.45 164.374 114.35V112.075C164.374 102.975 166.182 95.1583 169.799 88.625C173.415 81.975 178.257 76.9 184.324 73.4C190.507 69.7833 197.332 67.975 204.799 67.975C210.749 67.975 215.766 68.7917 219.849 70.425C224.049 71.9417 227.432 73.925 229.999 76.375C232.565 78.7083 234.549 81.2167 235.949 83.9H238.399V33.5H252.224V156H238.749V141.825H236.299C233.965 146.142 230.349 149.992 225.449 153.375C220.549 156.758 213.665 158.45 204.799 158.45ZM208.474 146.2C217.34 146.2 224.515 143.4 229.999 137.8C235.599 132.083 238.399 124.15 238.399 114V112.425C238.399 102.275 235.599 94.4 229.999 88.8C224.515 83.0833 217.34 80.225 208.474 80.225C199.724 80.225 192.491 83.0833 186.774 88.8C181.174 94.4 178.374 102.275 178.374 112.425V114C178.374 124.15 181.174 132.083 186.774 137.8C192.491 143.4 199.724 146.2 208.474 146.2Z" fill="#D9D9D9"/> 6 + <path d="M281.196 156V70.425H295.021V156H281.196Z" fill="#D9D9D9"/> 7 + <path d="M324.091 156L324 42.5H337.825L337.916 105.6H340.366L379.566 70.425H398.816L351.391 112.075L400.216 156H381.141L340.366 118.725H337.916V156H324.091Z" fill="#D9D9D9"/> 8 + <path d="M453.192 158.45C444.559 158.45 436.917 156.642 430.267 153.025C423.734 149.408 418.601 144.333 414.867 137.8C411.251 131.15 409.442 123.333 409.442 114.35V112.075C409.442 103.208 411.251 95.45 414.867 88.8C418.601 82.15 423.734 77.0167 430.267 73.4C436.917 69.7833 444.559 67.975 453.192 67.975C461.826 67.975 469.409 69.7833 475.942 73.4C482.592 77.0167 487.726 82.15 491.342 88.8C495.076 95.45 496.942 103.208 496.942 112.075V114.35C496.942 123.333 495.076 131.15 491.342 137.8C487.726 144.333 482.592 149.408 475.942 153.025C469.409 156.642 461.826 158.45 453.192 158.45Z" fill="#D9D9D9"/> 9 + <path d="M293 8C306.807 8 318 19.1929 318 33C318 46.8071 306.807 58 293 58C279.193 58 268 46.8071 268 33C268 19.1929 279.193 8 293 8ZM292.5 22C286.701 22 282 26.701 282 32.5C282 38.299 286.701 43 292.5 43C298.299 43 303 38.299 303 32.5C303 26.701 298.299 22 292.5 22Z" fill="#D9D9D9"/> 10 + </mask> 11 + <g mask="url(#mask0_1_2)"> 12 + <rect x="9" width="503" height="178" fill="url(#paint0_linear_1_2)"/> 13 + </g> 14 + <defs> 15 + <linearGradient id="paint0_linear_1_2" x1="356.649" y1="-80.3086" x2="363.972" y2="226.341" gradientUnits="userSpaceOnUse"> 16 + <stop offset="0.664432" stop-color="#AB4967"/> 17 + <stop offset="1" stop-color="#BE2B62"/> 18 + </linearGradient> 19 + </defs> 20 + </svg>
+48
src/client/index.ts
··· 1 + const token = localStorage.getItem('indiko_session'); 2 + const footer = document.getElementById('footer') as HTMLElement; 3 + 4 + // Check auth and display user 5 + async function checkAuth() { 6 + if (!token) { 7 + window.location.href = '/login'; 8 + return; 9 + } 10 + 11 + try { 12 + const response = await fetch('/api/hello', { 13 + headers: { 14 + 'Authorization': `Bearer ${token}`, 15 + }, 16 + }); 17 + 18 + if (response.status === 401) { 19 + localStorage.removeItem('indiko_session'); 20 + window.location.href = '/login'; 21 + return; 22 + } 23 + 24 + const data = await response.json(); 25 + 26 + footer.innerHTML = `signed in as <strong>${data.username}</strong> • <a href="/login" id="logoutLink">sign out</a>`; 27 + 28 + // Handle logout 29 + document.getElementById('logoutLink')?.addEventListener('click', async (e) => { 30 + e.preventDefault(); 31 + try { 32 + await fetch('/auth/logout', { 33 + method: 'POST', 34 + headers: { 35 + 'Authorization': `Bearer ${token}`, 36 + }, 37 + }); 38 + } catch (e) { } 39 + localStorage.removeItem('indiko_session'); 40 + window.location.href = '/login'; 41 + }); 42 + } catch (error) { 43 + console.error('Auth check failed:', error); 44 + footer.textContent = 'error loading user info'; 45 + } 46 + } 47 + 48 + checkAuth();
+154
src/client/login.ts
··· 1 + import { startAuthentication, startRegistration } from '@simplewebauthn/browser'; 2 + 3 + const loginForm = document.getElementById('loginForm') as HTMLFormElement; 4 + const registerForm = document.getElementById('registerForm') as HTMLFormElement; 5 + const message = document.getElementById('message') as HTMLDivElement; 6 + 7 + // Check if registration is allowed on page load 8 + async function checkRegistrationAllowed() { 9 + try { 10 + const response = await fetch('/auth/can-register'); 11 + const {canRegister} = await response.json(); 12 + 13 + if (canRegister) { 14 + // First user - show as admin registration 15 + const subtitleElement = document.querySelector('.subtitle'); 16 + if (subtitleElement) { 17 + subtitleElement.textContent = 'create admin account'; 18 + } 19 + (document.getElementById('registerUsername') as HTMLInputElement).placeholder = 'admin username'; 20 + (document.getElementById('registerBtn') as HTMLButtonElement).textContent = 'create admin account'; 21 + // Hide login form for first setup 22 + loginForm.style.display = 'none'; 23 + registerForm.style.display = 'block'; 24 + } 25 + } catch (error) { 26 + console.error('Failed to check registration status:', error); 27 + } 28 + } 29 + 30 + checkRegistrationAllowed(); 31 + 32 + function showMessage(text: string, type: 'error' | 'success' = 'error') { 33 + message.textContent = text; 34 + message.className = `message show ${type}`; 35 + setTimeout(() => message.classList.remove('show'), 5000); 36 + } 37 + 38 + // Login flow 39 + loginForm.addEventListener('submit', async (e) => { 40 + e.preventDefault(); 41 + const username = (document.getElementById('username') as HTMLInputElement).value; 42 + const loginBtn = document.getElementById('loginBtn') as HTMLButtonElement; 43 + 44 + try { 45 + loginBtn.disabled = true; 46 + loginBtn.textContent = 'preparing...'; 47 + 48 + // Get authentication options 49 + const optionsRes = await fetch('/auth/login/options', { 50 + method: 'POST', 51 + headers: {'Content-Type': 'application/json'}, 52 + body: JSON.stringify({username}) 53 + }); 54 + 55 + if (!optionsRes.ok) { 56 + const error = await optionsRes.json(); 57 + throw new Error(error.error || 'Failed to get auth options'); 58 + } 59 + 60 + const options = await optionsRes.json(); 61 + 62 + loginBtn.textContent = 'use your passkey...'; 63 + 64 + // Start authentication 65 + const authResponse = await startAuthentication(options); 66 + 67 + loginBtn.textContent = 'verifying...'; 68 + 69 + // Verify authentication 70 + const verifyRes = await fetch('/auth/login/verify', { 71 + method: 'POST', 72 + headers: {'Content-Type': 'application/json'}, 73 + body: JSON.stringify({username, response: authResponse}) 74 + }); 75 + 76 + if (!verifyRes.ok) { 77 + const error = await verifyRes.json(); 78 + throw new Error(error.error || 'Authentication failed'); 79 + } 80 + 81 + const {token} = await verifyRes.json(); 82 + localStorage.setItem('indiko_session', token); 83 + 84 + showMessage('Login successful!', 'success'); 85 + const redirectTimer = setTimeout(() => { 86 + window.location.href = '/'; 87 + }, 1000); 88 + (redirectTimer as unknown as number); 89 + 90 + } catch (error) { 91 + showMessage((error as Error).message || 'Authentication failed'); 92 + loginBtn.disabled = false; 93 + loginBtn.textContent = 'sign in'; 94 + } 95 + }); 96 + 97 + // Registration flow 98 + registerForm.addEventListener('submit', async (e) => { 99 + e.preventDefault(); 100 + const username = (document.getElementById('registerUsername') as HTMLInputElement).value; 101 + const registerBtn = document.getElementById('registerBtn') as HTMLButtonElement; 102 + 103 + try { 104 + registerBtn.disabled = true; 105 + registerBtn.textContent = 'preparing...'; 106 + 107 + // Get registration options 108 + const optionsRes = await fetch('/auth/register/options', { 109 + method: 'POST', 110 + headers: {'Content-Type': 'application/json'}, 111 + body: JSON.stringify({username}) 112 + }); 113 + 114 + if (!optionsRes.ok) { 115 + const error = await optionsRes.json(); 116 + throw new Error(error.error || 'Failed to get registration options'); 117 + } 118 + 119 + const options = await optionsRes.json(); 120 + 121 + registerBtn.textContent = 'create your passkey...'; 122 + 123 + // Start registration 124 + const regResponse = await startRegistration(options); 125 + 126 + registerBtn.textContent = 'verifying...'; 127 + 128 + // Verify registration 129 + const verifyRes = await fetch('/auth/register/verify', { 130 + method: 'POST', 131 + headers: {'Content-Type': 'application/json'}, 132 + body: JSON.stringify({username, response: regResponse, challenge: options.challenge}) 133 + }); 134 + 135 + if (!verifyRes.ok) { 136 + const error = await verifyRes.json(); 137 + throw new Error(error.error || 'Registration failed'); 138 + } 139 + 140 + const {token} = await verifyRes.json(); 141 + localStorage.setItem('indiko_session', token); 142 + 143 + showMessage('Registration successful!', 'success'); 144 + const redirectTimer = setTimeout(() => { 145 + window.location.href = '/'; 146 + }, 1000); 147 + (redirectTimer as unknown as number); 148 + 149 + } catch (error) { 150 + showMessage((error as Error).message || 'Registration failed'); 151 + registerBtn.disabled = false; 152 + registerBtn.textContent = 'register passkey'; 153 + } 154 + });
+14
src/db.ts
··· 1 + import { Database } from "bun:sqlite"; 2 + import { getMigrations, migrate } from "bun-sqlite-migrations"; 3 + 4 + Bun.write("data/.gitkeep", ""); 5 + 6 + const db = new Database("data/indiko.db"); 7 + 8 + db.run("PRAGMA journal_mode = WAL;"); 9 + db.run("PRAGMA foreign_keys = ON;"); 10 + db.run("PRAGMA synchronous = NORMAL;"); 11 + 12 + migrate(db, getMigrations("src/migrations")); 13 + 14 + export { db };
+108
src/html/index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + 4 + <head> 5 + <meta charset="UTF-8" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 + <title>indiko</title> 8 + <link rel="icon" href="../../public/favicon.svg" type="image/svg+xml" /> 9 + <link rel="preconnect" href="https://fonts.googleapis.com"> 10 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 11 + <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet"> 12 + <style> 13 + :root { 14 + --mahogany: #26242b; 15 + --lavender: #d9d0de; 16 + --old-rose: #bc8da0; 17 + --rosewood: #a04668; 18 + --berry-crush: #ab4967; 19 + } 20 + 21 + * { 22 + margin: 0; 23 + padding: 0; 24 + box-sizing: border-box; 25 + } 26 + 27 + body { 28 + font-family: "Space Grotesk", sans-serif; 29 + background: var(--mahogany); 30 + color: var(--lavender); 31 + min-height: 100vh; 32 + display: flex; 33 + flex-direction: column; 34 + align-items: center; 35 + padding: 2.5rem 1.25rem; 36 + } 37 + 38 + header { 39 + width: 100%; 40 + max-width: 56.25rem; 41 + align-self: flex-start; 42 + margin-left: auto; 43 + margin-right: auto; 44 + } 45 + 46 + h1 { 47 + font-size: 2rem; 48 + font-weight: 700; 49 + background: linear-gradient(135deg, var(--old-rose), var(--berry-crush), var(--rosewood)); 50 + -webkit-background-clip: text; 51 + -webkit-text-fill-color: transparent; 52 + background-clip: text; 53 + letter-spacing: -0.125rem; 54 + } 55 + 56 + main { 57 + flex: 1; 58 + display: flex; 59 + align-items: center; 60 + justify-content: center; 61 + width: 100%; 62 + } 63 + 64 + footer { 65 + width: 100%; 66 + max-width: 56.25rem; 67 + padding: 1rem; 68 + text-align: center; 69 + color: var(--old-rose); 70 + font-size: 0.875rem; 71 + font-weight: 300; 72 + letter-spacing: 0.05rem; 73 + } 74 + 75 + footer strong { 76 + color: var(--lavender); 77 + font-weight: 500; 78 + } 79 + 80 + footer a { 81 + color: var(--berry-crush); 82 + text-decoration: none; 83 + transition: color 0.2s; 84 + } 85 + 86 + footer a:hover { 87 + color: var(--rosewood); 88 + text-decoration: underline; 89 + } 90 + </style> 91 + </head> 92 + 93 + <body> 94 + <header> 95 + <img src="../../public/logo.svg" alt="indiko" style="height: 2rem; margin-bottom: 0.5rem;" /> 96 + </header> 97 + 98 + <main> 99 + </main> 100 + 101 + <footer id="footer"> 102 + loading... 103 + </footer> 104 + 105 + <script type="module" src="../client/index.ts"></script> 106 + </body> 107 + 108 + </html>
+269
src/html/login.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + 4 + <head> 5 + <meta charset="UTF-8" /> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 + <title>login • indiko</title> 8 + <link rel="icon" href="../../public/favicon.svg" type="image/svg+xml" /> 9 + <link rel="preconnect" href="https://fonts.googleapis.com"> 10 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 11 + <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet"> 12 + <style> 13 + :root { 14 + --mahogany: #26242b; 15 + --lavender: #d9d0de; 16 + --old-rose: #bc8da0; 17 + --rosewood: #a04668; 18 + --berry-crush: #ab4967; 19 + } 20 + 21 + * { 22 + margin: 0; 23 + padding: 0; 24 + box-sizing: border-box; 25 + } 26 + 27 + body { 28 + font-family: "Space Grotesk", sans-serif; 29 + background: var(--mahogany); 30 + color: var(--lavender); 31 + min-height: 100vh; 32 + padding: 2.5rem 1.25rem; 33 + display: flex; 34 + flex-direction: column; 35 + align-items: center; 36 + justify-content: center; 37 + } 38 + 39 + main { 40 + max-width: 28rem; 41 + width: 100%; 42 + text-align: center; 43 + } 44 + 45 + h1 { 46 + font-size: 3rem; 47 + margin-bottom: 0.5rem; 48 + font-weight: 700; 49 + background: linear-gradient(135deg, var(--old-rose), var(--berry-crush), var(--rosewood)); 50 + -webkit-background-clip: text; 51 + -webkit-text-fill-color: transparent; 52 + background-clip: text; 53 + letter-spacing: -0.125rem; 54 + } 55 + 56 + .subtitle { 57 + color: var(--old-rose); 58 + margin-bottom: 2rem; 59 + font-size: 1rem; 60 + font-weight: 300; 61 + letter-spacing: 0.05rem; 62 + } 63 + 64 + .auth-box { 65 + background: rgba(188, 141, 160, 0.05); 66 + border: 1px solid var(--old-rose); 67 + border-radius: 0; 68 + padding: 2rem; 69 + margin-bottom: 1rem; 70 + } 71 + 72 + input[type="text"] { 73 + width: 100%; 74 + padding: 0.875rem 1rem; 75 + background: rgba(12, 23, 19, 0.6); 76 + border: 2px solid var(--rosewood); 77 + border-radius: 0; 78 + color: var(--lavender); 79 + font-size: 1rem; 80 + font-family: "Space Grotesk", sans-serif; 81 + margin-bottom: 1rem; 82 + transition: border-color 0.2s; 83 + letter-spacing: 0.025rem; 84 + } 85 + 86 + input[type="text"]:focus { 87 + outline: none; 88 + border-color: var(--berry-crush); 89 + background: rgba(12, 23, 19, 0.8); 90 + } 91 + 92 + input::placeholder { 93 + color: rgba(217, 208, 222, 0.4); 94 + } 95 + 96 + button { 97 + position: relative; 98 + width: 100%; 99 + padding: 1.25rem 2rem; 100 + background: var(--berry-crush); 101 + color: var(--lavender); 102 + border: 4px solid var(--mahogany); 103 + border-radius: 0; 104 + font-size: 1.125rem; 105 + font-weight: 700; 106 + cursor: pointer; 107 + font-family: "Space Grotesk", sans-serif; 108 + transition: all 0.15s ease; 109 + text-transform: uppercase; 110 + letter-spacing: 0.1rem; 111 + margin-bottom: 0.75rem; 112 + box-shadow: 6px 6px 0 var(--mahogany); 113 + } 114 + 115 + button::before { 116 + content: ''; 117 + position: absolute; 118 + top: -4px; 119 + left: -4px; 120 + right: -4px; 121 + bottom: -4px; 122 + background: transparent; 123 + border: 4px solid var(--rosewood); 124 + pointer-events: none; 125 + transition: all 0.15s ease; 126 + } 127 + 128 + button:hover:not(:disabled) { 129 + transform: translate(3px, 3px); 130 + box-shadow: 3px 3px 0 var(--mahogany); 131 + } 132 + 133 + button:hover:not(:disabled)::before { 134 + top: -7px; 135 + left: -7px; 136 + right: -7px; 137 + bottom: -7px; 138 + } 139 + 140 + button:active:not(:disabled) { 141 + transform: translate(6px, 6px); 142 + box-shadow: 0 0 0 var(--mahogany); 143 + } 144 + 145 + button:disabled { 146 + opacity: 0.5; 147 + cursor: not-allowed; 148 + } 149 + 150 + .secondary-btn { 151 + background: transparent; 152 + color: var(--old-rose); 153 + box-shadow: 4px 4px 0 var(--mahogany); 154 + } 155 + 156 + .secondary-btn::before { 157 + border-color: var(--old-rose); 158 + } 159 + 160 + .secondary-btn:hover:not(:disabled) { 161 + background: rgba(188, 141, 160, 0.1); 162 + } 163 + 164 + .message { 165 + margin-bottom: 1rem; 166 + padding: 0.875rem; 167 + border-radius: 0.5rem; 168 + font-size: 0.875rem; 169 + letter-spacing: 0.025rem; 170 + display: none; 171 + } 172 + 173 + .message.show { 174 + display: block; 175 + } 176 + 177 + .message.error { 178 + background: rgba(160, 70, 104, 0.2); 179 + border: 2px solid var(--rosewood); 180 + color: var(--lavender); 181 + } 182 + 183 + .message.success { 184 + background: rgba(188, 141, 160, 0.2); 185 + border: 2px solid var(--old-rose); 186 + color: var(--lavender); 187 + } 188 + 189 + .divider { 190 + display: flex; 191 + align-items: center; 192 + text-align: center; 193 + margin: 1.5rem 0; 194 + color: var(--old-rose); 195 + font-size: 0.875rem; 196 + font-weight: 300; 197 + } 198 + 199 + .divider::before, 200 + .divider::after { 201 + content: ''; 202 + flex: 1; 203 + border-bottom: 1px solid rgba(188, 141, 160, 0.3); 204 + } 205 + 206 + .divider span { 207 + padding: 0 1rem; 208 + } 209 + 210 + .info { 211 + margin-top: 1.5rem; 212 + padding: 1rem; 213 + background: rgba(188, 141, 160, 0.05); 214 + border-radius: 0.5rem; 215 + font-size: 0.8125rem; 216 + color: var(--old-rose); 217 + line-height: 1.6; 218 + text-align: left; 219 + } 220 + 221 + .info strong { 222 + color: var(--lavender); 223 + } 224 + 225 + a { 226 + color: var(--berry-crush); 227 + text-decoration: none; 228 + transition: color 0.2s; 229 + } 230 + 231 + a:hover { 232 + color: var(--rosewood); 233 + text-decoration: underline; 234 + } 235 + </style> 236 + </head> 237 + 238 + <body> 239 + <main> 240 + <img src="../../public/logo.svg" alt="indiko" style="height: 3rem; margin-bottom: 0.5rem;" /> 241 + <p class="subtitle">sign in with passkey</p> 242 + 243 + <div id="message" class="message"></div> 244 + 245 + <div class="auth-box"> 246 + <form id="loginForm"> 247 + <input type="text" id="username" placeholder="username" required autocomplete="username webauthn" /> 248 + <button type="submit" id="loginBtn">sign in</button> 249 + </form> 250 + 251 + <form id="registerForm" style="display: none;"> 252 + <input type="text" id="registerUsername" placeholder="create username" required 253 + autocomplete="username webauthn" /> 254 + <button type="submit" class="secondary-btn" id="registerBtn">create passkey</button> 255 + </form> 256 + </div> 257 + 258 + <div class="info"> 259 + <strong>What's a passkey?</strong><br> 260 + A passwordless login using your device's biometrics (fingerprint, face) or security key. More secure than 261 + passwords, no typing required. 262 + </div> 263 + </main> 264 + 265 + <script type="module" src="../client/login.ts"></script> 266 + 267 + </body> 268 + 269 + </html>
+56
src/index.ts
··· 1 + import { env } from "bun"; 2 + import { db } from "./db"; 3 + import indexHTML from "./html/index.html"; 4 + import loginHTML from "./html/login.html"; 5 + import { canRegister, registerOptions, registerVerify, loginOptions, loginVerify } from "./routes/auth"; 6 + import { hello } from "./routes/api"; 7 + 8 + (() => { 9 + const required = ["ORIGIN", "RP_ID"]; 10 + 11 + const missing = required.filter((key) => !process.env[key]); 12 + 13 + if (missing.length > 0) { 14 + console.warn( 15 + `[Startup] Missing required envivonment variables: ${missing.join(", ")}`, 16 + ); 17 + process.exit(1); 18 + } 19 + })(); 20 + 21 + const server = Bun.serve({ 22 + port: env.PORT ? Number.parseInt(env.PORT, 10) : 3000, 23 + routes: { 24 + "/": indexHTML, 25 + "/login": loginHTML, 26 + // API endpoints 27 + "/api/hello": hello, 28 + "/auth/can-register": canRegister, 29 + "/auth/register/options": registerOptions, 30 + "/auth/register/verify": registerVerify, 31 + "/auth/login/options": loginOptions, 32 + "/auth/login/verify": loginVerify, 33 + }, 34 + development: process.env.NODE_ENV === "dev", 35 + }); 36 + 37 + console.log("[Indiko] running on", env.ORIGIN); 38 + 39 + let is_shutting_down = false; 40 + function shutdown(sig: string) { 41 + if (is_shutting_down) return; 42 + is_shutting_down = true; 43 + 44 + console.log(`[Shutdown] triggering shutdown due to ${sig}`); 45 + 46 + server.stop(); 47 + console.log("[Shutdown] stopped server"); 48 + 49 + db.close(); 50 + console.log("[Shutdown] closed db"); 51 + 52 + process.exit(0); 53 + } 54 + 55 + process.on("SIGTERM", () => shutdown("SIGTERM")); 56 + process.on("SIGINT", () => shutdown("SIGINT"));
+58
src/migrations/001_init.sql
··· 1 + -- Full schema for indiko 2 + CREATE TABLE IF NOT EXISTS users ( 3 + id INTEGER PRIMARY KEY AUTOINCREMENT, 4 + username TEXT NOT NULL UNIQUE, 5 + name TEXT NOT NULL, 6 + email TEXT, 7 + photo TEXT, 8 + url TEXT, 9 + is_admin INTEGER NOT NULL DEFAULT 0, 10 + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) 11 + ); 12 + 13 + CREATE TABLE IF NOT EXISTS credentials ( 14 + id INTEGER PRIMARY KEY AUTOINCREMENT, 15 + user_id INTEGER NOT NULL, 16 + credential_id BLOB NOT NULL UNIQUE, 17 + public_key BLOB NOT NULL, 18 + counter INTEGER NOT NULL DEFAULT 0, 19 + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), 20 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 21 + ); 22 + 23 + CREATE TABLE IF NOT EXISTS sessions ( 24 + id INTEGER PRIMARY KEY AUTOINCREMENT, 25 + token TEXT NOT NULL UNIQUE, 26 + user_id INTEGER NOT NULL, 27 + expires_at INTEGER NOT NULL, 28 + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), 29 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 30 + ); 31 + 32 + CREATE TABLE IF NOT EXISTS challenges ( 33 + id INTEGER PRIMARY KEY AUTOINCREMENT, 34 + challenge TEXT NOT NULL UNIQUE, 35 + username TEXT NOT NULL, 36 + type TEXT NOT NULL CHECK(type IN ('registration', 'authentication')), 37 + expires_at INTEGER NOT NULL, 38 + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) 39 + ); 40 + 41 + CREATE TABLE IF NOT EXISTS invites ( 42 + id INTEGER PRIMARY KEY AUTOINCREMENT, 43 + code TEXT NOT NULL UNIQUE, 44 + created_by INTEGER NOT NULL, 45 + used INTEGER NOT NULL DEFAULT 0, 46 + used_by INTEGER, 47 + used_at INTEGER, 48 + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), 49 + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, 50 + FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL 51 + ); 52 + 53 + CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); 54 + CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); 55 + CREATE INDEX IF NOT EXISTS idx_challenges_challenge ON challenges(challenge); 56 + CREATE INDEX IF NOT EXISTS idx_challenges_expires_at ON challenges(expires_at); 57 + CREATE INDEX IF NOT EXISTS idx_credentials_user_id ON credentials(user_id); 58 + CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(code);
+38
src/routes/api.ts
··· 1 + import { db } from "../db"; 2 + 3 + export function hello(req: Request): Response { 4 + const authHeader = req.headers.get("Authorization"); 5 + 6 + if (!authHeader || !authHeader.startsWith("Bearer ")) { 7 + return Response.json({ error: "Unauthorized" }, { status: 401 }); 8 + } 9 + 10 + const token = authHeader.substring(7); 11 + 12 + // Look up session 13 + const session = db 14 + .query( 15 + `SELECT s.expires_at, u.username, u.is_admin 16 + FROM sessions s 17 + JOIN users u ON s.user_id = u.id 18 + WHERE s.token = ?`, 19 + ) 20 + .get(token) as 21 + | { expires_at: number; username: string; is_admin: number } 22 + | undefined; 23 + 24 + if (!session) { 25 + return Response.json({ error: "Invalid session" }, { status: 401 }); 26 + } 27 + 28 + const now = Math.floor(Date.now() / 1000); 29 + if (session.expires_at < now) { 30 + return Response.json({ error: "Session expired" }, { status: 401 }); 31 + } 32 + 33 + return Response.json({ 34 + message: `Hello ${session.username}! You're authenticated with passkeys.`, 35 + username: session.username, 36 + isAdmin: session.is_admin === 1, 37 + }); 38 + }
+390
src/routes/auth.ts
··· 1 + import { db } from "../db"; 2 + import { 3 + generateRegistrationOptions, 4 + verifyRegistrationResponse, 5 + generateAuthenticationOptions, 6 + verifyAuthenticationResponse, 7 + type VerifiedRegistrationResponse, 8 + type VerifiedAuthenticationResponse, 9 + type PublicKeyCredentialCreationOptionsJSON, 10 + type RegistrationResponseJSON, 11 + type PublicKeyCredentialRequestOptionsJSON, 12 + type AuthenticationResponseJSON, 13 + } from "@simplewebauthn/server"; 14 + 15 + const RP_NAME = "Indiko"; 16 + 17 + export function canRegister(req: Request): Response { 18 + const userCount = db 19 + .query("SELECT COUNT(*) as count FROM users") 20 + .get() as { count: number }; 21 + 22 + return Response.json({ 23 + canRegister: userCount.count === 0, 24 + bootstrapMode: userCount.count === 0, 25 + }); 26 + } 27 + 28 + export async function registerOptions(req: Request): Promise<Response> { 29 + try { 30 + const body = await req.json(); 31 + const { username } = body; 32 + 33 + if (!username || typeof username !== "string") { 34 + return Response.json({ error: "Username required" }, { status: 400 }); 35 + } 36 + 37 + // Check if username already exists 38 + const existingUser = db 39 + .query("SELECT id FROM users WHERE username = ?") 40 + .get(username); 41 + 42 + if (existingUser) { 43 + return Response.json( 44 + { error: "Username already taken" }, 45 + { status: 400 }, 46 + ); 47 + } 48 + 49 + // Check if this is bootstrap (first user) 50 + const userCount = db 51 + .query("SELECT COUNT(*) as count FROM users") 52 + .get() as { count: number }; 53 + 54 + const isBootstrap = userCount.count === 0; 55 + 56 + if (!isBootstrap) { 57 + return Response.json({ error: "Registration closed" }, { status: 403 }); 58 + } 59 + 60 + // Generate WebAuthn registration options 61 + const options: PublicKeyCredentialCreationOptionsJSON = 62 + await generateRegistrationOptions({ 63 + rpName: RP_NAME, 64 + rpID: process.env.RP_ID!, 65 + userName: username, 66 + userDisplayName: username, 67 + attestationType: "none", 68 + authenticatorSelection: { 69 + residentKey: "required", 70 + userVerification: "required", 71 + authenticatorAttachment: "platform", 72 + }, 73 + }); 74 + 75 + // Store challenge 76 + const expiresAt = Math.floor(Date.now() / 1000) + 300; // 5 minutes 77 + db.query( 78 + "INSERT INTO challenges (challenge, username, type, expires_at) VALUES (?, ?, 'registration', ?)", 79 + ).run(options.challenge, username, expiresAt); 80 + 81 + return Response.json(options); 82 + } catch (error) { 83 + console.error("Registration options error:", error); 84 + return Response.json({ error: "Internal server error" }, { status: 500 }); 85 + } 86 + } 87 + 88 + export async function registerVerify(req: Request): Promise<Response> { 89 + try { 90 + const body = await req.json(); 91 + const { username, response, challenge: expectedChallenge } = body as { 92 + username: string; 93 + response: RegistrationResponseJSON; 94 + challenge?: string; 95 + }; 96 + 97 + if (!username || !response) { 98 + return Response.json( 99 + { error: "Username and response required" }, 100 + { status: 400 }, 101 + ); 102 + } 103 + 104 + // Check if username already exists 105 + const existingUser = db 106 + .query("SELECT id FROM users WHERE username = ?") 107 + .get(username); 108 + 109 + if (existingUser) { 110 + return Response.json( 111 + { error: "Username already taken" }, 112 + { status: 400 }, 113 + ); 114 + } 115 + 116 + // Verify challenge exists and is valid 117 + const challenge = db 118 + .query( 119 + "SELECT challenge, expires_at FROM challenges WHERE challenge = ? AND username = ? AND type = 'registration'", 120 + ) 121 + .get(expectedChallenge, username) as 122 + | { challenge: string; expires_at: number } 123 + | undefined; 124 + 125 + if (!challenge) { 126 + return Response.json({ error: "Invalid challenge" }, { status: 400 }); 127 + } 128 + 129 + const now = Math.floor(Date.now() / 1000); 130 + if (challenge.expires_at < now) { 131 + return Response.json({ error: "Challenge expired" }, { status: 400 }); 132 + } 133 + 134 + // Check if this is bootstrap (first user) 135 + const userCount = db 136 + .query("SELECT COUNT(*) as count FROM users") 137 + .get() as { count: number }; 138 + 139 + const isBootstrap = userCount.count === 0; 140 + 141 + if (!isBootstrap) { 142 + return Response.json({ error: "Registration closed" }, { status: 403 }); 143 + } 144 + 145 + // Verify WebAuthn response 146 + let verification: VerifiedRegistrationResponse; 147 + try { 148 + verification = await verifyRegistrationResponse({ 149 + response, 150 + expectedChallenge: challenge.challenge, 151 + expectedOrigin: process.env.ORIGIN!, 152 + expectedRPID: process.env.RP_ID!, 153 + }); 154 + } catch (error) { 155 + console.error("WebAuthn verification failed:", error); 156 + return Response.json( 157 + { error: "Verification failed" }, 158 + { status: 400 }, 159 + ); 160 + } 161 + 162 + if (!verification.verified || !verification.registrationInfo) { 163 + return Response.json( 164 + { error: "Verification failed" }, 165 + { status: 400 }, 166 + ); 167 + } 168 + 169 + const { credential } = verification.registrationInfo; 170 + 171 + // Create user (bootstrap is always admin) 172 + const insertUser = db.query( 173 + "INSERT INTO users (username, name, is_admin) VALUES (?, ?, 1) RETURNING id", 174 + ); 175 + const user = insertUser.get(username, username) as { 176 + id: number; 177 + }; 178 + 179 + // Store credential 180 + // credential.id is a Uint8Array, convert to Buffer for storage 181 + db.query( 182 + "INSERT INTO credentials (user_id, credential_id, public_key, counter) VALUES (?, ?, ?, ?)", 183 + ).run( 184 + user.id, 185 + Buffer.from(credential.id), 186 + Buffer.from(credential.publicKey), 187 + credential.counter, 188 + ); 189 + 190 + // Delete challenge 191 + db.query("DELETE FROM challenges WHERE challenge = ?").run( 192 + challenge.challenge, 193 + ); 194 + 195 + // Create session 196 + const token = crypto.randomUUID(); 197 + const expiresAt = Math.floor(Date.now() / 1000) + 86400; // 24 hours 198 + db.query( 199 + "INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)", 200 + ).run(token, user.id, expiresAt); 201 + 202 + return Response.json({ 203 + token, 204 + username, 205 + isAdmin: true, 206 + }); 207 + } catch (error) { 208 + console.error("Registration verify error:", error); 209 + return Response.json({ error: "Internal server error" }, { status: 500 }); 210 + } 211 + } 212 + 213 + export async function loginOptions(req: Request): Promise<Response> { 214 + try { 215 + const body = await req.json(); 216 + const { username } = body; 217 + 218 + if (!username || typeof username !== "string") { 219 + return Response.json({ error: "Username required" }, { status: 400 }); 220 + } 221 + 222 + // Check if user exists 223 + const user = db 224 + .query("SELECT id FROM users WHERE username = ?") 225 + .get(username) as { id: number } | undefined; 226 + 227 + if (!user) { 228 + return Response.json({ error: "User not found" }, { status: 404 }); 229 + } 230 + 231 + // Get user's credentials (just to verify they exist) 232 + const credentials = db 233 + .query("SELECT credential_id FROM credentials WHERE user_id = ?") 234 + .all(user.id) as { credential_id: Buffer }[]; 235 + 236 + if (credentials.length === 0) { 237 + return Response.json( 238 + { error: "No credentials found" }, 239 + { status: 404 }, 240 + ); 241 + } 242 + 243 + // Generate authentication options 244 + // For discoverable credentials, omit allowCredentials to let password managers 245 + // show all available passkeys for this RP ID 246 + const options: PublicKeyCredentialRequestOptionsJSON = 247 + await generateAuthenticationOptions({ 248 + rpID: process.env.RP_ID!, 249 + userVerification: "required", 250 + }); 251 + 252 + // Store challenge 253 + const expiresAt = Math.floor(Date.now() / 1000) + 300; // 5 minutes 254 + db.query( 255 + "INSERT INTO challenges (challenge, username, type, expires_at) VALUES (?, ?, 'authentication', ?)", 256 + ).run(options.challenge, username, expiresAt); 257 + 258 + return Response.json(options); 259 + } catch (error) { 260 + console.error("Login options error:", error); 261 + return Response.json({ error: "Internal server error" }, { status: 500 }); 262 + } 263 + } 264 + 265 + export async function loginVerify(req: Request): Promise<Response> { 266 + try { 267 + const body = await req.json(); 268 + const { username, response } = body as { 269 + username: string; 270 + response: AuthenticationResponseJSON; 271 + }; 272 + 273 + if (!username || !response) { 274 + return Response.json( 275 + { error: "Username and response required" }, 276 + { status: 400 }, 277 + ); 278 + } 279 + 280 + // Look up credential by ID 281 + // Current database has credential_id stored as Buffer containing ASCII text of base64url string 282 + // So we need to compare the string value, not decode it 283 + const credentialIdString = response.id; // This is the base64url string like "rHvdOyMkR-6nxGBcDmtV4g" 284 + 285 + const credentialWithUser = db 286 + .query( 287 + "SELECT c.credential_id, c.public_key, c.counter, c.user_id, u.username FROM credentials c JOIN users u ON c.user_id = u.id WHERE c.credential_id = ?", 288 + ) 289 + .get(Buffer.from(credentialIdString)) as 290 + | { credential_id: Buffer; public_key: Buffer; counter: number; user_id: number; username: string } 291 + | undefined; 292 + 293 + if (!credentialWithUser) { 294 + return Response.json( 295 + { error: "Credential not found" }, 296 + { status: 404 }, 297 + ); 298 + } 299 + 300 + // Verify the username matches (if provided) 301 + if (username && credentialWithUser.username !== username) { 302 + return Response.json( 303 + { error: "Credential does not belong to this user" }, 304 + { status: 403 }, 305 + ); 306 + } 307 + 308 + const credential = { 309 + credential_id: credentialWithUser.credential_id, 310 + public_key: credentialWithUser.public_key, 311 + counter: credentialWithUser.counter, 312 + }; 313 + const user = { id: credentialWithUser.user_id }; 314 + 315 + // Verify challenge exists and is valid 316 + // Use the discovered username from the credential 317 + const challenge = db 318 + .query( 319 + "SELECT challenge, expires_at FROM challenges WHERE username = ? AND type = 'authentication' ORDER BY created_at DESC LIMIT 1", 320 + ) 321 + .get(credentialWithUser.username) as 322 + | { challenge: string; expires_at: number } 323 + | undefined; 324 + 325 + if (!challenge) { 326 + return Response.json({ error: "Invalid challenge" }, { status: 400 }); 327 + } 328 + 329 + const now = Math.floor(Date.now() / 1000); 330 + if (challenge.expires_at < now) { 331 + return Response.json({ error: "Challenge expired" }, { status: 400 }); 332 + } 333 + 334 + // Verify authentication response 335 + let verification: VerifiedAuthenticationResponse; 336 + try { 337 + verification = await verifyAuthenticationResponse({ 338 + response, 339 + expectedChallenge: challenge.challenge, 340 + expectedOrigin: process.env.ORIGIN!, 341 + expectedRPID: process.env.RP_ID!, 342 + credential: { 343 + id: credential.credential_id, 344 + publicKey: credential.public_key, 345 + counter: credential.counter, 346 + }, 347 + }); 348 + } catch (error) { 349 + console.error("WebAuthn verification failed:", error); 350 + return Response.json( 351 + { error: "Verification failed" }, 352 + { status: 400 }, 353 + ); 354 + } 355 + 356 + if (!verification.verified) { 357 + return Response.json( 358 + { error: "Verification failed" }, 359 + { status: 400 }, 360 + ); 361 + } 362 + 363 + // Update credential counter 364 + db.query("UPDATE credentials SET counter = ? WHERE user_id = ? AND credential_id = ?").run( 365 + verification.authenticationInfo.newCounter, 366 + user.id, 367 + credential.credential_id, 368 + ); 369 + 370 + // Delete challenge 371 + db.query("DELETE FROM challenges WHERE challenge = ?").run( 372 + challenge.challenge, 373 + ); 374 + 375 + // Create session 376 + const token = crypto.randomUUID(); 377 + const expiresAt = Math.floor(Date.now() / 1000) + 86400; // 24 hours 378 + db.query( 379 + "INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)", 380 + ).run(token, user.id, expiresAt); 381 + 382 + return Response.json({ 383 + token, 384 + username, 385 + }); 386 + } catch (error) { 387 + console.error("Login verify error:", error); 388 + return Response.json({ error: "Internal server error" }, { status: 500 }); 389 + } 390 + }
+33
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + // Environment setup & latest features 4 + "lib": ["ESNext", "DOM", "DOM.Iterable"], 5 + "target": "ESNext", 6 + "module": "Preserve", 7 + "moduleDetection": "force", 8 + "jsx": "preserve", 9 + "allowJs": true, 10 + 11 + // Bundler mode 12 + "moduleResolution": "bundler", 13 + "allowImportingTsExtensions": true, 14 + "verbatimModuleSyntax": true, 15 + "noEmit": true, 16 + 17 + // Decorators 18 + "experimentalDecorators": true, 19 + "useDefineForClassFields": false, 20 + 21 + // Best practices 22 + "strict": true, 23 + "skipLibCheck": true, 24 + "noFallthroughCasesInSwitch": true, 25 + "noUncheckedIndexedAccess": true, 26 + "noImplicitOverride": true, 27 + 28 + // Some stricter flags (disabled by default) 29 + "noUnusedLocals": false, 30 + "noUnusedParameters": false, 31 + "noPropertyAccessFromIndexSignature": false 32 + } 33 + }
+8
types/env.d.ts
··· 1 + declare module "bun" { 2 + interface Env { 3 + ORIGIN: string; 4 + RP_ID: string; 5 + NODE_ENV?: "dev" | "production"; 6 + PORT?: string; 7 + } 8 + }