From b574c7f000db83735fddae5d84da352ca928bc04 Mon Sep 17 00:00:00 2001 From: Kieran Klukas Date: Mon, 15 Dec 2025 15:47:50 -0500 Subject: [PATCH] feat: init --- .gitignore | 7 + CRUSH.md | 47 ++++ README.md | 2 +- SPEC.md | 478 ++++++++++++++++++++++++++++++++++++ bun.lock | 82 +++++++ crush.json | 13 + package.json | 22 ++ public/favicon.svg | 7 + public/logo.svg | 20 ++ src/client/index.ts | 48 ++++ src/client/login.ts | 154 ++++++++++++ src/db.ts | 14 ++ src/html/index.html | 108 ++++++++ src/html/login.html | 269 ++++++++++++++++++++ src/index.ts | 56 +++++ src/migrations/001_init.sql | 58 +++++ src/routes/api.ts | 38 +++ src/routes/auth.ts | 390 +++++++++++++++++++++++++++++ tsconfig.json | 33 +++ types/env.d.ts | 8 + 20 files changed, 1853 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 CRUSH.md create mode 100644 SPEC.md create mode 100644 bun.lock create mode 100644 crush.json create mode 100644 package.json create mode 100644 public/favicon.svg create mode 100644 public/logo.svg create mode 100644 src/client/index.ts create mode 100644 src/client/login.ts create mode 100644 src/db.ts create mode 100644 src/html/index.html create mode 100644 src/html/login.html create mode 100644 src/index.ts create mode 100644 src/migrations/001_init.sql create mode 100644 src/routes/api.ts create mode 100644 src/routes/auth.ts create mode 100644 tsconfig.json create mode 100644 types/env.d.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9aef3af --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +.wrangler/ +dist/ +.dev.vars +.env +bun.lockb +data/ diff --git a/CRUSH.md b/CRUSH.md new file mode 100644 index 0000000..7180cbb --- /dev/null +++ b/CRUSH.md @@ -0,0 +1,47 @@ +# Crush Memory - Indiko Project + +## User Preferences + +- **DO NOT** run the server - user will always run it themselves +- **DO NOT** test the server by starting it +- Use Bun's `routes` object in server config, not manual fetch handler routing + +## 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 +- Use Bun's built-in routing: `routes: { "/path": handler }` +- Example: `src/routes/auth.ts` contains authentication-related routes + +### Project Structure +``` +src/ +├── db.ts # Database setup and exports +├── index.ts # Main server entry point +├── routes/ # Route handlers (server-side) +│ └── auth.ts # Authentication routes +├── client/ # Client-side TypeScript modules +│ └── login.ts # Login page logic +├── html/ # HTML templates (Bun bundles them with script imports) +└── migrations/ # SQL migrations +``` + +### 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 +- Static assets (images, favicons) in `public/` are served at root path +- 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 + +## Commands + +(Add test/lint/build commands here as discovered) + +## Code Style + +- Use tabs for indentation +- TypeScript with Bun runtime +- Use SQLite with WAL mode +- Route handlers: `(req: Request) => Response` diff --git a/README.md b/README.md index 0dda0d4..17e3dbd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Indiko -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. +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. The canonical repo for this is hosted on tangled over at [`dunkirk.sh/indiko`](https://tangled.org/@dunkirk.sh/indiko) diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..9812508 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,478 @@ +# indiko - IndieAuth Server Specification + +## 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 +- Per-app access control +- Invite-based user registration + +## 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 +- Users grant/revoke app access via consent + +## User Identifier Format + +Users are identified by: `https://indiko.yourdomain.com/u/{username}` + +## Data Structures + +### Users +``` +user:{username} -> { + credential: { + credentialID: Uint8Array, + publicKey: Uint8Array, + counter: number + }, + isAdmin: boolean, + profile: { + name: string, + email: string, + photo: string, // URL + url: string // personal website + }, + createdAt: timestamp +} +``` + +### Admin Marker +``` +admin:user -> username // marks first/admin user +``` + +### Sessions +``` +session:{token} -> { + username: string, + expiresAt: timestamp +} +// TTL: 24 hours +``` + +### Apps (Auto-registered) +``` +app:{client_id} -> { + client_id: string, // e.g. "https://blog.kierank.dev" + redirect_uris: string[], + first_seen: timestamp, + last_used: timestamp, + name?: string // optional, from client metadata +} +``` + +### User Permissions (Per-App) +``` +permission:{username}:{client_id} -> { + scopes: string[], // e.g. ["profile", "email"] + granted_at: timestamp, + last_used: timestamp +} +``` + +### Authorization Codes (Short-lived) +``` +authcode:{code} -> { + username: string, + client_id: string, + redirect_uri: string, + scopes: string[], + code_challenge: string, // PKCE + expires_at: timestamp, + used: boolean +} +// TTL: 60 seconds +// Single-use only +``` + +### Invites +``` +invite:{code} -> { + code: string, + created_by: string, // admin username + created_at: timestamp, + used: boolean, + used_by?: string, + used_at?: timestamp +} +``` + +### Challenges (WebAuthn) +``` +challenge:{challenge} -> { + username: string, + type: "registration" | "authentication", + expires_at: timestamp +} +// TTL: 5 minutes +``` + +## Supported Scopes + +- `profile` - Name, photo, URL +- `email` - Email address +- (Future: custom scopes as needed) + +## Routes + +### 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 +- First user marked as admin +- 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 }` + +### 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 +- `state` (required) - CSRF protection +- `code_challenge` (required) - PKCE challenge +- `code_challenge_method=S256` (required) +- `scope` (optional) - Space-separated scopes (default: "profile") +- `me` (optional) - User's URL (hint) + +**Flow:** +1. Validate parameters +2. Auto-register app if not exists +3. If no session → redirect to `/login` +4. If session exists → show consent screen +5. Check if user previously approved this app + - If yes → auto-approve (skip consent) + - 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) +- `code_challenge` (required) +- `scopes` (required) +- `action` (required) - "allow" | "deny" + +**Flow:** +1. Validate CSRF token +2. Validate session +3. If denied → redirect with error +4. If allowed: + - Create authorization code + - Store permission grant + - Update app last_used + - 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", + "code": "authcode123", + "client_id": "https://blog.kierank.dev", + "redirect_uri": "https://blog.kierank.dev/auth/callback", + "code_verifier": "pkce_verifier_string" +} +``` + +**Flow:** +1. Validate authorization code exists +2. Verify code not expired +3. Verify code not already used +4. Verify client_id matches +5. Verify redirect_uri matches +6. Verify PKCE code_verifier +7. Mark code as used +8. Return user identity + profile + +**Success Response:** +```json +{ + "me": "https://indiko.yourdomain.com/u/kieran", + "profile": { + "name": "Kieran Klukas", + "email": "kieran@example.com", + "photo": "https://...", + "url": "https://kierank.dev" + } +} +``` + +**Error Response:** +```json +{ + "error": "invalid_grant", + "error_description": "Authorization code expired" +} +``` + +#### `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", + "name": "Kieran Klukas", + "email": "kieran@example.com", + "picture": "https://...", + "website": "https://kierank.dev" +} +``` + +### 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", + "email": "kieran@example.com", + "photo": "https://...", + "url": "https://kierank.dev" +} +``` + +**Response:** +```json +{ + "success": true, + "profile": { ... } +} +``` + +#### `POST /settings/apps/:client_id/revoke` +Revoke app access + +**Response:** +```json +{ + "success": true +} +``` + +#### `GET /u/:username` +Public user profile page (h-card) + +**Response:** +HTML page with microformats h-card: +```html +
+ + Kieran Klukas + email +
+``` + +### Admin Endpoints + +#### `POST /api/invites/create` +Create invite link (admin only) + +**Headers:** +- `Authorization: Bearer {token}` + +**Response:** +```json +{ + "inviteCode": "abc123xyz" +} +``` + +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 + - Generate invite link button + - Invite display + +#### `GET /api/hello` +Test endpoint (requires session) + +**Headers:** +- `Authorization: Bearer {token}` + +**Response:** +```json +{ + "message": "Hello kieran! You're authenticated with passkeys.", + "username": "kieran", + "isAdmin": true +} +``` + +## Session Behavior + +### Single Sign-On +- Once logged into indiko (valid session), subsequent app authorization requests: + - Skip passkey authentication + - Show consent screen directly + - If app previously approved, auto-approve +- Session duration: 24 hours +- Passkey required only when session expires + +### Security +- PKCE required for all authorization flows +- Authorization codes: + - Single-use only + - 60-second expiration + - Bound to client_id and redirect_uri +- State parameter required for CSRF protection + +## Client Integration Example + +### 1. Initiate Authorization +```javascript +const params = new URLSearchParams({ + response_type: 'code', + client_id: 'https://blog.kierank.dev', + redirect_uri: 'https://blog.kierank.dev/auth/callback', + state: generateRandomState(), + code_challenge: generatePKCEChallenge(verifier), + code_challenge_method: 'S256', + scope: 'profile email' +}); + +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'); +const state = new URLSearchParams(window.location.search).get('state'); + +// Verify state matches + +// Exchange code for profile +const response = await fetch('https://indiko.yourdomain.com/auth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'authorization_code', + code, + client_id: 'https://blog.kierank.dev', + redirect_uri: 'https://blog.kierank.dev/auth/callback', + code_verifier: storedVerifier + }) +}); + +const { me, profile } = await response.json(); +// me: "https://indiko.yourdomain.com/u/kieran" +// profile: { name, email, photo, url } + +// Create session for user +``` + +## Future Enhancements + +- Token endpoint for longer-lived access tokens +- Refresh tokens +- Client metadata endpoint discovery +- Micropub support +- WebSub notifications +- Multiple passkey support per user +- Email notifications for new logins +- Audit log for admin +- Rate limiting +- Account recovery flow + +## Standards Compliance + +- [IndieAuth Specification](https://indieauth.spec.indieweb.org/) +- [WebAuthn/FIDO2](https://www.w3.org/TR/webauthn-2/) +- [OAuth 2.0 PKCE](https://tools.ietf.org/html/rfc7636) +- [Microformats h-card](http://microformats.org/wiki/h-card) diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..c7508b0 --- /dev/null +++ b/bun.lock @@ -0,0 +1,82 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "indiko", + "dependencies": { + "@simplewebauthn/browser": "^13.2.2", + "@simplewebauthn/server": "^13.2.2", + "bun-sqlite-migrations": "^1.0.2", + }, + "devDependencies": { + "@simplewebauthn/types": "^12.0.0", + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], + + "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], + + "@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=="], + + "@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=="], + + "@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=="], + + "@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=="], + + "@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=="], + + "@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=="], + + "@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=="], + + "@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=="], + + "@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=="], + + "@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=="], + + "@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=="], + + "@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=="], + + "@simplewebauthn/browser": ["@simplewebauthn/browser@13.2.2", "", {}, "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA=="], + + "@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=="], + + "@simplewebauthn/types": ["@simplewebauthn/types@12.0.0", "", {}, "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA=="], + + "@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=="], + + "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=="], + + "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=="], + + "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=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + } +} diff --git a/crush.json b/crush.json new file mode 100644 index 0000000..a689f84 --- /dev/null +++ b/crush.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://charm.land/crush.json", + "lsp": { + "biome": { + "command": "bunx", + "args": ["biome", "lsp-proxy"] + }, + "typescript": { + "command": "bunx", + "args": ["typescript-language-server", "--stdio"] + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..352b2dd --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "indiko", + "module": "index.ts", + "type": "module", + "private": true, + "scripts": { + "dev": "bun run --hot src/index.ts", + "start": "bun run src/index.ts" + }, + "devDependencies": { + "@simplewebauthn/types": "^12.0.0", + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "@simplewebauthn/browser": "^13.2.2", + "@simplewebauthn/server": "^13.2.2", + "bun-sqlite-migrations": "^1.0.2" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..4223df9 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..95566e3 --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/client/index.ts b/src/client/index.ts new file mode 100644 index 0000000..f984b42 --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,48 @@ +const token = localStorage.getItem('indiko_session'); +const footer = document.getElementById('footer') as HTMLElement; + +// Check auth and display user +async function checkAuth() { + if (!token) { + window.location.href = '/login'; + return; + } + + try { + const response = await fetch('/api/hello', { + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + + if (response.status === 401) { + localStorage.removeItem('indiko_session'); + window.location.href = '/login'; + return; + } + + const data = await response.json(); + + footer.innerHTML = `signed in as ${data.username} • sign out`; + + // Handle logout + document.getElementById('logoutLink')?.addEventListener('click', async (e) => { + e.preventDefault(); + try { + await fetch('/auth/logout', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + } catch (e) { } + localStorage.removeItem('indiko_session'); + window.location.href = '/login'; + }); + } catch (error) { + console.error('Auth check failed:', error); + footer.textContent = 'error loading user info'; + } +} + +checkAuth(); diff --git a/src/client/login.ts b/src/client/login.ts new file mode 100644 index 0000000..f3fc615 --- /dev/null +++ b/src/client/login.ts @@ -0,0 +1,154 @@ +import { startAuthentication, startRegistration } from '@simplewebauthn/browser'; + +const loginForm = document.getElementById('loginForm') as HTMLFormElement; +const registerForm = document.getElementById('registerForm') as HTMLFormElement; +const message = document.getElementById('message') as HTMLDivElement; + +// Check if registration is allowed on page load +async function checkRegistrationAllowed() { + try { + const response = await fetch('/auth/can-register'); + const {canRegister} = await response.json(); + + if (canRegister) { + // First user - show as admin registration + const subtitleElement = document.querySelector('.subtitle'); + if (subtitleElement) { + subtitleElement.textContent = 'create admin account'; + } + (document.getElementById('registerUsername') as HTMLInputElement).placeholder = 'admin username'; + (document.getElementById('registerBtn') as HTMLButtonElement).textContent = 'create admin account'; + // Hide login form for first setup + loginForm.style.display = 'none'; + registerForm.style.display = 'block'; + } + } catch (error) { + console.error('Failed to check registration status:', error); + } +} + +checkRegistrationAllowed(); + +function showMessage(text: string, type: 'error' | 'success' = 'error') { + message.textContent = text; + message.className = `message show ${type}`; + setTimeout(() => message.classList.remove('show'), 5000); +} + +// Login flow +loginForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const username = (document.getElementById('username') as HTMLInputElement).value; + const loginBtn = document.getElementById('loginBtn') as HTMLButtonElement; + + try { + loginBtn.disabled = true; + loginBtn.textContent = 'preparing...'; + + // Get authentication options + const optionsRes = await fetch('/auth/login/options', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({username}) + }); + + if (!optionsRes.ok) { + const error = await optionsRes.json(); + throw new Error(error.error || 'Failed to get auth options'); + } + + const options = await optionsRes.json(); + + loginBtn.textContent = 'use your passkey...'; + + // Start authentication + const authResponse = await startAuthentication(options); + + loginBtn.textContent = 'verifying...'; + + // Verify authentication + const verifyRes = await fetch('/auth/login/verify', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({username, response: authResponse}) + }); + + if (!verifyRes.ok) { + const error = await verifyRes.json(); + throw new Error(error.error || 'Authentication failed'); + } + + const {token} = await verifyRes.json(); + localStorage.setItem('indiko_session', token); + + showMessage('Login successful!', 'success'); + const redirectTimer = setTimeout(() => { + window.location.href = '/'; + }, 1000); + (redirectTimer as unknown as number); + + } catch (error) { + showMessage((error as Error).message || 'Authentication failed'); + loginBtn.disabled = false; + loginBtn.textContent = 'sign in'; + } +}); + +// Registration flow +registerForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const username = (document.getElementById('registerUsername') as HTMLInputElement).value; + const registerBtn = document.getElementById('registerBtn') as HTMLButtonElement; + + try { + registerBtn.disabled = true; + registerBtn.textContent = 'preparing...'; + + // Get registration options + const optionsRes = await fetch('/auth/register/options', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({username}) + }); + + if (!optionsRes.ok) { + const error = await optionsRes.json(); + throw new Error(error.error || 'Failed to get registration options'); + } + + const options = await optionsRes.json(); + + registerBtn.textContent = 'create your passkey...'; + + // Start registration + const regResponse = await startRegistration(options); + + registerBtn.textContent = 'verifying...'; + + // Verify registration + const verifyRes = await fetch('/auth/register/verify', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({username, response: regResponse, challenge: options.challenge}) + }); + + if (!verifyRes.ok) { + const error = await verifyRes.json(); + throw new Error(error.error || 'Registration failed'); + } + + const {token} = await verifyRes.json(); + localStorage.setItem('indiko_session', token); + + showMessage('Registration successful!', 'success'); + const redirectTimer = setTimeout(() => { + window.location.href = '/'; + }, 1000); + (redirectTimer as unknown as number); + + } catch (error) { + showMessage((error as Error).message || 'Registration failed'); + registerBtn.disabled = false; + registerBtn.textContent = 'register passkey'; + } +}); diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..9292612 --- /dev/null +++ b/src/db.ts @@ -0,0 +1,14 @@ +import { Database } from "bun:sqlite"; +import { getMigrations, migrate } from "bun-sqlite-migrations"; + +Bun.write("data/.gitkeep", ""); + +const db = new Database("data/indiko.db"); + +db.run("PRAGMA journal_mode = WAL;"); +db.run("PRAGMA foreign_keys = ON;"); +db.run("PRAGMA synchronous = NORMAL;"); + +migrate(db, getMigrations("src/migrations")); + +export { db }; diff --git a/src/html/index.html b/src/html/index.html new file mode 100644 index 0000000..be8e7e8 --- /dev/null +++ b/src/html/index.html @@ -0,0 +1,108 @@ + + + + + + + indiko + + + + + + + + +
+ indiko +
+ +
+
+ + + + + + + \ No newline at end of file diff --git a/src/html/login.html b/src/html/login.html new file mode 100644 index 0000000..09cf61a --- /dev/null +++ b/src/html/login.html @@ -0,0 +1,269 @@ + + + + + + + login • indiko + + + + + + + + +
+ indiko +

sign in with passkey

+ +
+ +
+
+ + +
+ + +
+ +
+ What's a passkey?
+ A passwordless login using your device's biometrics (fingerprint, face) or security key. More secure than + passwords, no typing required. +
+
+ + + + + + \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..3117c44 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,56 @@ +import { env } from "bun"; +import { db } from "./db"; +import indexHTML from "./html/index.html"; +import loginHTML from "./html/login.html"; +import { canRegister, registerOptions, registerVerify, loginOptions, loginVerify } from "./routes/auth"; +import { hello } from "./routes/api"; + +(() => { + const required = ["ORIGIN", "RP_ID"]; + + const missing = required.filter((key) => !process.env[key]); + + if (missing.length > 0) { + console.warn( + `[Startup] Missing required envivonment variables: ${missing.join(", ")}`, + ); + process.exit(1); + } +})(); + +const server = Bun.serve({ + port: env.PORT ? Number.parseInt(env.PORT, 10) : 3000, + routes: { + "/": indexHTML, + "/login": loginHTML, + // API endpoints + "/api/hello": hello, + "/auth/can-register": canRegister, + "/auth/register/options": registerOptions, + "/auth/register/verify": registerVerify, + "/auth/login/options": loginOptions, + "/auth/login/verify": loginVerify, + }, + development: process.env.NODE_ENV === "dev", +}); + +console.log("[Indiko] running on", env.ORIGIN); + +let is_shutting_down = false; +function shutdown(sig: string) { + if (is_shutting_down) return; + is_shutting_down = true; + + console.log(`[Shutdown] triggering shutdown due to ${sig}`); + + server.stop(); + console.log("[Shutdown] stopped server"); + + db.close(); + console.log("[Shutdown] closed db"); + + process.exit(0); +} + +process.on("SIGTERM", () => shutdown("SIGTERM")); +process.on("SIGINT", () => shutdown("SIGINT")); diff --git a/src/migrations/001_init.sql b/src/migrations/001_init.sql new file mode 100644 index 0000000..33a2c73 --- /dev/null +++ b/src/migrations/001_init.sql @@ -0,0 +1,58 @@ +-- Full schema for indiko +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + email TEXT, + photo TEXT, + url TEXT, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +CREATE TABLE IF NOT EXISTS credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + credential_id BLOB NOT NULL UNIQUE, + public_key BLOB NOT NULL, + counter INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT NOT NULL UNIQUE, + user_id INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS challenges ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + challenge TEXT NOT NULL UNIQUE, + username TEXT NOT NULL, + type TEXT NOT NULL CHECK(type IN ('registration', 'authentication')), + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); + +CREATE TABLE IF NOT EXISTS invites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + created_by INTEGER NOT NULL, + used INTEGER NOT NULL DEFAULT 0, + used_by INTEGER, + used_at INTEGER, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); +CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); +CREATE INDEX IF NOT EXISTS idx_challenges_challenge ON challenges(challenge); +CREATE INDEX IF NOT EXISTS idx_challenges_expires_at ON challenges(expires_at); +CREATE INDEX IF NOT EXISTS idx_credentials_user_id ON credentials(user_id); +CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(code); diff --git a/src/routes/api.ts b/src/routes/api.ts new file mode 100644 index 0000000..aabb8b3 --- /dev/null +++ b/src/routes/api.ts @@ -0,0 +1,38 @@ +import { db } from "../db"; + +export function hello(req: Request): Response { + const authHeader = req.headers.get("Authorization"); + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const token = authHeader.substring(7); + + // Look up session + const session = db + .query( + `SELECT s.expires_at, u.username, u.is_admin + FROM sessions s + JOIN users u ON s.user_id = u.id + WHERE s.token = ?`, + ) + .get(token) as + | { expires_at: number; username: string; is_admin: number } + | undefined; + + if (!session) { + return Response.json({ error: "Invalid session" }, { status: 401 }); + } + + const now = Math.floor(Date.now() / 1000); + if (session.expires_at < now) { + return Response.json({ error: "Session expired" }, { status: 401 }); + } + + return Response.json({ + message: `Hello ${session.username}! You're authenticated with passkeys.`, + username: session.username, + isAdmin: session.is_admin === 1, + }); +} diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..daa4693 --- /dev/null +++ b/src/routes/auth.ts @@ -0,0 +1,390 @@ +import { db } from "../db"; +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, + type VerifiedRegistrationResponse, + type VerifiedAuthenticationResponse, + type PublicKeyCredentialCreationOptionsJSON, + type RegistrationResponseJSON, + type PublicKeyCredentialRequestOptionsJSON, + type AuthenticationResponseJSON, +} from "@simplewebauthn/server"; + +const RP_NAME = "Indiko"; + +export function canRegister(req: Request): Response { + const userCount = db + .query("SELECT COUNT(*) as count FROM users") + .get() as { count: number }; + + return Response.json({ + canRegister: userCount.count === 0, + bootstrapMode: userCount.count === 0, + }); +} + +export async function registerOptions(req: Request): Promise { + try { + const body = await req.json(); + const { username } = body; + + if (!username || typeof username !== "string") { + return Response.json({ error: "Username required" }, { status: 400 }); + } + + // Check if username already exists + const existingUser = db + .query("SELECT id FROM users WHERE username = ?") + .get(username); + + if (existingUser) { + return Response.json( + { error: "Username already taken" }, + { status: 400 }, + ); + } + + // Check if this is bootstrap (first user) + const userCount = db + .query("SELECT COUNT(*) as count FROM users") + .get() as { count: number }; + + const isBootstrap = userCount.count === 0; + + if (!isBootstrap) { + return Response.json({ error: "Registration closed" }, { status: 403 }); + } + + // Generate WebAuthn registration options + const options: PublicKeyCredentialCreationOptionsJSON = + await generateRegistrationOptions({ + rpName: RP_NAME, + rpID: process.env.RP_ID!, + userName: username, + userDisplayName: username, + attestationType: "none", + authenticatorSelection: { + residentKey: "required", + userVerification: "required", + authenticatorAttachment: "platform", + }, + }); + + // Store challenge + const expiresAt = Math.floor(Date.now() / 1000) + 300; // 5 minutes + db.query( + "INSERT INTO challenges (challenge, username, type, expires_at) VALUES (?, ?, 'registration', ?)", + ).run(options.challenge, username, expiresAt); + + return Response.json(options); + } catch (error) { + console.error("Registration options error:", error); + return Response.json({ error: "Internal server error" }, { status: 500 }); + } +} + +export async function registerVerify(req: Request): Promise { + try { + const body = await req.json(); + const { username, response, challenge: expectedChallenge } = body as { + username: string; + response: RegistrationResponseJSON; + challenge?: string; + }; + + if (!username || !response) { + return Response.json( + { error: "Username and response required" }, + { status: 400 }, + ); + } + + // Check if username already exists + const existingUser = db + .query("SELECT id FROM users WHERE username = ?") + .get(username); + + if (existingUser) { + return Response.json( + { error: "Username already taken" }, + { status: 400 }, + ); + } + + // Verify challenge exists and is valid + const challenge = db + .query( + "SELECT challenge, expires_at FROM challenges WHERE challenge = ? AND username = ? AND type = 'registration'", + ) + .get(expectedChallenge, username) as + | { challenge: string; expires_at: number } + | undefined; + + if (!challenge) { + return Response.json({ error: "Invalid challenge" }, { status: 400 }); + } + + const now = Math.floor(Date.now() / 1000); + if (challenge.expires_at < now) { + return Response.json({ error: "Challenge expired" }, { status: 400 }); + } + + // Check if this is bootstrap (first user) + const userCount = db + .query("SELECT COUNT(*) as count FROM users") + .get() as { count: number }; + + const isBootstrap = userCount.count === 0; + + if (!isBootstrap) { + return Response.json({ error: "Registration closed" }, { status: 403 }); + } + + // Verify WebAuthn response + let verification: VerifiedRegistrationResponse; + try { + verification = await verifyRegistrationResponse({ + response, + expectedChallenge: challenge.challenge, + expectedOrigin: process.env.ORIGIN!, + expectedRPID: process.env.RP_ID!, + }); + } catch (error) { + console.error("WebAuthn verification failed:", error); + return Response.json( + { error: "Verification failed" }, + { status: 400 }, + ); + } + + if (!verification.verified || !verification.registrationInfo) { + return Response.json( + { error: "Verification failed" }, + { status: 400 }, + ); + } + + const { credential } = verification.registrationInfo; + + // Create user (bootstrap is always admin) + const insertUser = db.query( + "INSERT INTO users (username, name, is_admin) VALUES (?, ?, 1) RETURNING id", + ); + const user = insertUser.get(username, username) as { + id: number; + }; + + // Store credential + // credential.id is a Uint8Array, convert to Buffer for storage + db.query( + "INSERT INTO credentials (user_id, credential_id, public_key, counter) VALUES (?, ?, ?, ?)", + ).run( + user.id, + Buffer.from(credential.id), + Buffer.from(credential.publicKey), + credential.counter, + ); + + // Delete challenge + db.query("DELETE FROM challenges WHERE challenge = ?").run( + challenge.challenge, + ); + + // Create session + const token = crypto.randomUUID(); + const expiresAt = Math.floor(Date.now() / 1000) + 86400; // 24 hours + db.query( + "INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)", + ).run(token, user.id, expiresAt); + + return Response.json({ + token, + username, + isAdmin: true, + }); + } catch (error) { + console.error("Registration verify error:", error); + return Response.json({ error: "Internal server error" }, { status: 500 }); + } +} + +export async function loginOptions(req: Request): Promise { + try { + const body = await req.json(); + const { username } = body; + + if (!username || typeof username !== "string") { + return Response.json({ error: "Username required" }, { status: 400 }); + } + + // Check if user exists + const user = db + .query("SELECT id FROM users WHERE username = ?") + .get(username) as { id: number } | undefined; + + if (!user) { + return Response.json({ error: "User not found" }, { status: 404 }); + } + + // Get user's credentials (just to verify they exist) + const credentials = db + .query("SELECT credential_id FROM credentials WHERE user_id = ?") + .all(user.id) as { credential_id: Buffer }[]; + + if (credentials.length === 0) { + return Response.json( + { error: "No credentials found" }, + { status: 404 }, + ); + } + + // Generate authentication options + // For discoverable credentials, omit allowCredentials to let password managers + // show all available passkeys for this RP ID + const options: PublicKeyCredentialRequestOptionsJSON = + await generateAuthenticationOptions({ + rpID: process.env.RP_ID!, + userVerification: "required", + }); + + // Store challenge + const expiresAt = Math.floor(Date.now() / 1000) + 300; // 5 minutes + db.query( + "INSERT INTO challenges (challenge, username, type, expires_at) VALUES (?, ?, 'authentication', ?)", + ).run(options.challenge, username, expiresAt); + + return Response.json(options); + } catch (error) { + console.error("Login options error:", error); + return Response.json({ error: "Internal server error" }, { status: 500 }); + } +} + +export async function loginVerify(req: Request): Promise { + try { + const body = await req.json(); + const { username, response } = body as { + username: string; + response: AuthenticationResponseJSON; + }; + + if (!username || !response) { + return Response.json( + { error: "Username and response required" }, + { status: 400 }, + ); + } + + // Look up credential by ID + // Current database has credential_id stored as Buffer containing ASCII text of base64url string + // So we need to compare the string value, not decode it + const credentialIdString = response.id; // This is the base64url string like "rHvdOyMkR-6nxGBcDmtV4g" + + const credentialWithUser = db + .query( + "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 = ?", + ) + .get(Buffer.from(credentialIdString)) as + | { credential_id: Buffer; public_key: Buffer; counter: number; user_id: number; username: string } + | undefined; + + if (!credentialWithUser) { + return Response.json( + { error: "Credential not found" }, + { status: 404 }, + ); + } + + // Verify the username matches (if provided) + if (username && credentialWithUser.username !== username) { + return Response.json( + { error: "Credential does not belong to this user" }, + { status: 403 }, + ); + } + + const credential = { + credential_id: credentialWithUser.credential_id, + public_key: credentialWithUser.public_key, + counter: credentialWithUser.counter, + }; + const user = { id: credentialWithUser.user_id }; + + // Verify challenge exists and is valid + // Use the discovered username from the credential + const challenge = db + .query( + "SELECT challenge, expires_at FROM challenges WHERE username = ? AND type = 'authentication' ORDER BY created_at DESC LIMIT 1", + ) + .get(credentialWithUser.username) as + | { challenge: string; expires_at: number } + | undefined; + + if (!challenge) { + return Response.json({ error: "Invalid challenge" }, { status: 400 }); + } + + const now = Math.floor(Date.now() / 1000); + if (challenge.expires_at < now) { + return Response.json({ error: "Challenge expired" }, { status: 400 }); + } + + // Verify authentication response + let verification: VerifiedAuthenticationResponse; + try { + verification = await verifyAuthenticationResponse({ + response, + expectedChallenge: challenge.challenge, + expectedOrigin: process.env.ORIGIN!, + expectedRPID: process.env.RP_ID!, + credential: { + id: credential.credential_id, + publicKey: credential.public_key, + counter: credential.counter, + }, + }); + } catch (error) { + console.error("WebAuthn verification failed:", error); + return Response.json( + { error: "Verification failed" }, + { status: 400 }, + ); + } + + if (!verification.verified) { + return Response.json( + { error: "Verification failed" }, + { status: 400 }, + ); + } + + // Update credential counter + db.query("UPDATE credentials SET counter = ? WHERE user_id = ? AND credential_id = ?").run( + verification.authenticationInfo.newCounter, + user.id, + credential.credential_id, + ); + + // Delete challenge + db.query("DELETE FROM challenges WHERE challenge = ?").run( + challenge.challenge, + ); + + // Create session + const token = crypto.randomUUID(); + const expiresAt = Math.floor(Date.now() / 1000) + 86400; // 24 hours + db.query( + "INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)", + ).run(token, user.id, expiresAt); + + return Response.json({ + token, + username, + }); + } catch (error) { + console.error("Login verify error:", error); + return Response.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1e1c5c9 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "preserve", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Decorators + "experimentalDecorators": true, + "useDefineForClassFields": false, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/types/env.d.ts b/types/env.d.ts new file mode 100644 index 0000000..77dd371 --- /dev/null +++ b/types/env.d.ts @@ -0,0 +1,8 @@ +declare module "bun" { + interface Env { + ORIGIN: string; + RP_ID: string; + NODE_ENV?: "dev" | "production"; + PORT?: string; + } +} -- 2.51.2