Skip to content

Commit

Permalink
jwt stateless verification
Browse files Browse the repository at this point in the history
  • Loading branch information
vhscom committed Jan 10, 2025
1 parent a14af8d commit 6f90a9f
Show file tree
Hide file tree
Showing 9 changed files with 517 additions and 97 deletions.
7 changes: 4 additions & 3 deletions .dev.vars.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
TURSO_URL = "libsql://auth-db-username.turso.io"
TURSO_AUTH_TOKEN = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
COOKIE_SIGNING = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
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"
125 changes: 95 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

Expand All @@ -77,34 +136,40 @@ $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)
- Salt: 128-bit random value (NIST recommended minimum)
- 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

Expand All @@ -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

Expand Down
159 changes: 159 additions & 0 deletions src/accounts/handler.spec.ts
Original file line number Diff line number Diff line change
@@ -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<undefined, typeof status, "redirect"> => {
return {
...new Response(null, {
status,
headers: { Location: location },
}),
status,
redirect: location,
_data: undefined,
_status: status,
_format: "redirect",
} as Response & TypedResponse<undefined, typeof status, "redirect">;
};

const mockContext = {
env: {
TURSO_URL: "libsql://test",
TURSO_AUTH_TOKEN: "test",
},
req: {
parseBody: async () => ({
email: "[email protected]",
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.",
);
});
});
});
2 changes: 2 additions & 0 deletions src/accounts/handler.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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");
Expand Down
20 changes: 10 additions & 10 deletions src/accounts/session-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
Loading

0 comments on commit 6f90a9f

Please sign in to comment.