Three independent feature specs covering: - API endpoint for fetching images from URLs with local storage - Public-read/authenticated-write auth with sessions and API keys - Built-in MCP server for Claude Code/Desktop integration Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
134 lines
4.6 KiB
Markdown
134 lines
4.6 KiB
Markdown
# Authentication — Design Spec
|
|
|
|
## Overview
|
|
|
|
Add authentication to GearBox with a public-read, authenticated-write model. Web UI uses cookie-based sessions. Programmatic access (MCP server, scripts) uses API keys. Single-user app — one admin account, created on first setup.
|
|
|
|
## Database Schema
|
|
|
|
### `users` table
|
|
|
|
```typescript
|
|
export const users = sqliteTable("users", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
username: text("username").notNull().unique(),
|
|
passwordHash: text("password_hash").notNull(),
|
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
|
});
|
|
```
|
|
|
|
### `sessions` table
|
|
|
|
```typescript
|
|
export const sessions = sqliteTable("sessions", {
|
|
id: text("id").primaryKey(), // random token
|
|
userId: integer("user_id").notNull().references(() => users.id),
|
|
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
|
});
|
|
```
|
|
|
|
### `apiKeys` table
|
|
|
|
```typescript
|
|
export const apiKeys = sqliteTable("api_keys", {
|
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
name: text("name").notNull(),
|
|
keyHash: text("key_hash").notNull(),
|
|
keyPrefix: text("key_prefix").notNull(), // first 8 chars for identification
|
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
|
|
});
|
|
```
|
|
|
|
## Password Hashing
|
|
|
|
Use `Bun.password.hash()` and `Bun.password.verify()` with argon2 (Bun's default). No external dependencies needed.
|
|
|
|
## Auth Middleware
|
|
|
|
Hono middleware applied to all write endpoints (POST, PUT, DELETE):
|
|
|
|
1. Check for `X-API-Key` header — if present, hash and compare against `api_keys` table
|
|
2. Check for session cookie (`gearbox_session`) — if present, look up in `sessions` table, verify not expired
|
|
3. If neither is valid, return `401 Unauthorized`
|
|
|
|
GET endpoints remain public — no middleware applied.
|
|
|
|
**Exempt from auth:** `/api/auth/login`, `/api/auth/setup`, and `/api/auth/me` (GET) are not protected by the write middleware.
|
|
|
|
**Before setup:** If no user account exists yet, write endpoints return `403` with `{ error: "setup_required" }` so the frontend can prompt account creation.
|
|
|
|
## API Endpoints
|
|
|
|
### Auth routes (`/api/auth`)
|
|
|
|
- `POST /api/auth/login` — accepts `{ username, password }`, creates session, sets cookie
|
|
- `POST /api/auth/logout` — clears session cookie, deletes session record
|
|
- `GET /api/auth/me` — returns current user info if authenticated, or `null`
|
|
- `POST /api/auth/setup` — initial account creation (only works if no users exist)
|
|
- `PUT /api/auth/password` — change password (requires current password)
|
|
|
|
### API key routes (`/api/auth/keys`) — all authenticated
|
|
|
|
- `GET /api/auth/keys` — list API keys (name, prefix, createdAt — never the full key)
|
|
- `POST /api/auth/keys` — create new key, returns the full key once
|
|
- `DELETE /api/auth/keys/:id` — revoke a key
|
|
|
|
## Session Management
|
|
|
|
- Session token: 32-byte random hex string
|
|
- Cookie: `gearbox_session`, httpOnly, sameSite=lax, path=/
|
|
- Session expiry: 30 days, refreshed on each authenticated request
|
|
- Sessions stored in SQLite — simple cleanup of expired rows
|
|
|
|
## Frontend Changes
|
|
|
|
### Login button (top-right, Gitea-style)
|
|
|
|
- When not logged in: "Sign in" button in the header/navbar
|
|
- When logged in: username display with dropdown (logout, settings link)
|
|
|
|
### Login page
|
|
|
|
- Route: `/login`
|
|
- Simple form: username + password + submit
|
|
- Redirects back to previous page on success
|
|
- Error message on invalid credentials
|
|
|
|
### Initial setup
|
|
|
|
- If `GET /api/auth/me` returns `null` and no users exist, show a setup prompt
|
|
- Setup form: create username + password
|
|
- Only shown once — after account creation, normal login flow applies
|
|
|
|
### Conditional UI
|
|
|
|
- Add/edit/delete buttons: hidden when not authenticated
|
|
- Forms (ItemForm, CandidateForm, etc.): only accessible when authenticated
|
|
- The app is fully browseable without login — you just can't modify anything
|
|
|
|
### Auth state
|
|
|
|
- `useAuth` hook using React Query: calls `GET /api/auth/me`
|
|
- Returns `{ user, isAuthenticated, login, logout }`
|
|
- Cached and invalidated on login/logout
|
|
|
|
### Settings page additions
|
|
|
|
- API key management section: list, create, revoke
|
|
- Change password form
|
|
|
|
## Testing
|
|
|
|
- Auth service tests: login, logout, session creation/validation, password change
|
|
- API key tests: create, verify, revoke
|
|
- Middleware tests: write endpoints reject without auth, read endpoints work without auth
|
|
- Setup flow test: first-user creation
|
|
|
|
## Security Considerations
|
|
|
|
- Passwords hashed with argon2 via Bun built-in
|
|
- Session tokens are cryptographically random
|
|
- API keys hashed before storage (only shown once on creation)
|
|
- httpOnly cookies prevent XSS access to session
|
|
- No CORS changes needed (single-origin app)
|