@rakettitiede/mcp-oauth-proxy
v3.1.0
Published
Express middleware and router bridging Custom GPT's OAuth expectations to Google as IdP, with API-key fallback. Extracted from Rakettitiede MCP servers.
Readme
@rakettitiede/mcp-oauth-proxy
Express middleware and router that bridges Custom GPT's OAuth expectations to Google as the identity provider, with API-key fallback.
Extracted from Rakettitiede's MCP servers where identical copies of this code lived in two places. Published so both consumers can depend on a single source of truth — and so external projects with the same pattern can use it too.
What it does
Custom GPT Actions require OAuth 2.0, but most Express apps don't want to run a full OAuth provider. This package provides a minimal proxy that:
- Accepts Custom GPT's OAuth dance (
/authorize,/callback,/token) - Delegates identity to Google (existing IdP, existing consent screen)
- Returns Google's tokens directly — no custom JWT minting, no session layer
- Validates incoming Bearer tokens via Google's tokeninfo endpoint
- Falls back to API key auth for programmatic / service-to-service calls
Two separate factories — use either or both.
Install
npm install @rakettitiede/mcp-oauth-proxyRequires Node.js ≥ 20. Express 4 or 5 as a peer dependency.
Quick start
import express from 'express';
import {
createRequireAuth,
createOAuthRouter,
} from '@rakettitiede/mcp-oauth-proxy';
const app = express();
const requireAuth = createRequireAuth({
apiKey: process.env.API_KEY,
googleClientId: process.env.GOOGLE_CLIENT_ID,
// Required together for IAM service-to-service auth (fail closed if omitted):
iamAudience: process.env.SERVICE_URL,
allowedIamCallers: (process.env.ALLOWED_IAM_CALLERS || '').split(',').filter(Boolean),
});
const { oauthRouter, oauthMeta } = createOAuthRouter({
googleClientId: process.env.GOOGLE_CLIENT_ID,
googleClientSecret: process.env.GOOGLE_CLIENT_SECRET,
});
app.use('/oauth', oauthRouter);
app.use('/api', requireAuth, yourProtectedRoutes);
app.listen(8080, () => console.log(oauthMeta.startupLog));createRequireAuth(config)
Returns an Express middleware that authenticates requests via Bearer token (Google-issued) OR API key. On success, req.user is populated; on failure, responds with 401.
Config
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| apiKey | string | ✅ | — | The API key to accept. Throws at factory time if missing. |
| googleClientId | string | | — | Optional. Your Google OAuth 2.0 client ID (used to validate the aud claim on OAuth access tokens). When omitted, the OAuth access token validation path is disabled and non-JWT Bearer tokens fall through to API key or 401. Use this if you do not need Custom GPT integration. |
| iamAudience | string | for IAM | — | Expected aud on IAM identity tokens (typically this service's URL). Required together with allowedIamCallers for IAM auth to succeed. |
| allowedIamCallers | string[] | for IAM | — | Exact service-account emails allowed for IAM auth. Empty or omitted → IAM path never accepts (fail closed). |
| googleTokeninfoUrl | string | — | https://oauth2.googleapis.com/tokeninfo | Google's tokeninfo endpoint. Override for testing. |
| logger | object | — | console | Any object with .log() and .error(). |
| nodeEnv | string | — | process.env.NODE_ENV | Controls log verbosity. Non-production logs more. |
Authentication flow
The middleware evaluates these in order and uses the first that succeeds:
- JWT-shaped Bearer token → validated as a Google IAM identity token (service-to-service). Requires both
iamAudienceand a non-emptyallowedIamCallers. The token'saudmust equaliamAudience, andemailmust be an exact allowlist match. If either config is missing, IAM auth is skipped (fail closed) and the request falls through. Callers must mint identity tokens withaudience = iamAudience. - Non-JWT Bearer token → validated as a Google OAuth access token. Must have
audequal to the configuredgoogleClientId. - API key → accepted from
X-API-Keyheader orapi_keyquery parameter, matched against the configuredapiKey. - Fallback →
401 Unauthorized.
If googleClientId is not provided, step 2 (OAuth access token validation) is skipped entirely. OAuth-only or API-key-only deployments can omit the IAM fields; IAM Bearer tokens will not be accepted until both are set.
Migrating to v3 (IAM)
v3 is a breaking change for IAM callers: audience is no longer derived from X-Forwarded-* / Host, and any service account is no longer accepted. Pass iamAudience (your service URL) and allowedIamCallers (caller SA emails). OAuth and API key paths are unchanged.
req.user shape
{
id: string, // Google sub, or 'api-key'
email: string | null,
authMethod: 'iam' | 'oauth' | 'api-key',
}createOAuthRouter(config)
Returns { oauthRouter, oauthMeta }:
oauthRouter— The Express Router. Mount withapp.use('/oauth', oauthRouter).oauthMeta— A frozen metadata object describing what this router mounts:oauthMeta.startupLog— string suitable for printing in yourapp.listencallback (e.g."🔐 OAuth: GET /oauth/authorize, ...")oauthMeta.endpoints—{ authorize, callback, token }map of route paths
Use oauthMeta to compose your server's startup logs without hardcoding OAuth route paths in consumer code.
Endpoints
The router contains three endpoints that together form an OAuth 2.0 authorization server, delegating the actual authentication to Google. Mount wherever you want the OAuth endpoints to live (conventionally /oauth).
Config
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| googleClientId | string | ✅ | — | Your Google OAuth 2.0 client ID. |
| googleClientSecret | string | ✅ | — | Your Google OAuth 2.0 client secret. |
| tokenStore | object | — | in-memory Map | Storage for the short-lived code↔tokens mapping. See below. |
| tokenTtlMs | number | — | 300_000 (5 min) | How long stored codes are valid. |
| fetchImpl | function | — | global fetch | HTTP client used for Google token exchanges. Primarily injectable for tests. |
| logger | object | — | console | Any object with .log() and .error(). |
Endpoints
| Method | Path | Description |
|---|---|---|
| GET | /authorize | Redirects the user to Google OAuth, encoding client's redirect_uri and state into Google's state param. |
| GET | /callback | Google redirects back here; the router exchanges the code for tokens, stores them under a fresh one-time code, and redirects the client with that code. |
| POST | /token | authorization_code: exchanges a previously-issued code for Google's tokens (one-time use). refresh_token: forwards Google's refresh grant and returns the new access token. |
Google OAuth setup
You'll need a Google OAuth 2.0 client from the Google Cloud Console with:
- Authorized redirect URI:
https://<your-domain>/oauth/callback(or wherever you mount the router) - Scopes:
openid email profile(the router requests these automatically)
TokenStore contract
Storage for the short-lived code↔tokens mapping during the OAuth dance. The default is an in-memory Map that lazy-expires on get. For multi-instance deployments or anything requiring persistence, implement this interface:
const tokenStore = {
// Return the stored entry, or undefined. May lazy-expire.
async get(code) { /* returns { tokens, expiresAt } | undefined */ },
// Store an entry.
async set(code, entry) { /* entry = { tokens, expiresAt } */ },
// Remove an entry (called after successful /token).
async delete(code) {},
};All methods may be sync or async — the router awaits both.
Example: Firestore-backed store
import { Firestore } from '@google-cloud/firestore';
function createFirestoreTokenStore({ collection = 'oauth-codes' } = {}) {
const db = new Firestore();
const col = db.collection(collection);
return {
async get(code) {
const doc = await col.doc(code).get();
if (!doc.exists) return undefined;
const entry = doc.data();
if (entry.expiresAt < Date.now()) {
await col.doc(code).delete();
return undefined;
}
return entry;
},
async set(code, entry) {
await col.doc(code).set(entry);
},
async delete(code) {
await col.doc(code).delete();
},
};
}API-key-only mode
If you're not using OAuth or IAM, just don't mount the OAuth router and omit googleClientId, iamAudience, and allowedIamCallers:
const requireAuth = createRequireAuth({
apiKey: process.env.API_KEY,
});
app.use('/api', requireAuth, yourRoutes);IAM service-to-service mode
const requireAuth = createRequireAuth({
apiKey: process.env.API_KEY,
iamAudience: process.env.SERVICE_URL, // e.g. https://mcp-xxx.run.app
allowedIamCallers: [
'[email protected]',
],
});What this package deliberately doesn't do
- No custom JWT signing — Google's tokens are returned directly, by design.
- No PKCE — server-to-server OAuth doesn't need it.
- No custom session tokens — Google's access and refresh tokens are returned as-is;
/tokenproxies the refresh grant to Google. - No session management — stateless by design.
- No user info endpoint — clients get Google's ID token and can decode it themselves.
- No consent screen — Google provides that.
Limitations
- The default
createMemoryTokenStoreis per-process. In a multi-instance deployment (Cloud Run with--min-instances > 1, Kubernetes with replicas > 1, etc.), an OAuth dance that lands on a different instance than it started on will fail. Use a persistentTokenStoreimplementation (Firestore, Redis, etc.) for multi-instance setups. - Bearer token validation hits Google's tokeninfo endpoint on every request. No local caching. For high-throughput services, consider adding a cache layer around the middleware.
Development
git clone https://github.com/rakettitiede/mcp-oauth-proxy.git
cd mcp-oauth-proxy
npm install
npm test40 tests across auth middleware, OAuth router, oauthMeta, and in-memory token store suites, using node:test — no external runner.
License
MIT © 2026 Rakettitiede Oy
