MCP Terminal Server

/** * Copyright 2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { DecodedAppCheckToken, getAppCheck } from 'firebase-admin/app-check'; import { DecodedIdToken, getAuth } from 'firebase-admin/auth'; import { UserFacingError } from 'genkit'; import { ContextProvider, RequestData } from 'genkit/context'; import { initializeAppIfNecessary } from './helpers.js'; /** * Debug features that can be enabled to simplify testing. * These features are in a JSON object for FIREBASE_DEBUG_FEATURES and only take * effect if FIREBASE_DEBUG_MODE=true. * * Do not set these variables in production. */ export interface DebugFeatures { skipTokenVerification?: boolean; } let cachedDebugSkipTokenVerification: boolean | undefined; export function setDebugSkipTokenVerification(skip: boolean) { cachedDebugSkipTokenVerification = skip; } function debugSkipTokenVerification(): boolean { if (cachedDebugSkipTokenVerification !== undefined) { return cachedDebugSkipTokenVerification; } if (!process.env.FIREBASE_DEBUG_MODE) { return false; } if (!process.env.FIREBASE_DEBUG_FEATURES) { return false; } const features = JSON.parse( process.env.FIREBASE_DEBUG_FEATURES ) as DebugFeatures; cachedDebugSkipTokenVerification = features.skipTokenVerification ?? false; return cachedDebugSkipTokenVerification; } /** * The type of data that will be added to an Action's context when using the fireabse middleware. * You can safely cast Action's context to a Firebase Context to help type checking and code complete. */ export interface FirebaseContext { /** * Information about the authorized user. * This comes from the Authentication header, which is a JWT bearer token. * Will be omitted if auth is not defined or the key is invalid. To reject requests in these cases * set signedIn in a declarative policy or check in a policy callback. */ auth?: { uid: string; token: DecodedIdToken; }; /** * Information about the AppCheck token for a request. * This comes form the X-Firebase-AppCheck header and is included in the firebase-functions * client libraries (which can be used for Genkit requests irrespective of whether they're hosted * on Firebase). * Will be omitted if AppCheck tokens are invalid. To reject requests in these cases, * set enforceAppCheck in a declaritve policy or check in a policy callback. */ app?: { appId: string; token: DecodedAppCheckToken; alreadyConsumed?: boolean; }; /** * An unverified token for a Firebase Instance ID. */ instanceIdToken?: string; } export interface FirebaseContextProvider<I = any> extends ContextProvider<FirebaseContext, I> { (request: RequestData<I>): Promise<FirebaseContext>; } /** * Helper methods that provide most common needs for an authorization policy. */ export interface DeclarativePolicy { /** * Requires the user to be signed in or not. * Implicitly part of hasClaims. */ signedIn?: boolean; /** * Requires the user's email to be verified. * Requires the user to be signed in. */ emailVerified?: boolean; /** * Clam or Claims that must be present in the request. * Can be a singel claim name or array of claim names to merely test the presence * of a clam or can be an object of claim names and values that must be present. * Requires the user to be signed in. */ hasClaim?: string | string[] | Record<string, string>; /** * Whether appCheck must be enforced */ enforceAppCheck?: boolean; /** * Whether app check enforcement includes consuming tokens. * Consuming tokens adds more security at the cost of performance. */ consumeAppCheckToken?: boolean; } /** * Calling firebaseContext() without any parameters merely parses firebase context data. * It does not do any validation on the data found. To do automatic validation, * pass either an options object or function for freeform validation. */ export function firebaseContext<I = any>(): FirebaseContextProvider<I>; /** * Calling firebaseContext() with a declarative policy both parses and enforces context. * Honors the same environment variables that Cloud Functions for Firebase does to * mock token validation in preproduction environmets. */ export function firebaseContext<I = any>( policy: DeclarativePolicy ): FirebaseContextProvider<I>; /** * Calling firebaseContext() with a policy callback parses context but delegates enforcement. * To control the message sent to a user, throw UserFacingError. * For security reasons, other error types will be returned as a 500 "internal error". */ export function firebaseContext<I = any>( policy: (context: FirebaseContext, input: I) => void | Promise<void> ): FirebaseContextProvider<I>; export function firebaseContext<I = any>( policy?: | DeclarativePolicy | ((context: FirebaseContext, input: I) => void | Promise<void>) ): FirebaseContextProvider<I> { return async function (request: RequestData): Promise<FirebaseContext> { initializeAppIfNecessary(); let auth: FirebaseContext['auth']; if ('authorization' in request.headers) { auth = await parseAuth(request.headers['authorization']); } let app: FirebaseContext['app']; if ('x-firebase-appcheck' in request.headers) { const consumeAppCheckToken = typeof policy === 'object' && policy['consumeAppCheckToken']; app = await parseAppCheck( request.headers['x-firebase-appcheck'], consumeAppCheckToken ?? false ); } let instanceIdToken: FirebaseContext['instanceIdToken']; if ('firebase-instance-id-token' in request.headers) { instanceIdToken = request.headers['firebase-instance-id-token']; } const context: FirebaseContext = {}; if (auth) { context.auth = auth; } if (app) { context.app = app; } if (instanceIdToken) { context.instanceIdToken = instanceIdToken; } if (typeof policy === 'function') { await policy(context, request.input); } else if (typeof policy === 'object') { enforceDelcarativePolicy(policy, context); } return context; }; } function verifyHasClaims(claims: string[], token: DecodedIdToken) { for (const claim of claims) { if (!token[claim] || token[claim] === 'false') { if (claim == 'email_verified') { throw new UserFacingError( 'PERMISSION_DENIED', 'Email must be verified' ); } if (claim === 'admin') { throw new UserFacingError('PERMISSION_DENIED', 'Must be an admin'); } throw new UserFacingError( 'PERMISSION_DENIED', `${claim} claim is required` ); } } } function enforceDelcarativePolicy( policy: DeclarativePolicy, context: FirebaseContext ) { if ( (policy.signedIn || policy.hasClaim || policy.emailVerified) && !context.auth ) { throw new UserFacingError('UNAUTHENTICATED', 'Auth is required'); } if (policy.hasClaim) { if (typeof policy.hasClaim === 'string') { verifyHasClaims([policy.hasClaim], context.auth!.token); } else if (Array.isArray(policy.hasClaim)) { verifyHasClaims(policy.hasClaim, context.auth!.token); } else if (typeof policy.hasClaim === 'object') { for (const [claim, value] of Object.entries(policy.hasClaim)) { if (context.auth!.token[claim] !== value) { throw new UserFacingError( 'PERMISSION_DENIED', `Claim ${claim} must be ${value}` ); } } } else { // Not a user facing error so this turns into a log + 500 internal to the user. throw Error(`Invalid type ${typeof policy.hasClaim} for hasClaim`); } } if (policy.emailVerified) { verifyHasClaims(['email_verified'], context.auth!.token); } if (policy.enforceAppCheck && !context.app) { throw new UserFacingError( 'PERMISSION_DENIED', `AppCheck token is required` ); } } async function parseAuth(authHeader: string): Promise<FirebaseContext['auth']> { const token = /[bB]earer (.*)/.exec(authHeader)?.[1]; if (!token) { return undefined; } if (debugSkipTokenVerification()) { const decoded = unsafeDecodeToken(token) as DecodedIdToken; return { uid: decoded['sub'], token: decoded, }; } try { const decoded = await getAuth().verifyIdToken(token); return { uid: decoded['sub'], token: decoded, }; } catch (err) { console.error(`Error decoding auth token: ${err}`); throw new UserFacingError('PERMISSION_DENIED', 'Invalid auth token'); } } async function parseAppCheck( token: string, consumeAppCheckToken: boolean ): Promise<FirebaseContext['app']> { if (debugSkipTokenVerification()) { const decoded = unsafeDecodeToken(token) as DecodedAppCheckToken; return { appId: decoded['sub'], token: decoded, alreadyConsumed: false, }; } try { return await getAppCheck().verifyToken(token, { consume: consumeAppCheckToken, }); } catch (err) { console.error(`Got error verifying AppCheck token: ${err}`); throw new UserFacingError('PERMISSION_DENIED', 'Invalid AppCheck token'); } } export function fakeToken(claims: Record<string, string>): string { return `fake.${Buffer.from(JSON.stringify(claims), 'utf-8').toString('base64')}.fake`; } const TOKEN_REGEX = /[a-zA-Z0-9_=-]+\.[a-zA-Z0-9_=-]+\.[a-zA-Z0-9_=-]+/; function unsafeDecodeToken(token: string): Record<string, unknown> { if (!TOKEN_REGEX.test(token)) { throw new UserFacingError( 'PERMISSION_DENIED', 'Invalid fake token. Use the fakeToken() method to create a valid fake token' ); } try { return JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()); } catch (err) { throw new UserFacingError( 'PERMISSION_DENIED', 'Invalid fake token. Use the fakeToken() method to create a valid fake token' ); } }