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 +
+``` + +### 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 @@ + + + + + + +sign in with passkey
+ + + +