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
This commit is contained in:
parent
1bcb85bb4f
commit
98cec052e4
4 changed files with 481 additions and 205 deletions
87
shared/auth-core.ts
Normal file
87
shared/auth-core.ts
Normal file
|
|
@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
282
shared/redis-auth.ts
Normal file
282
shared/redis-auth.ts
Normal file
|
|
@ -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<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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
92
shared/types.ts
Normal file
92
shared/types.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,271 +1,86 @@
|
||||||
import { SetOptions } from "@redis/client";
|
|
||||||
import crypto from "crypto";
|
|
||||||
import { redisClient } from "../redis.js";
|
import { redisClient } from "../redis.js";
|
||||||
import { McpInstallation, PendingAuthorization, TokenExchange } from "../types.js";
|
import { McpInstallation, PendingAuthorization, TokenExchange } from "../types.js";
|
||||||
import { OAuthClientInformationFull, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
import { OAuthClientInformationFull, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
|
||||||
import { logger } from "../utils/logger.js";
|
|
||||||
|
|
||||||
export function generatePKCEChallenge(verifier: string): string {
|
// Re-export from shared modules for backward compatibility
|
||||||
const buffer = Buffer.from(verifier);
|
export {
|
||||||
const hash = crypto.createHash("sha256").update(buffer);
|
generatePKCEChallenge,
|
||||||
return hash.digest("base64url");
|
generateToken,
|
||||||
}
|
decryptString,
|
||||||
|
generateMcpTokens
|
||||||
|
} from "../../shared/auth-core.js";
|
||||||
|
|
||||||
export function generateToken(): string {
|
import * as sharedRedisAuth from "../../shared/redis-auth.js";
|
||||||
return crypto.randomBytes(32).toString("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
function sha256(data: string): string {
|
// Wrapper functions that pass redisClient to shared module functions
|
||||||
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<T>({
|
|
||||||
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<T>({
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveClientRegistration(
|
export async function saveClientRegistration(
|
||||||
clientId: string,
|
clientId: string,
|
||||||
registration: OAuthClientInformationFull,
|
registration: OAuthClientInformationFull,
|
||||||
) {
|
) {
|
||||||
await redisClient.set(
|
return sharedRedisAuth.saveClientRegistration(redisClient, clientId, registration);
|
||||||
CLIENT_REGISTRATION_KEY_PREFIX + clientId,
|
|
||||||
JSON.stringify(registration),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getClientRegistration(
|
export async function getClientRegistration(
|
||||||
clientId: string,
|
clientId: string,
|
||||||
): Promise<OAuthClientInformationFull | undefined> {
|
): Promise<OAuthClientInformationFull | undefined> {
|
||||||
const data = await redisClient.get(CLIENT_REGISTRATION_KEY_PREFIX + clientId);
|
return sharedRedisAuth.getClientRegistration(redisClient, clientId);
|
||||||
if (!data) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return JSON.parse(data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function savePendingAuthorization(
|
export async function savePendingAuthorization(
|
||||||
authorizationCode: string,
|
authorizationCode: string,
|
||||||
pendingAuthorization: PendingAuthorization,
|
pendingAuthorization: PendingAuthorization,
|
||||||
) {
|
) {
|
||||||
await saveEncrypted({
|
return sharedRedisAuth.savePendingAuthorization(redisClient, authorizationCode, pendingAuthorization);
|
||||||
prefix: PENDING_AUTHORIZATION_KEY_PREFIX,
|
|
||||||
key: authorizationCode,
|
|
||||||
data: pendingAuthorization,
|
|
||||||
options: { EX: REDIS_PENDING_AUTHORIZATION_EXPIRY_SEC },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readPendingAuthorization(
|
export async function readPendingAuthorization(
|
||||||
authorizationCode: string,
|
authorizationCode: string,
|
||||||
): Promise<PendingAuthorization | undefined> {
|
): Promise<PendingAuthorization | undefined> {
|
||||||
return readEncrypted<PendingAuthorization>({
|
return sharedRedisAuth.readPendingAuthorization(redisClient, authorizationCode);
|
||||||
prefix: PENDING_AUTHORIZATION_KEY_PREFIX,
|
|
||||||
key: authorizationCode,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveMcpInstallation(
|
export async function saveMcpInstallation(
|
||||||
mcpAccessToken: string,
|
mcpAccessToken: string,
|
||||||
installation: McpInstallation,
|
installation: McpInstallation,
|
||||||
) {
|
) {
|
||||||
await saveEncrypted({
|
return sharedRedisAuth.saveMcpInstallation(redisClient, mcpAccessToken, installation);
|
||||||
prefix: MCP_AUTHORIZATION_KEY_PREFIX,
|
|
||||||
key: mcpAccessToken,
|
|
||||||
data: installation,
|
|
||||||
options: { EX: REDIS_UPSTREAM_INSTALLATION_EXPIRY_SEC },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readMcpInstallation(
|
export async function readMcpInstallation(
|
||||||
mcpAccessToken: string,
|
mcpAccessToken: string,
|
||||||
): Promise<McpInstallation | undefined> {
|
): Promise<McpInstallation | undefined> {
|
||||||
return readEncrypted<McpInstallation>({
|
return sharedRedisAuth.readMcpInstallation(redisClient, mcpAccessToken);
|
||||||
prefix: MCP_AUTHORIZATION_KEY_PREFIX,
|
|
||||||
key: mcpAccessToken,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// This just links the refresh token to the upstream installation + mcp access token
|
|
||||||
export async function saveRefreshToken(
|
export async function saveRefreshToken(
|
||||||
refreshToken: string,
|
refreshToken: string,
|
||||||
mcpAccessToken: string,
|
mcpAccessToken: string,
|
||||||
) {
|
) {
|
||||||
saveEncrypted({
|
return sharedRedisAuth.saveRefreshToken(redisClient, refreshToken, mcpAccessToken);
|
||||||
prefix: REFRESH_TOKEN_KEY_PREFIX,
|
|
||||||
key: refreshToken,
|
|
||||||
data: mcpAccessToken,
|
|
||||||
options: { EX: REDIS_REFRESH_TOKEN_EXPIRY_SEC },
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readRefreshToken(
|
export async function readRefreshToken(
|
||||||
refreshToken: string,
|
refreshToken: string,
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
return readEncrypted<string>({
|
return sharedRedisAuth.readRefreshToken(redisClient, refreshToken);
|
||||||
prefix: REFRESH_TOKEN_KEY_PREFIX,
|
|
||||||
key: refreshToken,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function revokeMcpInstallation(
|
export async function revokeMcpInstallation(
|
||||||
mcpAccessToken: string,
|
mcpAccessToken: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const installation = await readEncrypted<McpInstallation>({
|
return sharedRedisAuth.revokeMcpInstallation(redisClient, mcpAccessToken);
|
||||||
prefix: MCP_AUTHORIZATION_KEY_PREFIX,
|
|
||||||
key: mcpAccessToken,
|
|
||||||
del: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!installation) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Revoke upstream tokens here
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveTokenExchange(
|
export async function saveTokenExchange(
|
||||||
authorizationCode: string,
|
authorizationCode: string,
|
||||||
tokenExchange: TokenExchange,
|
tokenExchange: TokenExchange,
|
||||||
) {
|
) {
|
||||||
await saveEncrypted({
|
return sharedRedisAuth.saveTokenExchange(redisClient, authorizationCode, tokenExchange);
|
||||||
prefix: TOKEN_EXCHANGE_KEY_PREFIX,
|
|
||||||
key: authorizationCode,
|
|
||||||
data: tokenExchange,
|
|
||||||
options: { EX: REDIS_TOKEN_EXCHANGE_EXPIRY_SEC },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Exchanges a temporary authorization code for an MCP access token. Will only succeed the first time.
|
|
||||||
*/
|
|
||||||
export async function exchangeToken(
|
export async function exchangeToken(
|
||||||
authorizationCode: string,
|
authorizationCode: string,
|
||||||
): Promise<TokenExchange | undefined> {
|
): Promise<TokenExchange | undefined> {
|
||||||
const data = await redisClient.get(TOKEN_EXCHANGE_KEY_PREFIX + sha256(authorizationCode));
|
return sharedRedisAuth.exchangeToken(redisClient, 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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue