/**
* postgres-mcp - OAuth Middleware Tests
*
* Tests for authentication middleware, bearer token extraction,
* scope validation, and error formatting.
*/
import { describe, it, expect, vi } from "vitest";
import {
extractBearerToken,
createAuthContext,
validateAuth,
requireScope,
requireAnyScope,
requireToolScope,
formatOAuthError,
type AuthenticatedContext,
} from "../middleware.js";
import {
TokenMissingError,
InvalidTokenError,
InsufficientScopeError,
} from "../errors.js";
import type { TokenValidator } from "../TokenValidator.js";
import type { TokenValidationResult } from "../types.js";
// Mock token validator
function createMockTokenValidator(
result: TokenValidationResult,
): TokenValidator {
return {
validate: vi.fn().mockResolvedValue(result),
invalidateCache: vi.fn(),
} as unknown as TokenValidator;
}
describe("OAuth Middleware", () => {
describe("extractBearerToken", () => {
it("should extract token from valid Bearer header", () => {
const token = extractBearerToken("Bearer abc123xyz");
expect(token).toBe("abc123xyz");
});
it("should return null for missing header", () => {
expect(extractBearerToken(undefined)).toBeNull();
});
it("should return null for empty header", () => {
expect(extractBearerToken("")).toBeNull();
});
it("should return null for non-Bearer header", () => {
expect(extractBearerToken("Basic abc123")).toBeNull();
});
it("should handle case-insensitive Bearer prefix", () => {
const token = extractBearerToken("bearer abc123xyz");
expect(token).toBe("abc123xyz");
});
it("should return empty string for malformed header (missing token)", () => {
// 'Bearer ' splits into ["Bearer", ""] which has 2 parts
// Implementation returns empty string which is falsy
expect(extractBearerToken("Bearer ")).toBe("");
});
it("should return null for malformed header (extra parts)", () => {
// 'Bearer token extra' splits into 3 parts, so returns null
expect(extractBearerToken("Bearer token extra")).toBeNull();
});
});
describe("createAuthContext", () => {
it("should return unauthenticated context when no token", async () => {
const mockValidator = createMockTokenValidator({ valid: true });
const context = await createAuthContext(undefined, mockValidator);
expect(context.authenticated).toBe(false);
expect(context.scopes).toEqual([]);
expect(context.claims).toBeUndefined();
});
it("should return authenticated context with valid token", async () => {
const mockValidator = createMockTokenValidator({
valid: true,
claims: {
sub: "user123",
scopes: ["read", "write"],
exp: Date.now() / 1000 + 3600,
iat: Date.now() / 1000,
},
});
const context = await createAuthContext(
"Bearer valid-token",
mockValidator,
);
expect(context.authenticated).toBe(true);
expect(context.scopes).toEqual(["read", "write"]);
expect(context.claims?.sub).toBe("user123");
});
it("should return unauthenticated context with invalid token", async () => {
const mockValidator = createMockTokenValidator({
valid: false,
error: "Invalid signature",
});
const context = await createAuthContext(
"Bearer invalid-token",
mockValidator,
);
expect(context.authenticated).toBe(false);
expect(context.scopes).toEqual([]);
});
});
describe("validateAuth", () => {
it("should throw TokenMissingError when required and no token", async () => {
const mockValidator = createMockTokenValidator({ valid: true });
await expect(
validateAuth(undefined, {
tokenValidator: mockValidator,
required: true,
}),
).rejects.toThrow(TokenMissingError);
});
it("should return unauthenticated when not required and no token", async () => {
const mockValidator = createMockTokenValidator({ valid: true });
const context = await validateAuth(undefined, {
tokenValidator: mockValidator,
required: false,
});
expect(context.authenticated).toBe(false);
});
it("should throw InvalidTokenError for invalid tokens", async () => {
const mockValidator = createMockTokenValidator({
valid: false,
error: "Token expired",
});
await expect(
validateAuth("Bearer expired-token", {
tokenValidator: mockValidator,
required: true,
}),
).rejects.toThrow(InvalidTokenError);
});
it("should throw InsufficientScopeError when missing required scopes", async () => {
const mockValidator = createMockTokenValidator({
valid: true,
claims: {
sub: "user123",
scopes: ["read"],
exp: Date.now() / 1000 + 3600,
iat: Date.now() / 1000,
},
});
await expect(
validateAuth("Bearer token", {
tokenValidator: mockValidator,
required: true,
requiredScopes: ["admin"],
}),
).rejects.toThrow(InsufficientScopeError);
});
it("should succeed when token has required scope", async () => {
const mockValidator = createMockTokenValidator({
valid: true,
claims: {
sub: "user123",
scopes: ["admin"],
exp: Date.now() / 1000 + 3600,
iat: Date.now() / 1000,
},
});
const context = await validateAuth("Bearer token", {
tokenValidator: mockValidator,
required: true,
requiredScopes: ["admin"],
});
expect(context.authenticated).toBe(true);
expect(context.scopes).toContain("admin");
});
});
describe("requireScope", () => {
const authenticatedContext: AuthenticatedContext = {
authenticated: true,
claims: {
sub: "user123",
scopes: ["read", "write"],
exp: Date.now() / 1000 + 3600,
iat: Date.now() / 1000,
},
scopes: ["read", "write"],
};
const unauthenticatedContext: AuthenticatedContext = {
authenticated: false,
scopes: [],
};
it("should pass when scope is present", () => {
expect(() => requireScope(authenticatedContext, "read")).not.toThrow();
});
it("should throw InsufficientScopeError when scope missing", () => {
expect(() => requireScope(authenticatedContext, "admin")).toThrow(
InsufficientScopeError,
);
});
it("should throw TokenMissingError when not authenticated", () => {
expect(() => requireScope(unauthenticatedContext, "read")).toThrow(
TokenMissingError,
);
});
});
describe("requireAnyScope", () => {
const context: AuthenticatedContext = {
authenticated: true,
claims: {
sub: "user123",
scopes: ["read"],
exp: Date.now() / 1000 + 3600,
iat: Date.now() / 1000,
},
scopes: ["read"],
};
it("should pass when any scope matches", () => {
expect(() => requireAnyScope(context, ["read", "write"])).not.toThrow();
});
it("should throw when no scopes match", () => {
expect(() => requireAnyScope(context, ["admin", "write"])).toThrow(
InsufficientScopeError,
);
});
it("should throw TokenMissingError when not authenticated (line 140)", () => {
const unauthenticatedContext: AuthenticatedContext = {
authenticated: false,
scopes: [],
};
expect(() => requireAnyScope(unauthenticatedContext, ["read"])).toThrow(
TokenMissingError,
);
});
});
describe("requireToolScope", () => {
const readContext: AuthenticatedContext = {
authenticated: true,
claims: {
sub: "user123",
scopes: ["read"],
exp: Date.now() / 1000 + 3600,
iat: Date.now() / 1000,
},
scopes: ["read"],
};
const writeContext: AuthenticatedContext = {
authenticated: true,
claims: {
sub: "user123",
scopes: ["write"],
exp: Date.now() / 1000 + 3600,
iat: Date.now() / 1000,
},
scopes: ["write"],
};
const customScopeContext: AuthenticatedContext = {
authenticated: true,
claims: {
sub: "user123",
scopes: ["db:production", "schema:public"],
exp: Date.now() / 1000 + 3600,
iat: Date.now() / 1000,
},
scopes: ["db:production", "schema:public"],
};
it("should map tool scope names to OAuth scopes", () => {
expect(() => requireToolScope(readContext, ["read"])).not.toThrow();
});
it("should throw for missing tool scopes", () => {
expect(() => requireToolScope(readContext, ["admin"])).toThrow(
InsufficientScopeError,
);
});
it("should throw TokenMissingError when not authenticated (line 153)", () => {
const unauthenticatedContext: AuthenticatedContext = {
authenticated: false,
scopes: [],
};
expect(() => requireToolScope(unauthenticatedContext, ["read"])).toThrow(
TokenMissingError,
);
});
it('should map "write" scope correctly (line 160)', () => {
expect(() => requireToolScope(writeContext, ["write"])).not.toThrow();
});
it("should pass custom/unknown scopes through unchanged (line 162)", () => {
expect(() =>
requireToolScope(customScopeContext, ["db:production"]),
).not.toThrow();
expect(() =>
requireToolScope(customScopeContext, ["schema:public"]),
).not.toThrow();
});
it("should throw for custom scopes not present", () => {
expect(() =>
requireToolScope(customScopeContext, ["db:staging"]),
).toThrow(InsufficientScopeError);
});
});
describe("formatOAuthError", () => {
it("should format TokenMissingError as 401", () => {
const error = new TokenMissingError();
const { status, body } = formatOAuthError(error);
expect(status).toBe(401);
expect(body).toHaveProperty("error", "invalid_token");
});
it("should format InvalidTokenError as 401", () => {
const error = new InvalidTokenError("Token is malformed");
const { status, body } = formatOAuthError(error);
expect(status).toBe(401);
expect(body).toHaveProperty("error", "invalid_token");
expect(body).toHaveProperty("error_description", "Token is malformed");
});
it("should format InsufficientScopeError as 403", () => {
const error = new InsufficientScopeError(["admin", "write"]);
const { status, body } = formatOAuthError(error);
expect(status).toBe(403);
expect(body).toHaveProperty("error", "insufficient_scope");
expect(body).toHaveProperty("scope", "admin write");
});
it("should format unknown errors as 500", () => {
const error = new Error("Something unexpected");
const { status, body } = formatOAuthError(error);
expect(status).toBe(500);
expect(body).toHaveProperty("error", "server_error");
});
});
});