Skip to content
Merged

Dev #34

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file removed apps/.gitkeep
Empty file.
2 changes: 2 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ PORT=3000
DB_PATH=data/appbase.sqlite
LOG_LEVEL=info
BASE_URL=http://localhost:3000
# Comma-separated browser origins allowed for credentialed CORS (e.g. your Next.js dev URL).
# CORS_ORIGINS=http://localhost:3001
AUTH_SECRET=replace-with-a-long-random-secret-at-least-32-chars
34 changes: 16 additions & 18 deletions apps/api/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@
2. Open `apps/api/api.http`.
3. Click **Send Request** above any `###` block to run that request.

### Using tokens
### Session cookie

After **Login**, copy the `refreshToken` from the response. For **Refresh** and **Logout**, replace `YOUR_REFRESH_TOKEN_HERE` in the `Authorization` header with that token.
After **Register** or **Login**, copy `appbase_session=...` from the response **`Set-Cookie`** header into the `@sessionCookie` variable in `api.http`, then run **Refresh** / **Logout**.

## Option 2: JetBrains HTTP Client

Expand All @@ -36,33 +36,31 @@ After **Login**, copy the `refreshToken` from the response. For **Refresh** and

## Option 3: cURL

Replace `YOUR_API_KEY` with the key from `create-dev-api-key.ts`:
Replace `YOUR_API_KEY` with the key from `create-dev-api-key.ts`. Use a cookie jar so **Refresh** / **Logout** reuse the session from **Login**:

```bash
# Health (no API key)
curl http://localhost:3000/health

# Register
curl -X POST http://localhost:3000/auth/register \
# Register (saves cookies to jar)
curl -c cookies.txt -X POST http://localhost:3000/auth/register \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-d '{"email":"test@example.com","password":"SecurePassword123!"}'

# Login
curl -X POST http://localhost:3000/auth/login \
# Login (updates jar)
curl -c cookies.txt -b cookies.txt -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-d '{"email":"test@example.com","password":"SecurePassword123!"}'

# Refresh (replace TOKEN with refreshToken from login)
curl -X POST http://localhost:3000/auth/refresh \
-H "x-api-key: YOUR_API_KEY" \
-H "Authorization: Bearer TOKEN"
# Refresh (sends session cookie from jar)
curl -b cookies.txt -X POST http://localhost:3000/auth/refresh \
-H "x-api-key: YOUR_API_KEY"

# Logout
curl -X POST http://localhost:3000/auth/logout \
-H "x-api-key: YOUR_API_KEY" \
-H "Authorization: Bearer TOKEN"
curl -b cookies.txt -X POST http://localhost:3000/auth/logout \
-H "x-api-key: YOUR_API_KEY"
```

## Option 4: Swagger UI
Expand All @@ -74,10 +72,10 @@ curl -X POST http://localhost:3000/auth/logout \
## Test flow

1. **Health** – Check the API is running.
2. **Register** – Create a user; you get `accessToken`, `refreshToken`, and `user`.
3. **Login** – Sign in with the same credentials.
4. **Refresh** – Send `refreshToken` in `Authorization: Bearer <token>` to get a new `accessToken`.
5. **Logout** – Send `refreshToken` to invalidate the session (optional; 200 even without a token).
2. **Register** – Create a user; you get `accessToken`, `expiresIn`, `user`, and a **`Set-Cookie`** for `appbase_session`.
3. **Login** – Same shape; updates the session cookie.
4. **Refresh** – `POST /auth/refresh` with the **session cookie** → new `accessToken`.
5. **Logout** – `POST /auth/logout` with the cookie (optional); clears cookie server-side. **200** even with no cookie.

## Automated tests

Expand Down
14 changes: 8 additions & 6 deletions apps/api/api.http
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
### Use with VS Code REST Client (humao.rest-client) or JetBrains HTTP Client
### Run: pnpm --filter api create-api-key to create an API key first
### Run requests in order: 1. Health → 2. Register → 3. Login → 4. Refresh → 5. Logout
### After Register/Login, copy `appbase_session=...` from the `Set-Cookie` response header into @sessionCookie below.

@baseUrl = http://localhost:3000
@apiKey = hs_live_mnlIKCBxPqvSGZLcDsNqAstnMihqTQwVAcwJESDFLxpgpBuxrvWzQBkpSjJkQVwl
@sessionCookie = appbase_session=PASTE_VALUE_FROM_SET_COOKIE

###############################################################################
# 1. Health check
Expand Down Expand Up @@ -73,7 +75,7 @@ x-api-key: {{apiKey}}
# 3. Auth – Login
###############################################################################

### Login – success (copy refreshToken from response for Refresh/Logout)
### Login – success (then copy session cookie for Refresh/Logout)
POST {{baseUrl}}/auth/login
Content-Type: application/json
x-api-key: {{apiKey}}
Expand All @@ -97,21 +99,21 @@ x-api-key: {{apiKey}}
# 4. Auth – Refresh
###############################################################################

### Refresh – success (paste refreshToken from login response)
### Refresh – success (session cookie from Register/Login)
POST {{baseUrl}}/auth/refresh
x-api-key: {{apiKey}}
Authorization: Bearer V5aFHu3fEIR1RjfptGj7VIJDgjY3mEaC
Cookie: {{sessionCookie}}

###############################################################################
# 5. Auth – Logout
###############################################################################

### Logout – success (optional: send refresh token to invalidate session)
### Logout – success (same session cookie)
POST {{baseUrl}}/auth/logout
x-api-key: {{apiKey}}
Authorization: Bearer 6pNHh2b6hmjAc5ySPeudHSkP2Hx1N5Bm
Cookie: {{sessionCookie}}

### Logout – without token (idempotent, always returns 200)
### Logout – without cookie (idempotent, always returns 200)
POST {{baseUrl}}/auth/logout
x-api-key: {{apiKey}}

Expand Down
Binary file modified apps/api/data/appbase.sqlite-shm
Binary file not shown.
Binary file modified apps/api/data/appbase.sqlite-wal
Binary file not shown.
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@appbase/db": "workspace:*",
"@appbase/types": "workspace:*",
"@better-auth/api-key": "^1.5.5",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/multipart": "^9.4.0",
"@fastify/swagger": "^9.7.0",
Expand Down
34 changes: 31 additions & 3 deletions apps/api/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const envSchema = z.object({
.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"])
.default("info"),
BASE_URL: z.string().url().optional(),
/** Comma-separated list of allowed browser origins for credentialed CORS (e.g. `http://localhost:3001`). */
CORS_ORIGINS: z.string().optional(),
AUTH_SECRET: z
.string()
.min(32, "AUTH_SECRET must be at least 32 characters")
Expand All @@ -17,11 +19,33 @@ const envSchema = z.object({

type ParsedEnv = z.output<typeof envSchema>;

export type AppEnv = Omit<ParsedEnv, "BASE_URL" | "AUTH_SECRET"> & {
export type AppEnv = Omit<ParsedEnv, "BASE_URL" | "AUTH_SECRET" | "CORS_ORIGINS"> & {
BASE_URL: string;
AUTH_SECRET: string;
corsAllowedOrigins: string[];
};

function buildCorsAllowedOrigins(corsOriginsEnv: string | undefined, baseUrl: string): string[] {
const fromEnv =
corsOriginsEnv
?.split(",")
.map((s) => s.trim())
.filter(Boolean) ?? [];
let baseOrigin: string;
try {
baseOrigin = new URL(baseUrl).origin;
} catch {
baseOrigin = "http://localhost:3000";
}
const defaults = [
baseOrigin,
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://[::1]:3000",
];
return [...new Set([...fromEnv, ...defaults])];
}

export function loadEnv(source: NodeJS.ProcessEnv): AppEnv {
const parsed = envSchema.parse(source);
const publicHost = parsed.HOST === "0.0.0.0" ? "localhost" : parsed.HOST;
Expand All @@ -33,9 +57,13 @@ export function loadEnv(source: NodeJS.ProcessEnv): AppEnv {
);
}

const baseUrl = parsed.BASE_URL ?? `http://${publicHost}:${parsed.PORT}`;
const { CORS_ORIGINS, ...rest } = parsed;

return {
...parsed,
BASE_URL: parsed.BASE_URL ?? `http://${publicHost}:${parsed.PORT}`,
...rest,
BASE_URL: baseUrl,
AUTH_SECRET: authSecret ?? "dev-secret-min-32-chars-required-for-auth",
corsAllowedOrigins: buildCorsAllowedOrigins(CORS_ORIGINS, baseUrl),
};
}
6 changes: 6 additions & 0 deletions apps/api/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ export const ACCESS_TOKEN_EXPIRY_STRING = "15m" as const;
/** @better-auth/api-key default key prefix. */
export const API_KEY_PREFIX = "hs_live_" as const;

/** HttpOnly session cookie (better-auth session token) for browser refresh/logout. */
export const SESSION_COOKIE_NAME = "appbase_session" as const;

/** Max-Age for session cookie — align with better-auth session duration (7d). */
export const SESSION_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 7;

/** better-auth internal routes relative to `BASE_URL`. */
export const AUTH_INTERNAL_PATHS = {
token: "/api/auth/token",
Expand Down
17 changes: 16 additions & 1 deletion apps/api/src/plugins/infrastructure.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import cookie from "@fastify/cookie";
import cors from "@fastify/cors";
import multipart from "@fastify/multipart";
import swagger from "@fastify/swagger";
Expand All @@ -6,7 +7,21 @@ import type { FastifyInstance } from "fastify";
import type { AppEnv } from "../config/env";

export async function registerInfrastructure(app: FastifyInstance, env: AppEnv) {
await app.register(cors, { origin: true });
await app.register(cookie);
await app.register(cors, {
credentials: true,
origin: (origin, cb) => {
if (!origin) {
cb(null, true);
return;
}
if (env.corsAllowedOrigins.includes(origin)) {
cb(null, true);
return;
}
cb(new Error("CORS origin not allowed"), false);
},
});
await app.register(multipart, { limits: { fileSize: 50 * 1024 * 1024 } });
await app.register(swagger, {
openapi: {
Expand Down
78 changes: 60 additions & 18 deletions apps/api/src/routes/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ import { describe, expect, it, beforeEach } from "vitest";
import { buildApp } from "../app";
import { loadEnv } from "../config/env";

function sessionCookiePair(registerRes: { headers: Record<string, unknown> }): string {
const setCookieHeader = registerRes.headers["set-cookie"];
expect(setCookieHeader).toBeDefined();
const parts = (Array.isArray(setCookieHeader) ? setCookieHeader : [setCookieHeader]).map(String);
const sessionLine = parts.find((p) => typeof p === "string" && p.startsWith("appbase_session="));
expect(sessionLine).toBeDefined();
return sessionLine!.split(";")[0]!;
}

describe("auth routes", () => {
const testEnv = loadEnv({
...process.env,
Expand Down Expand Up @@ -30,7 +39,6 @@ describe("auth routes", () => {
expect(body.success).toBe(true);
expect(body.data).toMatchObject({
accessToken: expect.any(String),
refreshToken: expect.any(String),
expiresIn: 900,
user: {
id: expect.any(String),
Expand All @@ -39,6 +47,25 @@ describe("auth routes", () => {
updatedAt: expect.any(String),
},
});
expect(res.headers["set-cookie"]).toBeDefined();
});

it("POST /auth/register - returns customIdentity when provided", async () => {
const res = await app.inject({
method: "POST",
url: "/auth/register",
payload: {
email: "custom-id@example.com",
password: "SecurePassword123!",
customIdentity: { displayName: "Test", company: "Acme" },
},
});

expect(res.statusCode).toBe(201);
expect(res.json().data.user.customIdentity).toEqual({
displayName: "Test",
company: "Acme",
});
});

it("POST /auth/register - conflict (duplicate email)", async () => {
Expand Down Expand Up @@ -105,7 +132,6 @@ describe("auth routes", () => {
expect(body.success).toBe(true);
expect(body.data).toMatchObject({
accessToken: expect.any(String),
refreshToken: expect.any(String),
expiresIn: 900,
user: { email: "login@example.com" },
});
Expand All @@ -124,18 +150,18 @@ describe("auth routes", () => {
expect(body.error.code).toBe("INVALID_CREDENTIALS");
});

it("POST /auth/refresh - success", async () => {
it("POST /auth/refresh - success with session cookie", async () => {
const registerRes = await app.inject({
method: "POST",
url: "/auth/register",
payload: { email: "refresh@example.com", password: "SecurePassword123!" },
});
const { refreshToken } = registerRes.json().data;
const cookiePair = sessionCookiePair(registerRes);

const res = await app.inject({
method: "POST",
url: "/auth/refresh",
headers: { Authorization: `Bearer ${refreshToken}` },
headers: { cookie: cookiePair },
});

expect(res.statusCode).toBe(200);
Expand All @@ -147,44 +173,60 @@ describe("auth routes", () => {
});
});

it("POST /auth/refresh - invalid/missing token", async () => {
it("POST /auth/refresh - 401 when session cookie missing", async () => {
const res = await app.inject({
method: "POST",
url: "/auth/refresh",
});
expect(res.statusCode).toBe(401);
});

it("POST /auth/refresh - 401 when Authorization Bearer present but no cookie", async () => {
const res = await app.inject({
method: "POST",
url: "/auth/refresh",
headers: { Authorization: "Bearer invalid-token" },
headers: { Authorization: "Bearer some-token" },
});
expect(res.statusCode).toBe(401);
});

it("POST /auth/refresh - 401 for invalid session cookie", async () => {
const res = await app.inject({
method: "POST",
url: "/auth/refresh",
headers: { cookie: "appbase_session=not-a-valid-session" },
});
expect(res.statusCode).toBe(401);
const body = res.json();
expect(body.success).toBe(false);
expect(["INVALID_TOKEN", "INVALID_API_KEY"]).toContain(body.error.code);
});

it("POST /auth/logout - success", async () => {
it("POST /auth/logout - success with session cookie and clears cookie", async () => {
const registerRes = await app.inject({
method: "POST",
url: "/auth/register",
payload: { email: "logout@example.com", password: "SecurePassword123!" },
});
const { refreshToken } = registerRes.json().data;
const cookiePair = sessionCookiePair(registerRes);

const res = await app.inject({
method: "POST",
url: "/auth/logout",
headers: { Authorization: `Bearer ${refreshToken}` },
headers: { cookie: cookiePair },
});

expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.success).toBe(true);
expect(body.data.loggedOut).toBe(true);
expect(res.json().data.loggedOut).toBe(true);

const cleared = res.headers["set-cookie"];
expect(cleared).toBeDefined();
const clearedJoined = Array.isArray(cleared) ? cleared.join("\n") : String(cleared);
expect(clearedJoined.toLowerCase()).toContain("appbase_session=");
expect(clearedJoined.toLowerCase()).toMatch(/max-age=0|expires=/);
});

it("POST /auth/logout - idempotent when no session", async () => {
it("POST /auth/logout - idempotent when no session cookie", async () => {
const res = await app.inject({
method: "POST",
url: "/auth/logout",
headers: { Authorization: "Bearer invalid-or-expired-token" },
});

expect(res.statusCode).toBe(200);
Expand Down
Loading
Loading