From 6f90a9f31a323baa7beaff751a264c52355f574e Mon Sep 17 00:00:00 2001 From: vhs Date: Tue, 7 Jan 2025 22:48:03 -0500 Subject: [PATCH] jwt stateless verification --- .dev.vars.example | 7 +- README.md | 125 +++++++++++++++++++------- src/accounts/handler.spec.ts | 159 +++++++++++++++++++++++++++++++++ src/accounts/handler.ts | 2 + src/accounts/session-config.ts | 20 ++--- src/accounts/session.ts | 59 ++++++------ src/accounts/token.ts | 113 +++++++++++++++++++++++ src/index.ts | 126 ++++++++++++++++++++++---- worker-configuration.d.ts | 3 +- 9 files changed, 517 insertions(+), 97 deletions(-) create mode 100644 src/accounts/handler.spec.ts create mode 100644 src/accounts/token.ts diff --git a/.dev.vars.example b/.dev.vars.example index 71263c6..d0a8397 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -1,3 +1,4 @@ -TURSO_URL = "libsql://auth-db-username.turso.io" -TURSO_AUTH_TOKEN = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" -COOKIE_SIGNING = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \ No newline at end of file +TURSO_URL="libsql://your-db.turso.io" +TURSO_AUTH_TOKEN="your-auth-token" +JWT_ACCESS_SECRET="your-access-secret" +JWT_REFRESH_SECRET="your-refresh-secret" \ No newline at end of file diff --git a/README.md b/README.md index fe5c244..69eb642 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,69 @@ # Private Landing -A boilerplate/starter project for quickly building RESTful APIs using [Cloudflare Workers](https://workers.cloudflare.com/), [Hono](https://honojs.dev/) and [Turso](https://turso.tech/). Inspired by Scott Tolinski, Mark Volkmann. - -## Security Features - -See [ADR-001: Authentication Implementation](docs/adr/001-auth-implementation.md) for detailed technical decisions and security features. +A boilerplate/starter project for quickly building RESTful APIs +using [Cloudflare Workers](https://workers.cloudflare.com/), [Hono](https://honojs.dev/) +and [Turso](https://turso.tech/). Inspired by Scott Tolinski, Mark Volkmann. + +## Authentication System + +The authentication system combines secure session management with JWT-based API access control, providing both +auditability and stateless verification. + +### Core Components + +1. **Session Management** + - Sessions stored in SQLite (via Turso) + - Tracks user devices, IP addresses, and activity + - Enforces session limits per user + - Implements sliding expiration + +2. **JWT Tokens** + - Access token (15min expiry) + - Refresh token (7 day expiry) + - Tokens linked to sessions via `session_id` + - HTTP-only secure cookies + +### Authentication Flow + +1. **Login Process**: + ``` + 1. Validate credentials against account table + 2. Create session record with: + - Unique session ID (nanoid) + - User agent and IP tracking + - Configurable expiration + 3. Generate JWT tokens: + - Access token: {user_id, session_id, type: "access"} + - Refresh token: {user_id, session_id, type: "refresh"} + 4. Set HTTP-only cookies: + - access_token: Short-lived API access + - refresh_token: Long-lived token for renewal + ``` + +2. **API Request Authentication**: + ``` + 1. Check access_token cookie + 2. Validate JWT signature and expiry + 3. Verify session still exists and is valid + 4. If token expired: + a. Check refresh token + b. Verify refresh token validity + c. Confirm session is still active + d. Issue new access token + 5. Update session expiry (sliding window) + ``` + +### Security Features + +- Session tracking and limiting +- Secure cookie configuration +- CSRF protection via Same-Site +- Session-JWT linkage for revocation +- IP and user agent tracking +- Sliding session expiration + +See [ADR-001: Authentication Implementation](docs/adr/001-auth-implementation.md) for detailed technical decisions and +security features. ## Database Schema @@ -25,33 +84,33 @@ erDiagram text created_at "not null" } - account ||--o{ session : "has" + account ||--o{ session: "has" ``` ## Prerequisites 1. Install [Turso CLI](https://docs.turso.tech/reference/cli) 2. Authenticate with Turso: -```bash -turso auth login -``` + ```shell + turso auth login + ``` 3. Create database and set up access: -```bash -# Create the database -turso db create auth-db - -# Get database info and connection URL -turso db show auth-db - -# Create auth token -turso db tokens create auth-db -``` + ```shell + # Create the database + turso db create auth-db + + # Get database info and connection URL + turso db show auth-db + + # Create auth token + turso db tokens create auth-db + ``` ## Database Setup The database can be managed using SQL scripts in the `src/db` directory: -```bash +```shell # First time setup: Create tables turso db shell auth-db < src/db/schema.sql @@ -77,6 +136,7 @@ $pbkdf2-sha384$v1$iterations$salt$hash$digest ``` Field details: + - Algorithm: PBKDF2 with SHA-384 (balance of security/performance) - Version: Schema version for future algorithm updates - Iterations: Key stretching count (100,000) @@ -84,27 +144,32 @@ Field details: - Hash: PBKDF2-derived key - Digest: Additional SHA-384 hash for verification -All binary data (salt, hash, digest) is stored as Base64. The format allows for future algorithm changes while maintaining backward compatibility. +All binary data (salt, hash, digest) is stored as Base64. The format allows for future algorithm changes while +maintaining backward compatibility. ## Environment Setup 1. Copy `.dev.vars.example` to `.dev.vars` for local development -2. For production, [set up the Turso integration](https://developers.cloudflare.com/workers/databases/native-integrations/turso/) in your Cloudflare dashboard: - - Go to Workers & Pages → Settings → Integrations - - Add Turso integration - - Your `TURSO_URL` and `TURSO_AUTH_TOKEN` will be automatically available -3. Use strong password for the `COOKIE_SIGNING` secret. +2. For + production, [set up the Turso integration](https://developers.cloudflare.com/workers/databases/native-integrations/turso/) + in your Cloudflare dashboard: + - Go to Workers & Pages → Settings → Integrations + - Add Turso integration + - Your `TURSO_URL` and `TURSO_AUTH_TOKEN` will be automatically available +3. Use strong passwords for JWT access and refresh token secrets Required environment variables: -```bash + +```shell TURSO_URL="libsql://your-db.turso.io" TURSO_AUTH_TOKEN="your-auth-token" -COOKIE_SIGNING="your-cookie-secret" # For session management +JWT_ACCESS_SECRET="your-access-secret" # For JWT access tokens +JWT_REFRESH_SECRET="your-refresh-secret" # For JWT refresh tokens ``` ## Development -```bash +```shell # Start development server bun run dev # Runs on port 8788 @@ -122,7 +187,7 @@ bun run check # Biome linter + formatter check Common database tasks: -```bash +```shell # Create database backup turso db dump auth-db > backup.sql diff --git a/src/accounts/handler.spec.ts b/src/accounts/handler.spec.ts new file mode 100644 index 0000000..e8b80f0 --- /dev/null +++ b/src/accounts/handler.spec.ts @@ -0,0 +1,159 @@ +import { describe, expect, mock, test } from "bun:test"; +import type { Context, TypedResponse } from "hono"; +import type { RedirectStatusCode } from "hono/utils/http-status"; +import { handleLogin, handleRegistration } from "./handler"; + +type ContextEnv = { + TURSO_URL: string; + TURSO_AUTH_TOKEN: string; +}; + +const createMockTypedResponse = ( + location: string, + status: RedirectStatusCode = 302, +): Response & TypedResponse => { + return { + ...new Response(null, { + status, + headers: { Location: location }, + }), + status, + redirect: location, + _data: undefined, + _status: status, + _format: "redirect", + } as Response & TypedResponse; +}; + +const mockContext = { + env: { + TURSO_URL: "libsql://test", + TURSO_AUTH_TOKEN: "test", + }, + req: { + parseBody: async () => ({ + email: "test@example.com", + password: "password123", + }), + } as unknown as Request, + redirect: (location: string, status: RedirectStatusCode = 302) => + createMockTypedResponse(location, status), + // Required Context properties + finalized: false, + error: null, + event: null, + executionCtx: null, + get: () => undefined, + header: () => undefined, + match: () => false, + newResponse: () => new Response(), + set: () => {}, + update: () => new Response(), + // Handle other required Context properties + param: () => "", + data: {}, + json: () => new Response(), + text: () => new Response(), + html: () => new Response(), + status: () => mockContext, + res: undefined, + // Add runtime type information + runtime: "bun", +} as unknown as Context<{ Bindings: ContextEnv }>; + +describe("Handler", () => { + describe("handleLogin", () => { + test("redirects with error for invalid credentials", async () => { + mock.module("./services", () => ({ + accountService: { + authenticate: async () => ({ authenticated: false }), + }, + })); + + const response = await handleLogin(mockContext); + expect(response.headers.get("Location")).toBe( + "/?error=Invalid email or password", + ); + }); + + test("creates session and redirects on successful login", async () => { + mock.module("./services", () => ({ + accountService: { + authenticate: async () => ({ authenticated: true, userId: 1 }), + }, + })); + + mock.module("./session", () => ({ + createSession: async () => "test-session-id", + })); + + const response = await handleLogin(mockContext); + expect(response.headers.get("Location")).toBe("/?authenticated=true"); + }); + + test("handles authentication errors gracefully", async () => { + mock.module("./services", () => ({ + accountService: { + authenticate: async () => { + throw new Error("Auth error"); + }, + }, + })); + + const response = await handleLogin(mockContext); + expect(response.headers.get("Location")).toBe( + "/?error=Authentication failed. Please try again.", + ); + }); + }); + + describe("handleRegistration", () => { + test("redirects on successful registration", async () => { + mock.module("./services", () => ({ + accountService: { + createAccount: async () => ({ rowsAffected: 1 }), + }, + })); + + const response = await handleRegistration(mockContext); + expect(response.headers.get("Location")).toBe("/?registered=true"); + }); + + test("handles validation errors", async () => { + const validationError = new Error("Password too short") as Error & { + code: string; + message: string; + }; + validationError.code = "VALIDATION_ERROR"; + validationError.message = "Password must be at least 8 characters"; + + mock.module("./services", () => ({ + accountService: { + createAccount: async () => { + throw validationError; + }, + }, + })); + + const response = await handleRegistration(mockContext); + expect(response.headers.get("Location")).toBe( + "/?error=Password must be at least 8 characters", + ); + }); + + test("handles unexpected registration errors", async () => { + mock.module("./services", () => ({ + accountService: { + createAccount: async () => { + throw new Error("Unexpected error"); + }, + }, + })); + + const response = await handleRegistration(mockContext); + expect(response.headers.get("Location")).toBe( + "/?error=Registration failed. Please try again.", + ); + }); + }); +}); diff --git a/src/accounts/handler.ts b/src/accounts/handler.ts index 58e71f3..def512e 100644 --- a/src/accounts/handler.ts +++ b/src/accounts/handler.ts @@ -1,6 +1,7 @@ import type { Context } from "hono"; import { accountService } from "./services"; import { createSession } from "./session.ts"; +import { tokenService } from "./token.ts"; export async function handleLogin(ctx: Context) { try { @@ -19,6 +20,7 @@ export async function handleLogin(ctx: Context) { if (authResult.userId) { const sessionId = await createSession(authResult.userId, ctx); + await tokenService.generateTokens(ctx, authResult.userId, sessionId); } return ctx.redirect("/?authenticated=true"); diff --git a/src/accounts/session-config.ts b/src/accounts/session-config.ts index 5ed12b9..710c62a 100644 --- a/src/accounts/session-config.ts +++ b/src/accounts/session-config.ts @@ -3,19 +3,19 @@ import type { CookieOptions } from "hono/utils/cookie"; /** * Session information stored in database. * @property id - 21 character nanoid session identifier - * @property userId - Associated user ID - * @property userAgent - Browser user agent string - * @property ipAddress - Client IP address - * @property expiresAt - Session expiration timestamp - * @property createdAt - Session creation timestamp + * @property user_id - Associated user ID + * @property user_agent - Browser user agent string + * @property ip_address - Client IP address + * @property expires_at - Session expiration timestamp + * @property created_at - Session creation timestamp */ export interface SessionData { id: string; - userId: number; - userAgent: string; - ipAddress: string; - expiresAt: Date; - createdAt: Date; + user_id: number; + user_agent: string; + ip_address: string; + expires_at: string; + created_at: string; } /** diff --git a/src/accounts/session.ts b/src/accounts/session.ts index 14e8ac3..d357531 100644 --- a/src/accounts/session.ts +++ b/src/accounts/session.ts @@ -1,7 +1,7 @@ import type { Client } from "@libsql/client/web"; import type { Context } from "hono"; import { getConnInfo } from "hono/cloudflare-workers"; -import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; +import { deleteCookie } from "hono/cookie"; import { nanoid } from "nanoid"; import { createDbClient } from "../db"; import { @@ -9,6 +9,7 @@ import { type SessionData, defaultSessionConfig, } from "./session-config"; +import type { TokenPayload } from "./token.ts"; /** * Removes expired sessions from the database. @@ -75,7 +76,7 @@ async function extendSession( /** * Creates a new session for authenticated user. - * Stores session data and sets secure cookie. + * Stores session data in the database. */ export async function createSession( userId: number, @@ -92,11 +93,13 @@ export async function createSession( const sessionData: SessionData = { id: sessionId, - userId, - userAgent: ctx.req.header("user-agent") || "unknown", - ipAddress: connInfo.remote?.address || "unknown", - expiresAt: new Date(Date.now() + config.sessionDuration * 1000), - createdAt: new Date(), + user_id: userId, + user_agent: ctx.req.header("user-agent") || "unknown", + ip_address: connInfo.remote?.address || "unknown", + expires_at: new Date( + Date.now() + config.sessionDuration * 1000, + ).toISOString(), + created_at: new Date().toISOString(), }; await dbClient.execute({ @@ -105,21 +108,14 @@ export async function createSession( VALUES (?, ?, ?, ?, ?, ?)`, args: [ sessionData.id, - sessionData.userId, - sessionData.userAgent, - sessionData.ipAddress, - sessionData.expiresAt.toISOString(), - sessionData.createdAt.toISOString(), + sessionData.user_id, + sessionData.user_agent, + sessionData.ip_address, + sessionData.expires_at, + sessionData.created_at, ], }); - await setSignedCookie( - ctx, - "session", - sessionId, - ctx.env.COOKIE_SIGNING, - config.cookie, - ); return sessionId; } @@ -131,11 +127,9 @@ export async function getSession( ctx: Context, config: SessionConfig = defaultSessionConfig, ): Promise { - const sessionId = await getSignedCookie( - ctx, - ctx.env.COOKIE_SIGNING, - "session", - ); + const payload = ctx.get("jwtPayload") as TokenPayload; + const sessionId = payload?.session_id; + if (!sessionId) return null; const dbClient = createDbClient(ctx.env); @@ -145,10 +139,8 @@ export async function getSession( dbClient, config.sessionDuration, ); - if (!extended) { - deleteCookie(ctx, "session", config.cookie); - return null; - } + + if (!extended) return null; const result = await dbClient.execute({ sql: "SELECT * FROM session WHERE id = ?", @@ -166,11 +158,9 @@ export async function endSession( ctx: Context, config: SessionConfig = defaultSessionConfig, ): Promise { - const sessionId = await getSignedCookie( - ctx, - ctx.env.COOKIE_SIGNING, - "session", - ); + const payload = ctx.get("jwtPayload") as TokenPayload; + const sessionId = payload.session_id; + if (!sessionId) return; const dbClient = createDbClient(ctx.env); @@ -181,5 +171,6 @@ export async function endSession( args: [sessionId], }); - deleteCookie(ctx, "session", config.cookie); + deleteCookie(ctx, "access_token", config.cookie); + deleteCookie(ctx, "refresh_token", config.cookie); } diff --git a/src/accounts/token.ts b/src/accounts/token.ts new file mode 100644 index 0000000..0f35e1f --- /dev/null +++ b/src/accounts/token.ts @@ -0,0 +1,113 @@ +import type { Context } from "hono"; +import { setCookie } from "hono/cookie"; +import { sign } from "hono/jwt"; + +interface TokenConfig { + accessTokenExpiry: number; // seconds + refreshTokenExpiry: number; // seconds + cookieSecure: boolean; + cookieSameSite: "Strict" | "Lax" | "None"; +} + +export interface TokenPayload { + user_id: number; + session_id: string; + type: "access" | "refresh"; + exp?: number; + [key: string]: string | number | undefined; // Index signature for JWT compatibility +} + +const tokenConfig: TokenConfig = { + accessTokenExpiry: 15 * 60, // 15 minutes + refreshTokenExpiry: 7 * 24 * 3600, // 7 days + cookieSecure: true, + cookieSameSite: "Strict", +}; + +/** + * Sets a secure cookie with the JWT token. + */ +function setSecureCookie( + ctx: Context, + name: string, + token: string, + maxAge: number, +): void { + setCookie(ctx, name, token, { + httpOnly: true, + secure: tokenConfig.cookieSecure, + sameSite: tokenConfig.cookieSameSite, + path: "/", + maxAge, + }); +} + +export const tokenService = { + generateTokens: async (ctx: Context, user_id: number, session_id: string) => { + if (!ctx.env.JWT_ACCESS_SECRET || !ctx.env.JWT_REFRESH_SECRET) { + throw new Error("Missing token signing secrets"); + } + + // Generate refresh token + const refreshPayload: TokenPayload = { + user_id, + session_id, + type: "refresh", + exp: Math.floor(Date.now() / 1000) + tokenConfig.refreshTokenExpiry, + }; + + const refreshToken = await sign(refreshPayload, ctx.env.JWT_REFRESH_SECRET); + + // Generate access token + const accessPayload: TokenPayload = { + user_id, + session_id, + type: "access", + exp: Math.floor(Date.now() / 1000) + tokenConfig.accessTokenExpiry, + }; + + const accessToken = await sign(accessPayload, ctx.env.JWT_ACCESS_SECRET); + + // Set cookies + setSecureCookie( + ctx, + "refresh_token", + refreshToken, + tokenConfig.refreshTokenExpiry, + ); + setSecureCookie( + ctx, + "access_token", + accessToken, + tokenConfig.accessTokenExpiry, + ); + + return { accessToken, refreshToken }; + }, + + refreshAccessToken: async (ctx: Context, payload: TokenPayload) => { + if (!ctx.env.JWT_ACCESS_SECRET) { + throw new Error("Missing access token signing secret"); + } + + // Generate new access token with same session_id + const accessPayload: TokenPayload = { + user_id: payload.user_id, + session_id: payload.session_id, + type: "access", + exp: Math.floor(Date.now() / 1000) + tokenConfig.accessTokenExpiry, + }; + + const accessToken = await sign(accessPayload, ctx.env.JWT_ACCESS_SECRET); + + // Set new access token cookie + setSecureCookie( + ctx, + "access_token", + accessToken, + tokenConfig.accessTokenExpiry, + ); + + return accessToken; + }, +}; diff --git a/src/index.ts b/src/index.ts index a185415..56bee56 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,25 @@ import type { Fetcher } from "@cloudflare/workers-types"; import { Hono } from "hono"; +import { getCookie } from "hono/cookie"; import { createMiddleware } from "hono/factory"; -import { jwt } from "hono/jwt"; -import { handleLogin, handleRegistration } from "./accounts/handler.ts"; -import { createDbClient } from "./db.ts"; +import { verify } from "hono/jwt"; +import { handleLogin, handleRegistration } from "./accounts/handler"; +import { getSession } from "./accounts/session"; +import { type TokenPayload, tokenService } from "./accounts/token"; +import { createDbClient } from "./db"; -const app = new Hono<{ Bindings: Env }>(); +// Extend variables to include JWT payload +type Variables = { + jwtPayload: TokenPayload; +}; + +const app = new Hono<{ Bindings: Env; Variables: Variables }>(); type ServeStaticOptions = { cache: string; }; +// Middleware that serves static content function serveStatic(opts: ServeStaticOptions) { return createMiddleware<{ Bindings: Env }>(async (ctx, next) => { const binding = ctx.env.ASSETS as Fetcher; @@ -32,33 +41,112 @@ function serveStatic(opts: ServeStaticOptions) { }); } -// Public routes (no JWT needed) +// Authentication middleware that verifies access tokens +const requireAuth = createMiddleware<{ Bindings: Env; Variables: Variables }>( + async (ctx, next) => { + try { + // Check for access token + const accessToken = getCookie(ctx, "access_token"); + if (!accessToken) { + return ctx.json({ error: "No access token provided" }, 401); + } + + // Verify access token + try { + const payload = (await verify( + accessToken, + ctx.env.JWT_ACCESS_SECRET, + )) as TokenPayload; + + if (payload.type !== "access") { + return ctx.json({ error: "Invalid token type" }, 401); + } + + // Verify session still exists and is valid + const session = await getSession(ctx); + if (!session || session.id !== payload.session_id) { + return ctx.json({ error: "Invalid session" }, 401); + } + + ctx.set("jwtPayload", payload); + } catch (error) { + // Try to refresh the access token + const refreshToken = getCookie(ctx, "refresh_token"); + if (!refreshToken) { + return ctx.json( + { error: "Access token expired and no refresh token present" }, + 401, + ); + } + + try { + // Verify refresh token + const refreshPayload = (await verify( + refreshToken, + ctx.env.JWT_REFRESH_SECRET, + )) as TokenPayload; + + if (refreshPayload.type !== "refresh") { + return ctx.json({ error: "Invalid refresh token type" }, 401); + } + + // Verify session still exists and is valid + const session = await getSession(ctx); + if (!session || session.id !== refreshPayload.session_id) { + return ctx.json({ error: "Invalid session" }, 401); + } + + // Generate new access token + const newAccessToken = await tokenService.refreshAccessToken( + ctx, + refreshPayload, + ); + ctx.set( + "jwtPayload", + await verify(newAccessToken, ctx.env.JWT_ACCESS_SECRET), + ); + } catch { + return ctx.json({ error: "Invalid or expired refresh token" }, 401); + } + } + + return await next(); + } catch (error) { + console.error("Auth middleware error:", error); + return ctx.json({ error: "Authentication failed" }, 401); + } + }, +); + +// Public routes (no authentication required) app.use("*", serveStatic({ cache: "key" })); app.post("/api/register", handleRegistration); -app.post("/api/login", handleLogin); - -// Then protect everything else under /api/* -app.use("/api/*", async (ctx, next) => { - if (!ctx.env.COOKIE_SIGNING) { - throw new Error("Missing cookie signing secret"); +app.post("/api/login", async (ctx) => { + const result = await handleLogin(ctx); + if ( + result.status === 302 && + result.headers.get("Location")?.includes("authenticated=true") + ) { + // Login successful, generate tokens + const session = await getSession(ctx); + if (session?.user_id) { + await tokenService.generateTokens(ctx, session.user_id, session.id); + } } - return jwt({ - secret: ctx.env.COOKIE_SIGNING, - cookie: "__Host-session", - })(ctx, next); + return result; }); -// Protected route - only accessible with valid JWT +// Protected routes (require authentication) +app.use("/api/*", requireAuth); + app.get("/api/ping", async (ctx) => { - // Get the JWT payload const payload = ctx.get("jwtPayload"); - const dbClient = createDbClient(ctx.env); const result = await dbClient.execute("SELECT sqlite_version();"); return ctx.json({ message: "Authenticated ping success!", - userId: payload.userId, // Access claims from the JWT + userId: payload.user_id, version: result, }); }); diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 3eeb189..25f0c1a 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -3,6 +3,7 @@ interface Env { TURSO_URL: string; TURSO_AUTH_TOKEN: string; - COOKIE_SIGNING: string; + JWT_ACCESS_SECRET: string; + JWT_REFRESH_SECRET: string; ASSETS: Fetcher; }