Authentication
Open Genie uses two separate authentication systems:
| Context | Method |
|---|---|
| Web UI (browser) | Google OAuth → server-signed HTTP-only cookie session (jose) |
| Native devices (phone, tablet, TV) | Short-lived access tokens + long-lived refresh tokens (jose) |
Device Authentication (JWT)
Native device apps authenticate via access tokens signed with HS256 using GENIE_JWT_SECRET. This is the primary auth system for WebSocket connections and REST API calls from mobile clients.
Token Structure
interface AccessTokenPayload {
deviceId: string;
userId: string;
deviceType: string; // "phone" | "tablet" | "tv"
sessionId: string;
scope: "admin" | "member" | "guest";
}
Default TTLs (configurable via env):
| Token | Default TTL | Env var |
|---|---|---|
| Access token | 15 minutes | GENIE_ACCESS_TOKEN_TTL_SECONDS |
| Refresh token | 90 days | GENIE_REFRESH_TOKEN_TTL_DAYS |
Obtaining Tokens — QR Pairing Flow
The recommended flow for first-time device setup. See docs/pairing.md for the complete protocol.
1. Admin opens Paired Devices page → POST /api/auth/pair/start
← { qr: "opengenie://pair?d=…", pairing: { id, code, scope, expiresAt } }
2. Device scans QR code, chooses best server URL from candidates
3. Device → POST /api/auth/pair/complete { code, secret, deviceInfo }
← { accessToken, refreshToken, expiresIn, device }
4. Device stores accessToken in memory, refreshToken in secure storage
Token Refresh
On 401, call POST /api/auth/refresh with the refresh token:
POST /api/auth/refresh
Authorization: Bearer <refresh-token>
Returns a new { accessToken, refreshToken, expiresIn }.
Using the Access Token
WebSocket — pass as query parameter or subprotocol:
ws://localhost:3000/ws?token=eyJ…
REST API — Bearer header:
Authorization: Bearer eyJ…
Key Files
| File | Exports |
|---|---|
lib/auth/jwt.ts | signAccessToken(), verifyAccessToken(), generateRefreshToken() |
lib/auth/middleware.ts | authenticateToken(), authenticateRequest() |
authenticateRequest(req)
Extracts the Bearer token from the Authorization header, verifies it, and checks the session revocation cache.
const auth = await authenticateRequest(req);
if (!auth) {
return c.json({ error: "Unauthorized" }, 401);
}
// auth = { deviceId, userId, deviceType, sessionId, scope }
Deprecated Routes (v1 → v2)
| Deprecated | Replaced by |
|---|---|
POST /api/paired-devices/pair | POST /api/auth/pair/start |
POST /api/paired-devices/verify | POST /api/auth/pair/complete |
| Long-lived 365-day tokens | Access token (15 min) + refresh token (90 days) |
Set GENIE_AUTH_V2=0 to re-enable v1 behavior during migration (one release window only).
Web Authentication (Google OAuth + jose)
The web UI uses hand-rolled Google OAuth with jose for browser-based access. There is no NextAuth dependency.
Flow
Browser → GET /api/auth/login → redirects to Google
Google → GET /api/auth/callback?code=… → server exchanges code,
verifies allowed email (GENIE_ALLOWED_EMAILS), signs session cookie
Browser → GET /api/auth/session → returns { user: { email, name, picture } | null }
Configuration
AUTH_SECRET=your-session-signing-secret # min 32 chars; required
AUTH_GOOGLE_ID=your-google-client-id
AUTH_GOOGLE_SECRET=your-google-client-secret
# Optional: restrict sign-in to specific addresses
GENIE_ALLOWED_EMAILS=alice@example.com,bob@example.com
Key Files
| File | Purpose |
|---|---|
apps/web/server/auth/routes.ts | /api/auth/login, /api/auth/callback, /api/auth/session, /api/auth/logout |
apps/web/server/auth/config.ts | getRedirectUri(), isAllowedUser() |
Usage in Components
import { useSession } from "@/lib/use-session";
function MyComponent() {
const { user, status, isAdmin } = useSession();
// user: { email, name, picture } | null
// status: "loading" | "authenticated" | "unauthenticated"
// isAdmin: true for any signed-in browser session
}
The useSession() hook fetches /api/auth/session once on mount. All signed-in browser sessions are treated as admin (the household owner).
Bootstrap Window
On first boot, while AUTH_SECRET is not yet set, the /setup wizard is accessible only from loopback and requires a one-time PIN printed to the server log. See the Docker deployment guide (docs/deploy-docker.md) for details.