diff --git a/src/config.ts b/src/config.ts index 916e691a0..19b59e485 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 { @@ -83,6 +83,15 @@ export class Config { productTelemetryEnabled: boolean; isHyperforce: boolean; breakGlassDisableGlobally: boolean; + oidc: { + expectedAudiences: string[]; + expectedHd: string; + tokeninfoUrl: string; + usernameClaim: string; + usernameMap: Record; + validationCacheTtlSeconds: number; + validationCacheMax: number; + }; constructor() { const cleansedVars = removeClaudeMcpBundleUserConfigTemplates(process.env); @@ -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 = ''; @@ -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"', ); @@ -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 ?? ''; diff --git a/src/index.ts b/src/index.ts index 13236c719..7270c4b63 100644 --- a/src/index.ts +++ b/src/index.ts @@ -70,7 +70,7 @@ async function startServer(): Promise { // 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.', diff --git a/src/logging/logger.test.ts b/src/logging/logger.test.ts index d9f9d94b6..cff14ae4a 100644 --- a/src/logging/logger.test.ts +++ b/src/logging/logger.test.ts @@ -84,7 +84,7 @@ describe('log', () => { expect(consoleSpy).toHaveBeenCalledWith( JSON.stringify({ ...entry, level: 'error', error }), - error, + { message: 'boom', name: 'Error' }, ); consoleSpy.mockRestore(); }); diff --git a/src/logging/logger.ts b/src/logging/logger.ts index c2422706f..9d08c731e 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -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'; @@ -34,6 +35,26 @@ export function parseLoggerTypes(value: string | undefined): Set { ); } +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)) { @@ -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); diff --git a/src/restApiInstance.ts b/src/restApiInstance.ts index ba693c21d..2b29c4102 100644 --- a/src/restApiInstance.ts +++ b/src/restApiInstance.ts @@ -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({ + defaultExpirationTimeMs: TABLEAU_SESSION_TTL_MS, +}); + type JwtScopes = | 'tableau:viz_data_service:read' | 'tableau:content:read' @@ -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.'); @@ -182,6 +222,38 @@ export const useRestApi = async ( }); 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. diff --git a/src/sdks/tableau/restApi.ts b/src/sdks/tableau/restApi.ts index 1d850df87..d1e038f66 100644 --- a/src/sdks/tableau/restApi.ts +++ b/src/sdks/tableau/restApi.ts @@ -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, diff --git a/src/server/express.ts b/src/server/express.ts index 12b198c10..8e963f9bc 100644 --- a/src/server/express.ts +++ b/src/server/express.ts @@ -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'; @@ -57,17 +61,25 @@ export async function startExpressServer({ ); const middleware: Array = [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()); diff --git a/src/server/oauth/accessTokenValidator.ts b/src/server/oauth/accessTokenValidator.ts index 6bfcc68a6..d2dc29957 100644 --- a/src/server/oauth/accessTokenValidator.ts +++ b/src/server/oauth/accessTokenValidator.ts @@ -6,7 +6,9 @@ import { fromError } from 'zod-validation-error/v3'; import { getConfig } from '../../config.js'; import { log } from '../../logging/logger.js'; +import { ExpiringMap } from '../../utils/expiringMap.js'; import { getSiteLuidFromAccessToken } from '../../utils/getSiteLuidFromAccessToken.js'; +import { GoogleTokenInfoClient, TokenInfoResponse } from './googleTokenInfoClient.js'; import { mcpAccessTokenSchema, mcpAccessTokenUserOnlySchema, @@ -178,3 +180,95 @@ export class TableauAccessTokenValidator extends AccessTokenValidator { } } } + +export class GoogleOpaqueAccessTokenValidator extends AccessTokenValidator { + private readonly client: GoogleTokenInfoClient; + private readonly cache: ExpiringMap; + private readonly maxCacheSize: number; + + constructor(client?: GoogleTokenInfoClient) { + super(); + + this.client = client ?? new GoogleTokenInfoClient({ + tokeninfoUrl: this.config.oidc.tokeninfoUrl, + }); + + this.cache = new ExpiringMap({ + defaultExpirationTimeMs: this.config.oidc.validationCacheTtlSeconds * 1000, + }); + this.maxCacheSize = this.config.oidc.validationCacheMax; + } + + async validate(token: string): Promise { + const cached = this.cache.get(token); + if (cached) { + return Ok(cached); + } + + let tokenInfo: TokenInfoResponse; + try { + tokenInfo = await this.client.validate(token); + } catch (error) { + log({ + message: 'Google token validation failed', + level: 'debug', + logger: 'oauth', + error, + }); + return new Err('Invalid or expired access token'); + } + + const { expectedAudiences, expectedHd } = this.config.oidc; + + if (!expectedAudiences.includes(tokenInfo.aud)) { + log({ + message: `Google token aud mismatch: got ${tokenInfo.aud}, expected one of [${expectedAudiences.join(', ')}]`, + level: 'info', + logger: 'oauth', + }); + return new Err('Token audience mismatch'); + } + + if (expectedHd && tokenInfo.hd !== expectedHd) { + log({ + message: `Google token hd mismatch: got ${tokenInfo.hd ?? 'undefined'}, expected ${expectedHd}`, + level: 'info', + logger: 'oauth', + }); + return new Err('hd mismatch'); + } + + const username = this.config.oidc.usernameMap[tokenInfo.email] ?? tokenInfo.email; + + const tableauAuthInfo: TableauAuthInfo = { + type: 'X-Tableau-Auth', + username, + server: this.config.server, + }; + + const authInfo: AuthInfo = { + token, + clientId: tokenInfo.aud, + scopes: tokenInfo.scope?.split(' ') ?? [], + expiresAt: Math.floor(Date.now() / 1000) + tokenInfo.expires_in, + extra: tableauAuthInfo, + }; + + const cacheTtlMs = Math.min( + tokenInfo.expires_in * 1000, + this.config.oidc.validationCacheTtlSeconds * 1000, + ); + + if (this.cache.size >= this.maxCacheSize) { + // Evict oldest entry (first key in Map iteration order) + const firstKey = this.cache.keys().next().value; + if (firstKey !== undefined) { + this.cache.delete(firstKey); + } + } + + this.cache.set(token, authInfo, cacheTtlMs); + + return Ok(authInfo); + } +} diff --git a/src/server/oauth/googleOpaqueAccessTokenValidator.test.ts b/src/server/oauth/googleOpaqueAccessTokenValidator.test.ts new file mode 100644 index 000000000..ed65305b1 --- /dev/null +++ b/src/server/oauth/googleOpaqueAccessTokenValidator.test.ts @@ -0,0 +1,225 @@ +import { GoogleOpaqueAccessTokenValidator } from './accessTokenValidator.js'; +import { GoogleTokenInfoClient, TokenInfoResponse } from './googleTokenInfoClient.js'; + +const MOCK_AUDIENCE = '843584980601-test.apps.googleusercontent.com'; +const MOCK_SERVER = 'https://my-tableau.example.com'; +const MOCK_EMAIL = 'martin@bragg.group'; + +function makeTokenInfoResponse(overrides: Partial = {}): TokenInfoResponse { + return { + aud: MOCK_AUDIENCE, + email: MOCK_EMAIL, + email_verified: true, + expires_in: 3600, + hd: 'bragg.group', + ...overrides, + }; +} + +function stubEnvForOidcPassthrough(overrides: Record = {}): void { + vi.stubEnv('AUTH', 'oidc-passthrough'); + vi.stubEnv('SERVER', MOCK_SERVER); + vi.stubEnv('OIDC_EXPECTED_AUDIENCE', MOCK_AUDIENCE); + vi.stubEnv('CONNECTED_APP_CLIENT_ID', 'client-123'); + vi.stubEnv('CONNECTED_APP_SECRET_ID', 'secret-123'); + vi.stubEnv('CONNECTED_APP_SECRET_VALUE', 'fake-secret-value'); + vi.stubEnv('TABLEAU_MCP_TEST', 'true'); + for (const [key, value] of Object.entries(overrides)) { + vi.stubEnv(key, value); + } +} + +describe('GoogleOpaqueAccessTokenValidator', () => { + let mockClient: GoogleTokenInfoClient; + let validator: GoogleOpaqueAccessTokenValidator; + + beforeEach(() => { + stubEnvForOidcPassthrough(); + mockClient = { + validate: vi.fn(), + } as unknown as GoogleTokenInfoClient; + validator = new GoogleOpaqueAccessTokenValidator(mockClient); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe('happy path', () => { + it('validates a Google access token and returns AuthInfo with username', async () => { + vi.mocked(mockClient.validate).mockResolvedValue(makeTokenInfoResponse()); + + const result = await validator.validate('ya29.valid-token'); + + expect(result.isOk()).toBe(true); + if (!result.isOk()) return; + + expect(result.value.clientId).toBe(MOCK_AUDIENCE); + expect(result.value.token).toBe('ya29.valid-token'); + + const extra = result.value.extra as Record; + expect(extra.type).toBe('X-Tableau-Auth'); + expect(extra.username).toBe(MOCK_EMAIL); + expect(extra.server).toBe(MOCK_SERVER); + }); + + it('sets expiresAt based on expires_in from tokeninfo', async () => { + const nowSec = Math.floor(Date.now() / 1000); + vi.mocked(mockClient.validate).mockResolvedValue(makeTokenInfoResponse({ expires_in: 1800 })); + + const result = await validator.validate('ya29.token'); + + expect(result.isOk()).toBe(true); + if (!result.isOk()) return; + + // Should be approximately now + 1800 seconds + expect(result.value.expiresAt).toBeGreaterThanOrEqual(nowSec + 1799); + expect(result.value.expiresAt).toBeLessThanOrEqual(nowSec + 1801); + }); + }); + + describe('audience validation', () => { + it('rejects token with wrong audience', async () => { + vi.mocked(mockClient.validate).mockResolvedValue( + makeTokenInfoResponse({ aud: 'wrong-audience.apps.googleusercontent.com' }), + ); + + const result = await validator.validate('ya29.wrong-aud'); + + expect(result.isErr()).toBe(true); + if (!result.isErr()) return; + expect(result.error).toBe('Token audience mismatch'); + }); + + it('accepts token when aud matches one of multiple expected audiences', async () => { + vi.unstubAllEnvs(); + stubEnvForOidcPassthrough({ + OIDC_EXPECTED_AUDIENCE: `${MOCK_AUDIENCE},other-client.apps.googleusercontent.com`, + }); + const multiAudValidator = new GoogleOpaqueAccessTokenValidator(mockClient); + + vi.mocked(mockClient.validate).mockResolvedValue(makeTokenInfoResponse()); + + const result = await multiAudValidator.validate('ya29.multi-aud'); + expect(result.isOk()).toBe(true); + }); + }); + + describe('hd (hosted domain) validation', () => { + it('rejects token with wrong hd when OIDC_EXPECTED_HD is set', async () => { + vi.unstubAllEnvs(); + stubEnvForOidcPassthrough({ OIDC_EXPECTED_HD: 'bragg.group' }); + const hdValidator = new GoogleOpaqueAccessTokenValidator(mockClient); + + vi.mocked(mockClient.validate).mockResolvedValue( + makeTokenInfoResponse({ hd: 'other-domain.com' }), + ); + + const result = await hdValidator.validate('ya29.wrong-hd'); + + expect(result.isErr()).toBe(true); + if (!result.isErr()) return; + expect(result.error).toBe('hd mismatch'); + }); + + it('rejects token with missing hd when OIDC_EXPECTED_HD is set', async () => { + vi.unstubAllEnvs(); + stubEnvForOidcPassthrough({ OIDC_EXPECTED_HD: 'bragg.group' }); + const hdValidator = new GoogleOpaqueAccessTokenValidator(mockClient); + + vi.mocked(mockClient.validate).mockResolvedValue( + makeTokenInfoResponse({ hd: undefined }), + ); + + const result = await hdValidator.validate('ya29.no-hd'); + + expect(result.isErr()).toBe(true); + if (!result.isErr()) return; + expect(result.error).toBe('hd mismatch'); + }); + + it('skips hd check when OIDC_EXPECTED_HD is not set', async () => { + vi.mocked(mockClient.validate).mockResolvedValue( + makeTokenInfoResponse({ hd: undefined }), + ); + + const result = await validator.validate('ya29.no-hd-no-check'); + expect(result.isOk()).toBe(true); + }); + }); + + describe('network failure', () => { + it('returns Err when Google tokeninfo is unreachable', async () => { + vi.mocked(mockClient.validate).mockRejectedValue(new Error('fetch failed')); + + const result = await validator.validate('ya29.unreachable'); + + expect(result.isErr()).toBe(true); + if (!result.isErr()) return; + expect(result.error).toBe('Invalid or expired access token'); + }); + }); + + describe('caching', () => { + it('returns cached result on second call with same token', async () => { + vi.mocked(mockClient.validate).mockResolvedValue(makeTokenInfoResponse()); + + const result1 = await validator.validate('ya29.cached-token'); + const result2 = await validator.validate('ya29.cached-token'); + + expect(result1.isOk()).toBe(true); + expect(result2.isOk()).toBe(true); + // tokeninfo should only be called once + expect(mockClient.validate).toHaveBeenCalledTimes(1); + }); + + it('does not cache failed validations', async () => { + vi.mocked(mockClient.validate).mockRejectedValueOnce(new Error('transient')); + vi.mocked(mockClient.validate).mockResolvedValueOnce(makeTokenInfoResponse()); + + const fail = await validator.validate('ya29.retry-token'); + expect(fail.isErr()).toBe(true); + + const success = await validator.validate('ya29.retry-token'); + expect(success.isOk()).toBe(true); + + expect(mockClient.validate).toHaveBeenCalledTimes(2); + }); + }); + + describe('username mapping', () => { + it('applies OIDC_USERNAME_MAP_JSON when present', async () => { + vi.unstubAllEnvs(); + stubEnvForOidcPassthrough({ + OIDC_USERNAME_MAP_JSON: JSON.stringify({ [MOCK_EMAIL]: 'martin.bergamasco' }), + }); + const mappedValidator = new GoogleOpaqueAccessTokenValidator(mockClient); + + vi.mocked(mockClient.validate).mockResolvedValue(makeTokenInfoResponse()); + + const result = await mappedValidator.validate('ya29.mapped'); + + expect(result.isOk()).toBe(true); + if (!result.isOk()) return; + const extra = result.value.extra as Record; + expect(extra.username).toBe('martin.bergamasco'); + }); + + it('falls through to email when not in the map', async () => { + vi.unstubAllEnvs(); + stubEnvForOidcPassthrough({ + OIDC_USERNAME_MAP_JSON: JSON.stringify({ 'other@bragg.group': 'other.user' }), + }); + const mappedValidator = new GoogleOpaqueAccessTokenValidator(mockClient); + + vi.mocked(mockClient.validate).mockResolvedValue(makeTokenInfoResponse()); + + const result = await mappedValidator.validate('ya29.unmapped'); + + expect(result.isOk()).toBe(true); + if (!result.isOk()) return; + const extra = result.value.extra as Record; + expect(extra.username).toBe(MOCK_EMAIL); + }); + }); +}); diff --git a/src/server/oauth/googleTokenInfoClient.ts b/src/server/oauth/googleTokenInfoClient.ts new file mode 100644 index 000000000..358be0dca --- /dev/null +++ b/src/server/oauth/googleTokenInfoClient.ts @@ -0,0 +1,60 @@ +import { z } from 'zod'; + +import { log } from '../../logging/logger.js'; + +const tokenInfoResponseSchema = z.object({ + aud: z.string(), + email: z.string().email(), + email_verified: z + .string() + .transform((v) => v === 'true') + .pipe(z.literal(true)), + expires_in: z.coerce.number().int().positive(), + hd: z.string().optional(), + scope: z.string().optional(), +}); + +export type TokenInfoResponse = z.infer; + +export class GoogleTokenInfoClient { + private readonly tokeninfoUrl: string; + private readonly timeoutMs: number; + + constructor({ + tokeninfoUrl = 'https://oauth2.googleapis.com/tokeninfo', + timeoutMs = 5000, + }: { + tokeninfoUrl?: string; + timeoutMs?: number; + } = {}) { + this.tokeninfoUrl = tokeninfoUrl; + this.timeoutMs = timeoutMs; + } + + async validate(accessToken: string): Promise { + const url = `${this.tokeninfoUrl}?access_token=${encodeURIComponent(accessToken)}`; + + const response = await fetch(url, { + signal: AbortSignal.timeout(this.timeoutMs), + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + log({ + message: `Google tokeninfo request failed: ${response.status} ${body}`, + level: 'debug', + logger: 'oauth', + }); + throw new Error(`Google tokeninfo returned ${response.status}`); + } + + const json: unknown = await response.json(); + const parsed = tokenInfoResponseSchema.safeParse(json); + + if (!parsed.success) { + throw new Error(`Invalid tokeninfo response: ${parsed.error.message}`); + } + + return parsed.data; + } +} diff --git a/src/server/oauth/provider.ts b/src/server/oauth/provider.ts index 3556d6c4a..612d25ee4 100644 --- a/src/server/oauth/provider.ts +++ b/src/server/oauth/provider.ts @@ -9,6 +9,7 @@ import { oauthProtectedResource } from './.well-known/oauth-protected-resource.j import { AccessTokenValidator, EmbeddedAccessTokenValidator, + GoogleOpaqueAccessTokenValidator, TableauAccessTokenValidator, } from './accessTokenValidator.js'; import { authMiddleware } from './authMiddleware.js'; @@ -139,3 +140,16 @@ export class TableauOAuthProvider extends OAuthProvider { return new TableauAccessTokenValidator(); } } + +/** + * OIDC Passthrough provider for Google opaque access tokens. + * + * Pure resource server — validates incoming Google access tokens via tokeninfo, + * extracts the user's email, and passes it downstream as the Tableau username. + * No /authorize, /token, or /register routes. + */ +export class OidcPassthroughOAuthProvider extends OAuthProvider { + get accessTokenValidator(): AccessTokenValidator { + return new GoogleOpaqueAccessTokenValidator(); + } +}