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:
309
packages/core/tests/integration/auth/api-tokens.test.ts
Normal file
309
packages/core/tests/integration/auth/api-tokens.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
475
packages/core/tests/integration/auth/authorization-code.test.ts
Normal file
475
packages/core/tests/integration/auth/authorization-code.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
594
packages/core/tests/integration/auth/device-flow.test.ts
Normal file
594
packages/core/tests/integration/auth/device-flow.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
372
packages/core/tests/integration/auth/oauth-clients.test.ts
Normal file
372
packages/core/tests/integration/auth/oauth-clients.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
430
packages/core/tests/integration/auth/rate-limit.test.ts
Normal file
430
packages/core/tests/integration/auth/rate-limit.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user