Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,309 @@
/**
* Integration tests for API token handlers.
*
* Tests token CRUD and resolution against a real in-memory SQLite database.
*/
import type { Kysely } from "kysely";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
handleApiTokenCreate,
handleApiTokenList,
handleApiTokenRevoke,
resolveApiToken,
resolveOAuthToken,
} from "../../../src/api/handlers/api-tokens.js";
import { generatePrefixedToken, TOKEN_PREFIXES } from "../../../src/auth/api-tokens.js";
import type { Database } from "../../../src/database/types.js";
import { setupTestDatabase } from "../../utils/test-db.js";
// Regex patterns for token validation
const PAT_PREFIX_REGEX = /^ec_pat_/;
let db: Kysely<Database>;
beforeEach(async () => {
db = await setupTestDatabase();
// Create a test user
await db
.insertInto("users")
.values({
id: "user_1",
email: "admin@test.com",
name: "Admin",
role: 50, // ADMIN
email_verified: 1,
})
.execute();
});
afterEach(async () => {
await db.destroy();
});
describe("handleApiTokenCreate", () => {
it("creates a token and returns the raw value", async () => {
const result = await handleApiTokenCreate(db, "user_1", {
name: "Test Token",
scopes: ["content:read", "content:write"],
});
expect(result.success).toBe(true);
expect(result.data).toBeDefined();
expect(result.data!.token).toMatch(PAT_PREFIX_REGEX);
expect(result.data!.info.name).toBe("Test Token");
expect(result.data!.info.scopes).toEqual(["content:read", "content:write"]);
expect(result.data!.info.userId).toBe("user_1");
expect(result.data!.info.prefix).toMatch(PAT_PREFIX_REGEX);
});
it("creates tokens with different hashes", async () => {
const result1 = await handleApiTokenCreate(db, "user_1", {
name: "Token 1",
scopes: ["content:read"],
});
const result2 = await handleApiTokenCreate(db, "user_1", {
name: "Token 2",
scopes: ["content:read"],
});
expect(result1.data!.token).not.toBe(result2.data!.token);
});
it("stores expiry date when provided", async () => {
const expiresAt = new Date(Date.now() + 86400000).toISOString();
const result = await handleApiTokenCreate(db, "user_1", {
name: "Expiring Token",
scopes: ["content:read"],
expiresAt,
});
expect(result.data!.info.expiresAt).toBe(expiresAt);
});
});
describe("handleApiTokenList", () => {
it("lists tokens for a user", async () => {
await handleApiTokenCreate(db, "user_1", {
name: "Token A",
scopes: ["content:read"],
});
await handleApiTokenCreate(db, "user_1", {
name: "Token B",
scopes: ["admin"],
});
const result = await handleApiTokenList(db, "user_1");
expect(result.success).toBe(true);
expect(result.data!.items).toHaveLength(2);
const names = result.data!.items.map((t) => t.name).toSorted();
expect(names).toEqual(["Token A", "Token B"]);
});
it("does not return tokens for other users", async () => {
await db
.insertInto("users")
.values({
id: "user_2",
email: "other@test.com",
name: "Other",
role: 50,
email_verified: 1,
})
.execute();
await handleApiTokenCreate(db, "user_1", {
name: "User 1 Token",
scopes: ["content:read"],
});
await handleApiTokenCreate(db, "user_2", {
name: "User 2 Token",
scopes: ["content:read"],
});
const result = await handleApiTokenList(db, "user_1");
expect(result.data!.items).toHaveLength(1);
expect(result.data!.items[0].name).toBe("User 1 Token");
});
it("never returns the token hash", async () => {
await handleApiTokenCreate(db, "user_1", {
name: "Test",
scopes: ["content:read"],
});
const result = await handleApiTokenList(db, "user_1");
const item = result.data!.items[0];
// Ensure no hash or raw token is exposed
expect(item).not.toHaveProperty("token_hash");
expect(item).not.toHaveProperty("tokenHash");
expect(item).not.toHaveProperty("token");
});
});
describe("handleApiTokenRevoke", () => {
it("revokes a token", async () => {
const createResult = await handleApiTokenCreate(db, "user_1", {
name: "To Revoke",
scopes: ["content:read"],
});
const tokenId = createResult.data!.info.id;
const result = await handleApiTokenRevoke(db, tokenId, "user_1");
expect(result.success).toBe(true);
// Should be gone from the list
const list = await handleApiTokenList(db, "user_1");
expect(list.data!.items).toHaveLength(0);
});
it("returns error for non-existent token", async () => {
const result = await handleApiTokenRevoke(db, "nonexistent", "user_1");
expect(result.success).toBe(false);
expect(result.error!.code).toBe("NOT_FOUND");
});
it("cannot revoke another user's token", async () => {
await db
.insertInto("users")
.values({
id: "user_2",
email: "other@test.com",
name: "Other",
role: 50,
email_verified: 1,
})
.execute();
const createResult = await handleApiTokenCreate(db, "user_1", {
name: "User 1 Token",
scopes: ["content:read"],
});
const tokenId = createResult.data!.info.id;
// User 2 tries to revoke user 1's token
const result = await handleApiTokenRevoke(db, tokenId, "user_2");
expect(result.success).toBe(false);
expect(result.error!.code).toBe("NOT_FOUND");
// Token should still exist
const list = await handleApiTokenList(db, "user_1");
expect(list.data!.items).toHaveLength(1);
});
});
describe("resolveApiToken", () => {
it("resolves a valid token to user and scopes", async () => {
const createResult = await handleApiTokenCreate(db, "user_1", {
name: "Test",
scopes: ["content:read", "media:write"],
});
const rawToken = createResult.data!.token;
const resolved = await resolveApiToken(db, rawToken);
expect(resolved).not.toBeNull();
expect(resolved!.userId).toBe("user_1");
expect(resolved!.scopes).toEqual(["content:read", "media:write"]);
});
it("returns null for invalid token", async () => {
const resolved = await resolveApiToken(db, "ec_pat_invalidtoken123");
expect(resolved).toBeNull();
});
it("returns null for expired token", async () => {
const pastDate = new Date(Date.now() - 86400000).toISOString(); // Yesterday
const createResult = await handleApiTokenCreate(db, "user_1", {
name: "Expired",
scopes: ["content:read"],
expiresAt: pastDate,
});
const rawToken = createResult.data!.token;
const resolved = await resolveApiToken(db, rawToken);
expect(resolved).toBeNull();
});
it("resolves non-expired token", async () => {
const futureDate = new Date(Date.now() + 86400000).toISOString(); // Tomorrow
const createResult = await handleApiTokenCreate(db, "user_1", {
name: "Future",
scopes: ["admin"],
expiresAt: futureDate,
});
const rawToken = createResult.data!.token;
const resolved = await resolveApiToken(db, rawToken);
expect(resolved).not.toBeNull();
expect(resolved!.scopes).toEqual(["admin"]);
});
});
describe("resolveOAuthToken", () => {
it("resolves a valid OAuth access token", async () => {
// Insert directly since we don't have a Device Flow handler yet
const { raw, hash } = generatePrefixedToken(TOKEN_PREFIXES.OAUTH_ACCESS);
const futureDate = new Date(Date.now() + 3600000).toISOString();
await db
.insertInto("_emdash_oauth_tokens")
.values({
token_hash: hash,
token_type: "access",
user_id: "user_1",
scopes: JSON.stringify(["content:read"]),
client_type: "cli",
expires_at: futureDate,
})
.execute();
const resolved = await resolveOAuthToken(db, raw);
expect(resolved).not.toBeNull();
expect(resolved!.userId).toBe("user_1");
expect(resolved!.scopes).toEqual(["content:read"]);
});
it("returns null for expired OAuth token", async () => {
const { raw, hash } = generatePrefixedToken(TOKEN_PREFIXES.OAUTH_ACCESS);
const pastDate = new Date(Date.now() - 3600000).toISOString();
await db
.insertInto("_emdash_oauth_tokens")
.values({
token_hash: hash,
token_type: "access",
user_id: "user_1",
scopes: JSON.stringify(["content:read"]),
client_type: "cli",
expires_at: pastDate,
})
.execute();
const resolved = await resolveOAuthToken(db, raw);
expect(resolved).toBeNull();
});
it("does not resolve refresh tokens", async () => {
const { raw, hash } = generatePrefixedToken(TOKEN_PREFIXES.OAUTH_REFRESH);
const futureDate = new Date(Date.now() + 3600000).toISOString();
await db
.insertInto("_emdash_oauth_tokens")
.values({
token_hash: hash,
token_type: "refresh",
user_id: "user_1",
scopes: JSON.stringify(["content:read"]),
client_type: "cli",
expires_at: futureDate,
})
.execute();
const resolved = await resolveOAuthToken(db, raw);
expect(resolved).toBeNull();
});
});

View File

@@ -0,0 +1,475 @@
/**
* Integration tests for OAuth 2.1 Authorization Code + PKCE handlers.
*
* Tests the full authorization code flow lifecycle against a real
* in-memory SQLite database.
*/
import { computeS256Challenge, Role } from "@emdash-cms/auth";
import { generateCodeVerifier } from "arctic";
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
buildDeniedRedirect,
cleanupExpiredAuthorizationCodes,
handleAuthorizationApproval,
handleAuthorizationCodeExchange,
} from "../../../src/api/handlers/oauth-authorization.js";
import { handleOAuthClientCreate } from "../../../src/api/handlers/oauth-clients.js";
import { hashApiToken } from "../../../src/auth/api-tokens.js";
import type { Database } from "../../../src/database/types.js";
import { setupTestDatabase } from "../../utils/test-db.js";
const ACCESS_TOKEN_PREFIX_REGEX = /^ec_oat_/;
const REFRESH_TOKEN_PREFIX_REGEX = /^ec_ort_/;
let db: Kysely<Database>;
beforeEach(async () => {
db = await setupTestDatabase();
// Create a test user
await db
.insertInto("users")
.values({
id: "user-1",
email: "test@example.com",
name: "Test User",
role: 50,
email_verified: 1,
})
.execute();
// Register OAuth clients used by tests
await handleOAuthClientCreate(db, {
id: "test-client",
name: "Test Client",
redirectUris: ["http://127.0.0.1:8080/callback", "https://myapp.example.com/callback"],
});
await handleOAuthClientCreate(db, {
id: "test",
name: "Test",
redirectUris: ["http://127.0.0.1:8080/callback"],
});
});
afterEach(async () => {
await db.destroy();
});
describe("Authorization Approval", () => {
it("should create an authorization code with valid params", async () => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = computeS256Challenge(codeVerifier);
const result = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
response_type: "code",
client_id: "test-client",
redirect_uri: "http://127.0.0.1:8080/callback",
scope: "content:read content:write",
state: "random-state-value",
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
expect(result.success).toBe(true);
if (!result.success) return;
const redirectUrl = new URL(result.data.redirect_url);
expect(redirectUrl.origin).toBe("http://127.0.0.1:8080");
expect(redirectUrl.pathname).toBe("/callback");
expect(redirectUrl.searchParams.get("code")).toBeTruthy();
expect(redirectUrl.searchParams.get("state")).toBe("random-state-value");
});
it("should reject unsupported response_type", async () => {
const result = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
response_type: "token",
client_id: "test-client",
redirect_uri: "http://127.0.0.1:8080/callback",
scope: "content:read",
code_challenge: "test",
code_challenge_method: "S256",
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("UNSUPPORTED_RESPONSE_TYPE");
});
it("should reject plain HTTP redirect to non-localhost", async () => {
const result = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
response_type: "code",
client_id: "test-client",
redirect_uri: "http://evil.com/callback",
scope: "content:read",
code_challenge: "test",
code_challenge_method: "S256",
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("INVALID_REDIRECT_URI");
});
it("should allow HTTPS redirects", async () => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = computeS256Challenge(codeVerifier);
const result = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
response_type: "code",
client_id: "test-client",
redirect_uri: "https://myapp.example.com/callback",
scope: "content:read",
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
expect(result.success).toBe(true);
});
it("should reject plain code challenge method", async () => {
const result = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
response_type: "code",
client_id: "test-client",
redirect_uri: "http://127.0.0.1:8080/callback",
scope: "content:read",
code_challenge: "test",
code_challenge_method: "plain",
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("INVALID_REQUEST");
});
it("should reject invalid scopes", async () => {
const result = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
response_type: "code",
client_id: "test-client",
redirect_uri: "http://127.0.0.1:8080/callback",
scope: "invalid:scope",
code_challenge: "test",
code_challenge_method: "S256",
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("INVALID_SCOPE");
});
});
describe("Authorization Code Exchange: Full Flow", () => {
it("should exchange code for tokens with valid PKCE", async () => {
// Step 1: Generate PKCE pair
const codeVerifier = generateCodeVerifier();
const codeChallenge = computeS256Challenge(codeVerifier);
// Step 2: Get authorization code
const approvalResult = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
response_type: "code",
client_id: "test-client",
redirect_uri: "http://127.0.0.1:8080/callback",
scope: "content:read content:write media:read",
state: "state123",
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
expect(approvalResult.success).toBe(true);
if (!approvalResult.success) return;
const redirectUrl = new URL(approvalResult.data.redirect_url);
const code = redirectUrl.searchParams.get("code")!;
// Step 3: Exchange code for tokens
const exchangeResult = await handleAuthorizationCodeExchange(db, {
grant_type: "authorization_code",
code,
redirect_uri: "http://127.0.0.1:8080/callback",
client_id: "test-client",
code_verifier: codeVerifier,
});
expect(exchangeResult.success).toBe(true);
if (!exchangeResult.success) return;
expect(exchangeResult.data.access_token).toMatch(ACCESS_TOKEN_PREFIX_REGEX);
expect(exchangeResult.data.refresh_token).toMatch(REFRESH_TOKEN_PREFIX_REGEX);
expect(exchangeResult.data.token_type).toBe("Bearer");
expect(exchangeResult.data.expires_in).toBe(3600);
expect(exchangeResult.data.scope).toBe("content:read content:write media:read");
// Step 4: Verify tokens are stored
const accessHash = hashApiToken(exchangeResult.data.access_token);
const accessRow = await db
.selectFrom("_emdash_oauth_tokens")
.selectAll()
.where("token_hash", "=", accessHash)
.executeTakeFirst();
expect(accessRow).toBeTruthy();
expect(accessRow!.token_type).toBe("access");
expect(accessRow!.user_id).toBe("user-1");
expect(accessRow!.client_id).toBe("test-client");
// Step 5: Authorization code is consumed (single-use)
const codeHash = hashApiToken(code);
const codeRow = await db
.selectFrom("_emdash_authorization_codes")
.selectAll()
.where("code_hash", "=", codeHash)
.executeTakeFirst();
expect(codeRow).toBeUndefined();
});
it("should reject wrong code verifier (PKCE failure)", async () => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = computeS256Challenge(codeVerifier);
const approvalResult = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
response_type: "code",
client_id: "test-client",
redirect_uri: "http://127.0.0.1:8080/callback",
scope: "content:read",
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
expect(approvalResult.success).toBe(true);
if (!approvalResult.success) return;
const redirectUrl = new URL(approvalResult.data.redirect_url);
const code = redirectUrl.searchParams.get("code")!;
// Use a DIFFERENT code verifier
const wrongVerifier = generateCodeVerifier();
const exchangeResult = await handleAuthorizationCodeExchange(db, {
grant_type: "authorization_code",
code,
redirect_uri: "http://127.0.0.1:8080/callback",
client_id: "test-client",
code_verifier: wrongVerifier,
});
expect(exchangeResult.success).toBe(false);
if (exchangeResult.success) return;
expect(exchangeResult.error.code).toBe("invalid_grant");
expect(exchangeResult.error.message).toContain("PKCE");
// Code should be deleted after failed PKCE verification
const codeHash = hashApiToken(code);
const codeRow = await db
.selectFrom("_emdash_authorization_codes")
.selectAll()
.where("code_hash", "=", codeHash)
.executeTakeFirst();
expect(codeRow).toBeUndefined();
});
it("should reject mismatched redirect_uri", async () => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = computeS256Challenge(codeVerifier);
const approvalResult = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
response_type: "code",
client_id: "test-client",
redirect_uri: "http://127.0.0.1:8080/callback",
scope: "content:read",
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
expect(approvalResult.success).toBe(true);
if (!approvalResult.success) return;
const redirectUrl = new URL(approvalResult.data.redirect_url);
const code = redirectUrl.searchParams.get("code")!;
const exchangeResult = await handleAuthorizationCodeExchange(db, {
grant_type: "authorization_code",
code,
redirect_uri: "http://127.0.0.1:9999/different",
client_id: "test-client",
code_verifier: codeVerifier,
});
expect(exchangeResult.success).toBe(false);
if (exchangeResult.success) return;
expect(exchangeResult.error.code).toBe("invalid_grant");
expect(exchangeResult.error.message).toContain("redirect_uri");
});
it("should reject mismatched client_id", async () => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = computeS256Challenge(codeVerifier);
const approvalResult = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
response_type: "code",
client_id: "test-client",
redirect_uri: "http://127.0.0.1:8080/callback",
scope: "content:read",
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
expect(approvalResult.success).toBe(true);
if (!approvalResult.success) return;
const redirectUrl = new URL(approvalResult.data.redirect_url);
const code = redirectUrl.searchParams.get("code")!;
const exchangeResult = await handleAuthorizationCodeExchange(db, {
grant_type: "authorization_code",
code,
redirect_uri: "http://127.0.0.1:8080/callback",
client_id: "different-client",
code_verifier: codeVerifier,
});
expect(exchangeResult.success).toBe(false);
if (exchangeResult.success) return;
expect(exchangeResult.error.code).toBe("invalid_grant");
expect(exchangeResult.error.message).toContain("client_id");
});
it("should reject expired authorization code", async () => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = computeS256Challenge(codeVerifier);
// Insert an expired code directly
const code = generateCodeVerifier();
const codeHash = hashApiToken(code);
await db
.insertInto("_emdash_authorization_codes")
.values({
code_hash: codeHash,
client_id: "test-client",
redirect_uri: "http://127.0.0.1:8080/callback",
user_id: "user-1",
scopes: JSON.stringify(["content:read"]),
code_challenge: codeChallenge,
code_challenge_method: "S256",
resource: null,
expires_at: new Date(Date.now() - 1000).toISOString(), // Already expired
})
.execute();
const exchangeResult = await handleAuthorizationCodeExchange(db, {
grant_type: "authorization_code",
code,
redirect_uri: "http://127.0.0.1:8080/callback",
client_id: "test-client",
code_verifier: codeVerifier,
});
expect(exchangeResult.success).toBe(false);
if (exchangeResult.success) return;
expect(exchangeResult.error.code).toBe("invalid_grant");
expect(exchangeResult.error.message).toContain("expired");
});
it("should reject code reuse (single-use enforcement)", async () => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = computeS256Challenge(codeVerifier);
const approvalResult = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
response_type: "code",
client_id: "test-client",
redirect_uri: "http://127.0.0.1:8080/callback",
scope: "content:read",
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
expect(approvalResult.success).toBe(true);
if (!approvalResult.success) return;
const redirectUrl = new URL(approvalResult.data.redirect_url);
const code = redirectUrl.searchParams.get("code")!;
// First exchange succeeds
const first = await handleAuthorizationCodeExchange(db, {
grant_type: "authorization_code",
code,
redirect_uri: "http://127.0.0.1:8080/callback",
client_id: "test-client",
code_verifier: codeVerifier,
});
expect(first.success).toBe(true);
// Second exchange with same code fails
const second = await handleAuthorizationCodeExchange(db, {
grant_type: "authorization_code",
code,
redirect_uri: "http://127.0.0.1:8080/callback",
client_id: "test-client",
code_verifier: codeVerifier,
});
expect(second.success).toBe(false);
if (second.success) return;
expect(second.error.code).toBe("invalid_grant");
});
});
describe("buildDeniedRedirect", () => {
it("should include error and state params", () => {
const url = buildDeniedRedirect("http://127.0.0.1:8080/callback", "state123");
const parsed = new URL(url);
expect(parsed.searchParams.get("error")).toBe("access_denied");
expect(parsed.searchParams.get("error_description")).toBeTruthy();
expect(parsed.searchParams.get("state")).toBe("state123");
});
it("should omit state when not provided", () => {
const url = buildDeniedRedirect("http://127.0.0.1:8080/callback");
const parsed = new URL(url);
expect(parsed.searchParams.get("error")).toBe("access_denied");
expect(parsed.searchParams.has("state")).toBe(false);
});
});
describe("cleanupExpiredAuthorizationCodes", () => {
it("should delete expired codes", async () => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = computeS256Challenge(codeVerifier);
// Insert an expired code
await db
.insertInto("_emdash_authorization_codes")
.values({
code_hash: "expired-hash",
client_id: "test",
redirect_uri: "http://127.0.0.1:8080/callback",
user_id: "user-1",
scopes: JSON.stringify(["content:read"]),
code_challenge: codeChallenge,
code_challenge_method: "S256",
resource: null,
expires_at: new Date(Date.now() - 1000).toISOString(),
})
.execute();
// Insert a valid code
await db
.insertInto("_emdash_authorization_codes")
.values({
code_hash: "valid-hash",
client_id: "test",
redirect_uri: "http://127.0.0.1:8080/callback",
user_id: "user-1",
scopes: JSON.stringify(["content:read"]),
code_challenge: codeChallenge,
code_challenge_method: "S256",
resource: null,
expires_at: new Date(Date.now() + 600000).toISOString(),
})
.execute();
const deleted = await cleanupExpiredAuthorizationCodes(db);
expect(deleted).toBe(1);
// Valid code should remain
const remaining = await db.selectFrom("_emdash_authorization_codes").selectAll().execute();
expect(remaining).toHaveLength(1);
expect(remaining[0]!.code_hash).toBe("valid-hash");
});
});

View File

@@ -0,0 +1,594 @@
/**
* Integration tests for OAuth Device Flow handlers.
*
* Tests the full device flow lifecycle against a real in-memory SQLite database.
*/
import { Role } from "@emdash-cms/auth";
import type { RoleLevel } from "@emdash-cms/auth";
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
handleDeviceAuthorize,
handleDeviceCodeRequest,
handleDeviceTokenExchange,
handleTokenRefresh,
handleTokenRevoke,
} from "../../../src/api/handlers/device-flow.js";
import { hashApiToken } from "../../../src/auth/api-tokens.js";
import type { Database } from "../../../src/database/types.js";
import { setupTestDatabase } from "../../utils/test-db.js";
const USER_CODE_FORMAT_REGEX = /^[A-Z0-9]{4}-[A-Z0-9]{4}$/;
const ACCESS_TOKEN_PREFIX_REGEX = /^ec_oat_/;
const REFRESH_TOKEN_PREFIX_REGEX = /^ec_ort_/;
const HYPHEN_REGEX = /-/g;
let db: Kysely<Database>;
beforeEach(async () => {
db = await setupTestDatabase();
// Create a test user
await db
.insertInto("users")
.values({
id: "user-1",
email: "test@example.com",
name: "Test User",
role: 50,
email_verified: 1,
})
.execute();
});
afterEach(async () => {
await db.destroy();
});
describe("Device Code Request", () => {
it("should create a device code with default scopes", async () => {
const result = await handleDeviceCodeRequest(
db,
{ client_id: "emdash-cli" },
"https://example.com/_emdash/device",
);
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data.device_code).toBeTruthy();
expect(result.data.user_code).toMatch(USER_CODE_FORMAT_REGEX);
expect(result.data.verification_uri).toBe("https://example.com/_emdash/device");
expect(result.data.expires_in).toBe(900); // 15 minutes
expect(result.data.interval).toBe(5);
});
it("should create a device code with custom scopes", async () => {
const result = await handleDeviceCodeRequest(
db,
{ scope: "content:read media:read" },
"https://example.com/_emdash/device",
);
expect(result.success).toBe(true);
if (!result.success) return;
// Verify scopes were stored
const row = await db
.selectFrom("_emdash_device_codes")
.selectAll()
.where("device_code", "=", result.data.device_code)
.executeTakeFirst();
expect(row).toBeTruthy();
expect(JSON.parse(row!.scopes)).toEqual(["content:read", "media:read"]);
});
it("should reject invalid scopes", async () => {
const result = await handleDeviceCodeRequest(
db,
{ scope: "invalid:scope" },
"https://example.com/_emdash/device",
);
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("INVALID_SCOPE");
});
});
describe("Device Flow: Full Lifecycle", () => {
it("should complete the full device flow: code → authorize → exchange", async () => {
// Step 1: Request device code
const codeResult = await handleDeviceCodeRequest(
db,
{ client_id: "emdash-cli" },
"https://example.com/_emdash/device",
);
expect(codeResult.success).toBe(true);
if (!codeResult.success) return;
const { device_code, user_code } = codeResult.data;
// Step 2: Poll before authorization → pending
const pendingResult = await handleDeviceTokenExchange(db, {
device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
});
expect(pendingResult.success).toBe(false);
expect(pendingResult.deviceFlowError).toBe("authorization_pending");
// Step 3: User authorizes (admin role = 50)
const authResult = await handleDeviceAuthorize(db, "user-1", Role.ADMIN, {
user_code,
});
expect(authResult.success).toBe(true);
if (!authResult.success) return;
expect(authResult.data.authorized).toBe(true);
// Step 4: Exchange for tokens
const tokenResult = await handleDeviceTokenExchange(db, {
device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
});
expect(tokenResult.success).toBe(true);
if (!tokenResult.success) return;
expect(tokenResult.data.access_token).toMatch(ACCESS_TOKEN_PREFIX_REGEX);
expect(tokenResult.data.refresh_token).toMatch(REFRESH_TOKEN_PREFIX_REGEX);
expect(tokenResult.data.token_type).toBe("Bearer");
expect(tokenResult.data.expires_in).toBe(3600);
expect(tokenResult.data.scope).toBeTruthy();
// Step 5: Device code should be consumed
const row = await db
.selectFrom("_emdash_device_codes")
.selectAll()
.where("device_code", "=", device_code)
.executeTakeFirst();
expect(row).toBeUndefined();
// Step 6: Tokens should be stored
const accessHash = hashApiToken(tokenResult.data.access_token);
const accessRow = await db
.selectFrom("_emdash_oauth_tokens")
.selectAll()
.where("token_hash", "=", accessHash)
.executeTakeFirst();
expect(accessRow).toBeTruthy();
expect(accessRow!.token_type).toBe("access");
expect(accessRow!.user_id).toBe("user-1");
const refreshHash = hashApiToken(tokenResult.data.refresh_token);
const refreshRow = await db
.selectFrom("_emdash_oauth_tokens")
.selectAll()
.where("token_hash", "=", refreshHash)
.executeTakeFirst();
expect(refreshRow).toBeTruthy();
expect(refreshRow!.token_type).toBe("refresh");
});
it("should handle denied authorization", async () => {
const codeResult = await handleDeviceCodeRequest(db, {}, "https://example.com/_emdash/device");
expect(codeResult.success).toBe(true);
if (!codeResult.success) return;
// User denies
const authResult = await handleDeviceAuthorize(db, "user-1", Role.ADMIN, {
user_code: codeResult.data.user_code,
action: "deny",
});
expect(authResult.success).toBe(true);
if (!authResult.success) return;
expect(authResult.data.authorized).toBe(false);
// Exchange should return access_denied
const tokenResult = await handleDeviceTokenExchange(db, {
device_code: codeResult.data.device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
});
expect(tokenResult.success).toBe(false);
expect(tokenResult.deviceFlowError).toBe("access_denied");
});
it("should normalize user codes (strip hyphens, case-insensitive)", async () => {
const codeResult = await handleDeviceCodeRequest(db, {}, "https://example.com/_emdash/device");
expect(codeResult.success).toBe(true);
if (!codeResult.success) return;
// Submit lowercase without hyphen
const code = codeResult.data.user_code.replace(HYPHEN_REGEX, "").toLowerCase();
const authResult = await handleDeviceAuthorize(db, "user-1", Role.ADMIN, {
user_code: code,
});
expect(authResult.success).toBe(true);
});
});
describe("Device Token Exchange: Error Cases", () => {
it("should reject invalid grant_type", async () => {
const result = await handleDeviceTokenExchange(db, {
device_code: "whatever",
grant_type: "invalid",
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("UNSUPPORTED_GRANT_TYPE");
});
it("should reject unknown device codes", async () => {
const result = await handleDeviceTokenExchange(db, {
device_code: "nonexistent",
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("INVALID_GRANT");
});
it("should reject a second exchange for an already-consumed device code", async () => {
const codeResult = await handleDeviceCodeRequest(
db,
{ client_id: "emdash-cli" },
"https://example.com/_emdash/device",
);
expect(codeResult.success).toBe(true);
if (!codeResult.success) return;
await handleDeviceAuthorize(db, "user-1", Role.ADMIN, {
user_code: codeResult.data.user_code,
});
// First exchange succeeds
const first = await handleDeviceTokenExchange(db, {
device_code: codeResult.data.device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
});
expect(first.success).toBe(true);
// Second exchange fails — device code was consumed atomically
const second = await handleDeviceTokenExchange(db, {
device_code: codeResult.data.device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
});
expect(second.success).toBe(false);
if (second.success) return;
expect(second.error.code).toBe("INVALID_GRANT");
});
it("should report expired device codes", async () => {
// Create a device code that's already expired
await db
.insertInto("_emdash_device_codes")
.values({
device_code: "expired-code",
user_code: "AAAA-BBBB",
scopes: JSON.stringify(["content:read"]),
status: "pending",
expires_at: new Date(Date.now() - 1000).toISOString(),
interval: 5,
})
.execute();
const result = await handleDeviceTokenExchange(db, {
device_code: "expired-code",
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
});
expect(result.success).toBe(false);
expect(result.deviceFlowError).toBe("expired_token");
});
});
describe("Token Refresh", () => {
it("should exchange a refresh token for a new access token", async () => {
// Complete a device flow first to get tokens
const codeResult = await handleDeviceCodeRequest(db, {}, "https://example.com/_emdash/device");
expect(codeResult.success).toBe(true);
if (!codeResult.success) return;
await handleDeviceAuthorize(db, "user-1", Role.ADMIN, {
user_code: codeResult.data.user_code,
});
const tokenResult = await handleDeviceTokenExchange(db, {
device_code: codeResult.data.device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
});
expect(tokenResult.success).toBe(true);
if (!tokenResult.success) return;
// Refresh
const refreshResult = await handleTokenRefresh(db, {
refresh_token: tokenResult.data.refresh_token,
grant_type: "refresh_token",
});
expect(refreshResult.success).toBe(true);
if (!refreshResult.success) return;
expect(refreshResult.data.access_token).toMatch(ACCESS_TOKEN_PREFIX_REGEX);
expect(refreshResult.data.access_token).not.toBe(tokenResult.data.access_token);
expect(refreshResult.data.refresh_token).toBe(tokenResult.data.refresh_token);
expect(refreshResult.data.expires_in).toBe(3600);
});
it("should reject invalid refresh tokens", async () => {
const result = await handleTokenRefresh(db, {
refresh_token: "ec_ort_invalid",
grant_type: "refresh_token",
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("INVALID_GRANT");
});
it("should reject wrong grant_type", async () => {
const result = await handleTokenRefresh(db, {
refresh_token: "ec_ort_whatever",
grant_type: "authorization_code",
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("UNSUPPORTED_GRANT_TYPE");
});
it("should reject wrong token prefix", async () => {
const result = await handleTokenRefresh(db, {
refresh_token: "ec_pat_notarefresh",
grant_type: "refresh_token",
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("INVALID_GRANT");
});
});
describe("Token Revoke", () => {
it("should revoke an access token", async () => {
// Get tokens via device flow
const codeResult = await handleDeviceCodeRequest(db, {}, "https://example.com/_emdash/device");
if (!codeResult.success) return;
await handleDeviceAuthorize(db, "user-1", Role.ADMIN, {
user_code: codeResult.data.user_code,
});
const tokenResult = await handleDeviceTokenExchange(db, {
device_code: codeResult.data.device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
});
if (!tokenResult.success) return;
// Revoke the access token
const revokeResult = await handleTokenRevoke(db, {
token: tokenResult.data.access_token,
});
expect(revokeResult.success).toBe(true);
// Access token should be gone
const accessHash = hashApiToken(tokenResult.data.access_token);
const row = await db
.selectFrom("_emdash_oauth_tokens")
.selectAll()
.where("token_hash", "=", accessHash)
.executeTakeFirst();
expect(row).toBeUndefined();
});
it("should revoke a refresh token and its access tokens", async () => {
// Get tokens via device flow
const codeResult = await handleDeviceCodeRequest(db, {}, "https://example.com/_emdash/device");
if (!codeResult.success) return;
await handleDeviceAuthorize(db, "user-1", Role.ADMIN, {
user_code: codeResult.data.user_code,
});
const tokenResult = await handleDeviceTokenExchange(db, {
device_code: codeResult.data.device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
});
if (!tokenResult.success) return;
// Revoke the refresh token
const revokeResult = await handleTokenRevoke(db, {
token: tokenResult.data.refresh_token,
});
expect(revokeResult.success).toBe(true);
// Both tokens should be gone
const tokenCount = await db
.selectFrom("_emdash_oauth_tokens")
.select(db.fn.count("token_hash").as("count"))
.executeTakeFirst();
expect(Number(tokenCount?.count ?? 0)).toBe(0);
});
it("should return success for unknown tokens (RFC 7009)", async () => {
const result = await handleTokenRevoke(db, {
token: "ec_oat_nonexistent",
});
expect(result.success).toBe(true);
});
});
describe("Device Authorize: Error Cases", () => {
it("should reject invalid user codes", async () => {
const result = await handleDeviceAuthorize(db, "user-1", Role.ADMIN, {
user_code: "INVALID-CODE",
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("INVALID_CODE");
});
it("should reject expired device codes", async () => {
await db
.insertInto("_emdash_device_codes")
.values({
device_code: "expired-dc",
user_code: "CCCC-DDDD",
scopes: JSON.stringify(["content:read"]),
status: "pending",
expires_at: new Date(Date.now() - 1000).toISOString(),
interval: 5,
})
.execute();
const result = await handleDeviceAuthorize(db, "user-1", Role.ADMIN, {
user_code: "CCCC-DDDD",
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("EXPIRED_CODE");
});
});
// ---------------------------------------------------------------------------
// Scope escalation prevention (SEC: CWE-269)
// ---------------------------------------------------------------------------
describe("Scope Clamping: Role-based scope restriction", () => {
/** Helper: run a full device flow with given requested scopes and user role */
async function completeDeviceFlow(
requestedScopes: string,
userRole: RoleLevel,
): Promise<{ scopes: string; success: boolean }> {
const codeResult = await handleDeviceCodeRequest(
db,
{ scope: requestedScopes },
"https://example.com/_emdash/device",
);
if (!codeResult.success) return { scopes: "", success: false };
const authResult = await handleDeviceAuthorize(db, "user-1", userRole, {
user_code: codeResult.data.user_code,
});
if (!authResult.success) return { scopes: "", success: false };
const tokenResult = await handleDeviceTokenExchange(db, {
device_code: codeResult.data.device_code,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
});
if (!tokenResult.success) return { scopes: "", success: false };
return { scopes: tokenResult.data.scope, success: true };
}
it("should strip admin scope from non-admin user tokens", async () => {
// CONTRIBUTOR requests admin scope — this is the core attack scenario
const result = await completeDeviceFlow("content:read content:write admin", Role.CONTRIBUTOR);
expect(result.success).toBe(true);
const scopes = result.scopes.split(" ");
expect(scopes).toContain("content:read");
expect(scopes).toContain("content:write");
expect(scopes).not.toContain("admin");
});
it("should strip schema:write from non-admin user tokens", async () => {
// EDITOR requests schema:write — only ADMIN gets schema:write
const result = await completeDeviceFlow("content:read schema:read schema:write", Role.EDITOR);
expect(result.success).toBe(true);
const scopes = result.scopes.split(" ");
expect(scopes).toContain("content:read");
expect(scopes).toContain("schema:read");
expect(scopes).not.toContain("schema:write");
});
it("should strip schema:read from contributor tokens", async () => {
// CONTRIBUTOR requests schema:read — only EDITOR+ gets schema:read
const result = await completeDeviceFlow("content:read schema:read", Role.CONTRIBUTOR);
expect(result.success).toBe(true);
const scopes = result.scopes.split(" ");
expect(scopes).toContain("content:read");
expect(scopes).not.toContain("schema:read");
});
it("should allow admin user to get all scopes", async () => {
const result = await completeDeviceFlow(
"content:read content:write media:read media:write schema:read schema:write admin",
Role.ADMIN,
);
expect(result.success).toBe(true);
const scopes = result.scopes.split(" ");
expect(scopes).toContain("admin");
expect(scopes).toContain("schema:write");
expect(scopes).toContain("content:write");
});
it("should return INSUFFICIENT_ROLE when no scopes survive clamping", async () => {
// SUBSCRIBER requests only admin scope — nothing survives
const codeResult = await handleDeviceCodeRequest(
db,
{ scope: "admin schema:write" },
"https://example.com/_emdash/device",
);
expect(codeResult.success).toBe(true);
if (!codeResult.success) return;
const authResult = await handleDeviceAuthorize(db, "user-1", Role.SUBSCRIBER, {
user_code: codeResult.data.user_code,
});
expect(authResult.success).toBe(false);
if (authResult.success) return;
expect(authResult.error.code).toBe("INSUFFICIENT_ROLE");
});
it("should clamp scopes in stored device code at authorize time", async () => {
// Verify that the stored scopes are clamped, not just the response
const codeResult = await handleDeviceCodeRequest(
db,
{ scope: "content:read content:write schema:write admin" },
"https://example.com/_emdash/device",
);
expect(codeResult.success).toBe(true);
if (!codeResult.success) return;
// Before authorize: scopes include admin and schema:write
const beforeRow = await db
.selectFrom("_emdash_device_codes")
.selectAll()
.where("device_code", "=", codeResult.data.device_code)
.executeTakeFirst();
expect(JSON.parse(beforeRow!.scopes)).toContain("admin");
expect(JSON.parse(beforeRow!.scopes)).toContain("schema:write");
// Authorize as CONTRIBUTOR — admin and schema:write must be stripped
await handleDeviceAuthorize(db, "user-1", Role.CONTRIBUTOR, {
user_code: codeResult.data.user_code,
});
// After authorize: scopes should be clamped in DB
const afterRow = await db
.selectFrom("_emdash_device_codes")
.selectAll()
.where("device_code", "=", codeResult.data.device_code)
.executeTakeFirst();
const storedScopes = JSON.parse(afterRow!.scopes) as string[];
expect(storedScopes).toContain("content:read");
expect(storedScopes).toContain("content:write");
expect(storedScopes).not.toContain("admin");
expect(storedScopes).not.toContain("schema:write");
});
it("should allow editor to get content + media + schema:read scopes", async () => {
const result = await completeDeviceFlow(
"content:read content:write media:read media:write schema:read",
Role.EDITOR,
);
expect(result.success).toBe(true);
const scopes = result.scopes.split(" ");
expect(scopes).toContain("content:read");
expect(scopes).toContain("content:write");
expect(scopes).toContain("media:read");
expect(scopes).toContain("media:write");
expect(scopes).toContain("schema:read");
});
});

View File

@@ -0,0 +1,372 @@
/**
* Integration tests for OAuth client management and redirect URI allowlist.
*
* Tests that the authorization endpoint rejects unregistered clients and
* redirect URIs not in the client's registered set.
*/
import { computeS256Challenge, Role } from "@emdash-cms/auth";
import { generateCodeVerifier } from "arctic";
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { handleAuthorizationApproval } from "../../../src/api/handlers/oauth-authorization.js";
import {
handleOAuthClientCreate,
handleOAuthClientDelete,
handleOAuthClientGet,
handleOAuthClientList,
handleOAuthClientUpdate,
lookupOAuthClient,
validateClientRedirectUri,
} from "../../../src/api/handlers/oauth-clients.js";
import type { Database } from "../../../src/database/types.js";
import { setupTestDatabase } from "../../utils/test-db.js";
let db: Kysely<Database>;
beforeEach(async () => {
db = await setupTestDatabase();
// Create a test user
await db
.insertInto("users")
.values({
id: "user-1",
email: "test@example.com",
name: "Test User",
role: 50,
email_verified: 1,
})
.execute();
});
afterEach(async () => {
await db.destroy();
});
// ---------------------------------------------------------------------------
// validateClientRedirectUri (unit-level)
// ---------------------------------------------------------------------------
describe("validateClientRedirectUri", () => {
it("should return null for a registered redirect URI", () => {
const result = validateClientRedirectUri("https://myapp.example.com/callback", [
"https://myapp.example.com/callback",
"http://127.0.0.1:8080/callback",
]);
expect(result).toBeNull();
});
it("should return error for an unregistered redirect URI", () => {
const result = validateClientRedirectUri("https://evil.com/callback", [
"https://myapp.example.com/callback",
]);
expect(result).toBeTruthy();
});
it("should require exact match (no prefix matching)", () => {
const result = validateClientRedirectUri("https://myapp.example.com/callback/extra", [
"https://myapp.example.com/callback",
]);
expect(result).toBeTruthy();
});
it("should require exact match (no query string tolerance)", () => {
const result = validateClientRedirectUri("https://myapp.example.com/callback?foo=bar", [
"https://myapp.example.com/callback",
]);
expect(result).toBeTruthy();
});
});
// ---------------------------------------------------------------------------
// OAuth Client CRUD
// ---------------------------------------------------------------------------
describe("OAuth Client CRUD", () => {
it("should create a client", async () => {
const result = await handleOAuthClientCreate(db, {
id: "test-client",
name: "Test Client",
redirectUris: ["https://myapp.example.com/callback"],
});
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data.id).toBe("test-client");
expect(result.data.name).toBe("Test Client");
expect(result.data.redirectUris).toEqual(["https://myapp.example.com/callback"]);
});
it("should reject duplicate client IDs", async () => {
await handleOAuthClientCreate(db, {
id: "test-client",
name: "Test Client",
redirectUris: ["https://myapp.example.com/callback"],
});
const result = await handleOAuthClientCreate(db, {
id: "test-client",
name: "Duplicate Client",
redirectUris: ["https://other.example.com/callback"],
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("CONFLICT");
});
it("should reject clients with empty redirect URIs", async () => {
const result = await handleOAuthClientCreate(db, {
id: "test-client",
name: "Test Client",
redirectUris: [],
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("VALIDATION_ERROR");
});
it("should reject clients with invalid redirect URIs", async () => {
const result = await handleOAuthClientCreate(db, {
id: "test-client",
name: "Test Client",
redirectUris: ["http://example.com/callback"],
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("VALIDATION_ERROR");
expect(result.error.message).toContain("HTTP redirect URIs are only allowed for localhost");
});
it("should list clients", async () => {
await handleOAuthClientCreate(db, {
id: "client-1",
name: "Client 1",
redirectUris: ["https://one.example.com/callback"],
});
await handleOAuthClientCreate(db, {
id: "client-2",
name: "Client 2",
redirectUris: ["https://two.example.com/callback"],
});
const result = await handleOAuthClientList(db);
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data.items).toHaveLength(2);
});
it("should get a client by ID", async () => {
await handleOAuthClientCreate(db, {
id: "test-client",
name: "Test Client",
redirectUris: ["https://myapp.example.com/callback"],
scopes: ["content:read"],
});
const result = await handleOAuthClientGet(db, "test-client");
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data.id).toBe("test-client");
expect(result.data.scopes).toEqual(["content:read"]);
});
it("should return NOT_FOUND for unknown client", async () => {
const result = await handleOAuthClientGet(db, "unknown");
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("NOT_FOUND");
});
it("should update a client", async () => {
await handleOAuthClientCreate(db, {
id: "test-client",
name: "Test Client",
redirectUris: ["https://myapp.example.com/callback"],
});
const result = await handleOAuthClientUpdate(db, "test-client", {
name: "Updated Client",
redirectUris: ["https://myapp.example.com/callback", "https://myapp.example.com/callback2"],
});
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data.name).toBe("Updated Client");
expect(result.data.redirectUris).toHaveLength(2);
});
it("should reject update with empty redirect URIs", async () => {
await handleOAuthClientCreate(db, {
id: "test-client",
name: "Test Client",
redirectUris: ["https://myapp.example.com/callback"],
});
const result = await handleOAuthClientUpdate(db, "test-client", {
redirectUris: [],
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("VALIDATION_ERROR");
});
it("should reject update with invalid redirect URIs", async () => {
await handleOAuthClientCreate(db, {
id: "test-client",
name: "Test Client",
redirectUris: ["https://myapp.example.com/callback"],
});
const result = await handleOAuthClientUpdate(db, "test-client", {
redirectUris: ["myapp://callback"],
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("VALIDATION_ERROR");
expect(result.error.message).toContain("Unsupported redirect URI scheme");
});
it("should delete a client", async () => {
await handleOAuthClientCreate(db, {
id: "test-client",
name: "Test Client",
redirectUris: ["https://myapp.example.com/callback"],
});
const result = await handleOAuthClientDelete(db, "test-client");
expect(result.success).toBe(true);
const getResult = await handleOAuthClientGet(db, "test-client");
expect(getResult.success).toBe(false);
});
it("should return NOT_FOUND when deleting unknown client", async () => {
const result = await handleOAuthClientDelete(db, "unknown");
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("NOT_FOUND");
});
});
// ---------------------------------------------------------------------------
// lookupOAuthClient
// ---------------------------------------------------------------------------
describe("lookupOAuthClient", () => {
it("should return redirect URIs for a registered client", async () => {
await handleOAuthClientCreate(db, {
id: "test-client",
name: "Test Client",
redirectUris: ["https://myapp.example.com/callback", "http://127.0.0.1:8080/callback"],
});
const client = await lookupOAuthClient(db, "test-client");
expect(client).toBeTruthy();
expect(client!.redirectUris).toEqual([
"https://myapp.example.com/callback",
"http://127.0.0.1:8080/callback",
]);
});
it("should return null for an unregistered client", async () => {
const client = await lookupOAuthClient(db, "unknown-client");
expect(client).toBeNull();
});
});
// ---------------------------------------------------------------------------
// Authorization with client redirect URI validation
// ---------------------------------------------------------------------------
describe("Authorization with redirect URI allowlist", () => {
beforeEach(async () => {
// Register a client with specific redirect URIs
await handleOAuthClientCreate(db, {
id: "test-client",
name: "Test Client",
redirectUris: ["http://127.0.0.1:8080/callback", "https://myapp.example.com/callback"],
});
});
it("should approve authorization with a registered redirect URI", async () => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = computeS256Challenge(codeVerifier);
const result = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
response_type: "code",
client_id: "test-client",
redirect_uri: "http://127.0.0.1:8080/callback",
scope: "content:read content:write",
state: "random-state-value",
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
expect(result.success).toBe(true);
if (!result.success) return;
const redirectUrl = new URL(result.data.redirect_url);
expect(redirectUrl.origin).toBe("http://127.0.0.1:8080");
expect(redirectUrl.searchParams.get("code")).toBeTruthy();
});
it("should reject authorization with unregistered redirect URI", async () => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = computeS256Challenge(codeVerifier);
const result = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
response_type: "code",
client_id: "test-client",
redirect_uri: "https://evil.example.com/callback",
scope: "content:read",
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("INVALID_REDIRECT_URI");
expect(result.error.message).toContain("not registered");
});
it("should reject authorization with unknown client_id", async () => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = computeS256Challenge(codeVerifier);
const result = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
response_type: "code",
client_id: "unknown-client",
redirect_uri: "http://127.0.0.1:8080/callback",
scope: "content:read",
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.code).toBe("INVALID_CLIENT");
});
it("should accept HTTPS redirect URI in allowlist", async () => {
const codeVerifier = generateCodeVerifier();
const codeChallenge = computeS256Challenge(codeVerifier);
const result = await handleAuthorizationApproval(db, "user-1", Role.ADMIN, {
response_type: "code",
client_id: "test-client",
redirect_uri: "https://myapp.example.com/callback",
scope: "content:read",
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
expect(result.success).toBe(true);
});
});

View File

@@ -0,0 +1,430 @@
/**
* Integration tests for database-backed rate limiting.
*
* Tests the rate limiter utility and slow_down enforcement
* against a real in-memory SQLite database.
*/
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
handleDeviceCodeRequest,
handleDeviceTokenExchange,
} from "../../../src/api/handlers/device-flow.js";
import {
checkRateLimit,
cleanupExpiredRateLimits,
getClientIp,
} from "../../../src/auth/rate-limit.js";
import type { Database } from "../../../src/database/types.js";
import { setupTestDatabase } from "../../utils/test-db.js";
let db: Kysely<Database>;
beforeEach(async () => {
db = await setupTestDatabase();
});
afterEach(async () => {
await db.destroy();
});
// ---------------------------------------------------------------------------
// Rate Limiter
// ---------------------------------------------------------------------------
describe("checkRateLimit", () => {
it("should allow requests within the limit", async () => {
const result1 = await checkRateLimit(db, "1.2.3.4", "test/endpoint", 3, 60);
expect(result1.allowed).toBe(true);
expect(result1.count).toBe(1);
const result2 = await checkRateLimit(db, "1.2.3.4", "test/endpoint", 3, 60);
expect(result2.allowed).toBe(true);
expect(result2.count).toBe(2);
const result3 = await checkRateLimit(db, "1.2.3.4", "test/endpoint", 3, 60);
expect(result3.allowed).toBe(true);
expect(result3.count).toBe(3);
});
it("should reject requests exceeding the limit", async () => {
// Use up the limit
await checkRateLimit(db, "1.2.3.4", "test/endpoint", 3, 60);
await checkRateLimit(db, "1.2.3.4", "test/endpoint", 3, 60);
await checkRateLimit(db, "1.2.3.4", "test/endpoint", 3, 60);
// 4th request should be rejected
const result = await checkRateLimit(db, "1.2.3.4", "test/endpoint", 3, 60);
expect(result.allowed).toBe(false);
expect(result.count).toBe(4);
expect(result.limit).toBe(3);
});
it("should track limits per IP independently", async () => {
// IP A uses its limit
await checkRateLimit(db, "1.2.3.4", "test/endpoint", 2, 60);
await checkRateLimit(db, "1.2.3.4", "test/endpoint", 2, 60);
const resultA = await checkRateLimit(db, "1.2.3.4", "test/endpoint", 2, 60);
expect(resultA.allowed).toBe(false);
// IP B should still be allowed
const resultB = await checkRateLimit(db, "5.6.7.8", "test/endpoint", 2, 60);
expect(resultB.allowed).toBe(true);
expect(resultB.count).toBe(1);
});
it("should track limits per endpoint independently", async () => {
// Use up limit on endpoint A
await checkRateLimit(db, "1.2.3.4", "endpoint-a", 1, 60);
const resultA = await checkRateLimit(db, "1.2.3.4", "endpoint-a", 1, 60);
expect(resultA.allowed).toBe(false);
// Endpoint B should still be allowed
const resultB = await checkRateLimit(db, "1.2.3.4", "endpoint-b", 1, 60);
expect(resultB.allowed).toBe(true);
});
it("should skip rate limiting when IP is null", async () => {
// Even after many calls, null IP is always allowed
for (let i = 0; i < 10; i++) {
const result = await checkRateLimit(db, null, "test/endpoint", 1, 60);
expect(result.allowed).toBe(true);
expect(result.count).toBe(0);
}
});
it("should reset after window expires", async () => {
// Use a 1-second window
await checkRateLimit(db, "1.2.3.4", "test/endpoint", 1, 1);
const blocked = await checkRateLimit(db, "1.2.3.4", "test/endpoint", 1, 1);
expect(blocked.allowed).toBe(false);
// Wait for the window to expire (advance past the 1-second boundary)
await new Promise((resolve) => setTimeout(resolve, 1100));
const allowed = await checkRateLimit(db, "1.2.3.4", "test/endpoint", 1, 1);
expect(allowed.allowed).toBe(true);
expect(allowed.count).toBe(1);
});
});
// ---------------------------------------------------------------------------
// IP Extraction
// ---------------------------------------------------------------------------
describe("getClientIp", () => {
/** Create a request with a fake `cf` object to simulate Cloudflare. */
function cfRequest(url: string, init?: RequestInit): Request {
const req = new Request(url, init);
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- test helper
(req as unknown as { cf: Record<string, unknown> }).cf = { country: "US" };
return req;
}
it("should extract IP from CF-Connecting-IP on Cloudflare", () => {
const request = cfRequest("http://localhost/test", {
headers: { "cf-connecting-ip": "198.51.100.1" },
});
expect(getClientIp(request)).toBe("198.51.100.1");
});
it("should extract IP from X-Forwarded-For on Cloudflare", () => {
const request = cfRequest("http://localhost/test", {
headers: { "x-forwarded-for": "203.0.113.50, 70.41.3.18, 150.172.238.178" },
});
expect(getClientIp(request)).toBe("203.0.113.50");
});
it("should return null when not on Cloudflare (no cf object)", () => {
const request = new Request("http://localhost/test");
expect(getClientIp(request)).toBeNull();
});
it("should return null when not on Cloudflare even with XFF header", () => {
const request = new Request("http://localhost/test", {
headers: { "x-forwarded-for": "203.0.113.50" },
});
expect(getClientIp(request)).toBeNull();
});
it("should reject non-IP values in X-Forwarded-For", () => {
const request = cfRequest("http://localhost/test", {
headers: { "x-forwarded-for": "<script>alert(1)</script>" },
});
expect(getClientIp(request)).toBeNull();
});
it("should handle IPv6 addresses on Cloudflare", () => {
const request = cfRequest("http://localhost/test", {
headers: { "x-forwarded-for": "2001:db8::1" },
});
expect(getClientIp(request)).toBe("2001:db8::1");
});
});
describe("getClientIp with trusted proxy headers", () => {
// On non-CF deployments behind an operator-controlled reverse proxy,
// the operator declares which header to trust. Without this they get
// null (which disables rate limiting) — a real operational foot-gun.
function cfRequest(url: string, init?: RequestInit): Request {
const req = new Request(url, init);
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- test helper
(req as unknown as { cf: Record<string, unknown> }).cf = { country: "US" };
return req;
}
it("reads the IP from a declared trusted header off-Cloudflare", () => {
const request = new Request("http://localhost/test", {
headers: { "x-real-ip": "203.0.113.50" },
});
expect(getClientIp(request, ["x-real-ip"])).toBe("203.0.113.50");
});
it("tries trusted headers in declared order", () => {
const request = new Request("http://localhost/test", {
headers: {
"x-real-ip": "203.0.113.50",
"fly-client-ip": "198.51.100.7",
},
});
expect(getClientIp(request, ["fly-client-ip", "x-real-ip"])).toBe("198.51.100.7");
});
it("falls through when earlier trusted header is missing", () => {
const request = new Request("http://localhost/test", {
headers: { "x-real-ip": "203.0.113.50" },
});
expect(getClientIp(request, ["fly-client-ip", "x-real-ip"])).toBe("203.0.113.50");
});
it("takes the first entry when a trusted header is XFF-style", () => {
const request = new Request("http://localhost/test", {
headers: { "x-forwarded-for": "203.0.113.50, 10.0.0.1" },
});
expect(getClientIp(request, ["x-forwarded-for"])).toBe("203.0.113.50");
});
it("rejects non-IP-shaped values from trusted headers", () => {
const request = new Request("http://localhost/test", {
headers: { "x-real-ip": "<script>alert(1)</script>" },
});
expect(getClientIp(request, ["x-real-ip"])).toBeNull();
});
it("does not read from headers that are not on the trusted list", () => {
const request = new Request("http://localhost/test", {
headers: { "x-client-ip": "203.0.113.50" },
});
expect(getClientIp(request, ["x-real-ip"])).toBeNull();
});
it("without cf, returns null when no trusted header is set", () => {
const request = new Request("http://localhost/test", {
headers: { "x-real-ip": "203.0.113.50" },
});
// Empty list — operator did not opt in. Current null-IP behaviour preserved.
expect(getClientIp(request, [])).toBeNull();
});
it("matches header names case-insensitively", () => {
const request = new Request("http://localhost/test", {
headers: { "X-Real-IP": "203.0.113.50" },
});
expect(getClientIp(request, ["x-real-ip"])).toBe("203.0.113.50");
});
it("CF-Connecting-IP wins over trusted headers on Cloudflare", () => {
// Operator on CF misconfigures trustedProxyHeaders — CF-Connecting-IP
// is cryptographically trustworthy and must not be overridden.
const request = cfRequest("http://localhost/test", {
headers: {
"cf-connecting-ip": "1.1.1.1",
"x-real-ip": "203.0.113.50",
},
});
expect(getClientIp(request, ["x-real-ip"])).toBe("1.1.1.1");
});
it("trusted headers fill in when the CF path produces no IP", () => {
const request = cfRequest("http://localhost/test", {
headers: { "x-real-ip": "203.0.113.50" },
});
expect(getClientIp(request, ["x-real-ip"])).toBe("203.0.113.50");
});
});
// ---------------------------------------------------------------------------
// Cleanup
// ---------------------------------------------------------------------------
describe("cleanupExpiredRateLimits", () => {
it("should delete expired entries", async () => {
// Insert a rate limit entry with a window in the past
const oldWindow = new Date(Date.now() - 7200 * 1000).toISOString();
const currentWindow = new Date(Math.floor(Date.now() / (60 * 1000)) * 60 * 1000).toISOString();
await db
.insertInto("_emdash_rate_limits")
.values([
{ key: "old:entry", window: oldWindow, count: 5 },
{ key: "current:entry", window: currentWindow, count: 2 },
])
.execute();
const deleted = await cleanupExpiredRateLimits(db, 3600);
expect(deleted).toBe(1);
// Current entry should still exist
const rows = await db.selectFrom("_emdash_rate_limits").selectAll().execute();
expect(rows).toHaveLength(1);
expect(rows[0]?.key).toBe("current:entry");
});
});
// ---------------------------------------------------------------------------
// RFC 8628 slow_down
// ---------------------------------------------------------------------------
describe("Device Token Exchange: slow_down enforcement", () => {
const GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
it("should return slow_down when polling faster than interval", async () => {
// Create a device code
const codeResult = await handleDeviceCodeRequest(
db,
{ client_id: "emdash-cli" },
"https://example.com/_emdash/device",
);
expect(codeResult.success).toBe(true);
if (!codeResult.success) return;
const { device_code } = codeResult.data;
// First poll — sets last_polled_at, returns authorization_pending
const poll1 = await handleDeviceTokenExchange(db, {
device_code,
grant_type: GRANT_TYPE,
});
expect(poll1.success).toBe(false);
expect(poll1.deviceFlowError).toBe("authorization_pending");
// Second poll immediately — should get slow_down with new interval
const poll2 = await handleDeviceTokenExchange(db, {
device_code,
grant_type: GRANT_TYPE,
});
expect(poll2.success).toBe(false);
expect(poll2.deviceFlowError).toBe("slow_down");
// Default interval (5) + SLOW_DOWN_INCREMENT (5) = 10
expect(poll2.deviceFlowInterval).toBe(10);
});
it("should increase interval by 5s on each slow_down", async () => {
const codeResult = await handleDeviceCodeRequest(
db,
{ client_id: "emdash-cli" },
"https://example.com/_emdash/device",
);
expect(codeResult.success).toBe(true);
if (!codeResult.success) return;
const { device_code } = codeResult.data;
// First poll — sets baseline
await handleDeviceTokenExchange(db, { device_code, grant_type: GRANT_TYPE });
// Rapid polls — each should trigger slow_down and increase interval
await handleDeviceTokenExchange(db, { device_code, grant_type: GRANT_TYPE });
// Check the interval was increased
const row = await db
.selectFrom("_emdash_device_codes")
.select("interval")
.where("device_code", "=", device_code)
.executeTakeFirst();
// Default interval is 5, after one slow_down it should be 10
expect(row?.interval).toBe(10);
// Another rapid poll — interval should increase again to 15
await handleDeviceTokenExchange(db, { device_code, grant_type: GRANT_TYPE });
const row2 = await db
.selectFrom("_emdash_device_codes")
.select("interval")
.where("device_code", "=", device_code)
.executeTakeFirst();
expect(row2?.interval).toBe(15);
});
it("should cap slow_down interval at 60 seconds", async () => {
const codeResult = await handleDeviceCodeRequest(
db,
{ client_id: "emdash-cli" },
"https://example.com/_emdash/device",
);
expect(codeResult.success).toBe(true);
if (!codeResult.success) return;
const { device_code } = codeResult.data;
// First poll — sets baseline
await handleDeviceTokenExchange(db, { device_code, grant_type: GRANT_TYPE });
// Set interval to just below the cap so the next slow_down would exceed it
await db
.updateTable("_emdash_device_codes")
.set({ interval: 58 })
.where("device_code", "=", device_code)
.execute();
// Rapid poll — triggers slow_down, interval should cap at 60 not 63
const poll = await handleDeviceTokenExchange(db, { device_code, grant_type: GRANT_TYPE });
expect(poll.deviceFlowInterval).toBe(60);
const row = await db
.selectFrom("_emdash_device_codes")
.select("interval")
.where("device_code", "=", device_code)
.executeTakeFirst();
expect(row?.interval).toBe(60);
});
it("should not return slow_down when polling at or above the interval", async () => {
const codeResult = await handleDeviceCodeRequest(
db,
{ client_id: "emdash-cli" },
"https://example.com/_emdash/device",
);
expect(codeResult.success).toBe(true);
if (!codeResult.success) return;
const { device_code } = codeResult.data;
// First poll — sets last_polled_at
await handleDeviceTokenExchange(db, { device_code, grant_type: GRANT_TYPE });
// Manually set last_polled_at to far enough in the past
await db
.updateTable("_emdash_device_codes")
.set({
last_polled_at: new Date(Date.now() - 10_000).toISOString(),
})
.where("device_code", "=", device_code)
.execute();
// This poll should NOT get slow_down (10s > 5s interval)
const poll = await handleDeviceTokenExchange(db, {
device_code,
grant_type: GRANT_TYPE,
});
expect(poll.success).toBe(false);
// Should be authorization_pending, not slow_down
expect(poll.deviceFlowError).toBe("authorization_pending");
});
});