import express, { Application, Request, Response, NextFunction } from 'express';
import path from 'path';
import crypto from 'crypto'; // For PKCE verification
import pkceChallenge from 'pkce-challenge'; // Keep for potential future use if needed
import { v4 as uuidv4 } from 'uuid';
import cookie from 'cookie'; // For parsing cookies
import { Database } from 'bun:sqlite'; // Import Database type if needed here
// Adjust SDK imports - Assuming types might be directly under sdk or specific submodules
// If these are still wrong, we might need the exact SDK structure/version.
import {
AuthInfo,
} from "@modelcontextprotocol/sdk/server/auth/types.js";
import {
OAuthClientInformationFull,
OAuthClientMetadata,
OAuthTokenRevocationRequest,
OAuthTokens
} from "@modelcontextprotocol/sdk/shared/auth.js";
// import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
// --- Local Imports ---
import { AppConfig } from './config.js'; // Import AppConfig
import {
UserSession,
ActiveTransportEntry,
createSessionFromEhrData, // Now synchronous
loadSessionFromDb, // Accepts filename + config now
// getSqliteFilePath is still used internally by sessionUtils
} from './sessionUtils.js';
import { ClientFullEHR } from '../clientTypes.js'; // Assuming clientTypes is in parent dir
import {
OAuthServerProvider,
} from '@modelcontextprotocol/sdk/server/auth/provider.js';
// --- Constants ---
const AuthGrantType = { // Define if not imported
AuthorizationCode: 'authorization_code',
// Add other grant types if needed
};
const PKCE_METHOD_S256 = 'S256';
// --- Internal State Management ---
// Temporary store for MCP authorization requests before user picks DB/connects EHR
export interface AuthzRequestState {
authzRequestId: string;
mcpClientId: string;
mcpRedirectUri: string;
mcpCodeChallenge?: string;
mcpCodeChallengeMethod?: string;
mcpState?: string;
mcpScope?: string;
createdAt: number;
}
const authzRequests = new Map<string, AuthzRequestState>(); // Renamed from pickerSessions
const AUTHZ_REQUEST_EXPIRY_MS = 5 * 60 * 1000; // Renamed from PICKER_SESSION_EXPIRY_MS
// Temporary store for state between initiating new EHR flow and the callback
interface AuthFlowState {
authFlowId: string;
mcpClientId: string;
mcpRedirectUri: string;
mcpCodeChallenge?: string;
mcpCodeChallengeMethod?: string;
mcpState?: string;
mcpScope?: string; // Store requested scope from original picker session
createdAt: number;
}
const authFlowStates = new Map<string, AuthFlowState>(); // Keyed by authFlowId
const AUTH_FLOW_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes for EHR login/fetch
const AUTH_FLOW_COOKIE_NAME = 'smartmcp_auth_flow';
// Stores completed UserSessions ready for token exchange, keyed by MCP Authorization Code
const sessionsByMcpAuthCode = new Map<string, UserSession>();
const MCP_AUTH_CODE_EXPIRY_MS = 2 * 60 * 1000; // 2 minutes
// Store for registered MCP clients (replace with dynamic registration/DB lookup if needed)
const registeredMcpClients = new Map<string, OAuthClientInformationFull>();
// --- Helper: SDK Basic Auth Parsing ---
// Re-implement or adjust based on actual SDK export if `parseBasicAuthHeader` is unavailable
// Define the missing type
interface BasicAuthCredentials {
clientId: string;
clientSecret?: string; // Make secret optional if applicable, adjust as needed
}
function parseSdkBasicAuthHeader(header: string): BasicAuthCredentials {
const base64Credentials = header.substring(6); // Remove "Basic "
const decoded = Buffer.from(base64Credentials, 'base64').toString('utf8');
const [clientId, clientSecret] = decoded.split(':', 2);
if (!clientId) {
throw new Error("Invalid Basic Auth header format");
}
return { clientId, clientSecret };
}
// --- Helper: OAuth Errors ---
// Define basic error classes if not available from SDK
class BaseOAuthError extends Error /* implements OAuthError */ { // Removed implements if OAuthError isn't found/correct
statusCode: number;
error: string;
constructor(statusCode: number, error: string, message: string) {
super(message);
this.statusCode = statusCode;
this.error = error;
Object.setPrototypeOf(this, new.target.prototype); // Preserve prototype chain
}
}
class InvalidRequestError extends BaseOAuthError { constructor(message: string) { super(400, 'invalid_request', message); } }
class InvalidClientError extends BaseOAuthError { constructor(message: string) { super(401, 'invalid_client', message); } }
class InvalidGrantError extends BaseOAuthError { constructor(message: string) { super(400, 'invalid_grant', message); } }
class UnsupportedGrantTypeError extends BaseOAuthError { constructor(message: string) { super(400, 'unsupported_grant_type', message); } }
class ServerError extends BaseOAuthError { constructor(message: string) { super(500, 'server_error', message); } }
class InvalidTokenError extends BaseOAuthError { constructor(message: string) { super(401, 'invalid_token', message); } } // Define InvalidTokenError
// --- OAuth Provider Implementation ---
// Remove explicit implementation, rely on structural typing via the getter
class MyOAuthClientStore /* implements OAuthClientStore */ {
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> {
let client = registeredMcpClients.get(clientId);
if (client) { console.log(`[AUTH Client Store] Found client: ${clientId}`); }
else { console.warn(`[AUTH Client Store] Client not found: ${clientId}`); }
return client;
}
async addClient(clientInfo: OAuthClientInformationFull): Promise<void> {
if (!clientInfo.client_id || !clientInfo.client_name || !clientInfo.redirect_uris || clientInfo.redirect_uris.length === 0) {
throw new InvalidRequestError("Client info missing required fields (client_id, client_name, redirect_uris)");
}
if (registeredMcpClients.has(clientInfo.client_id)) {
console.warn(`[AUTH Client Store] Attempted to register duplicate client ID: ${clientInfo.client_id}`);
throw new InvalidRequestError(`Client ID ${clientInfo.client_id} is already registered.`);
}
if (!clientInfo.grant_types || clientInfo.grant_types.length === 0) {
clientInfo.grant_types = [AuthGrantType.AuthorizationCode];
}
console.log(`[AUTH Client Store] Registering client: ${clientInfo.client_id} (${clientInfo.client_name})`);
registeredMcpClients.set(clientInfo.client_id, clientInfo);
}
async removeClient(clientId: string): Promise<void> {
if (registeredMcpClients.has(clientId)) {
console.log(`[AUTH Client Store] Removing client: ${clientId}`);
registeredMcpClients.delete(clientId);
} else {
console.warn(`[AUTH Client Store] Attempted to remove non-existent client: ${clientId}`);
}
}
}
export class MyOAuthServerProvider implements OAuthServerProvider {
private clientStore = new MyOAuthClientStore();
private activeSessions: Map<string, UserSession>;
constructor(activeSessionsRef: Map<string, UserSession>) {
this.activeSessions = activeSessionsRef;
console.log("[AUTH Provider] Initialized MyOAuthServerProvider.");
}
// --- Implement OAuthClientStore methods (delegated) ---
async getClient(clientId: string): Promise<OAuthClientInformationFull | undefined> { return this.clientStore.getClient(clientId); }
async addClient(clientInfo: OAuthClientInformationFull): Promise<void> { return this.clientStore.addClient(clientInfo); }
async removeClient(clientId: string): Promise<void> { return this.clientStore.removeClient(clientId); }
// --- Public getter for clientsStore as required by interface ---
public get clientsStore(): MyOAuthClientStore {
return this.clientStore;
}
// --- Existing Custom Methods ---
async createAuthCode(session: UserSession): Promise<string> {
const code = uuidv4();
console.log(`[AUTH Provider] Creating auth code for session ${session.sessionId.substring(0,8)}...`);
sessionsByMcpAuthCode.set(code, session);
setTimeout(() => {
if (sessionsByMcpAuthCode.has(code)) {
console.log(`[AUTH Provider] Expiring auth code ${code.substring(0,8)}...`);
sessionsByMcpAuthCode.delete(code);
}
}, MCP_AUTH_CODE_EXPIRY_MS);
return code;
}
async getSessionByAuthCode(code: string): Promise<UserSession | undefined> {
console.log(`[AUTH Provider] Looking up session for auth code ${code.substring(0,8)}...`);
const session = sessionsByMcpAuthCode.get(code);
if (session) {
console.log(`[AUTH Provider] Found and consuming session for auth code ${code.substring(0,8)}...`);
sessionsByMcpAuthCode.delete(code);
// Ensure the authzRequestState is present if expected (it should be)
if (!session.authzRequestState) {
console.error(`[AUTH Provider] CRITICAL: Session ${session.sessionId.substring(0,8)} found for auth code ${code.substring(0,8)} but is missing authzRequestState!`);
// Decide how to handle - maybe throw an error? For now, return undefined as if session wasn't found.
return undefined;
}
return session;
} else {
console.warn(`[AUTH Provider] Auth code ${code.substring(0,8)}... not found or expired.`);
return undefined;
}
}
async createToken(session: UserSession, clientId: string, scope?: string): Promise<AuthInfo> {
const token = session.sessionId;
console.log(`[AUTH Provider] Creating token for session ${session.sessionId.substring(0,8)}... (Client: ${clientId})`);
if (!this.activeSessions.has(token)) {
console.warn(`[AUTH Provider] Session ${token.substring(0,8)}... not found in central activeSessions map during token creation. Adding it.`);
this.activeSessions.set(token, session);
} else {
const existingSession = this.activeSessions.get(token)!;
existingSession.mcpClientInfo = session.mcpClientInfo; // Keep this - general client info for the session
existingSession.authzRequestState = session.authzRequestState; // Ensure authz state is also updated if needed
}
// Use 'scopes' from AuthzRequestState if available, otherwise default
const scopesArray = (session.authzRequestState?.mcpScope || '').split(' ').filter(s => s);
const authInfo: AuthInfo = {
token: token,
clientId: clientId, // clientId here is confirmed during token exchange
scopes: scopesArray,
};
console.log(`[AUTH Provider] Token created: ${token.substring(0,8)}... for client ${clientId}`);
return authInfo;
}
async getTokenInfo(token: string): Promise<AuthInfo | undefined> {
const session = this.activeSessions.get(token);
if (!session) {
console.warn(`[AUTH Provider] Token validation failed: Session not found for token ${token.substring(0,8)}...`);
return undefined;
}
// Use 'scopes' from AuthzRequestState if available
const scopesArray = (session.authzRequestState?.mcpScope || '').split(' ').filter(s => s);
const authInfo: AuthInfo = {
token: token,
// Client ID associated with the session (verified at token exchange)
clientId: session.mcpClientInfo.client_id,
scopes: scopesArray,
};
return authInfo;
}
async revokeToken(client: OAuthClientInformationFull, request: OAuthTokenRevocationRequest): Promise<void> {
const token = request.token;
console.log(`[AUTH Provider] Revoking token ${token.substring(0,8)}... (Client: ${client.client_id})`);
// Optional: Add checks using client if needed (e.g., ensure client owns the token)
const session = this.activeSessions.get(token);
if (session) {
if (session.db) {
try {
console.log(`[AUTH Provider] Closing DB for revoked session ${token.substring(0, 8)}...`);
session.db.close();
} catch (dbErr) {
console.error(`[AUTH Provider] Error closing DB for revoked session ${token.substring(0, 8)}...:`, dbErr);
}
}
this.activeSessions.delete(token);
console.log(`[AUTH Provider] Session removed for revoked token ${token.substring(0, 8)}... Active sessions: ${this.activeSessions.size}`);
} else {
console.warn(`[AUTH Provider] Attempted to revoke token ${token.substring(0,8)}... but no active session found.`);
}
}
async verifyAccessToken(mcpAccessToken: string): Promise<AuthInfo> {
console.log(`[AUTH Provider] Verifying MCP token: ${mcpAccessToken}...`);
const session = this.activeSessions.get(mcpAccessToken);
if (!session) {
console.warn(`[AUTH Provider] MCP Token ${mcpAccessToken.substring(0,8)}... not found in active sessions.`);
throw new InvalidTokenError("Invalid or expired access token");
}
console.log(`[AUTH Provider] MCP Token verified for client: ${session.mcpClientInfo.client_id}`);
// Return AuthInfo based on the found session
// Use session.mcpClientInfo.scopes consistently
return {
token: mcpAccessToken,
clientId: session.mcpClientInfo.client_id,
// Split the scope string into an array
scopes: (session.mcpClientInfo.scope || '').split(' ').filter(s => s), // Split and filter empty strings
};
}
// --- Implement Required OAuthServerProvider Methods (Stubs/Updated Signatures) ---
async authorize(request: any): Promise<any> { // Replace 'any' with actual request/response types from SDK if known
console.error("[AUTH Provider] authorize() called but not implemented. Logic is currently in /authorize route.");
throw new ServerError("Authorization logic not implemented in provider.");
// TODO: Refactor logic from GET /authorize route handler here
}
async challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<string> {
console.log(`[AUTH Provider] challengeForAuthorizationCode() called for client ${client.client_id}.`);
// Use the passed authorizationCode
const session = sessionsByMcpAuthCode.get(authorizationCode);
// Check against authzRequestState
if (session && session.authzRequestState?.mcpCodeChallenge) {
// Ensure the code belongs to the correct client
if (session.authzRequestState.mcpClientId !== client.client_id) {
console.warn(`[AUTH Provider] challengeForAuthorizationCode: Client mismatch. Expected ${session.authzRequestState.mcpClientId}, got ${client.client_id}.`);
sessionsByMcpAuthCode.delete(authorizationCode); // Consume the code to prevent reuse
throw new InvalidGrantError("Authorization code client mismatch.");
}
// Return only the challenge string
return session.authzRequestState.mcpCodeChallenge;
}
console.warn(`[AUTH Provider] challengeForAuthorizationCode: No session or challenge found for code ${authorizationCode.substring(0,8)}...`);
// Throw error as the interface expects a string promise
throw new InvalidGrantError("Invalid or expired authorization code.");
}
async exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<OAuthTokens> {
console.error("[AUTH Provider] exchangeAuthorizationCode() called but not implemented. Logic is currently in POST /token route.");
// Params like redirect_uri, code_verifier would need to be accessed differently if this signature is correct
// console.log("[AUTH Provider] exchangeAuthorizationCode params received:", params);
throw new ServerError("Authorization code exchange logic not implemented in provider.");
// TODO: Refactor logic from POST /token route handler (for authorization_code grant) here
}
async exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[]): Promise<OAuthTokens> {
console.warn("[AUTH Provider] exchangeRefreshToken() called but refresh tokens are not supported.");
throw new UnsupportedGrantTypeError("Refresh token grant type not supported.");
}
}
// --- Route Setup Function ---
export function addOauthRoutesAndProvider(
app: Application,
config: AppConfig, // Accept AppConfig
activeSessions: Map<string, UserSession>,
): MyOAuthServerProvider {
const oauthProvider = new MyOAuthServerProvider(activeSessions);
function cleanupExpiredState() {
const now = Date.now();
authzRequests.forEach((state, id) => { if (now > state.createdAt + AUTHZ_REQUEST_EXPIRY_MS) { console.log(`[AUTH State Cleanup] Expiring authz request: ${id}`); authzRequests.delete(id); } }); // Renamed
authFlowStates.forEach((state, id) => { if (now > state.createdAt + AUTH_FLOW_EXPIRY_MS) { console.log(`[AUTH State Cleanup] Expiring auth flow state: ${id}`); authFlowStates.delete(id); } });
}
setInterval(cleanupExpiredState, 60 * 1000);
const baseUrl = function(req){
const protocol = req.get('X-Forwarded-Proto') || req.protocol;
const host = req.get('X-Forwarded-Host') || req.hostname;
// req.originalUrl contains the path and query string (e.g., "/users/123?format=json")
const fullUrl = `${protocol}://${host}${req.originalUrl}`;
console.log('--- Request Details ---');
console.log('Original Protocol:', protocol);
console.log('Original Host:', host);
console.log('Path + Query:', req.originalUrl);
console.log('Full Original URL:', fullUrl);
const base = `${protocol}://${host}`;
console.log("base", base);
return base;
}
app.get('/.well-known/oauth-protected-resource', (req, res) => {
console.log("[.well-known protected resource] Request received.", req.originalUrl);
const base = baseUrl(req);
res.json({
resource: base,
authorization_servers: [base],
bearer_methods_supported: ["header"],
scopes_supported: ["openid", "fhirUser", "launch/patient", "patient/*.read", "offline_access"],
});
});
app.get('/.well-known/oauth-authorization-server', (req, res) => {
console.log("[.well-known] Request received.");
const base = baseUrl(req);
res.json({
issuer: base,
registration_endpoint: `${base}/register`,
authorization_endpoint: `${base}/authorize`,
token_endpoint: `${base}/token`,
revocation_endpoint: `${base}/revoke`,
scopes_supported: ["openid", "fhirUser", "launch/patient", "patient/*.read", "offline_access"],
response_types_supported: ["code"],
grant_types_supported: [AuthGrantType.AuthorizationCode],
token_endpoint_auth_methods_supported: ["none"],
code_challenge_methods_supported: [PKCE_METHOD_S256]
});
});
app.get('/authorize', async (req, res, next) => {
console.log("[/authorize GET] Received authorization request query:", req.query);
const { response_type, client_id, redirect_uri, code_challenge, code_challenge_method, state, scope } = req.query;
if (response_type !== 'code') return next(new InvalidRequestError('Invalid response_type. Only "code" is supported.'));
if (!client_id || typeof client_id !== 'string') return next(new InvalidRequestError('Missing or invalid client_id.'));
if (!redirect_uri || typeof redirect_uri !== 'string') return next(new InvalidRequestError('Missing or invalid redirect_uri.'));
if (!code_challenge || typeof code_challenge !== 'string') return next(new InvalidRequestError('Missing PKCE code_challenge.'));
if (code_challenge_method && code_challenge_method !== PKCE_METHOD_S256) return next(new InvalidRequestError('Unsupported code_challenge_method. Only S256 is supported.'));
try {
const client = await oauthProvider.getClient(client_id);
if (!client) return next(new InvalidClientError(`Client not registered: ${client_id}`));
if (!client.redirect_uris.includes(redirect_uri)) {
console.error(`[/authorize GET] Redirect URI mismatch for client ${client_id}. Provided: ${redirect_uri}, Allowed: ${client.redirect_uris}`);
return next(new InvalidRequestError('Invalid redirect_uri.'));
}
cleanupExpiredState();
const authzRequestId = uuidv4(); // Renamed
const authzState: AuthzRequestState = { // Renamed
authzRequestId: authzRequestId, // Renamed
mcpClientId: client_id,
mcpRedirectUri: redirect_uri,
mcpCodeChallenge: code_challenge,
mcpCodeChallengeMethod: code_challenge_method || PKCE_METHOD_S256,
mcpState: typeof state === 'string' ? state : undefined,
mcpScope: typeof scope === 'string' ? scope : undefined,
createdAt: Date.now(),
};
authzRequests.set(authzRequestId, authzState); // Renamed
console.log(`[/authorize GET] Stored authz request ${authzRequestId} for client ${client_id}. Redirecting to picker UI.`); // Renamed
const pickerUrl = `/db-picker.html?authzRequestId=${authzRequestId}`; // Renamed
res.redirect(pickerUrl);
} catch (error) {
console.error("[/authorize GET] Error during authorization:", error);
next(error instanceof BaseOAuthError ? error : new ServerError('An unexpected error occurred during authorization.'));
}
});
app.get('/initiate-session-from-db', async (req, res, next): Promise<void> => {
console.log("[/initiate-session-from-db GET] Received request query:", req.query);
const databaseId = req.query.databaseId as string | undefined; // This is the databaseFilename
const authzRequestId = req.query.authzRequestId as string | undefined;
let authzRequestState: AuthzRequestState | undefined = undefined;
let loadedSession: UserSession | null = null;
// let db: Database | undefined = undefined; // No longer need to manage DB handle here
try {
// --- Parameter Validation (Query Params) ---
if (!databaseId) throw new InvalidRequestError("Missing required query parameter: databaseId");
if (!authzRequestId) throw new InvalidRequestError("Missing required query parameter: authzRequestId");
console.log(`[/initiate-session-from-db GET] Params: dbId=${databaseId}, authzId=${authzRequestId}`);
// --- Retrieve and Validate Authz Request State ---
authzRequestState = authzRequests.get(authzRequestId);
if (!authzRequestState) {
console.warn(`[/initiate-session-from-db GET] Authz request state not found or expired: ${authzRequestId}`);
throw new InvalidRequestError("Invalid or expired authorization request ID.");
} else {
authzRequests.delete(authzRequestId); // Consume the state
console.log(`[/initiate-session-from-db GET] Retrieved authz state for client ${authzRequestState.mcpClientId}`);
}
// --- Get/Validate MCP Client Info (using authzRequestState) ---
const client = await oauthProvider.getClient(authzRequestState.mcpClientId);
if (!client) {
console.error(`[/initiate-session-from-db GET] Client ${authzRequestState.mcpClientId} not found after retrieving authz state.`);
throw new InvalidClientError(`MCP Client not found: ${authzRequestState.mcpClientId}`);
}
if (!client.redirect_uris.includes(authzRequestState.mcpRedirectUri)) {
console.error(`[/initiate-session-from-db GET] Redirect URI mismatch. Client: ${client.redirect_uris}, Authz: ${authzRequestState.mcpRedirectUri}`);
throw new InvalidRequestError("Redirect URI from authorization request does not match client registration.");
}
// --- Load Session Data using Filename ---
// Persistence check happens inside loadSessionFromDb via config
console.log(`[/initiate-session-from-db GET] Calling loadSessionFromDb for file ID: ${databaseId}`);
loadedSession = await loadSessionFromDb(client, databaseId, authzRequestState, config);
if (!loadedSession) {
// loadSessionFromDb handles DB opening/closing and internal errors (like sqliteToEhr errors)
console.error(`[/initiate-session-from-db GET] loadSessionFromDb failed for file ID: ${databaseId}`);
// Map the generic failure to a server error for the client,
// as specific reasons (file not found vs. data corruption) are handled internally.
// A more specific error could be thrown from loadSessionFromDb if needed.
throw new ServerError("Failed to initialize session from the specified record.");
}
// SUCCESS CASE: loadSessionFromDb succeeded.
// The loadedSession now has the data and potentially an open DB handle managed internally.
// --- Generate MCP Auth Code & Store Session ---
const mcpAuthCode = await oauthProvider.createAuthCode(loadedSession);
console.log(`[/initiate-session-from-db GET] Session loaded successfully from DB ${databaseId}. Auth code ${mcpAuthCode.substring(0,8)} generated.`);
// --- Redirect back to MCP Client ---
const redirectUrl = new URL(authzRequestState.mcpRedirectUri);
redirectUrl.searchParams.set('code', mcpAuthCode);
if (authzRequestState.mcpState) redirectUrl.searchParams.set('state', authzRequestState.mcpState);
console.log(`[/initiate-session-from-db GET] Success. Redirecting client ${authzRequestState.mcpClientId} to ${redirectUrl.toString()}`);
res.redirect(302, redirectUrl.toString());
} catch (error: any) {
console.error(`[/initiate-session-from-db GET] Error initiating session:`, error);
// --- Error Handling: DB handle is managed internally by loadSessionFromDb now ---
// No need to manually close 'db' here.
// Try to redirect back to the MCP client with an error
const clientRedirectUriOnError = authzRequestState?.mcpRedirectUri;
if (clientRedirectUriOnError && !res.headersSent) {
try {
const redirectUrl = new URL(clientRedirectUriOnError);
if (error instanceof BaseOAuthError) {
redirectUrl.searchParams.set("error", error.error);
redirectUrl.searchParams.set("error_description", error.message || "An OAuth error occurred.");
} else if (error.message?.includes("Failed to open database file") || error.message?.includes("Failed to access database")) {
redirectUrl.searchParams.set("error", "invalid_request"); // Map to OAuth error
redirectUrl.searchParams.set("error_description", `Specified record not found or inaccessible (ID: ${databaseId}).`);
} else {
redirectUrl.searchParams.set("error", "server_error");
redirectUrl.searchParams.set("error_description", "Failed to initialize session from stored record: " + (error?.message || 'Unknown error'));
}
if (authzRequestState?.mcpState) { // Use optional chaining
redirectUrl.searchParams.set("state", authzRequestState.mcpState);
}
console.log(`[/initiate-session-from-db GET] Redirecting to client with error: ${redirectUrl.toString()}`);
res.redirect(302, redirectUrl.toString());
return; // Explicit return after redirect
} catch (urlError) {
console.error(`[/initiate-session-from-db GET] Invalid redirect URI for error reporting: ${clientRedirectUriOnError}`, urlError);
// Fall through to generic error handler / next()
}
}
// Fallback: Pass error to central OAuth error handler or generic Express handler
if (!res.headersSent) {
if (error instanceof BaseOAuthError) {
next(error);
} else {
console.error("[/initiate-session-from-db GET] Could not redirect error to client. Sending 500.");
// Ensure a generic message if the specific error isn't an OAuth one
next(new ServerError("Internal server error initiating session from stored record."));
}
}
}
});
// Still uses authzRequestId from query param
app.get('/initiate-new-ehr-flow', async (req, res) => {
console.log("[/initiate-new-ehr-flow GET] Received request query:", req.query);
const authzRequestId = req.query.authzRequestId as string | undefined; // Renamed
if (!authzRequestId || typeof authzRequestId !== 'string') {
console.error("[/initiate-new-ehr-flow GET] Missing or invalid authzRequestId query parameter."); // Renamed
res.status(400).send("Missing or invalid authzRequestId parameter."); // Renamed
return;
}
const authzState = authzRequests.get(authzRequestId); // Renamed
if (!authzState) {
console.warn(`[/initiate-new-ehr-flow GET] Authz request state not found or expired: ${authzRequestId}`); // Renamed
res.status(400).send("Invalid or expired authorization request."); // Renamed
return;
}
// Consume the state *after* validation
authzRequests.delete(authzRequestId); // Renamed
console.log(`[/initiate-new-ehr-flow GET] Starting flow for authz request ${authzRequestId}, client ${authzState.mcpClientId}`); // Renamed
try {
cleanupExpiredState(); // Clean up any other expired states first
const authFlowId = uuidv4();
const flowState: AuthFlowState = { // Keep AuthFlowState for cookie-based linking
authFlowId: authFlowId,
mcpClientId: authzState.mcpClientId,
mcpRedirectUri: authzState.mcpRedirectUri,
mcpCodeChallenge: authzState.mcpCodeChallenge,
mcpCodeChallengeMethod: authzState.mcpCodeChallengeMethod,
mcpState: authzState.mcpState,
mcpScope: authzState.mcpScope,
createdAt: Date.now(),
};
authFlowStates.set(authFlowId, flowState);
console.log(`[/initiate-new-ehr-flow GET] Created auth flow state ${authFlowId} for client ${authzState.mcpClientId}.`);
res.setHeader('Set-Cookie', cookie.serialize(AUTH_FLOW_COOKIE_NAME, authFlowId, {
httpOnly: true,
secure: config.server.https?.enabled ?? false,
path: '/',
maxAge: AUTH_FLOW_EXPIRY_MS / 1000,
sameSite: 'lax'
}));
const retrieverUrl = '/ehretriever.html#deliver-to:mcp-callback';
console.log(`[/initiate-new-ehr-flow GET] Redirecting user agent to EHR retriever: ${retrieverUrl}`);
res.redirect(302, retrieverUrl); // Use 302 for redirect
} catch (error: any) {
console.error(`[/initiate-new-ehr-flow GET] Error initiating new EHR flow for authz request ${authzRequestId}:`, error);
// Generic error back to browser if something unexpected happens
if (!res.headersSent) {
res.status(500).send("Internal server error initiating EHR flow.");
}
}
});
app.get(config.server.ehrCallbackPath || '/ehr-callback', (req, res) => {
console.log(`[${config.server.ehrCallbackPath || '/ehr-callback'} GET] Received SMART callback. Query:`, req.query);
res.sendFile(path.resolve(process.cwd(), 'static', 'ehretriever.html'));
});
app.post('/ehr-retriever-callback', express.json({ limit: '50mb' }), async (req, res, next) => {
console.log("[/ehr-retriever-callback POST] Received data from EHR retriever.");
const ehrData: ClientFullEHR = req.body;
const cookies = cookie.parse(req.headers.cookie || '');
const authFlowId = cookies[AUTH_FLOW_COOKIE_NAME];
if (!authFlowId) {
console.error("[/ehr-retriever-callback POST] Missing auth flow cookie.");
res.status(400).json({ success: false, error: "Authorization session expired or invalid." });
return;
}
const flowState = authFlowStates.get(authFlowId);
if (!flowState) {
console.error(`[/ehr-retriever-callback POST] Auth flow state not found or expired for ID: ${authFlowId}`);
res.setHeader('Set-Cookie', cookie.serialize(AUTH_FLOW_COOKIE_NAME, '', { maxAge: -1, path: '/' }));
res.status(400).json({ success: false, error: "Authorization session expired or invalid." });
return;
}
authFlowStates.delete(authFlowId);
res.setHeader('Set-Cookie', cookie.serialize(AUTH_FLOW_COOKIE_NAME, '', { maxAge: -1, path: '/' }));
if (!ehrData) {
console.error(`[/ehr-retriever-callback POST] Error reported by EHR retriever: ${ 'Missing EHR data'}`);
const redirectUrl = new URL(flowState.mcpRedirectUri);
redirectUrl.searchParams.set('error', 'access_denied');
redirectUrl.searchParams.set('error_description', `Failed to retrieve EHR data: ${ 'Unknown error'}`);
if (flowState.mcpState) redirectUrl.searchParams.set('state', flowState.mcpState);
console.log(`[/ehr-retriever-callback POST] Redirecting client ${flowState.mcpClientId} to error URL: ${redirectUrl.toString()}`);
res.json({ success: false, error: 'Missing EHR data', redirectUrl: redirectUrl.toString() });
return;
}
try {
console.log(`[/ehr-retriever-callback POST] Successfully received EHR data for flow ${authFlowId}. Processing...`);
console.log(`[/ehr-retriever-callback POST] FHIR Resource Types: ${Object.keys(ehrData.fhir || {}).length}, Attachments: ${ehrData.attachments?.length ?? 0}`);
const client = await oauthProvider.getClient(flowState.mcpClientId);
if (!client) {
console.error(`[/ehr-retriever-callback POST] Client ${flowState.mcpClientId} not found after retrieving flow state.`);
throw new Error("Internal server error: Client not found.");
}
client.scope = flowState.mcpScope; // Assign scope(s)
const newSessionId = uuidv4();
// Call the updated createSessionFromEhrData (now synchronous)
const newSession = await createSessionFromEhrData(
newSessionId,
client,
ehrData,
config // Pass the full config object
);
// Construct and store the AuthzRequestState on the session
const authzStateForSession: AuthzRequestState = {
authzRequestId: flowState.authFlowId, // Use authFlowId as the original ID is gone
mcpClientId: flowState.mcpClientId,
mcpRedirectUri: flowState.mcpRedirectUri,
mcpCodeChallenge: flowState.mcpCodeChallenge,
mcpCodeChallengeMethod: flowState.mcpCodeChallengeMethod,
mcpState: flowState.mcpState,
mcpScope: flowState.mcpScope,
createdAt: flowState.createdAt // Reflects start of auth flow
};
newSession.authzRequestState = authzStateForSession;
const mcpAuthCode = await oauthProvider.createAuthCode(newSession);
const redirectUrl = new URL(flowState.mcpRedirectUri);
redirectUrl.searchParams.set('code', mcpAuthCode);
if (flowState.mcpState) redirectUrl.searchParams.set('state', flowState.mcpState);
console.log(`[/ehr-retriever-callback POST] New session ${newSessionId.substring(0,8)} created. Redirecting client ${flowState.mcpClientId} to ${redirectUrl.toString()}`);
res.json({ success: true, redirectTo: redirectUrl.toString() });
} catch (error) {
console.error("[/ehr-retriever-callback POST] Error processing EHR data:", error);
const redirectUrl = new URL(flowState.mcpRedirectUri);
redirectUrl.searchParams.set('error', 'server_error');
redirectUrl.searchParams.set('error_description', `Internal server error processing EHR data.`);
if (flowState.mcpState) redirectUrl.searchParams.set('state', flowState.mcpState);
// Ensure response indicates failure correctly
if (!res.headersSent) {
res.status(500).json({ success: false, error: "Internal server error processing data.", redirectUrl: redirectUrl.toString() });
} else {
console.error("[/ehr-retriever-callback POST] Headers already sent, cannot send error JSON response.");
}
}
});
app.post('/token', express.urlencoded({ extended: true }), async (req, res, next) => {
console.log("[/token POST] Received token request body:", req.body);
const { grant_type, code, redirect_uri, client_id, code_verifier } = req.body;
let providedClientId = client_id;
let clientSecret: string | undefined = undefined;
const authHeader = req.headers.authorization;
if (authHeader && authHeader.toLowerCase().startsWith('basic ')) {
try {
const creds = parseSdkBasicAuthHeader(authHeader);
providedClientId = creds.clientId;
clientSecret = creds.clientSecret;
console.log(`[/token POST] Basic Auth detected for client: ${providedClientId}`);
} catch (e: any) {
return next(new InvalidRequestError(`Invalid Basic Authorization header: ${e.message}`));
}
}
if (!providedClientId || typeof providedClientId !== 'string') {
return next(new InvalidRequestError('Missing client_id (required in body or Basic Auth).'));
}
try {
const client = await oauthProvider.getClient(providedClientId);
if (!client) {
console.error(`[/token POST] Client not registered: ${providedClientId}`);
return next(new InvalidClientError(`Client not registered: ${providedClientId}`));
}
if (grant_type === AuthGrantType.AuthorizationCode) {
if (!code || typeof code !== 'string') return next(new InvalidRequestError('Missing authorization code.'));
if (!code_verifier || typeof code_verifier !== 'string') return next(new InvalidRequestError('Missing PKCE code_verifier.'));
const session = await oauthProvider.getSessionByAuthCode(code);
if (!session || !session.authzRequestState) { // Check for session and state
console.warn(`[/token POST] Invalid or expired authorization code (or missing state): ${code.substring(0,8)}...`);
return next(new InvalidGrantError('Invalid or expired authorization code.'));
}
// Verify client ID against the one stored in the authz request state
if (session.authzRequestState.mcpClientId !== client.client_id) {
console.error(`[/token POST] Client ID mismatch! Token requested by ${client.client_id}, but code belongs to client ${session.authzRequestState.mcpClientId}.`);
// Session is consumed by getSessionByAuthCode, just deny grant
return next(new InvalidGrantError('Client ID does not match the authorization code grant.'));
}
// --- Conditional Redirect URI Verification ---
const skipRedirectUriCheck = config.security?.disableClientChecks;
// Access redirect URI from authzRequestState
const originalRedirectUri = session.authzRequestState.mcpRedirectUri;
if (!skipRedirectUriCheck && originalRedirectUri) {
if (!redirect_uri) {
console.error(`[/token POST] Missing redirect_uri in token request, required because present in authz request for client ${client.client_id}.`);
return next(new InvalidGrantError('Missing redirect_uri parameter, required because it was present in the authorization request.'));
}
if (redirect_uri !== originalRedirectUri) {
console.error(`[/token POST] Redirect URI mismatch for client ${client.client_id}. Provided: ${redirect_uri}, Expected: ${originalRedirectUri}`);
return next(new InvalidGrantError('Invalid redirect_uri: Does not match the one used in the authorization request.'));
}
console.log(`[/token POST] Redirect URI verified successfully for client ${client.client_id}.`);
} else if (originalRedirectUri) {
console.log(`[/token POST] Skipping redirect_uri check for client ${client.client_id} due to config.`);
} else {
console.log(`[/token POST] No redirect_uri check needed for client ${client.client_id} as it wasn't in the original auth request.`);
}
// --- PKCE Verification using crypto ---
// Access challenge and method from authzRequestState
const challenge = session.authzRequestState.mcpCodeChallenge;
const method = session.authzRequestState.mcpCodeChallengeMethod || PKCE_METHOD_S256;
if (!challenge) {
console.error(`[/token POST] Missing code_challenge in session authz state for client ${client.client_id}. PKCE was required.`);
// Don't revoke token as it wasn't issued, just deny grant
return next(new InvalidGrantError('PKCE challenge failed: Challenge missing from authorization request.'));
}
let calculatedChallenge: string;
if (method === PKCE_METHOD_S256) {
try {
calculatedChallenge = crypto.createHash('sha256')
.update(code_verifier) // Hash the verifier
.digest('base64') // Get base64 digest
.replace(/\+/g, '-') // Replace + with -
.replace(/\//g, '_') // Replace / with _
.replace(/=+$/, ''); // Remove trailing =
} catch (pkceError) {
console.error(`[/token POST] Error generating PKCE challenge from verifier:`, pkceError);
// Don't revoke token, just deny grant
return next(new InvalidGrantError('PKCE challenge failed: Error during verification.'));
}
} else {
console.error(`[/token POST] Unsupported PKCE method found in session authz state: ${method}`);
// Don't revoke token, just deny grant
return next(new InvalidGrantError('PKCE challenge failed: Unsupported method.'));
}
console.log(`[/token POST] PKCE Check. Stored: ${challenge}, Derived: ${calculatedChallenge}`);
if (challenge !== calculatedChallenge) {
console.error(`[/token POST] PKCE challenge mismatch for client ${client.client_id}.`);
// Don't revoke token, just deny grant
return next(new InvalidGrantError('PKCE challenge failed: Verifier does not match challenge.'));
}
console.log(`[/token POST] PKCE verification successful for client ${client.client_id}.`);
// Use 'scopes' from authzRequestState
const tokenInfo = await oauthProvider.createToken(session, client.client_id, session.authzRequestState.mcpScope);
// Remove expires_in from response
res.json({
access_token: tokenInfo.token,
token_type: "Bearer",
scopes: tokenInfo.scopes, // Use 'scopes'
});
console.log(`[/token POST] Issued token ${tokenInfo.token.substring(0,8)}... to client ${client.client_id}`);
} else {
console.warn(`[/token POST] Unsupported grant_type requested: ${grant_type}`);
return next(new UnsupportedGrantTypeError(`Unsupported grant_type: ${grant_type}`));
}
} catch (error) {
console.error("[/token POST] Error during token exchange:", error);
next(error instanceof BaseOAuthError ? error : new ServerError('An unexpected error occurred during token exchange.'));
}
});
app.post('/register', express.json(), async (req, res, next) => {
console.log("[/register POST] Received client registration request:", req.body);
try {
const clientInfo: OAuthClientInformationFull = {
client_id: uuidv4(),
client_name: req.body.client_name,
redirect_uris: req.body.redirect_uris,
grant_types: req.body.grant_types || [AuthGrantType.AuthorizationCode],
token_endpoint_auth_method: req.body.token_endpoint_auth_method || 'none',
// Assign scopes if provided
scope: req.body.scopes,
};
await oauthProvider.addClient(clientInfo);
const responseClientInfo = { ...clientInfo };
console.log(`[/register POST] Successfully registered client ${clientInfo.client_id} (${clientInfo.client_name})`);
res.status(201).json(responseClientInfo);
} catch (error) {
console.error("[/register POST] Error during client registration:", error);
if (error instanceof InvalidRequestError) {
res.status(400).json({ error: error.error, error_description: error.message });
} else {
res.status(500).json({ error: "server_error", error_description: "Failed to register client." });
}
}
});
app.post('/revoke', express.urlencoded({ extended: true }), async (req, res, next) => {
console.log("[/revoke POST] Received token revocation request:", req.body);
const { token, token_type_hint } = req.body;
let providedClientId: string | undefined = undefined;
const authHeader = req.headers.authorization;
if (authHeader && authHeader.toLowerCase().startsWith('basic ')) {
try {
providedClientId = parseSdkBasicAuthHeader(authHeader).clientId;
console.log(`[/revoke POST] Basic Auth detected for client: ${providedClientId}`);
} catch { /* ignore invalid header */ }
}
if (!providedClientId && req.body.client_id) {
providedClientId = req.body.client_id;
console.log(`[/revoke POST] Client ID found in body: ${providedClientId}`);
}
if (!token || typeof token !== 'string') return next(new InvalidRequestError('Missing token.'));
if (token_type_hint && token_type_hint !== 'access_token') {
console.log(`[/revoke POST] Ignoring revocation request for unsupported token type: ${token_type_hint}`);
res.status(200).send();
return;
}
try {
if (providedClientId) {
const tokenInfo = await oauthProvider.getTokenInfo(token);
const client = await oauthProvider.getClient(providedClientId);
if (!client) {
// Client specified but not found - treat as invalid request?
// Or proceed with revocation anyway?
// Let's proceed but log a warning.
console.warn(`[/revoke POST] Client ${providedClientId} specified but not found.`);
// Decide: error out or allow anonymous revocation?
// Let's error for now if client auth was provided but invalid.
return next(new InvalidClientError(`Client not registered: ${providedClientId}`));
}
// Check if token belongs to the client requesting revocation
if (client && tokenInfo && tokenInfo.clientId !== client.client_id) {
console.warn(`[/revoke POST] Client ${client.client_id} attempted to revoke token belonging to client ${tokenInfo.clientId}. Revocation still proceeding.`);
// NOTE: Spec allows revoking tokens client doesn't own in some cases.
// If stricter check is needed, throw an error here.
}
// If client is required for revocation by the provider method:
if (client) {
await oauthProvider.revokeToken(client, { token });
} else {
// Handle case where client wasn't found but revocation might still be possible
// This depends on whether revokeToken *requires* the client object.
// Assuming it does based on the signature, this path shouldn't be hit
// unless we remove the client check above.
// For now, let's assume the method requires the client.
console.warn(`[/revoke POST] Client ${providedClientId} not found, cannot call revokeToken.`);
// Decide: error out or allow anonymous revocation?
// Let's error for now if client auth was provided but invalid.
return next(new InvalidClientError(`Client not registered: ${providedClientId}`));
}
} else {
// No client authentication provided - this provider might not support anonymous revocation.
// However, the revokeToken method signature implies a client is needed.
console.warn("[/revoke POST] Client authentication required for revocation.");
return next(new InvalidClientError("Client authentication required."));
}
console.log(`[/revoke POST] Revocation processed for token ${token.substring(0,8)}...`);
res.status(200).send();
} catch (error) {
console.error("[/revoke POST] Error during token revocation:", error);
next(error instanceof BaseOAuthError ? error : new ServerError('An unexpected error occurred during token revocation.'));
}
});
// --- OAuth Error Handling Middleware ---
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
if (err instanceof BaseOAuthError) {
console.warn(`[AUTH Error] OAuth Error occurred: ${err.error} - ${err.message}`);
res.status(err.statusCode).json({
error: err.error,
error_description: err.message
});
} else {
// Pass non-OAuth errors to the next error handler
console.warn(`[AUTH Error] Non-OAuth error passed through: ${err.message}`);
next(err);
}
});
return oauthProvider;
}