/** * 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; 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"); }); });