From 98cec052e43c436547ba5b03f94aff6225112742 Mon Sep 17 00:00:00 2001 From: Basil Hosmer Date: Wed, 3 Sep 2025 23:48:27 -0400 Subject: [PATCH] Phase 2: Extract shared auth logic - Created shared/types.ts with well-documented type definitions - Created shared/auth-core.ts with core auth functions (PKCE, tokens, encryption) - Created shared/redis-auth.ts with Redis operations for auth data - Updated src/services/auth.ts to use shared modules - Added TokenIntrospectionResponse interface for Mode 2 --- shared/auth-core.ts | 87 +++++++++++++ shared/redis-auth.ts | 282 +++++++++++++++++++++++++++++++++++++++++++ shared/types.ts | 92 ++++++++++++++ src/services/auth.ts | 225 +++------------------------------- 4 files changed, 481 insertions(+), 205 deletions(-) create mode 100644 shared/auth-core.ts create mode 100644 shared/redis-auth.ts create mode 100644 shared/types.ts diff --git a/shared/auth-core.ts b/shared/auth-core.ts new file mode 100644 index 0000000..5feb735 --- /dev/null +++ b/shared/auth-core.ts @@ -0,0 +1,87 @@ +import crypto from "crypto"; +import { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; + +/** + * Generates a PKCE code challenge from a verifier string. + * Uses S256 method as specified in RFC 7636. + * @param verifier The code verifier string + * @returns Base64url-encoded SHA256 hash of the verifier + */ +export function generatePKCEChallenge(verifier: string): string { + const buffer = Buffer.from(verifier); + const hash = crypto.createHash("sha256").update(buffer); + return hash.digest("base64url"); +} + +/** + * Generates a cryptographically secure random token. + * @returns 64-character hexadecimal string + */ +export function generateToken(): string { + return crypto.randomBytes(32).toString("hex"); +} + +/** + * Computes SHA256 hash of input data. + * @param data The string to hash + * @returns Hexadecimal representation of the hash + */ +export function sha256(data: string): string { + return crypto.createHash("sha256").update(data).digest("hex"); +} + +/** + * Encrypts a string using AES-256-CBC encryption. + * @param text The plaintext to encrypt + * @param key The encryption key (64 hex characters) + * @returns Encrypted string in format "iv:ciphertext" + */ +export function encryptString({ text, key }: { text: string; key: string }): string { + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv("aes-256-cbc", Buffer.from(key, "hex"), iv); + let encrypted = cipher.update(text, "utf-8", "hex"); + encrypted += cipher.final("hex"); + return `${iv.toString("hex")}:${encrypted}`; +} + +/** + * Decrypts a string encrypted with encryptString. + * @param encryptedText The encrypted string in format "iv:ciphertext" + * @param key The encryption key (64 hex characters) + * @returns Decrypted plaintext + */ +export function decryptString({ + encryptedText, + key, +}: { + encryptedText: string; + key: string; +}): string { + const [ivHex, encrypted] = encryptedText.split(":"); + const iv = Buffer.from(ivHex, "hex"); + const decipher = crypto.createDecipheriv("aes-256-cbc", Buffer.from(key, "hex"), iv); + let decrypted = decipher.update(encrypted, "hex", "utf-8"); + decrypted += decipher.final("utf-8"); + return decrypted; +} + +/** + * Access token expiry time in seconds (1 hour) + */ +export const ACCESS_TOKEN_EXPIRY_SEC = 60 * 60; + +/** + * Generates a complete set of MCP OAuth tokens. + * @returns OAuth tokens with access token, refresh token, and expiry + */ +export function generateMcpTokens(): OAuthTokens { + const mcpAccessToken = generateToken(); + const mcpRefreshToken = generateToken(); + + return { + access_token: mcpAccessToken, + refresh_token: mcpRefreshToken, + expires_in: ACCESS_TOKEN_EXPIRY_SEC, + token_type: "Bearer", + }; +} \ No newline at end of file diff --git a/shared/redis-auth.ts b/shared/redis-auth.ts new file mode 100644 index 0000000..aa54b3d --- /dev/null +++ b/shared/redis-auth.ts @@ -0,0 +1,282 @@ +import { SetOptions } from "@redis/client"; +import { OAuthClientInformationFull } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { RedisClient } from "../src/redis.js"; +import { McpInstallation, PendingAuthorization, TokenExchange } from "./types.js"; +import { sha256, encryptString, decryptString } from "./auth-core.js"; +import { logger } from "../src/utils/logger.js"; + +/** + * Redis key prefixes for different data types + */ +export const REDIS_KEY_PREFIXES = { + CLIENT_REGISTRATION: "client:", + PENDING_AUTHORIZATION: "pending:", + MCP_AUTHORIZATION: "mcp:", + TOKEN_EXCHANGE: "exch:", + REFRESH_TOKEN: "refresh:", +} as const; + +/** + * Redis key expiry times in seconds + */ +export const REDIS_EXPIRY_TIMES = { + 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( + redisClient: RedisClient, + { + prefix, + key, + data, + options, + }: { + prefix: string; + key: string; + data: T; + options?: SetOptions; + } +): Promise { + 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( + redisClient: RedisClient, + { + prefix, + key, + del = false, + }: { + prefix: string; + key: string; + del?: boolean; + } +): Promise { + 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 { + await redisClient.set( + REDIS_KEY_PREFIXES.CLIENT_REGISTRATION + clientId, + JSON.stringify(registration) + ); +} + +/** + * Retrieves a client registration from Redis. + */ +export async function getClientRegistration( + redisClient: RedisClient, + clientId: string +): Promise { + 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 { + 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 { + return readEncrypted(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 { + 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 { + return readEncrypted(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 { + 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 { + return readEncrypted(redisClient, { + prefix: REDIS_KEY_PREFIXES.REFRESH_TOKEN, + key: refreshToken, + }); +} + +/** + * Revokes an MCP installation. + */ +export async function revokeMcpInstallation( + redisClient: RedisClient, + mcpAccessToken: string +): Promise { + const installation = await readEncrypted(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 { + 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 { + 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; +} \ No newline at end of file diff --git a/shared/types.ts b/shared/types.ts new file mode 100644 index 0000000..d87ce13 --- /dev/null +++ b/shared/types.ts @@ -0,0 +1,92 @@ +import { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; + +/** + * Represents a pending OAuth authorization that hasn't been exchanged for tokens yet. + * Stored in Redis with the authorization code as the key. + */ +export interface PendingAuthorization { + /** The redirect URI where the client expects to receive the authorization code */ + redirectUri: string; + /** PKCE code challenge - a derived value from the code verifier */ + codeChallenge: string; + /** Method used to derive the code challenge (currently only S256 supported) */ + codeChallengeMethod: string; + /** The OAuth client ID that initiated the authorization */ + clientId: string; + /** Optional state parameter for CSRF protection */ + state?: string; +} + +/** + * Represents the exchange of an authorization code for an MCP access token. + * Used to prevent replay attacks by tracking if a code has been used. + */ +export interface TokenExchange { + /** The MCP access token that was issued for this authorization code */ + mcpAccessToken: string; + /** Whether this authorization code has already been exchanged for tokens */ + alreadyUsed: boolean; +} + +/** + * Represents fake upstream tokens for demonstration purposes. + * In production, this would contain real upstream provider tokens. + */ +export interface FakeUpstreamInstallation { + /** Simulated access token from the fake upstream provider */ + fakeAccessTokenForDemonstration: string; + /** Simulated refresh token from the fake upstream provider */ + fakeRefreshTokenForDemonstration: string; +} + +/** + * The complete installation object stored in Redis, containing both + * upstream provider information and MCP-specific tokens. + * This object is encrypted using the MCP access token as the key. + */ +export interface McpInstallation { + /** Information from the upstream authentication provider */ + fakeUpstreamInstallation: FakeUpstreamInstallation; + /** MCP OAuth tokens issued to the client */ + mcpTokens: OAuthTokens; + /** The OAuth client ID associated with this installation */ + clientId: string; + /** Unix timestamp (seconds) when the tokens were issued */ + issuedAt: number; + /** Unique identifier for the user (not the OAuth client) */ + userId: string; +} + +/** + * OAuth 2.0 Token Introspection Response + * Based on RFC 7662: https://tools.ietf.org/html/rfc7662 + * Used when validating tokens with an external authorization server. + */ +export interface TokenIntrospectionResponse { + /** Whether the token is currently active */ + active: boolean; + /** Space-separated list of scopes associated with the token */ + scope?: string; + /** Client identifier for the OAuth client that requested the token */ + client_id?: string; + /** Human-readable identifier for the resource owner */ + username?: string; + /** Type of the token (e.g., "Bearer") */ + token_type?: string; + /** Expiration time as seconds since Unix epoch */ + exp?: number; + /** Time at which the token was issued as seconds since Unix epoch */ + iat?: number; + /** Time before which the token is not valid as seconds since Unix epoch */ + nbf?: number; + /** Subject identifier for the resource owner */ + sub?: string; + /** Intended audience for the token */ + aud?: string | string[]; + /** Issuer of the token */ + iss?: string; + /** Unique identifier for the token */ + jti?: string; + /** Custom field for our implementation to store user ID */ + userId?: string; +} \ No newline at end of file diff --git a/src/services/auth.ts b/src/services/auth.ts index 02dae0f..bae5f42 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -1,271 +1,86 @@ -import { SetOptions } from "@redis/client"; -import crypto from "crypto"; import { redisClient } from "../redis.js"; import { McpInstallation, PendingAuthorization, TokenExchange } from "../types.js"; import { OAuthClientInformationFull, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; -import { logger } from "../utils/logger.js"; -export function generatePKCEChallenge(verifier: string): string { - const buffer = Buffer.from(verifier); - const hash = crypto.createHash("sha256").update(buffer); - return hash.digest("base64url"); -} +// Re-export from shared modules for backward compatibility +export { + generatePKCEChallenge, + generateToken, + decryptString, + generateMcpTokens +} from "../../shared/auth-core.js"; -export function generateToken(): string { - return crypto.randomBytes(32).toString("hex"); -} +import * as sharedRedisAuth from "../../shared/redis-auth.js"; -function sha256(data: string): string { - return crypto.createHash("sha256").update(data).digest("hex"); -} - -function encryptString({ text, key }: { text: string; key: string }): string { - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv("aes-256-cbc", Buffer.from(key, "hex"), iv); - let encrypted = cipher.update(text, "utf-8", "hex"); - encrypted += cipher.final("hex"); - return `${iv.toString("hex")}:${encrypted}`; -} - -export function decryptString({ - encryptedText, - key, -}: { - encryptedText: string; - key: string; -}): string { - const [ivHex, encrypted] = encryptedText.split(":"); - const iv = Buffer.from(ivHex, "hex"); - const decipher = crypto.createDecipheriv("aes-256-cbc", Buffer.from(key, "hex"), iv); - let decrypted = decipher.update(encrypted, "hex", "utf-8"); - decrypted += decipher.final("utf-8"); - return decrypted; -} - -const CLIENT_REGISTRATION_KEY_PREFIX = "client:"; -const PENDING_AUTHORIZATION_KEY_PREFIX = "pending:"; -const MCP_AUTHORIZATION_KEY_PREFIX = "mcp:"; -const TOKEN_EXCHANGE_KEY_PREFIX = "exch:"; -const REFRESH_TOKEN_KEY_PREFIX = "refresh:"; - -// Timeouts of redis keys for different stages of the OAuth flow -const REDIS_PENDING_AUTHORIZATION_EXPIRY_SEC = 10 * 60; // 10 minutes in seconds - authorization code -> PendingAuthorization -const REDIS_TOKEN_EXCHANGE_EXPIRY_SEC = 10 * 60; // 10 minutes in seconds - authorization code -> MCP access token -const REDIS_UPSTREAM_INSTALLATION_EXPIRY_SEC = 7 * 24 * 60 * 60; // 7 days in seconds - MCP access token -> UpstreamInstallation -const REDIS_REFRESH_TOKEN_EXPIRY_SEC = 7 * 24 * 60 * 60; // 7 days in seconds - MCP refresh token -> access token - -// Access token expiry -const ACCESS_TOKEN_EXPIRY_SEC = 60 * 60 // 1 hour in seconds - -async function saveEncrypted({ - prefix, - key, - data, - options, -}: { - prefix: string; - key: string; - data: T; - options?: SetOptions; -}) { - const value = encryptString({ - text: JSON.stringify(data), - key: key, - }); - - return await redisClient.set(prefix + sha256(key), value, options); -} - -async function readEncrypted({ - prefix, - key, - del = false, -}: { - prefix: string; - key: string; - del?: boolean; -}): Promise { - 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); -} - -export function generateMcpTokens(): OAuthTokens { - // Generate MCP access token and store both tokens - const mcpAccessToken = generateToken(); - const mcpRefreshToken = generateToken(); - - return { - access_token: mcpAccessToken, - refresh_token: mcpRefreshToken, - expires_in: ACCESS_TOKEN_EXPIRY_SEC, - token_type: "Bearer", - } -} +// Wrapper functions that pass redisClient to shared module functions export async function saveClientRegistration( clientId: string, registration: OAuthClientInformationFull, ) { - await redisClient.set( - CLIENT_REGISTRATION_KEY_PREFIX + clientId, - JSON.stringify(registration), - ); + return sharedRedisAuth.saveClientRegistration(redisClient, clientId, registration); } export async function getClientRegistration( clientId: string, ): Promise { - const data = await redisClient.get(CLIENT_REGISTRATION_KEY_PREFIX + clientId); - if (!data) { - return undefined; - } - return JSON.parse(data); + return sharedRedisAuth.getClientRegistration(redisClient, clientId); } export async function savePendingAuthorization( authorizationCode: string, pendingAuthorization: PendingAuthorization, ) { - await saveEncrypted({ - prefix: PENDING_AUTHORIZATION_KEY_PREFIX, - key: authorizationCode, - data: pendingAuthorization, - options: { EX: REDIS_PENDING_AUTHORIZATION_EXPIRY_SEC }, - }); + return sharedRedisAuth.savePendingAuthorization(redisClient, authorizationCode, pendingAuthorization); } export async function readPendingAuthorization( authorizationCode: string, ): Promise { - return readEncrypted({ - prefix: PENDING_AUTHORIZATION_KEY_PREFIX, - key: authorizationCode, - }); + return sharedRedisAuth.readPendingAuthorization(redisClient, authorizationCode); } export async function saveMcpInstallation( mcpAccessToken: string, installation: McpInstallation, ) { - await saveEncrypted({ - prefix: MCP_AUTHORIZATION_KEY_PREFIX, - key: mcpAccessToken, - data: installation, - options: { EX: REDIS_UPSTREAM_INSTALLATION_EXPIRY_SEC }, - }); + return sharedRedisAuth.saveMcpInstallation(redisClient, mcpAccessToken, installation); } export async function readMcpInstallation( mcpAccessToken: string, ): Promise { - return readEncrypted({ - prefix: MCP_AUTHORIZATION_KEY_PREFIX, - key: mcpAccessToken, - }); + return sharedRedisAuth.readMcpInstallation(redisClient, mcpAccessToken); } -// This just links the refresh token to the upstream installation + mcp access token export async function saveRefreshToken( refreshToken: string, mcpAccessToken: string, ) { - saveEncrypted({ - prefix: REFRESH_TOKEN_KEY_PREFIX, - key: refreshToken, - data: mcpAccessToken, - options: { EX: REDIS_REFRESH_TOKEN_EXPIRY_SEC }, - }) + return sharedRedisAuth.saveRefreshToken(redisClient, refreshToken, mcpAccessToken); } export async function readRefreshToken( refreshToken: string, ): Promise { - return readEncrypted({ - prefix: REFRESH_TOKEN_KEY_PREFIX, - key: refreshToken, - }); + return sharedRedisAuth.readRefreshToken(redisClient, refreshToken); } export async function revokeMcpInstallation( mcpAccessToken: string, ): Promise { - const installation = await readEncrypted({ - prefix: MCP_AUTHORIZATION_KEY_PREFIX, - key: mcpAccessToken, - del: true, - }); - - if (!installation) { - return; - } - // Revoke upstream tokens here + return sharedRedisAuth.revokeMcpInstallation(redisClient, mcpAccessToken); } export async function saveTokenExchange( authorizationCode: string, tokenExchange: TokenExchange, ) { - await saveEncrypted({ - prefix: TOKEN_EXCHANGE_KEY_PREFIX, - key: authorizationCode, - data: tokenExchange, - options: { EX: REDIS_TOKEN_EXCHANGE_EXPIRY_SEC }, - }); + return sharedRedisAuth.saveTokenExchange(redisClient, authorizationCode, tokenExchange); } -/** - * Exchanges a temporary authorization code for an MCP access token. Will only succeed the first time. - */ export async function exchangeToken( authorizationCode: string, ): Promise { - const data = await redisClient.get(TOKEN_EXCHANGE_KEY_PREFIX + 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(tokenExchange.mcpAccessToken); - throw new Error("Duplicate use of authorization code detected; tokens revoked"); - } - - const rereadData = await saveEncrypted({ - prefix: TOKEN_EXCHANGE_KEY_PREFIX, - 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(tokenExchange.mcpAccessToken); - throw new Error("Duplicate use of authorization code detected; tokens revoked"); - } - - return tokenExchange; + return sharedRedisAuth.exchangeToken(redisClient, authorizationCode); }