Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 53 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000;
export const THIRTY_DAYS_IN_MS = 30 * 24 * 60 * 60 * 1000;
export const ONE_YEAR_IN_MS = 365.25 * 24 * 60 * 60 * 1000;

const authTypes = ['pat', 'uat', 'direct-trust', 'oauth'] as const;
const authTypes = ['pat', 'uat', 'direct-trust', 'oauth', 'oidc-passthrough'] as const;
type AuthType = (typeof authTypes)[number];

function isAuthType(auth: unknown): auth is AuthType {
Expand Down Expand Up @@ -83,6 +83,15 @@ export class Config {
productTelemetryEnabled: boolean;
isHyperforce: boolean;
breakGlassDisableGlobally: boolean;
oidc: {
expectedAudiences: string[];
expectedHd: string;
tokeninfoUrl: string;
usernameClaim: string;
usernameMap: Record<string, string>;
validationCacheTtlSeconds: number;
validationCacheMax: number;
};

constructor() {
const cleansedVars = removeClaudeMcpBundleUserConfigTemplates(process.env);
Expand Down Expand Up @@ -146,6 +155,13 @@ export class Config {
PRODUCT_TELEMETRY_ENABLED: productTelemetryEnabled,
IS_HYPERFORCE: isHyperforce,
BREAK_GLASS_DISABLE_GLOBALLY: breakGlassDisableGlobally,
OIDC_EXPECTED_AUDIENCE: oidcExpectedAudience,
OIDC_EXPECTED_HD: oidcExpectedHd,
OIDC_TOKENINFO_URL: oidcTokeninfoUrl,
OIDC_USERNAME_CLAIM: oidcUsernameClaim,
OIDC_USERNAME_MAP_JSON: oidcUsernameMapJson,
OIDC_VALIDATION_CACHE_TTL_SECONDS: oidcValidationCacheTtlSeconds,
OIDC_VALIDATION_CACHE_MAX: oidcValidationCacheMax,
} = cleansedVars;

let jwtUsername = '';
Expand Down Expand Up @@ -277,9 +293,15 @@ export class Config {
this.breakGlassDisableGlobally = breakGlassDisableGlobally === 'true';

this.auth = isAuthType(auth) ? auth : this.oauth.enabled ? 'oauth' : 'pat';
this.transport = isTransport(transport) ? transport : this.oauth.enabled ? 'http' : 'stdio';
const needsHttp = this.oauth.enabled || this.auth === 'oidc-passthrough';
this.transport = isTransport(transport) ? transport : needsHttp ? 'http' : 'stdio';

if (this.transport === 'http' && !disableOauthOverride && !this.oauth.issuer) {
if (
this.transport === 'http' &&
!disableOauthOverride &&
!this.oauth.issuer &&
this.auth !== 'oidc-passthrough'
) {
throw new Error(
'OAUTH_ISSUER must be set when TRANSPORT is "http" unless DANGEROUSLY_DISABLE_OAUTH is "true"',
);
Expand Down Expand Up @@ -377,8 +399,36 @@ export class Config {
) {
throw new Error(`UAT private key path does not exist: ${uatPrivateKeyPath}`);
}
} else if (this.auth === 'oidc-passthrough') {
invariant(oidcExpectedAudience, 'The environment variable OIDC_EXPECTED_AUDIENCE is not set');
invariant(clientId, 'The environment variable CONNECTED_APP_CLIENT_ID is not set');
invariant(secretId, 'The environment variable CONNECTED_APP_SECRET_ID is not set');
invariant(secretValue, 'The environment variable CONNECTED_APP_SECRET_VALUE is not set');

// In oidc-passthrough mode, jwtUsername is set per-request from the validated Google token
jwtUsername = '{OAUTH_USERNAME}';
}

this.oidc = {
expectedAudiences: oidcExpectedAudience
? oidcExpectedAudience.split(',').map((s) => s.trim())
: [],
expectedHd: oidcExpectedHd ?? '',
tokeninfoUrl: oidcTokeninfoUrl || 'https://oauth2.googleapis.com/tokeninfo',
usernameClaim: oidcUsernameClaim || 'email',
usernameMap: oidcUsernameMapJson ? JSON.parse(oidcUsernameMapJson) : {},
validationCacheTtlSeconds: parseNumber(oidcValidationCacheTtlSeconds, {
defaultValue: 300,
minValue: 1,
maxValue: 3600,
}),
validationCacheMax: parseNumber(oidcValidationCacheMax, {
defaultValue: 1000,
minValue: 1,
maxValue: 100000,
}),
};

this.server = server ?? '';
this.patName = patName ?? '';
this.patValue = patValue ?? '';
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ async function startServer(): Promise<void> {
// Port is now open. Wait for server info before logging the ready message.
await serverInfoReady;

if (!config.oauth.enabled) {
if (!config.oauth.enabled && config.auth !== 'oidc-passthrough') {
log({
message:
'⚠️ TRANSPORT is "http" but OAuth is disabled! Your MCP server may not be protected from unauthorized access! By having explicitly disabled OAuth by setting the DANGEROUSLY_DISABLE_OAUTH environment variable to "true", you accept any and all risks associated with this decision.',
Expand Down
2 changes: 1 addition & 1 deletion src/logging/logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ describe('log', () => {

expect(consoleSpy).toHaveBeenCalledWith(
JSON.stringify({ ...entry, level: 'error', error }),
error,
{ message: 'boom', name: 'Error' },
);
consoleSpy.mockRestore();
});
Expand Down
23 changes: 22 additions & 1 deletion src/logging/logger.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getConfig } from '../config.js';
import { isAxiosError } from '../utils/axios.js';
import { getFileLogger } from './fileLogger.js';
import { LogEntry, LogLevel, logLevelSeverity } from './types.js';

Expand Down Expand Up @@ -34,6 +35,26 @@ export function parseLoggerTypes(value: string | undefined): Set<LoggerType> {
);
}

function sanitizeError(error: unknown): unknown {
if (isAxiosError(error)) {
return {
message: error.message,
code: error.code,
status: error.response?.status,
statusText: error.response?.statusText,
responseData: error.response?.data,
url: error.config?.url,
method: error.config?.method,
};
}

if (error instanceof Error) {
return { message: error.message, name: error.name };
}

return error;
}

export function log(entry: LogEntry): void {
const config = getConfig();
if (!shouldLog(entry.level, config.logLevel)) {
Expand All @@ -44,7 +65,7 @@ export function log(entry: LogEntry): void {
if (config.transport === 'http') {
if (entry.error) {
// eslint-disable-next-line no-console -- console.log is intentional here since the transport is not stdio.
console.log(message, entry.error);
console.log(message, sanitizeError(entry.error));
} else {
// eslint-disable-next-line no-console -- console.log is intentional here since the transport is not stdio.
console.log(message);
Expand Down
72 changes: 72 additions & 0 deletions src/restApiInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,18 @@ import { Server, userAgent } from './server.js';
import { TableauAuthInfo } from './server/oauth/schemas.js';
import { TableauRequestHandlerExtra } from './tools/toolContext.js';
import { isAxiosError } from './utils/axios.js';
import { ExpiringMap } from './utils/expiringMap.js';
import { getExceptionMessage } from './utils/getExceptionMessage.js';
import invariant from './utils/invariant.js';

// Tableau session cache for oidc-passthrough mode.
// Keyed by "email|siteName", stores X-Tableau-Auth credentials.
// Avoids a UAT signin on every tool call (~200-500ms over the Internet).
const TABLEAU_SESSION_TTL_MS = 239 * 60 * 1000 - 60_000; // Tableau Cloud default (240 min) minus 1 min buffer
const tableauSessionCache = new ExpiringMap<string, { accessToken: string; userId: string }>({
defaultExpirationTimeMs: TABLEAU_SESSION_TTL_MS,
});

type JwtScopes =
| 'tableau:viz_data_service:read'
| 'tableau:content:read'
Expand Down Expand Up @@ -148,6 +157,37 @@ const getNewRestApiInstanceAsync = async (
});
setSiteLuid?.(restApi.siteId);
setUserLuid?.(restApi.userId);
} else if (config.auth === 'oidc-passthrough') {
const username = getJwtUsername(config, tableauAuthInfo);
invariant(username, 'OIDC username could not be determined from the request');

const cacheKey = `${username}|${config.siteName}`;
const cached = tableauSessionCache.get(cacheKey);

if (cached) {
signOutWhenCompleted = false;
restApi.setCredentials(cached.accessToken, cached.userId);
} else {
await restApi.signIn({
type: 'direct-trust',
siteName: config.siteName,
username,
clientId: config.connectedAppClientId,
secretId: config.connectedAppSecretId,
secretValue: config.connectedAppSecretValue,
scopes: jwtScopes,
additionalPayload: getJwtAdditionalPayload(config, tableauAuthInfo),
});

tableauSessionCache.set(cacheKey, {
accessToken: restApi.accessToken,
userId: restApi.userId,
});

signOutWhenCompleted = false;
setSiteLuid?.(restApi.siteId);
setUserLuid?.(restApi.userId);
}
} else if (config.auth === 'oauth') {
invariant(tableauAuthInfo, 'Tableau auth info not provided.');

Expand Down Expand Up @@ -182,6 +222,38 @@ export const useRestApi = async <T>(
});
try {
return await callback(restApi);
} catch (error) {
// In oidc-passthrough mode, evict cached session on 401 and retry once
if (
remaining.config.auth === 'oidc-passthrough' &&
isAxiosError(error) &&
error.response?.status === 401 &&
remaining.tableauAuthInfo?.type === 'X-Tableau-Auth'
) {
const username = remaining.tableauAuthInfo.username;
const cacheKey = `${username}|${remaining.config.siteName}`;
if (tableauSessionCache.has(cacheKey)) {
tableauSessionCache.delete(cacheKey);
log({
message: `Evicted stale Tableau session for ${username}, retrying signin`,
level: 'info',
logger: 'auth',
});
const { restApi: retryApi, signOutWhenCompleted: retrySignOut } =
await getNewRestApiInstanceAsync({
...remaining,
jwtScopes: new Set(args.jwtScopes),
});
try {
return await callback(retryApi);
} finally {
if (retrySignOut) {
await retryApi.signOut();
}
}
}
}
throw error;
} finally {
if (signOutWhenCompleted) {
// Tableau REST sessions for 'pat' and 'direct-trust' are intentionally ephemeral.
Expand Down
8 changes: 8 additions & 0 deletions src/sdks/tableau/restApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,14 @@ export class RestApi {
return getBearerTokenPayload(this.creds.token)['https://tableau.com/userId'] ?? '';
}

get accessToken(): string {
if (this.creds.type === 'X-Tableau-Auth') {
return this.creds.token;
}

return this.creds.token;
}

private get authenticationMethods(): AuthenticationMethods {
const authenticationMethods = new AuthenticationMethods(RestApi.baseUrl, {
timeout: this._maxRequestTimeoutMs,
Expand Down
32 changes: 22 additions & 10 deletions src/server/express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import { createSession, getSession, Session } from '../sessions.js';
import { latencyMiddleware } from './latencyMiddleware.js';
import { handlePingRequest } from './middleware.js';
import { getTableauAuthInfo } from './oauth/getTableauAuthInfo.js';
import { EmbeddedOAuthProvider, TableauOAuthProvider } from './oauth/provider.js';
import {
EmbeddedOAuthProvider,
OidcPassthroughOAuthProvider,
TableauOAuthProvider,
} from './oauth/provider.js';
import { TableauAuthInfo } from './oauth/schemas.js';
import { AuthenticatedRequest } from './oauth/types.js';
import { passthroughAuthMiddleware, X_TABLEAU_AUTH_HEADER } from './passthroughAuthMiddleware.js';
Expand Down Expand Up @@ -57,17 +61,25 @@ export async function startExpressServer({
);

const middleware: Array<RequestHandler> = [handlePingRequest];
if (config.enablePassthroughAuth) {
middleware.push(passthroughAuthMiddleware());
}
if (config.auth === 'oidc-passthrough') {
// In oidc-passthrough mode, skip passthroughAuthMiddleware to avoid header confusion.
// The OIDC provider handles auth via Google tokeninfo validation.
const oidcProvider = new OidcPassthroughOAuthProvider();
oidcProvider.setupRoutes(app);
middleware.push(oidcProvider.authMiddleware);
} else {
if (config.enablePassthroughAuth) {
middleware.push(passthroughAuthMiddleware());
}

if (config.oauth.enabled) {
const oauthProvider = config.oauth.embeddedAuthzServer
? new EmbeddedOAuthProvider()
: new TableauOAuthProvider();
if (config.oauth.enabled) {
const oauthProvider = config.oauth.embeddedAuthzServer
? new EmbeddedOAuthProvider()
: new TableauOAuthProvider();

oauthProvider.setupRoutes(app);
middleware.push(oauthProvider.authMiddleware);
oauthProvider.setupRoutes(app);
middleware.push(oauthProvider.authMiddleware);
}
}
middleware.push(latencyMiddleware());

Expand Down
Loading