Remove embedded OAuth implementation and flatten directory structure

Simplified the repository to have a single OAuth pattern with separate auth and MCP servers at the top level. Preserved embedded OAuth documentation as an alternative pattern.
This commit is contained in:
Basil Hosmer 2025-10-06 21:07:18 -04:00
commit c03a9b4e77
No known key found for this signature in database
3 changed files with 1556 additions and 15846 deletions

View file

@ -0,0 +1,292 @@
import { jest } from '@jest/globals';
import crypto from "crypto";
import {
decryptString,
exchangeToken,
generateMcpTokens,
generatePKCEChallenge,
generateToken,
getClientRegistration,
readMcpInstallation,
readPendingAuthorization,
readRefreshToken,
revokeMcpInstallation,
saveClientRegistration,
saveMcpInstallation,
savePendingAuthorization,
saveRefreshToken,
saveTokenExchange,
} from "./auth.js";
import { MockRedisClient, setRedisClient } from "../redis.js";
import { McpInstallation, PendingAuthorization, TokenExchange } from "../types.js";
import { OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js";
describe("auth utils", () => {
let mockRedis: MockRedisClient;
beforeEach(() => {
mockRedis = new MockRedisClient();
setRedisClient(mockRedis);
jest.resetAllMocks();
});
describe("generateToken", () => {
it("generates a 64-character hex string", () => {
const token = generateToken();
expect(token).toMatch(/^[0-9a-f]{64}$/);
});
it("generates unique tokens", () => {
const token1 = generateToken();
const token2 = generateToken();
expect(token1).not.toBe(token2);
});
});
describe("generateMcpTokens", () => {
it("generates valid token structure", () => {
const tokens = generateMcpTokens();
expect(tokens.access_token).toBeDefined();
expect(tokens.access_token).toMatch(/^[0-9a-f]{64}$/);
expect(tokens.refresh_token).toBeDefined();
expect(tokens.refresh_token).toMatch(/^[0-9a-f]{64}$/);
expect(tokens.token_type).toBe("Bearer");
expect(tokens.expires_in).toBeDefined();
});
it("generates unique tokens", () => {
const tokens1 = generateMcpTokens();
const tokens2 = generateMcpTokens();
expect(tokens1.access_token).not.toBe(tokens2.access_token);
expect(tokens1.refresh_token).not.toBe(tokens2.refresh_token);
});
});
describe("client registration", () => {
it("saves and retrieves client registration", async () => {
const clientId = "test-client-id";
const registration: OAuthClientInformationFull = {
client_id: clientId,
client_name: "Test Client",
client_uri: "https://example.com",
redirect_uris: ["https://example.com/callback"]
};
await saveClientRegistration(clientId, registration);
const retrieved = await getClientRegistration(clientId);
expect(retrieved).toEqual(registration);
});
it("returns undefined for non-existent client", async () => {
const result = await getClientRegistration("non-existent");
expect(result).toBeUndefined();
});
});
describe("pending authorization", () => {
it("saves and retrieves pending authorization", async () => {
const authCode = generateToken();
const pendingAuth: PendingAuthorization = {
redirectUri: "https://example.com/callback",
codeChallenge: "test-challenge",
codeChallengeMethod: "S256",
clientId: "test-client-id"
};
await savePendingAuthorization(authCode, pendingAuth);
const retrieved = await readPendingAuthorization(authCode);
expect(retrieved).toEqual(pendingAuth);
});
it("returns undefined for non-existent code", async () => {
const result = await readPendingAuthorization("non-existent");
expect(result).toBeUndefined();
});
});
describe("token exchange", () => {
it("saves and retrieves token exchange data", async () => {
const authCode = generateToken();
const tokenExchange: TokenExchange = {
mcpAccessToken: generateToken(),
alreadyUsed: false
};
// For this test, we'll directly manipulate Redis to check the token
// instead of using exchangeToken which changes the value
await saveTokenExchange(authCode, tokenExchange);
// Get the key used by saveTokenExchange (now with auth: prefix)
const key = "auth:exch:" + crypto.createHash("sha256").update(authCode).digest("hex");
// Get the encrypted data
const encryptedData = await mockRedis.get(key);
expect(encryptedData).not.toBeNull();
// Decrypt it manually to verify
const decoded = JSON.parse(decryptString({
encryptedText: encryptedData!,
key: authCode
}));
expect(decoded).toEqual(tokenExchange);
});
it("prevents duplicate token exchange", async () => {
const authCode = generateToken();
const tokenExchange: TokenExchange = {
mcpAccessToken: generateToken(),
alreadyUsed: false
};
await saveTokenExchange(authCode, tokenExchange);
// First exchange succeeds
const first = await exchangeToken(authCode);
expect(first).toBeDefined();
// Mock console.error to suppress expected error message
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
// Second exchange throws
await expect(exchangeToken(authCode)).rejects.toThrow(
"Duplicate use of authorization code detected"
);
// Restore console.error
consoleErrorSpy.mockRestore();
});
it("returns undefined for non-existent code", async () => {
const result = await exchangeToken("non-existent");
expect(result).toBeUndefined();
});
});
describe("MCP installation", () => {
it("returns undefined for missing tokens", async () => {
const accessToken = generateToken();
const result = await readMcpInstallation(accessToken);
expect(result).toBeUndefined();
});
it("saves and retrieves installation data", async () => {
const accessToken = generateToken();
const mcpInstallation: McpInstallation = {
mockUpstreamInstallation: {
mockUpstreamAccessToken: "fake-upstream-access-token",
mockUpstreamRefreshToken: "fake-upstream-refresh-token",
},
mcpTokens: {
access_token: accessToken,
token_type: "Bearer",
expires_in: 3600,
},
clientId: "client-id",
issuedAt: Date.now() / 1000,
userId: "test-user-id",
}
await saveMcpInstallation(accessToken, mcpInstallation);
const result = await readMcpInstallation(accessToken);
expect(result).toEqual(mcpInstallation);
});
});
describe("refresh token", () => {
it("saves and retrieves refresh token mapping", async () => {
const refreshToken = generateToken();
const accessToken = generateToken();
await saveRefreshToken(refreshToken, accessToken);
const retrieved = await readRefreshToken(refreshToken);
expect(retrieved).toBe(accessToken);
});
it("returns undefined for non-existent refresh token", async () => {
const result = await readRefreshToken("non-existent");
expect(result).toBeUndefined();
});
});
describe("revokeMcpInstallation", () => {
it("revokes token for valid installation", async () => {
// For this test, we'll simply test if the WebClient is called with our mocked token
// Create mock installation
const accessToken = generateToken();
// Save it to Redis with actual function
await saveMcpInstallation(accessToken, {
mockUpstreamInstallation: {
mockUpstreamAccessToken: "fake-upstream-access-token",
mockUpstreamRefreshToken: "fake-upstream-refresh-token",
},
mcpTokens: {
access_token: accessToken,
token_type: "Bearer",
expires_in: 3600,
},
clientId: "client-id",
issuedAt: Date.now() / 1000,
userId: "test-user-id",
});
const getDel = jest.spyOn(mockRedis, 'getDel').mockImplementationOnce(() => {
// Need to return encrypted data for successful decryption in the revoke function
// Create encrypted data using our access token
const mcpInstallation = {
mcpTokens: {
access_token: accessToken,
token_type: "Bearer",
expires_in: 3600,
},
clientId: "client-id",
issuedAt: Date.now() / 1000,
userId: "test-user-id",
};
const value = JSON.stringify(mcpInstallation);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv("aes-256-cbc", Buffer.from(accessToken, "hex"), iv);
let encrypted = cipher.update(value, "utf-8", "hex");
encrypted += cipher.final("hex");
return Promise.resolve(`${iv.toString("hex")}:${encrypted}`);
});
await revokeMcpInstallation(accessToken);
// Should have called getDel with the correct key (now auth:installation:)
expect(getDel).toHaveBeenCalledWith(expect.stringContaining("auth:installation:"));
});
it("handles non-existent installation without error", async () => {
await expect(revokeMcpInstallation("non-existent")).resolves.not.toThrow();
});
});
describe("generatePKCEChallenge", () => {
it("generates base64url-encoded SHA256 hash", () => {
const verifier = "test_verifier";
const challenge = generatePKCEChallenge(verifier);
expect(challenge).toBe("0Ku4rR8EgR1w3HyHLBCxVLtPsAAks5HOlpmTEt0XhVA");
});
it("generates different challenges for different verifiers", () => {
const challenge1 = generatePKCEChallenge("verifier1");
const challenge2 = generatePKCEChallenge("verifier2");
expect(challenge1).not.toBe(challenge2);
});
});
});

View file

@ -0,0 +1,86 @@
import { redisClient } from "../redis.js";
import { McpInstallation, PendingAuthorization, TokenExchange } from "../types.js";
import { OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js";
// Re-export from auth-core module
export {
generatePKCEChallenge,
generateToken,
decryptString,
generateMcpTokens
} from "../auth/auth-core.js";
import * as sharedRedisAuth from "./redis-auth.js";
// Wrapper functions that pass redisClient to shared module functions
export async function saveClientRegistration(
clientId: string,
registration: OAuthClientInformationFull,
) {
return sharedRedisAuth.saveClientRegistration(redisClient, clientId, registration);
}
export async function getClientRegistration(
clientId: string,
): Promise<OAuthClientInformationFull | undefined> {
return sharedRedisAuth.getClientRegistration(redisClient, clientId);
}
export async function savePendingAuthorization(
authorizationCode: string,
pendingAuthorization: PendingAuthorization,
) {
return sharedRedisAuth.savePendingAuthorization(redisClient, authorizationCode, pendingAuthorization);
}
export async function readPendingAuthorization(
authorizationCode: string,
): Promise<PendingAuthorization | undefined> {
return sharedRedisAuth.readPendingAuthorization(redisClient, authorizationCode);
}
export async function saveMcpInstallation(
mcpAccessToken: string,
installation: McpInstallation,
) {
return sharedRedisAuth.saveMcpInstallation(redisClient, mcpAccessToken, installation);
}
export async function readMcpInstallation(
mcpAccessToken: string,
): Promise<McpInstallation | undefined> {
return sharedRedisAuth.readMcpInstallation(redisClient, mcpAccessToken);
}
export async function saveRefreshToken(
refreshToken: string,
mcpAccessToken: string,
) {
return sharedRedisAuth.saveRefreshToken(redisClient, refreshToken, mcpAccessToken);
}
export async function readRefreshToken(
refreshToken: string,
): Promise<string | undefined> {
return sharedRedisAuth.readRefreshToken(redisClient, refreshToken);
}
export async function revokeMcpInstallation(
mcpAccessToken: string,
): Promise<void> {
return sharedRedisAuth.revokeMcpInstallation(redisClient, mcpAccessToken);
}
export async function saveTokenExchange(
authorizationCode: string,
tokenExchange: TokenExchange,
) {
return sharedRedisAuth.saveTokenExchange(redisClient, authorizationCode, tokenExchange);
}
export async function exchangeToken(
authorizationCode: string,
): Promise<TokenExchange | undefined> {
return sharedRedisAuth.exchangeToken(redisClient, authorizationCode);
}

View file

@ -0,0 +1,285 @@
import { SetOptions } from "@redis/client";
import { OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js";
import { RedisClient } from "../redis.js";
import { McpInstallation, PendingAuthorization, TokenExchange } from "../types.js";
import { sha256, encryptString, decryptString } from "../auth/auth-core.js";
import { logger } from "../utils/logger.js";
/**
* Redis key prefixes for different data types
* All auth-related keys use "auth:" prefix to avoid collision with MCP session keys
*/
export const REDIS_KEY_PREFIXES = {
CLIENT_REGISTRATION: "auth:client:",
PENDING_AUTHORIZATION: "auth:pending:",
MCP_AUTHORIZATION: "auth:installation:", // Changed from "mcp:" to avoid collision
TOKEN_EXCHANGE: "auth:exch:",
REFRESH_TOKEN: "auth:refresh:",
} as const;
/**
* Redis key expiry times in seconds
*/
export const REDIS_EXPIRY_TIMES = {
CLIENT_REGISTRATION: 30 * 24 * 60 * 60, // 30 days - client app credentials
PENDING_AUTHORIZATION: 10 * 60, // 10 minutes - authorization code -> PendingAuthorization
TOKEN_EXCHANGE: 10 * 60, // 10 minutes - authorization code -> MCP access token
UPSTREAM_INSTALLATION: 7 * 24 * 60 * 60, // 7 days - MCP access token -> UpstreamInstallation
REFRESH_TOKEN: 7 * 24 * 60 * 60, // 7 days - MCP refresh token -> access token
} as const;
/**
* Saves encrypted data to Redis with optional expiry.
*/
async function saveEncrypted<T>(
redisClient: RedisClient,
{
prefix,
key,
data,
options,
}: {
prefix: string;
key: string;
data: T;
options?: SetOptions;
}
): Promise<string | null> {
const value = encryptString({
text: JSON.stringify(data),
key: key,
});
return await redisClient.set(prefix + sha256(key), value, options);
}
/**
* Reads and decrypts data from Redis.
*/
async function readEncrypted<T>(
redisClient: RedisClient,
{
prefix,
key,
del = false,
}: {
prefix: string;
key: string;
del?: boolean;
}
): Promise<T | undefined> {
const data = del
? await redisClient.getDel(prefix + sha256(key))
: await redisClient.get(prefix + sha256(key));
if (!data) {
return undefined;
}
const decoded = decryptString({
encryptedText: data,
key: key,
});
return JSON.parse(decoded);
}
/**
* Saves a client registration to Redis.
*/
export async function saveClientRegistration(
redisClient: RedisClient,
clientId: string,
registration: OAuthClientInformationFull
): Promise<void> {
await redisClient.set(
REDIS_KEY_PREFIXES.CLIENT_REGISTRATION + clientId,
JSON.stringify(registration),
{ EX: REDIS_EXPIRY_TIMES.CLIENT_REGISTRATION }
);
}
/**
* Retrieves a client registration from Redis.
*/
export async function getClientRegistration(
redisClient: RedisClient,
clientId: string
): Promise<OAuthClientInformationFull | undefined> {
const data = await redisClient.get(REDIS_KEY_PREFIXES.CLIENT_REGISTRATION + clientId);
if (!data) {
return undefined;
}
return JSON.parse(data);
}
/**
* Saves a pending authorization to Redis.
*/
export async function savePendingAuthorization(
redisClient: RedisClient,
authorizationCode: string,
pendingAuthorization: PendingAuthorization
): Promise<void> {
await saveEncrypted(redisClient, {
prefix: REDIS_KEY_PREFIXES.PENDING_AUTHORIZATION,
key: authorizationCode,
data: pendingAuthorization,
options: { EX: REDIS_EXPIRY_TIMES.PENDING_AUTHORIZATION },
});
}
/**
* Reads a pending authorization from Redis.
*/
export async function readPendingAuthorization(
redisClient: RedisClient,
authorizationCode: string
): Promise<PendingAuthorization | undefined> {
return readEncrypted<PendingAuthorization>(redisClient, {
prefix: REDIS_KEY_PREFIXES.PENDING_AUTHORIZATION,
key: authorizationCode,
});
}
/**
* Saves an MCP installation to Redis.
*/
export async function saveMcpInstallation(
redisClient: RedisClient,
mcpAccessToken: string,
installation: McpInstallation
): Promise<void> {
await saveEncrypted(redisClient, {
prefix: REDIS_KEY_PREFIXES.MCP_AUTHORIZATION,
key: mcpAccessToken,
data: installation,
options: { EX: REDIS_EXPIRY_TIMES.UPSTREAM_INSTALLATION },
});
}
/**
* Reads an MCP installation from Redis.
*/
export async function readMcpInstallation(
redisClient: RedisClient,
mcpAccessToken: string
): Promise<McpInstallation | undefined> {
return readEncrypted<McpInstallation>(redisClient, {
prefix: REDIS_KEY_PREFIXES.MCP_AUTHORIZATION,
key: mcpAccessToken,
});
}
/**
* Links a refresh token to an MCP access token.
*/
export async function saveRefreshToken(
redisClient: RedisClient,
refreshToken: string,
mcpAccessToken: string
): Promise<void> {
await saveEncrypted(redisClient, {
prefix: REDIS_KEY_PREFIXES.REFRESH_TOKEN,
key: refreshToken,
data: mcpAccessToken,
options: { EX: REDIS_EXPIRY_TIMES.REFRESH_TOKEN },
});
}
/**
* Reads the access token associated with a refresh token.
*/
export async function readRefreshToken(
redisClient: RedisClient,
refreshToken: string
): Promise<string | undefined> {
return readEncrypted<string>(redisClient, {
prefix: REDIS_KEY_PREFIXES.REFRESH_TOKEN,
key: refreshToken,
});
}
/**
* Revokes an MCP installation.
*/
export async function revokeMcpInstallation(
redisClient: RedisClient,
mcpAccessToken: string
): Promise<void> {
const installation = await readEncrypted<McpInstallation>(redisClient, {
prefix: REDIS_KEY_PREFIXES.MCP_AUTHORIZATION,
key: mcpAccessToken,
del: true,
});
if (!installation) {
return;
}
// In production, would revoke upstream tokens here
}
/**
* Saves a token exchange record.
*/
export async function saveTokenExchange(
redisClient: RedisClient,
authorizationCode: string,
tokenExchange: TokenExchange
): Promise<void> {
await saveEncrypted(redisClient, {
prefix: REDIS_KEY_PREFIXES.TOKEN_EXCHANGE,
key: authorizationCode,
data: tokenExchange,
options: { EX: REDIS_EXPIRY_TIMES.TOKEN_EXCHANGE },
});
}
/**
* Exchanges a temporary authorization code for an MCP access token.
* Will only succeed the first time to prevent replay attacks.
*/
export async function exchangeToken(
redisClient: RedisClient,
authorizationCode: string
): Promise<TokenExchange | undefined> {
const data = await redisClient.get(
REDIS_KEY_PREFIXES.TOKEN_EXCHANGE + sha256(authorizationCode)
);
if (!data) {
return undefined;
}
const decoded = decryptString({
encryptedText: data,
key: authorizationCode,
});
const tokenExchange: TokenExchange = JSON.parse(decoded);
if (tokenExchange.alreadyUsed) {
logger.error('Duplicate use of authorization code detected; revoking tokens', undefined, {
authorizationCode: authorizationCode.substring(0, 8) + '...'
});
await revokeMcpInstallation(redisClient, tokenExchange.mcpAccessToken);
throw new Error("Duplicate use of authorization code detected; tokens revoked");
}
const rereadData = await saveEncrypted(redisClient, {
prefix: REDIS_KEY_PREFIXES.TOKEN_EXCHANGE,
key: authorizationCode,
data: { ...tokenExchange, alreadyUsed: true },
options: { KEEPTTL: true, GET: true },
});
if (rereadData !== data) {
// Data concurrently changed while we were updating it. This necessarily means a duplicate use.
logger.error('Duplicate use of authorization code detected (concurrent update); revoking tokens', undefined, {
authorizationCode: authorizationCode.substring(0, 8) + '...'
});
await revokeMcpInstallation(redisClient, tokenExchange.mcpAccessToken);
throw new Error("Duplicate use of authorization code detected; tokens revoked");
}
return tokenExchange;
}