Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
373 lines
12 KiB
TypeScript
373 lines
12 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|