diff --git a/.gitignore b/.gitignore index fb72611..d1129dd 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,4 @@ packages/**/src/*.d.ts packages/**/src/*.d.ts.map tsconfig.tsbuildinfo +*.tsbuildinfo diff --git a/README.md b/README.md index b9e1104..ed7395a 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,17 @@ # GatewayStack — Agentic Control Plane for User-Scoped AI Governance +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) +![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue) +![Cloud Run](https://img.shields.io/badge/Cloud%20Run-ready-4285F4) +![Auth0](https://img.shields.io/badge/Auth0-RS256-orange) +[![MCP/Auth Conformance](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fdavidcrowe%2Fgatewaystack%2Fmain%2Fdocs%2Fconformance.json&query=$.version&label=MCP%2FAuth%20Conformance)](https://github.com/davidcrowe/gatewaystack/tree/main/docs/conformance.json) + An open-source control plane that makes AI agents **enterprise-ready** by enforcing user-scoped identity, policy, and audit trails on every model call. +> ⚠️ Early-stage: GatewayStack is under active development +> The first layer, `identifiabl`, is live and published (`npm i identifiabl`) +> Additional modules (`transformabl`, `validatabl`, `limitabl`, `proxyabl`, `explicabl`) are on the roadmap. + **The problem:** AI models and user data need each other... but can't safely connect. @@ -13,7 +23,7 @@ Both require **cryptographic proof of user identity** tied to every AI request.. **Gatewaystack solves this.** -Drop it between AI clients (ChatGPT, Claude, MCP) and your backend. It validates OAuth tokens, enforces scopes, and injects verified identity—so you can safely answer the two questions that matter most: +Drop GatewayStack between AI clients (ChatGPT, Claude, your own self-hosted models, MCP) and your backend. It validates OAuth tokens, enforces scopes, and injects verified identity—so you can safely answer the two questions that matter most: 1. **Who** did what, with **which** data, via **which** model? 2. Was it **authorized**, **bounded**, and **logged** under policy? @@ -21,28 +31,38 @@ Drop it between AI clients (ChatGPT, Claude, MCP) and your backend. It validates Every AI request flows through six governance checkpoints: > **Identified → Transformed → Validated → Constrained → Routed → Audited** +**In short, GatewayStack lets you:** + +- Verify **real user identity** on every AI request (RS256 JWTs via your IdP) +- Enforce **per-user / per-tenant** policies and scopes for tools and models +- Apply **rate limits & spend caps** per user/team/org +- Inject **X-User-Id / X-Org-Id** into downstream services (no JWT handling there) +- Emit **audit-ready logs** for “who did what, with which data, via which model” + --- ### The Three-Party Problem +> Read more about the [Three-Party Problem](https://reducibl.com/2025/12/01/the-three-party-identity-problem-in-mcp-servers.html) + Modern AI apps are really **three-party systems**: -**👤 The User** — a real human with identity, roles, and permissions -**🤖 The LLM** — a model acting on their behalf (ChatGPT, Claude) -**🔒 Your Backend** — the trusted data and tools the model needs to access +**1 - The User** — a real human with identity, roles, and permissions +**2 - The LLM** — a model acting on their behalf (ChatGPT, Claude) +**3 - Your Backend** — the trusted data and tools the model needs to access -These three parties all influence each other, but they don’t share a common, cryptographically verified identity layer. +These three parties all talk to each other, but they don’t share a common, cryptographically verified identity layer. **The gap:** The LLM knows who the user is (they logged into ChatGPT). Your backend doesn't. So it can't: -- Filter data per-user (*"show me my calendar"* → returns everyone's calendar ❌) -- Enforce per-user policies (*"only doctors use medical models"* → anyone can ❌) -- Audit by user (*"who made this query?"* → can't answer ❌) +- Filter data per-user (*"show me my calendar"* → returns everyone's calendar) +- Enforce per-user policies (*"only doctors use medical models"* → anyone can) +- Audit by user (*"who made this query?"* → can't answer) **Without a unifying identity layer, you get:** -- ❌ Shared API keys (everyone sees everything, or no one sees anything) -- ❌ No enforcement ("who can use which models for what") -- ❌ No audit trail (can't prove compliance) -- ❌ Enterprises block AI access entirely (too risky) +- Shared API keys (everyone sees everything, or no one sees anything) +- No enforcement ("who can use which models for what") +- No audit trail (can't prove compliance) +- Enterprises block AI access entirely (too risky) This instability across user ↔ LLM ↔ backend is what Gatewaystack calls the **Three-Party Problem**. @@ -61,7 +81,7 @@ It shows up in two directions: app.post('/chat', async (req, res) => { const { model, prompt } = req.body; const response = await openai.chat.completions.create({ - model, // ❌ Anyone can use gpt-4-medical + model, // Anyone can use gpt-4-medical messages: [{ role: 'user', content: prompt }] }); res.json(response); @@ -107,7 +127,7 @@ The gateway enforces role + scope checks **before** forwarding to your backend. **Without Gatewaystack:** ```typescript app.get('/calendar', async (_req, res) => { - const events = await getAllEvents(); // ❌ Everyone sees everything + const events = await getAllEvents(); // Everyone sees everything res.json(events); }); ``` @@ -115,7 +135,7 @@ app.get('/calendar', async (_req, res) => { **With Gatewaystack:** ```typescript app.get('/calendar', async (req, res) => { - const userId = req.headers['x-user-id']; // ✅ Verified by gateway + const userId = req.headers['x-user-id']; // Verified by gateway const events = await getUserEvents(userId); res.json(events); }); @@ -126,6 +146,7 @@ The gateway validates the OAuth token, extracts the user identity, and injects ` --- ### Why Both Directions Matter +Attaching a cryptographically confirmed user identity to a shared request context is the key that makes request level governance possible: **Without solving the Three-Party Problem, you can't:** - Filter data per-user (Direction 1: everyone sees everything) @@ -136,15 +157,49 @@ The gateway validates the OAuth token, extracts the user identity, and injects ` **Gatewaystack solves both** by binding cryptographic user identity to every AI request: -* ✅ OAuth login per user (RS256 JWT, cryptographic identity proof) -* ✅ Per-user / per-tenant data isolation by default -* ✅ Deny-by-default authorization (scopes per tool/model/role) -* ✅ Immutable audit trails (who, what, when, which model) -* ✅ Rate limits & spend caps (per user/team/org) -* ✅ Drop-in between AI clients and your backend (no SDK changes) +* OAuth login per user (RS256 JWT, cryptographic identity proof) +* Per-user / per-tenant data isolation by default +* Deny-by-default authorization (scopes per tool/model/role) +* Immutable audit trails (who, what, when, which model) +* Rate limits & spend caps (per user/team/org) +* Drop-in between AI clients and your backend (no SDK changes) Gatewaystack is composed of modular packages that can run **standalone** or as a cohesive **six-layer pipeline** for complete AI governance. +--- + +### Core governance layers + +Every request flows through the same six-layer composable pipeline: + +| Layer | Status | Purpose | +| ------------ | ------ | ---------------------------------------------------------------------------- | +| **identifiabl** | ✅ | **Foundational Trust & Identity Binding** — verifies RS256 JWTs, pins issuer/audience, and establishes the canonical subject for downstream authorization and audit. | +| **transformabl** | ⚪ | **Content Safety Preprocessing & Risk Mitigation** — normalizes, redacts, or classifies inputs/outputs before policy and routing are applied. | +| **validatabl** | ⚪ | **Authorization & Policy Enforcement** — deny-by-default, scope-driven access to protected resources, tools, and models. | +| **limitabl** | ⚪ | **Rate & Spend Governance** — throttles per user/tenant to prevent runaway agents and unbounded cost. | +| **proxyabl** | ✅ | **Execution Control & Identity-Aware Routing** — routes calls to the right tool/model backend, injects verified identity, and presents OAuth/PRM metadata. | +| **explicabl** | ⚪ | **Accountability & Runtime Audit** — emits immutable, correlated events to your SIEM/observability stack and exposes health/conformance endpoints. | + +Drop it between AI clients and your backend — no SDK modification needed. Handles **RS256 JWTs**, audience/issuer checks, per-tool scopes, role-based policies, and optional **DCR** client promotion. + +--- + +### GatewayStack vs Traditional API Gateways + +| Feature | Kong/Apigee/AWS API Gateway | Gatewaystack | +|---------|---------------------------|--------------| +| **JWT validation** | ✅ Built-in | ✅ Built-in | +| **Rate limiting** | ✅ Built-in | ✅ Built-in | +| **Path/method routing** | ✅ Built-in | ✅ Built-in | +| **User identity normalization** | ❌ Manual (custom plugin) | ✅ Built-in | +| **Per-tool scope enforcement** | ❌ Manual (custom policy) | ✅ Built-in | +| **Apps SDK / MCP compliance** | ❌ Manual (PRM endpoint) | ✅ Built-in | +| **Pre-flight cost checks** | ❌ Manual (custom plugin) | ✅ Roadmap | +| **Model-specific policies** | ❌ Manual (custom logic) | ✅ Built-in | +| **AI audit trails** | ❌ Manual (log forwarding) | ✅ Built-in | +| **Setup time** | 100+ hours (custom dev) | 2 hours (config) | + ## Repository layout | Path | Highlights | @@ -205,23 +260,6 @@ Toggles worth noting: --- -### Core governance layers - -Every request flows through the same six-layer composable pipeline: - -| Layer | Status | Purpose | -| ------------ | ------ | ---------------------------------------------------------------------------- | -| **identifiabl** | ✅ | **Foundational Trust & Identity Binding** — verifies RS256 JWTs, pins issuer/audience, and establishes the canonical subject for downstream authorization and audit. | -| **transformabl** | ⚪ | **Content Safety Preprocessing & Risk Mitigation** — normalizes, redacts, or classifies inputs/outputs before policy and routing are applied. | -| **validatabl** | ✅ | **Authorization & Policy Enforcement** — deny-by-default, scope-driven access to protected resources, tools, and models. | -| **limitabl** | ✅ | **Rate & Spend Governance** — throttles per user/tenant to prevent runaway agents and unbounded cost. | -| **proxyabl** | 🧩 | **Execution Control & Identity-Aware Routing** — routes calls to the right tool/model backend, injects verified identity, and presents OAuth/PRM metadata. | -| **explicabl** | ⚪ | **Accountability & Runtime Audit** — emits immutable, correlated events to your SIEM/observability stack and exposes health/conformance endpoints. | - -Drop it between AI clients and your backend — no SDK modification needed. Handles **RS256 JWTs**, audience/issuer checks, per-tool scopes, role-based policies, and optional **DCR** client promotion. - ---- - ### Conformance summary Verified against Apps SDK / MCP OAuth 2.1 + RS256 flow. @@ -244,117 +282,780 @@ Verified against Apps SDK / MCP OAuth 2.1 + RS256 flow. * 🛡️ [Production Checklist](#production-checklist) * 🆘 [Troubleshooting](#troubleshooting) -[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) -![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue) -![Cloud Run](https://img.shields.io/badge/Cloud%20Run-ready-4285F4) -![Auth0](https://img.shields.io/badge/Auth0-RS256-orange) -[![MCP/Auth Conformance](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fdavidcrowe%2Fgatewaystack%2Fmain%2Fdocs%2Fconformance.json&query=$.version&label=MCP%2FAuth%20Conformance)](https://github.com/davidcrowe/gatewaystack/tree/main/docs/conformance.json) +## Demos + +| Command | Components | What it proves | +| ------- | ---------- | -------------- | +| `npm run demo:mcp` | Runs the MCP issuer (`demos/mcp-server` on :5051), the gateway in demo mode (:8080), and the MCP JSON-RPC surface. | 401→PRM→token handshake, `/protected/*` isolation, per-tool scopes, `/proxy` identity injection. | +| `npm run demo:apps` | Adds the ChatGPT Apps SDK-style connector on :5052 (`demos/chatgpt-connector`) while reusing the issuer and gateway. | Shows the same JWT/scope enforcement works for Apps SDK connectors. | + +Both demos share the local issuer + JWKS hosted by `demos/mcp-server`. Mint reader/writer tokens with: +```bash +curl -s -X POST http://localhost:5051/mint \ + -H 'content-type: application/json' \ + --data '{"scope":"tool:read tool:write","sub":"demo-user"}' +``` -## Use Cases +See `demos/mcp-server/README.md` and `demos/chatgpt-connector/README.md` for the curl walkthroughs and troubleshooting tips. -### Healthcare SaaS — HIPAA-Compliant AI Diagnostics +--- -A platform with 10,000+ doctors needs to ensure every AI-assisted diagnosis is tied to the licensed physician who requested it, with full audit trails for HIPAA and internal review. +### Demo mode env -**Before Gatewaystack:** All AI calls run through a shared OpenAI key — impossible to prove *which physician* made *which request*. +Set these in `apps/gateway-server/.env` to enable local demos without Auth0: -**With Gatewaystack:** -- `identifiabl` binds every request to a verified physician (`user_id`, `org_id` = clinic/hospital) -- `validatabl` enforces `role:physician` and `scope:diagnosis:write` per tool -- `explicabl` emits immutable audit logs with the physician's identity on every model call +```bash +DEMO_MODE=true +OAUTH_ISSUER_DEMO=http://localhost:5051/ +OAUTH_AUDIENCE_DEMO=https://gateway.local/api +OAUTH_JWKS_URI_DEMO=http://localhost:5051/.well-known/jwks.json +OAUTH_SCOPES_DEMO=tool:read tool:write +ENABLE_TEST_ROUTES=true +``` -**Result:** User-bound, tenant-aware, fully auditable AI diagnostics. +With demo mode on you can run `npm run demo:*`, call `/protected/ping`, exercise `/proxy/*`, and run MCP JSON-RPC flows without touching Auth0. ---- +## Getting Started -### Enterprise Copilot — Per-Employee Policy Enforcement +Choose your path: -A global company rolls out an internal copilot that can search Confluence, Jira, Google Drive, and internal APIs. Employees authenticate with SSO (Okta / Entra / Auth0), but the copilot calls the LLM with a shared API key. +**Quickstart (10 min)** → Run local demos without Auth0 +**Production Setup (30 min)** → Connect to Auth0/Okta + ChatGPT/Claude +**Deployment** → Cloud Run, Docker, Kubernetes -**Before Gatewaystack:** Security teams can't enforce "only finance analysts can run this tool" or audit which employee triggered which action. -**With Gatewaystack:** -- `identifiabl` binds the copilot session to the employee's SSO identity (`sub` from Okta) -- `validatabl` enforces per-role tool access ("legal can see these repos, not those") -- `limitabl` applies per-user rate limits and spend caps -- `explicabl` produces identity-level audit trails for every copilot interaction +## Quickstart (10 minutes) + +```bash +git clone gatewaystack +cd gatewaystack +npm install +npm run dev +``` + +### What You Get + +- ✅ **RS256 JWT Verification** via JWKS (issuer, audience, exp, nbf, sub checks) +- ✅ **Per-tool scope enforcement** (deny-by-default; 401/403 outcomes) +- ✅ **Protected resource endpoint** for smoke tests +- ✅ **Verified Identity Injection** — The gateway injects a cryptographically verified user ID (`X-User-Id`) into every proxied request, so downstream services can enforce per-user/per-tenant filtering without ever handling JWTs or seeing upstream API keys. This turns "shared key chaos" into "every call is attributable." +- ✅ **Rate limiting** (user/tenant aware) +- ✅ **Health endpoints** (`/health`, `/health/auth0`) +- ✅ *(Optional)* **DCR webhook** to auto-promote new OAuth clients from Auth0 logs +- ✅ **Echo test servers** to validate proxy/header injection + +### Prerequisites + +- Node.js **20+** (or 22) +- npm **10+** (or pnpm 9) +- An **Auth0 tenant** (or equivalent OIDC provider issuing RS256 access tokens) +- *(Optional)* Google Cloud SDK for Cloud Run deploys + + +## Environment Variables + +Core OAuth config uses the `OAUTH_*` prefix: +- `OAUTH_ISSUER`, `OAUTH_AUDIENCE`, `OAUTH_JWKS_URI`, `OAUTH_ENFORCE_ALG` -**Result:** Full identity-level governance without changing the copilot's business logic. +Auth0-specific features use `AUTH0_*`: +- `AUTH0_DOMAIN`, `AUTH0_MGMT_CLIENT_ID`, `AUTH0_MGMT_CLIENT_SECRET` (for DCR webhook and `/health/auth0`) + +Demo mode uses `OAUTH_*_DEMO` variants: +- `OAUTH_ISSUER_DEMO`, `OAUTH_AUDIENCE_DEMO`, etc. + +See `apps/gateway-server/.env.example` for the full reference, including: +- Rate limiting (`RATE_LIMIT_*`) +- Proxy config (`PROXY_TARGET`, `PROXY_INJECT_*`) +- Tool scopes (`TOOL_SCOPES_JSON`) +- DCR webhook (`MGMT_*`, `LOG_WEBHOOK_SECRET`) --- -### Multi-Tenant SaaS — Per-Tenant Cost Tracking +### Start the Test Backends (Echo Servers) + +These help prove proxy + header injection: + +```bash +# Echo server that returns headers, query, and body +npm run -w @gatewaystack/echo-server dev +# default: http://localhost:3333 +``` + +These tests are your **governance smoke test**. + +The echo server simply returns the headers, query, and body it receives. Combined with the `/proxy` routes in `proxyabl`, this lets you prove that the authenticated subject has been injected as a **verified, canonical user identifier** (for example `X-User-Id`) — so downstream services can enforce per-user/per-tenant data filtering without ever seeing upstream API keys. + +Need a fake tool backend instead of an echo? Run `tsx tools/mock-tools-server/index.ts` to spin up JSON handlers (on :9090 by default) that mimic `generateDreamSummary`, `chatWithEmbeddingsv3`, etc. for end-to-end proxy tests. + +--- -A SaaS platform offers AI features across free, pro, and enterprise tiers. Today, all AI usage runs through a single OpenAI key per environment — making it impossible to answer "how much did Org X spend?" or "which users hit quota?" +### Run the Gateway (dev) -**Before Gatewaystack:** One big shared key. No tenant-level attribution. Cost overruns are invisible until the bill arrives. +From the repo root: -**With Gatewaystack:** -- `identifiabl` attaches `user_id` and `org_id` to every request -- `validatabl` enforces tier-based feature access (`plan:free`, `plan:pro`, `feature:advanced-rag`) -- `limitabl` enforces per-tenant quotas and budgets -- `explicabl` produces per-tenant usage reports +```bash +# Or individually: +npm run dev:server # apps/gateway-server +npm run dev:admin # apps/admin-ui +``` -**Result:** Per-tenant accountability without changing app logic. +- `npm run dev` starts the gateway and Admin UI together. +- `npm run dev:server` starts the Express gateway only. +- `npm run dev:admin` starts the Admin UI, which is primarily used to visualize `/health` and related outputs. --- -## What's Different from Traditional API Gateways? +### Health & Basic Smoke Tests + +```bash +# Health (served by healthRoutes at /health) +curl -s http://localhost:8080/health | jq . + +# Auth0 checks (JWKS reachability, mgmt token if set) +curl -s http://localhost:8080/health/auth0 | jq . + +# Protected resource metadata (expect 401 without token + WWW-Authenticate) +curl -i http://localhost:8080/.well-known/oauth-protected-resource +``` + +When called **without** a token, you should see a `401` with a `WWW-Authenticate` header that points ChatGPT / MCP to this URL as the **Protected Resource Metadata (PRM)** endpoint. + +When called **with** a valid token, you should see JSON similar to: + +```json +{ + "authorization_servers": ["https://.auth0.com/"], + "scopes_supported": [ + "openid", + "email", + "profile", + "tool:read", + "tool:write" + ], + "resource": "https://gateway.local/api" +} +``` + +Where: + +- `authorization_servers` tells the client which issuer(s) can mint access tokens. +- `scopes_supported` is derived from your configured tool scopes (`TOOL_SCOPES` → `REQUIRED_SCOPES` in the gateway). +- `resource` is your API Identifier / audience (`AUTH_AUDIENCE` / `OAUTH_AUDIENCE`). + +When you add a new tool scope in `TOOL_SCOPES`, the gateway automatically: -**Identity Providers (Auth0, Okta, Cognito, Entra ID)** -Handle login and token minting, but stop at the edge of your app. They don't understand model calls, tools, or which provider a request is going to — and they don't enforce user identity inside the AI gateway. +- Updates `REQUIRED_SCOPES` +- Exposes it in `scopes_supported` +- Includes it in the `scope=` parameter of the `WWW-Authenticate` header +- Ensures the client grant includes the new scope (if using the Auth0 DCR helper) -**API Gateways and Service Meshes (Kong, Apigee, AWS API Gateway, Istio, Envoy)** -Great at path/method-level auth and rate limiting, but they treat LLMs like any other HTTP backend. **You can build AI governance on top of them** (Kong plugins, Istio policies, Lambda authorizers), **but it requires 100+ hours of custom development** to replicate what Gatewaystack provides out-of-the-box: user-scoped identity normalization, per-tool scope enforcement, pre-flight cost checks, Apps SDK / MCP compliance, and AI-specific audit trails. +* `/health` → `{ ok: true, ... }` +* `/health/auth0` → issuer/audience OK, JWKS reachable +* Protected resource → **401** w/o token, **200** w/ token -**Cloud AI Gateways (Cloudflare AI Gateway, Azure OpenAI + API Management, Vertex AI, Bedrock Guardrails)** -Focus on provider routing, quota, and safety filters at the tenant or API key level. User identity is usually out-of-band or left to the application. +## Testing -**Hand-Rolled Middleware** -Many teams glue together JWT validation, headers, and logging inside their app or a thin Node/Go proxy. It works... until you need to support multiple agents, providers, tenants, and audit/regulatory requirements. +Run the full test suite: +```bash +npm test +``` -**Gatewaystack is different:** -- **User-scoped by default** — every request is tied to a verified user, not a shared key -- **Model-aware** — understands tools, scopes, and provider semantics (Apps SDK, MCP, OpenAI, Anthropic) -- **Composable governance** — each layer (identity, policy, limits, routing, audit) can run standalone or as part of the full control plane -- **Built for agents** — prevents runaway loops, enforces per-workflow budgets, and tracks multi-step traces +This runs Vitest plus the conformance report writer that updates `docs/conformance.json`. -**Example: Kong + OpenAI** +For detailed testing workflows, see: +- `docs/testing.md` — `/__test__/echo` routes, scope checks, proxy validation +- `CONTRIBUTING.md` — Pre-PR checklist -To get user-scoped AI governance with Kong, you'd need to: -1. Install `jwt` plugin (validate tokens) -2. Install `request-transformer` plugin (inject headers) -3. Write custom Lua script to normalize identity claims -4. Write custom Lua script for scope-to-tool mapping -5. Write custom plugin for pre-flight cost estimation -6. Build separate service for Protected Resource Metadata -7. Configure DCR flow manually -8. Build custom audit log forwarding +### DCR Webhook (Optional) -**Estimate: 100-200 hours of development + ongoing maintenance.** +The DCR helper is implemented inside the **Explicabl** router and exposed at: -**With Gatewaystack:** Configure `.env` file, deploy, done. (2 hours) +```http +POST /webhooks/auth0/logs +``` -You can still run Gatewaystack alongside traditional API gateways — it's the **user-scoped identity and governance slice** of your AI stack. +See the readme at `docs/auth0/dcr-log-webhook.md` --- -### GatewayStack vs Traditional API Gateways +### Troubleshooting + +> **Using Auth0 + ChatGPT?** For Auth0-specific issues (Post-Login Actions, JWE vs JWS tokens, scopes not showing up, etc.), see `docs/auth0/chatgpt-post-login-action.md` → “Troubleshooting checklist”. + +**401 with valid token** + +* Check `OAUTH_AUDIENCE` matches the token `aud` +* Check `OAUTH_ISSUER` matches token `iss` and the JWKS URL resolves +* Ensure **RS256** is used; HS256 will be rejected when `OAUTH_ENFORCE_ALG=RS256` + +**403 on write** + +* Your token likely lacks `tool:write`; confirm “Add Permissions in the Access Token” is enabled on the API + +**Proxy not injecting user** + +* Verify `PROXY_TARGET` is reachable +* Confirm `PROXY_INJECT_HEADER` / `PROXY_INJECT_QUERY` are set and your route is going through the proxy handler + +**Rate limit never triggers** + +* Lower `RATE_LIMIT_MAX` and ensure identifier (user/tenant) is parsed from the token’s `sub` / `org_id` + +--- + +### Production Checklist + +* ✅ RS256 enforced; JWKS timeout & caching tuned +* ✅ Strict CORS (exact origins) +* ✅ Deny-by-default policies per route/tool +* ✅ Rate-limit per user/tenant with sane ceilings +* ✅ Logs redact PII; audit fields: `{sub, tool, path, decision, latency}` +* ✅ Health probes hooked into your orchestrator +* ✅ *(Optional)* DCR webhook secret rotated; Mgmt API scopes minimal + +## Deployment + +### Cloud Run (Recommended for Quick Start) + +**Why Cloud Run:** +- ✅ Serverless (no infrastructure management) +- ✅ Auto-scaling (0 to 1000+ instances) +- ✅ FedRAMP Moderate authorized (government/enterprise ready) +- ✅ Built-in HTTPS, load balancing, health checks +- ✅ Deploy in 3 commands + +**Quick Deploy:** +```bash +# Use the included deploy script +./tools/deploy/cloud-run.sh apps/gateway-server + +# Or manually: +gcloud builds submit --tag gcr.io/YOUR-PROJECT/gatewaystack +gcloud run deploy gatewaystack \ + --image gcr.io/YOUR-PROJECT/gatewaystack \ + --set-env-vars="OAUTH_ISSUER=https://your-tenant.auth0.com/" +``` + +**Cost:** ~$5-50/month depending on usage (generous free tier) + +**See:** `docs/deployment/cloud-run.md` for full walkthrough + +--- + +### Docker (Self-Hosted) + +**Pre-built images:** +```bash +docker pull ghcr.io/davidcrowe/gatewaystack:latest +``` + +**Build yourself:** +```bash +# Gateway server +docker build -f apps/gateway-server/Dockerfile -t gatewaystack . + +# Admin UI +docker build -f apps/admin-ui/Dockerfile -t gatewaystack-admin . +``` + +**Run locally:** +```bash +docker run -p 8080:8080 \ + -e OAUTH_ISSUER=https://your-tenant.auth0.com/ \ + -e OAUTH_AUDIENCE=https://gateway.local/api \ + gatewaystack +``` + +**See:** `docs/deployment/docker.md` for Docker Compose, Kubernetes manifests, etc. + +--- + +### Other Platforms + +| Platform | Difficulty | Guide | +|----------|------------|-------| +| **AWS ECS/Fargate** | Medium | `docs/deployment/aws.md` | +| **Azure Container Instances** | Medium | `docs/deployment/azure.md` | +| **Fly.io** | Easy | `docs/deployment/fly.md` | +| **Railway** | Easy | `docs/deployment/railway.md` | +| **Kubernetes** | Hard | `docs/deployment/kubernetes.md` | + +**Air-gapped / on-prem deployments:** Fully supported (Docker + self-hosted) + +--- + +### CI/CD + +`.github/workflows/conformance.yml` runs `npm test` and updates `docs/conformance.json` on every push to main. + +**To add deployment:** +```yaml +# Add to conformance.yml after tests pass +- name: Deploy to Cloud Run + if: github.ref == 'refs/heads/main' + run: ./tools/deploy/cloud-run.sh apps/gateway-server + env: + GCP_PROJECT: ${{ secrets.GCP_PROJECT }} +``` + +## Connect to Claude (MCP) + +If you’re using this gateway as an **MCP server** (e.g. Claude Desktop, Cursor, etc.), no code changes are required. +The gateway is **auth-initiator agnostic** — it simply validates RS256 tokens and enforces scopes, regardless of who started the OAuth flow. + +### 1️⃣ Return 401 with Protected Resource Metadata (PRM) pointer + +```http +HTTP/1.1 401 Unauthorized +WWW-Authenticate: Bearer realm="mcp", resource_metadata="https:///.well-known/oauth-protected-resource" +Content-Type: application/json + +{"error":"unauthorized","error_description":"Access token required"} +``` + +> The PRM URL tells the MCP client where to discover OAuth configuration for this resource. + +### 2️⃣ Protected Resource Metadata (PRM) Example + +Serve this JSON at `/.well-known/oauth-protected-resource`: + +```json +{ + "resource": "https:///mcp", + "authorization_servers": ["https:///"], + "scopes_supported": ["tool:read", "tool:write"] +} +``` + +| **Field** | **Description** | +| ----------------------- | ----------------------------------------- | +| `resource` | Identifier for this gateway’s MCP surface | +| `authorization_servers` | OAuth / OIDC issuer (e.g. Auth0, Okta) | +| `scopes_supported` | Scopes mapped to your route allowlist | + +### 3️⃣ Scopes → Routes (deny-by-default) + +| **Scope** | **Routes** | +| ------------ | ------------------ | +| `tool:read` | `GET /v1/tools/*` | +| `tool:write` | `POST /v1/tools/*` | + +Requests with a valid token but missing scope will receive **403 Forbidden**. + +### 4️⃣ Redirect URIs (common MCP clients) + +Register your client with the IdP and allow its redirect URI(s): + +* **Claude Desktop/Web:** documented callback (e.g. `https://claude.ai/api/mcp/auth_callback`) +* **Cursor IDE:** their documented OAuth callback + +If your IdP supports **Dynamic Client Registration (DCR)**, you can enable it instead of pre-registering. + +### 5️⃣ Smoke Test +```bash +# No token → 401 + WWW-Authenticate header +curl -i https:///protected + +# Valid token with tool:read → 200 +curl -i -H "Authorization: Bearer $TOKEN" https:///protected + +# Valid token, insufficient scope → 403 +curl -i -H "Authorization: Bearer $TOKEN" https:///writer-only +``` + +### ⚙️ Implementation Notes + +* Always validate `iss`, **pin `aud`**, enforce `alg = RS256`, and honor `exp`/`nbf`. +* Keep tokens **short-lived**; rotate/revoke via your IdP. +* Use gateway **identity injection** (e.g. `X-User-Id`) to pass user context downstream — never expose upstream API keys to the LLM client. + +## What's Next? + +### For Developers +- 📖 Read the [Architecture Guide](docs/architecture.md) +- 🧪 Run the [conformance tests](docs/testing.md) +- 🔧 Explore the [API Reference](docs/api-reference.md) +- 💬 Ask questions in [GitHub Discussions](https://github.com/davidcrowe/gatewaystack/discussions) + +### For Enterprises +- 🏢 Review the [Enterprise Features](docs/enterprise.md) +- 🔒 See [Compliance Documentation](docs/compliance.md) (SOC 2, HIPAA, FedRAMP) +- 📞 Contact us for [deployment support](https://reducibl.com) + +### For Contributors +- 🤝 Read [CONTRIBUTING.md](CONTRIBUTING.md) +- 🐛 Report bugs via [GitHub Issues](https://github.com/davidcrowe/gatewaystack/issues) +- ⭐ Star the repo if this helps you! + + + +Built by [reducibl applied AI studio](https://reducibl.com) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +# GatewayStack — Agentic Control Plane for User-Scoped AI Governance + +An open-source control plane that makes AI agents **enterprise-ready** by enforcing user-scoped identity, policy, and audit trails on every model call. + +>> **Note** — this repo is an early-stage repo under active dev +>> the first layer of GatewayStack - `identifiabl` - was launched in Dec 2025 +>> additional GatewayStack modules to follow +>> `npm i identifiabl` + +**The problem:** + +AI models and user data need each other... but can't safely connect. + +- Users want AI to access *their* data (ChatGPT reading *my* calendar). +- Enterprises want to control *who* can use AI models (only doctors can use medical models, only employees can send sensitive prompts). + +Both require **cryptographic proof of user identity** tied to every AI request... but AI platforms authenticate users on their side while your backend has no verified identity to enforce policies, filter data, or log actions. + +**Gatewaystack solves this.** + +Drop GatewayStack between AI clients (ChatGPT, Claude, your own self-hosted models, MCP) and your backend. It validates OAuth tokens, enforces scopes, and injects verified identity—so you can safely answer the two questions that matter most: + +1. **Who** did what, with **which** data, via **which** model? +2. Was it **authorized**, **bounded**, and **logged** under policy? + +Every AI request flows through six governance checkpoints: +> **Identified → Transformed → Validated → Constrained → Routed → Audited** + +--- + +### The Three-Party Problem + +Modern AI apps are really **three-party systems**: + +**The User** — a real human with identity, roles, and permissions +**The LLM** — a model acting on their behalf (ChatGPT, Claude) +**Your Backend** — the trusted data and tools the model needs to access + +These three parties all talk to each other, but they don’t share a common, cryptographically verified identity layer. + +**The gap:** The LLM knows who the user is (they logged into ChatGPT). Your backend doesn't. So it can't: +- Filter data per-user (*"show me my calendar"* → returns everyone's calendar) +- Enforce per-user policies (*"only doctors use medical models"* → anyone can) +- Audit by user (*"who made this query?"* → can't answer) + +**Without a unifying identity layer, you get:** +- Shared API keys (everyone sees everything, or no one sees anything) +- No enforcement ("who can use which models for what") +- No audit trail (can't prove compliance) +- Enterprises block AI access entirely (too risky) + +This instability across user ↔ LLM ↔ backend is what Gatewaystack calls the **Three-Party Problem**. + +It shows up in two directions: + +--- + + +### Direction 1: Enterprises controlling who can use which models and tools +*"How do I ensure only **licensed doctors** use medical models, only **analysts** access financial data, and **contractors** can't send sensitive prompts?"* + +> user ↔ backend ↔ LLM + +**Without Gatewaystack:** +```typescript +app.post('/chat', async (req, res) => { + const { model, prompt } = req.body; + const response = await openai.chat.completions.create({ + model, // Anyone can use gpt-4-medical + messages: [{ role: 'user', content: prompt }] + }); + res.json(response); +}); +``` + +**With Gatewaystack:** +```typescript +app.post('/chat', async (req, res) => { + const userId = req.headers['x-user-id']; + const userRole = req.headers['x-user-role']; // "doctor", "analyst", etc. + const userScopes = req.headers['x-user-scopes']?.split(' ') || []; + + // Gateway already enforced: only doctors with medical:write can reach here + const response = await openai.chat.completions.create({ + model: req.body.model, + messages: [{ role: 'user', content: req.body.prompt }], + user: userId // OpenAI audit trail + }); + res.json(response); +}); +``` + +**Gateway policy:** +```json +{ + "gpt-4-medical": { + "requiredRoles": ["doctor", "physician_assistant"], + "requiredScopes": ["medical:write"] + } +} +``` + +The gateway enforces role + scope checks **before** forwarding to your backend. If a nurse tries to use `gpt-4-medical`, they get `403 Forbidden`. + +--- + +### Direction 2: Users accessing their own data via AI +*"How do I let ChatGPT read **my** calendar without exposing **everyone's** calendar?"* + +> user ↔ LLM ↔ backend + +**Without Gatewaystack:** +```typescript +app.get('/calendar', async (_req, res) => { + const events = await getAllEvents(); // Everyone sees everything + res.json(events); +}); +``` + +**With Gatewaystack:** +```typescript +app.get('/calendar', async (req, res) => { + const userId = req.headers['x-user-id']; // Verified by gateway + const events = await getUserEvents(userId); + res.json(events); +}); +``` + +The gateway validates the OAuth token, extracts the user identity, and injects `X-User-Id` — so your backend can safely filter data per-user. + +--- + +### Why Both Directions Matter +Attaching a cryptographically confirmed user identity to a shared request context is the key that makes request level governance possible: + +**Without solving the Three-Party Problem, you can't:** +- Filter data per-user (Direction 1: everyone sees everything) +- Enforce "who can use which models" (Direction 2: no role-based access) +- Audit "who did what" (compliance impossible) +- Rate limit per-user (shared quotas get exhausted) +- Attribute costs (can't charge back to teams/users) + +**Gatewaystack solves both** by binding cryptographic user identity to every AI request: + +* OAuth login per user (RS256 JWT, cryptographic identity proof) +* Per-user / per-tenant data isolation by default +* Deny-by-default authorization (scopes per tool/model/role) +* Immutable audit trails (who, what, when, which model) +* Rate limits & spend caps (per user/team/org) +* Drop-in between AI clients and your backend (no SDK changes) + +Gatewaystack is composed of modular packages that can run **standalone** or as a cohesive **six-layer pipeline** for complete AI governance. + +## Repository layout + +| Path | Highlights | +| ---- | ---------- | +| `apps/gateway-server` | Express reference server wiring all six governance layers, `/protected/*` samples, demo/test routes, and a ready-to-build Docker image. | +| `apps/admin-ui` | Minimal Vite/React dashboard that polls `/health` so you can monitor the gateway while iterating. | +| `packages/` | Publishable packages for each layer plus helpers like `compat`, `request-context`, and `integrations`. | +| `demos/` | Working MCP issuer + ChatGPT Apps SDK connectors that mint demo JWTs and exercise the gateway. | +| `tools/` | Supporting utilities (echo server, mock tool backend, Cloud Run deploy helper, smoke harnesses). | +| `tests/` | Vitest entry points and placeholder smoke tests for parity. | +| `docs/` | Auth0 walkthroughs, conformance output, endpoint references, troubleshooting notes. | + +### Package breakdown + +- `@gatewaystack/identifiabl` – **Trust & Identity Binding.** Express middleware that verifies RS256 JWTs (via `jose`), enforces `iss`/`aud`, and attaches a canonical `req.user` for downstream policy, routing, and audit. + +- `@gatewaystack/transformabl` – **Content Safety & Normalization.** Request/response normalization hook. Currently a no-op, reserved for redaction/classification/safety transforms that run *before* authorization and routing. + +- `@gatewaystack/validatabl-core` / `@gatewaystack/validatabl` – **Authorization & Policy Enforcement.** Scope utilities plus Express helpers (e.g. `requireScope`) and the Protected Resource Metadata `.well-known` route. Enforces deny-by-default, fine-grained access to tools and models. + +- `@gatewaystack/limitabl` – **Spend Controls & Resource Governance.** Rate limiting keyed on `sub`/`org_id` (falling back to IP) to prevent runaway agents, abuse, and unbounded cost at the user/tenant level. + +- `@gatewaystack/proxyabl` – **Execution Control & Identity-Aware Routing.** Tool gateway + proxy router that: + - Serves PRM/OIDC metadata for OAuth 2.1 / MCP / Apps SDK + - Enforces scope-to-tool mappings (`TOOL_SCOPES_JSON`) + - Injects verified identity into headers/queries (e.g. `X-User-Id`) + - Hosts Auth0 integration points for log streams / DCR + +- `@gatewaystack/explicabl` / `@gatewaystack/explicabl-core` – **Runtime Audit & Conformance.** Read-only on the RequestContext, write-only to external systems. Health endpoints, log/webhook handlers, and the conformance reporter (`saveReport.ts`) that emit correlated events to SIEM/observability stacks without blocking the critical path. + +- `@gatewaystack/compat` – **Interop & Parity Harness.** Legacy/test router that mirrors the original `/echo` shape for quick interoperability and regression testing. + +- `@gatewaystack/request-context`, `@gatewaystack/integrations`, and additional `*-core` folders – Shared types, RequestContext helpers, and staging areas for upcoming connectors as the Agentic Control Plane expands. + +--- + +### Reference server (apps/gateway-server) + +`apps/gateway-server/src/app.ts` composes the six governance layers in order: + +1. Public **Protected Resource Metadata** via `protectedResourceRouter`. +2. `/protected/*` pipeline → `identifiabl` (JWT) → `limitabl` → `transformabl`. +3. Sample handlers (`GET /protected/ping`, `POST /protected/echo`) with `requireScope("tool:write")`. +4. `toolGatewayRouter` for PRM/OIDC well-knowns, MCP/Apps JSON-RPC, `/proxy`, and the Auth0 log webhook. +5. `explicablRouter` for `/health`, `/health/auth0`, and `/webhooks/auth0`. + +Toggles worth noting: + +- `DEMO_MODE=true` swaps in `OAUTH_*_DEMO` overrides so demos can mint JWTs locally. +- `ENABLE_TEST_ROUTES=true` + `TOOL_SCOPE_ALLOWLIST_JSON` expose `/__test__/echo` for conformance runs. +- `RATE_LIMIT_WINDOW_MS` / `RATE_LIMIT_MAX` tune limitabl without editing TypeScript. +- `.env.example` plus `apps/gateway-server/.env.example` enumerate every knob. + +--- +### Admin UI (apps/admin-ui) + +`npm run dev:admin` launches a tiny Vite/React panel (`apps/admin-ui/src/App.tsx`) that fetches `/health` and renders the JSON so you can keep gateway status visible while iterating. + +--- + +### Core governance layers + +Every request flows through the same six-layer composable pipeline: + +| Layer | Status | Purpose | +| ------------ | ------ | ---------------------------------------------------------------------------- | +| **identifiabl** | ✅ | **Foundational Trust & Identity Binding** — verifies RS256 JWTs, pins issuer/audience, and establishes the canonical subject for downstream authorization and audit. | +| **transformabl** | ⚪ | **Content Safety Preprocessing & Risk Mitigation** — normalizes, redacts, or classifies inputs/outputs before policy and routing are applied. | +| **validatabl** | ✅ | **Authorization & Policy Enforcement** — deny-by-default, scope-driven access to protected resources, tools, and models. | +| **limitabl** | ✅ | **Rate & Spend Governance** — throttles per user/tenant to prevent runaway agents and unbounded cost. | +| **proxyabl** | 🧩 | **Execution Control & Identity-Aware Routing** — routes calls to the right tool/model backend, injects verified identity, and presents OAuth/PRM metadata. | +| **explicabl** | ⚪ | **Accountability & Runtime Audit** — emits immutable, correlated events to your SIEM/observability stack and exposes health/conformance endpoints. | + +Drop it between AI clients and your backend — no SDK modification needed. Handles **RS256 JWTs**, audience/issuer checks, per-tool scopes, role-based policies, and optional **DCR** client promotion. + +--- + +### Conformance summary + +Verified against Apps SDK / MCP OAuth 2.1 + RS256 flow. +- ✅ JWT validation (iss/aud/sub/exp/nbf) +- ✅ Scope allowlist / deny-by-default +- ✅ Expiry handling +- ✅ Health & protected resource endpoints + +--- + +### Quick links + +* ▶️ [Quickstart (10 minutes)](#quickstart-10-minutes) +* 🔐 [Identity Provider Setup](#identity-provider-setup) + * [Auth0](#auth0-setup-10-minutes) (quickest to get started) + * [Okta](#okta-setup) (coming soon) + * [Entra ID](#entra-id-setup) (coming soon) +* 🤝 [Connect to ChatGPT / Claude (MCP)](#mcp-quick-connect-oauth-21-user-scoped) +* 🩺 [Health & protected-resource metadata](#health--basic-smoke-tests) +* 🛡️ [Production Checklist](#production-checklist) +* 🆘 [Troubleshooting](#troubleshooting) + +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) +![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue) +![Cloud Run](https://img.shields.io/badge/Cloud%20Run-ready-4285F4) +![Auth0](https://img.shields.io/badge/Auth0-RS256-orange) +[![MCP/Auth Conformance](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fdavidcrowe%2Fgatewaystack%2Fmain%2Fdocs%2Fconformance.json&query=$.version&label=MCP%2FAuth%20Conformance)](https://github.com/davidcrowe/gatewaystack/tree/main/docs/conformance.json) + + + + + + + -| Feature | Kong/Apigee/AWS API Gateway | Gatewaystack | -|---------|---------------------------|--------------| -| **JWT validation** | ✅ Built-in | ✅ Built-in | -| **Rate limiting** | ✅ Built-in | ✅ Built-in | -| **Path/method routing** | ✅ Built-in | ✅ Built-in | -| **User identity normalization** | ❌ Manual (custom plugin) | ✅ Built-in | -| **Per-tool scope enforcement** | ❌ Manual (custom policy) | ✅ Built-in | -| **Apps SDK / MCP compliance** | ❌ Manual (PRM endpoint) | ✅ Built-in | -| **Pre-flight cost checks** | ❌ Manual (custom plugin) | ✅ Roadmap | -| **Model-specific policies** | ❌ Manual (custom logic) | ✅ Built-in | -| **AI audit trails** | ❌ Manual (log forwarding) | ✅ Built-in | -| **Setup time** | 100+ hours (custom dev) | 2 hours (config) | ## Demos diff --git a/apps/gateway-server/src/app.ts b/apps/gateway-server/src/app.ts index e5d11de..bd1458b 100644 --- a/apps/gateway-server/src/app.ts +++ b/apps/gateway-server/src/app.ts @@ -1,6 +1,7 @@ import express, { type RequestHandler } from "express"; import bodyParser from "body-parser"; +import { runWithGatewayContext } from "@gatewaystack/request-context"; import { identifiabl } from "@gatewaystack/identifiabl"; import { transformabl } from "@gatewaystack/transformabl"; import { @@ -8,13 +9,24 @@ import { requireScope, } from "@gatewaystack/validatabl"; import { limitabl } from "@gatewaystack/limitabl"; -import { toolGatewayRouter } from "@gatewaystack/proxyabl"; +import { + createProxyablRouter, + configFromEnv as proxyablConfigFromEnv, +} from "@gatewaystack/proxyabl"; import { explicablRouter } from "@gatewaystack/explicabl"; import { testEchoRoutes } from "./routes/testEcho"; import rateLimit from "express-rate-limit"; +function trimTrailingSlashes(input: string): string { + let out = input; + while (out.endsWith("/")) { + out = out.slice(0, -1); + } + return out; +} + export function buildApp(env: NodeJS.ProcessEnv) { // ----------------------------- // Boot & configuration @@ -69,6 +81,21 @@ export function buildApp(env: NodeJS.ProcessEnv) { const app = express(); app.use(bodyParser.json({ limit: "2mb" })); + // 🔹 NEW: create a GatewayContext for every incoming request + app.use((req, _res, next) => { + runWithGatewayContext( + { + request: { + method: req.method, + path: req.path, + ip: (req as any).ip, + userAgent: req.get("user-agent") ?? undefined, + }, + }, + () => next() + ); + }); + // Simple root health check (extra; explicabl has richer health) app.get("/", (_req, res) => res.status(200).json({ ok: true })); @@ -96,25 +123,17 @@ export function buildApp(env: NodeJS.ProcessEnv) { // Layer 3: Mount validatabl’s PRM router early for public metadata // validatabl is added as added via requireScope() in the protected area below // ----------------------------- - // app.use( - // protectedResourceRouter({ - // issuer: OAUTH_ISSUER.replace(/\/+$/, ""), - // audience: OAUTH_AUDIENCE, - // scopes: OAUTH_SCOPES, - // }) as unknown as RequestHandler - // ); app.use( "/prm", prmLimiter, protectedResourceRouter({ - issuer: OAUTH_ISSUER.replace(/\/+$/, ""), + issuer: trimTrailingSlashes(OAUTH_ISSUER), audience: OAUTH_AUDIENCE, scopes: OAUTH_SCOPES, }) as unknown as RequestHandler ); - // ----------------------------- // Layer 4: limitabl (per-identity rate limiting) // ----------------------------- @@ -146,7 +165,27 @@ export function buildApp(env: NodeJS.ProcessEnv) { // ----------------------------- // Layer 5: proxyabl (tool / MCP gateway) // ----------------------------- - app.use(toolGatewayRouter as unknown as RequestHandler); + const proxyablConfig = proxyablConfigFromEnv(env); + + // Rate limiter for /tools (satisfies CodeQL, reasonable defaults) + const toolsRateLimiter = rateLimit({ + windowMs, + max: limit * 10, // more generous than per-user limitabl + standardHeaders: true, + legacyHeaders: false, + }); + + // /tools pipeline: + // GatewayContext (already set globally) + // → toolsRateLimiter + // → identifiabl (JWT → ctx.identity) + // → proxyabl router (scopes + routing) + app.use( + "/tools", + toolsRateLimiter, + identifiablMiddleware, + createProxyablRouter(proxyablConfig) as unknown as RequestHandler + ); // ----------------------------- // Layer 6: explicabl (health, logs, webhooks) diff --git a/apps/gateway-server/src/routes/testEcho.ts b/apps/gateway-server/src/routes/testEcho.ts index eda6014..1e826f4 100644 --- a/apps/gateway-server/src/routes/testEcho.ts +++ b/apps/gateway-server/src/routes/testEcho.ts @@ -2,7 +2,7 @@ import express from "express"; import type { Request, Response, NextFunction } from "express"; import { createRemoteJWKSet, jwtVerify } from "jose"; -import rateLimit from "express-rate-limit"; +import rateLimit, { ipKeyGenerator } from "express-rate-limit"; // Read allowlist from env the same way your handler does (or import your util if you have one) function parseAllowlist(env: NodeJS.ProcessEnv): Record { @@ -18,9 +18,10 @@ function parseAllowlist(env: NodeJS.ProcessEnv): Record { function keyFromReq(req: any): string { const xf = req.get?.("x-forwarded-for"); if (typeof xf === "string" && xf.length > 0) { - return xf.split(",")[0].trim(); + const ip = xf.split(",")[0].trim(); + return ipKeyGenerator(ip); } - return req.ip || "unknown"; + return ipKeyGenerator(req.ip); } function createTestEchoLimiter(env: NodeJS.ProcessEnv): express.RequestHandler { diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..77cf5ac --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,154 @@ +## error handling + +gatewaystack modules use a **fail-safe strategy** based on the security/availability tradeoff: + +| module | on failure | reason | +|--------|-----------|--------| +| **identifiabl** | deny (fail closed) | no anonymous requests allowed | +| **transformabl** | continue (fail open) | allow untransformed content with warning | +| **validatabl** | deny (fail closed) | safety first — block when policy engine fails | +| **limitabl (pre-flight)** | continue (fail open) | favor availability over limit enforcement | +| **proxyabl** | fallback → error | try alternative providers, then return error | +| **limitabl (accounting)** | log error | accounting failure shouldn't block response delivery | +| **explicabl** | log error | audit failure logged but doesn't block request | + +**circuit breakers**: modules that make external calls (jwks validation, policy stores, redis) implement circuit breakers with configurable thresholds. + +**degraded mode**: when upstream dependencies fail, gatewaystack can operate in degraded mode with reduced governance guarantees (configurable per environment). + +## inputs + +- oidc tokens / apps sdk identity tokens +- user context (user_id, org_id, roles, scopes) +- model request (messages, params, attachments) +- configured policies +- routing rules +- quota & budget settings + +## outputs + +- authenticated, user-bound requests +- policy decisions (allow, deny, modify) +- routing decisions (provider/model/tool) +- enforced limits (rate, cost, budgets) +- full audit logs and runtime metadata + +## shared requestcontext + +all modules operate on a shared `RequestContext` object that flows through the pipeline. this context carries identity, content, metadata, and decisions from each module. + +> 📘 **full type definitions and integration examples**: see [`docs/reference/interfaces.md`](https://github.com/davidcrowe/gatewaystack/blob/main/docs/reference/interfaces.md) in the repo + +**core types** (simplified view): +```ts +// shared across modules +export type Identity = { + user_id: string; + org_id?: string; + tenant?: string; + roles: string[]; + scopes: string[]; +}; + +export type ContentMetadata = { + contains_pii?: boolean; + classification?: string[]; // ["sensitive", "financial"] + risk_score?: number; // 0–1 + topics?: string[]; + // extensible bag + [key: string]: unknown; +}; + +export type ModelRequest = { + model: string; // "gpt-4", "smart-model" + tools?: string[]; + max_tokens?: number; + messages: any[]; + attachments?: any[]; +}; + +export type PolicyDecision = { + effect: 'allow' | 'deny' | 'modify'; + reasons: string[]; + modifications?: Partial; +}; + +export type LimitsDecision = { + effect: 'ok' | 'throttle' | 'deny' | 'fallback' | 'degrade'; + reasons: string[]; + constraints?: { + max_tokens?: number; + max_cost?: number; + }; +}; + +export type RoutingDecision = { + provider: string; // "openai", "azure-openai" + model: string; // concrete provider model + region?: string; + alias?: string; // "smart-model" +}; + +export type UsageRecord = { + input_tokens: number; + output_tokens: number; + total_cost: number; + latency_ms: number; +}; + +export type RequestContext = { + request_id: string; + trace_id?: string; + + identity?: Identity; + content?: { + messages: any[]; + attachments?: any[]; + }; + metadata?: ContentMetadata; + modelRequest: ModelRequest; + + policyDecision?: PolicyDecision; + limitsDecision?: LimitsDecision; + routingDecision?: RoutingDecision; + usage?: UsageRecord; + + // arbitrary module extensions + [key: string]: unknown; +}; +``` + +## module boundaries + +**identifiabl** +- **input**: `RequestContext` with `modelRequest` + http auth header +- **reads**: authorization header, jwks endpoint +- **writes**: `identity`, `trace_id`, identity headers (`x-user-id`, `x-org-id`, etc.) + +**transformabl** +- **input**: `RequestContext` with `identity`, `content` +- **reads**: `content` (messages, attachments) +- **writes**: `metadata` (pii flags, classification, risk score), redacted `content`, transformation logs + +**validatabl** +- **input**: `identity`, `content`, `metadata`, `modelRequest` +- **reads**: all above + policy definitions +- **writes**: `policyDecision` (allow/deny/modify + reasons) + +**limitabl** (two-phase) +- **pre-flight phase**: + - **reads**: `identity`, `modelRequest`, `policyDecision` + - **writes**: `limitsDecision` (constraints, budget availability) +- **accounting phase**: + - **reads**: provider response (tokens, cost) + - **writes**: `usage`, updates quota/budget stores, emits usage events + +**proxyabl** +- **input**: `modelRequest`, `identity`, `metadata`, `policyDecision`, `limitsDecision` +- **reads**: routing rules, provider configurations, secrets +- **writes**: `routingDecision`, normalized provider response + +**explicabl** +- **input**: full `RequestContext` + events from all modules +- **reads**: everything (for audit trail construction) +- **writes**: external logs and traces only (no modification of request context) \ No newline at end of file diff --git a/docs/auth0/auth0-setup.md b/docs/auth0/auth0-setup.md new file mode 100644 index 0000000..e69de29 diff --git a/docs/conformance.json b/docs/conformance.json index 303579f..11c9e3b 100644 --- a/docs/conformance.json +++ b/docs/conformance.json @@ -8,5 +8,5 @@ "allowlist": "pass", "expiry": "pass" }, - "timestamp": "2025-11-26T21:38:28.617Z" + "timestamp": "2025-12-06T05:25:44.281Z" } \ No newline at end of file diff --git a/docs/idp-requirements.md b/docs/idp-requirements.md new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json index 1c99d99..ca11478 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,6 @@ "apps/admin-ui/node_modules/react": { "version": "18.3.1", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -141,7 +140,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -853,6 +851,10 @@ "resolved": "packages/identifiabl", "link": true }, + "node_modules/@gatewaystack/identifiabl-core": { + "resolved": "packages/identifiabl-core", + "link": true + }, "node_modules/@gatewaystack/limitabl": { "resolved": "packages/limitabl", "link": true @@ -1440,7 +1442,6 @@ "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1465,7 +1466,6 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1950,7 +1950,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -2618,7 +2617,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.0.tgz", "integrity": "sha512-c2iPh3xp5vvCLgaHK03+mWLFPhox7j1LwyxcZwFVApEv5i0X+IjPpbT50SJJwwLpdBVfp45AkK/v+AFgv/XlfQ==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -4175,7 +4173,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5172,7 +5169,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -5768,7 +5764,6 @@ "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -6022,6 +6017,7 @@ "name": "@gatewaystack/identifiabl", "version": "0.0.1", "dependencies": { + "@gatewaystack/request-context": "0.1.0", "identifiabl": "0.1.0", "jose": "^5.0.0" }, @@ -6030,7 +6026,7 @@ } }, "packages/identifiabl-core": { - "name": "identifiabl", + "name": "@gatewaystack/identifiabl-core", "version": "0.1.0", "license": "MIT", "dependencies": { @@ -6058,8 +6054,9 @@ "name": "@gatewaystack/proxyabl", "version": "0.0.1", "dependencies": { - "express": "^4.22.0", - "jose": "^5.9.6" + "@gatewaystack/proxyabl-core": "0.0.1", + "@gatewaystack/request-context": "0.1.0", + "express": "^4.22.0" }, "devDependencies": { "@types/express": "^4.17.21", diff --git a/packages/explicabl/src/webhooks/auth0LogWebhook.ts b/packages/explicabl/src/webhooks/auth0LogWebhook.ts index 3c03102..5254c72 100644 --- a/packages/explicabl/src/webhooks/auth0LogWebhook.ts +++ b/packages/explicabl/src/webhooks/auth0LogWebhook.ts @@ -1,7 +1,7 @@ // packages/explicabl-express/src/webhooks/auth0-log-webhook.ts import * as express from "express"; import type { Request, Response } from "express"; -import rateLimit from "express-rate-limit"; +import rateLimit, { ipKeyGenerator } from "express-rate-limit"; /** * Env @@ -32,9 +32,10 @@ const LOG_WEBHOOK_MAX_PER_WINDOW = +(process.env.LOG_WEBHOOK_MAX_PER_WINDOW || 6 const webhookKeyFromReq = (req: any): string => { const xf = req.get?.("x-forwarded-for"); if (typeof xf === "string" && xf.length > 0) { - return xf.split(",")[0].trim(); + const ip = xf.split(",")[0].trim(); + return ipKeyGenerator(ip); } - return req.ip || "unknown"; + return ipKeyGenerator(req.ip); }; const auth0WebhookLimiter = rateLimit({ diff --git a/packages/identifiabl-core/package.json b/packages/identifiabl-core/package.json index b61a30b..7963598 100644 --- a/packages/identifiabl-core/package.json +++ b/packages/identifiabl-core/package.json @@ -1,5 +1,6 @@ { - "name": "identifiabl", + "name": "@gatewaystack/identifiabl-core", + "private": false, "version": "0.1.0", "description": "RS256 JWT verification and identity mapping for GatewayStack.", "main": "dist/index.js", diff --git a/packages/identifiabl-core/tsconfig.json b/packages/identifiabl-core/tsconfig.json index 9cd7a78..5c317ea 100644 --- a/packages/identifiabl-core/tsconfig.json +++ b/packages/identifiabl-core/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "composite": true, "rootDir": "src", - "outDir": "dist" + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" }, "include": ["src"], "references": [ diff --git a/packages/identifiabl/package.json b/packages/identifiabl/package.json index c450406..a3fa391 100644 --- a/packages/identifiabl/package.json +++ b/packages/identifiabl/package.json @@ -1,13 +1,19 @@ { "name": "@gatewaystack/identifiabl", + "private": false, "version": "0.0.1", "main": "dist/index.js", "types": "dist/index.d.ts", "dependencies": { "jose": "^5.0.0", - "identifiabl": "0.1.0" + "identifiabl": "0.1.0", + "@gatewaystack/request-context": "0.1.0" }, "peerDependencies": { "express": "^4.0.0 || ^5.0.0" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "prepublishOnly": "npm run build" } } \ No newline at end of file diff --git a/packages/identifiabl/src/index.ts b/packages/identifiabl/src/index.ts index 440cd24..84a0716 100644 --- a/packages/identifiabl/src/index.ts +++ b/packages/identifiabl/src/index.ts @@ -1,5 +1,9 @@ import type { RequestHandler } from "express"; import { createRemoteJWKSet, jwtVerify } from "jose"; +import { + updateGatewayContext, + type GatewayIdentity, +} from "@gatewaystack/request-context"; export interface IdentifiablConfig { /** @@ -43,7 +47,10 @@ function buildIssuerPattern(issuer: string): RegExp { import { createIdentifiablVerifier } from "identifiabl"; export function identifiabl(config: IdentifiablConfig): RequestHandler { - const verify = createIdentifiablVerifier(config); + const verify = createIdentifiablVerifier({ + ...config, + scopeClaim: "scope", // ⬅️ add this + }); const middleware: RequestHandler = async (req: any, res, next) => { const auth = req.headers.authorization || ""; @@ -58,7 +65,15 @@ export function identifiabl(config: IdentifiablConfig): RequestHandler { return res.status(401).json(result); } - req.user = result.identity; + // 🔹 result.identity is structurally compatible with GatewayIdentity + const identity = result.identity as GatewayIdentity; + + // 🔹 publish identity into the shared GatewayContext + updateGatewayContext({ identity }); + + // Keep legacy behavior so existing code still works + req.user = identity; + return next(); }; diff --git a/packages/identifiabl/tsconfig.json b/packages/identifiabl/tsconfig.json index 38dbd4d..5dc7471 100644 --- a/packages/identifiabl/tsconfig.json +++ b/packages/identifiabl/tsconfig.json @@ -1,9 +1,13 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "../../dist", // changed from "outDir": "../../dist/identifiabl", + "outDir": "dist", "composite": true }, - "include": ["src"] + "include": ["src"], + "references": [ + { "path": "../identifiabl-core" }, + { "path": "../request-context" } + ] } diff --git a/packages/proxyabl-core/package.json b/packages/proxyabl-core/package.json index 450a73f..8be96c1 100644 --- a/packages/proxyabl-core/package.json +++ b/packages/proxyabl-core/package.json @@ -1,6 +1,11 @@ { "name": "@gatewaystack/proxyabl-core", "version": "0.0.1", + "type": "module", "main": "dist/index.js", - "types": "dist/index.d.ts" + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc -b" + }, + "files": ["dist"] } diff --git a/packages/proxyabl-core/src/auth.ts b/packages/proxyabl-core/src/auth.ts new file mode 100644 index 0000000..78f7c94 --- /dev/null +++ b/packages/proxyabl-core/src/auth.ts @@ -0,0 +1,135 @@ +// packages/proxyabl-core/src/auth.ts +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; +import type { ProxyablConfig } from "./config"; +import { trimTrailingSlashes } from "./config"; + +/** + * Normalized, verified access token surface returned by core. + * Express wrappers (proxyabl) can adapt this into HTTP response logic. + */ +export interface VerifiedAccessToken { + sub: string; + email?: string; + effectiveScopes: string[]; + payload: JWTPayload; +} + +/** + * Core auth error type for proxyabl. + * No HTTP semantics here beyond status code + code string. + */ +export class ProxyablAuthError extends Error { + code: string; + status: number; + + constructor(code: string, message: string, status = 401) { + super(message); + this.code = code; + this.status = status; + } +} + +interface VerifyAccessTokenOptions { + debugAuth?: boolean; + logger?: (msg: string, meta: Record) => void; +} + +/** + * Verify an OAuth access token using OIDC/JWKS and return a normalized view. + * - Validates issuer and audience (if configured) + * - Extracts sub/email/scopes into a canonical structure + */ +export async function verifyAccessToken( + config: ProxyablConfig, + token: string, + opts: VerifyAccessTokenOptions = {}, +): Promise { + const issuerRaw = config.oidc.issuer; + const issuer = trimTrailingSlashes(issuerRaw); + + const jwksUri = config.oidc.jwksUri ?? `${issuer}/.well-known/jwks.json`; + + const JWKS = createRemoteJWKSet(new URL(jwksUri)); + const { debugAuth, logger } = opts; + const log = logger ?? (() => {}); + + try { + const { payload } = await jwtVerify(token, JWKS, { + issuer: [issuer, `${issuer}/`], + ...(config.oidc.audience ? { audience: config.oidc.audience } : {}), + }); + + const sub = String(payload.sub || ""); + if (!sub) { + throw new ProxyablAuthError("TOKEN_NO_SUB", "Token missing sub claim", 401); + } + + const email = + typeof (payload as any).email === "string" + ? ((payload as any).email as string) + : undefined; + + const scopeStr = + typeof (payload as any).scope === "string" + ? ((payload as any).scope as string) + : ""; + const scopeList = scopeStr.split(" ").filter(Boolean); + + const permissions = Array.isArray((payload as any).permissions) + ? ((payload as any).permissions as string[]) + : []; + + const effectiveScopes = Array.from(new Set([...scopeList, ...permissions])); + + if (debugAuth) { + log("jwt_verified", { + iss: payload.iss, + aud: payload.aud, + sub, + scopeCount: effectiveScopes.length, + }); + } + + return { sub, email, effectiveScopes, payload }; + } catch (err: any) { + if (err instanceof ProxyablAuthError) { + throw err; + } + + const message = err?.message || "JWT verification failed"; + throw new ProxyablAuthError("JWT_VERIFY_FAILED", message, 401); + } +} + +/** + * Return the scopes required for a given tool name, based on config.toolScopes. + */ +export function getRequiredScopesForTool( + config: ProxyablConfig, + toolName: string, +): string[] { + const toolScopes = config.toolScopes ?? {}; + return toolScopes[toolName] ?? []; +} + +/** + * Assert that the caller's scopes satisfy the requirements for `toolName`. + * Throws ProxyablAuthError(INSUFFICIENT_SCOPE) if not satisfied. + */ +export function assertToolScopes( + config: ProxyablConfig, + toolName: string, + have: string[], +): void { + const need = getRequiredScopesForTool(config, toolName); + if (!need.length) return; + + const ok = need.every((s) => have.includes(s)); + if (!ok) { + throw new ProxyablAuthError( + "INSUFFICIENT_SCOPE", + `Caller lacks required scopes for tool ${toolName}`, + 403, + ); + } +} diff --git a/packages/proxyabl-core/src/config.ts b/packages/proxyabl-core/src/config.ts new file mode 100644 index 0000000..c509b2a --- /dev/null +++ b/packages/proxyabl-core/src/config.ts @@ -0,0 +1,221 @@ +export interface OidcConfig { + /** + * OIDC / OAuth issuer, e.g. "https://YOUR_TENANT.us.auth0.com". + * Trailing slashes will be trimmed internally. + */ + issuer: string; + /** + * Optional audience (API identifier) to enforce on incoming access tokens. + */ + audience?: string; + /** + * Optional explicit JWKS URI. If omitted, will default to `${issuer}/.well-known/jwks.json`. + */ + jwksUri?: string; +} + +export interface ToolScopesConfig { + /** + * Map from tool name → list of required scopes/permissions. + * Example: + * { + * "listEvents": ["inner.events:read"], + * "chatWithEmbeddingsv3": ["inner.dreams:write"] + * } + */ + [toolName: string]: string[]; +} + +export interface ProxyConfig { + /** + * Base upstream URL, e.g. "http://127.0.0.1:3333". + * Must be http/https and will be validated. + */ + target: string; + /** + * Path prefix on the gateway side, e.g. "/proxy". Defaults to "/proxy". + */ + prefix?: string; + /** + * Allowed upstream path prefixes (SSRF mitigation). + * Example: ["/api", "/health"]. Defaults to ["/"]. + */ + allowedPaths?: string[]; + /** + * Optional header to inject with the verified subject (user id), e.g. "x-user-id". + */ + injectHeader?: string; + /** + * Optional query param to inject with the verified subject, e.g. "userId". + */ + injectQuery?: string; + /** + * Upstream timeout in milliseconds. Defaults to 5000. + */ + timeoutMs?: number; +} + +export interface Auth0DcrConfig { + /** + * Auth0 tenant domain, e.g. "my-tenant.us.auth0.com". + */ + mgmtDomain: string; + /** + * Auth0 Management API client_id used for DCR promotion. + */ + clientId: string; + /** + * Auth0 Management API client_secret used for DCR promotion. + */ + clientSecret: string; + /** + * Shared secret used to authenticate the Auth0 Log Stream webhook. + */ + logWebhookSecret: string; + /** + * Optional Auth0 connection name for Google (default "google-oauth2"). + */ + googleConnectionName?: string; +} + +export interface ProxyablConfig { + /** + * OIDC / OAuth configuration for validating access tokens. + */ + oidc: OidcConfig; + + /** + * Map from tool name → required scopes/permissions. + */ + toolScopes: ToolScopesConfig; + + /** + * Base URL for downstream tools/functions, e.g. your Cloud Functions base. + * Example: "https://us-central1-.cloudfunctions.net". + */ + functionsBase?: string; + + /** + * Allowed origin for CORS. Defaults to "*". + */ + appOrigin?: string; + + /** + * Optional reverse proxy configuration for /proxy-style endpoints. + */ + proxy?: ProxyConfig; + + /** + * Optional Auth0 DCR automation configuration. + * If omitted or null, the DCR webhook route can be disabled. + */ + auth0Dcr?: Auth0DcrConfig | null; +} + +/** + * Parses a TOOL_SCOPES_JSON-style string into a ToolScopesConfig. + * Example raw: {"listEvents":["inner.events:read"],"chatWithEmbeddingsv3":["inner.dreams:write"]} + */ +export function parseToolScopesJson(raw?: string | null): ToolScopesConfig { + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") return {}; + const out: ToolScopesConfig = {}; + for (const [name, scopes] of Object.entries(parsed)) { + if (Array.isArray(scopes)) { + out[name] = scopes.filter((s): s is string => typeof s === "string" && s.trim().length > 0); + } + } + return out; + } catch { + // If JSON is invalid, fail closed with empty config. + return {}; + } +} + +export interface EnvLike { + [key: string]: string | undefined; +} + +export function trimTrailingSlashes(input: string): string { + let out = input; + while (out.endsWith("/")) { + out = out.slice(0, -1); + } + return out; +} + +/** + * Builds a ProxyablConfig from process.env-style variables. + * This is a convenience for apps that prefer env-driven configuration. + * + * NOTE: This does not validate the values; the router should still perform + * some sanity checks (issuer format, proxy target scheme, etc.). + */ +export function configFromEnv(env: EnvLike = process.env): ProxyablConfig { + const rawIssuer = env.OAUTH_ISSUER ?? "https://YOUR_TENANT.us.auth0.com/"; + const issuerTrimmed = trimTrailingSlashes(rawIssuer); + + const oidc: OidcConfig = { + issuer: issuerTrimmed, + audience: env.OAUTH_AUDIENCE || undefined, + jwksUri: env.JWKS_URI || `${issuerTrimmed}/.well-known/jwks.json` + }; + + const toolScopes = parseToolScopesJson(env.TOOL_SCOPES_JSON); + + const rawFunctionsBase = + env.FUNCTIONS_BASE ?? + "https://us-central1-.cloudfunctions.net"; + const functionsBase = trimTrailingSlashes(rawFunctionsBase); + + const appOrigin = env.APP_ORIGIN || "*"; + + // Proxy configuration (optional). Only set if PROXY_TARGET is present. + let proxy: ProxyConfig | undefined; + if (env.PROXY_TARGET) { + const allowedPathsEnv = env.PROXY_ALLOWED_PATHS || "/"; + const allowedPaths = allowedPathsEnv + .split(",") + .map(p => p.trim()) + .filter(p => p.length > 0); + + proxy = { + target: env.PROXY_TARGET, + prefix: env.PROXY_PREFIX || "/proxy", + allowedPaths: allowedPaths.length ? allowedPaths : ["/"], + injectHeader: env.PROXY_INJECT_HEADER || undefined, + injectQuery: env.PROXY_INJECT_QUERY || undefined, + timeoutMs: env.PROXY_TIMEOUT_MS ? Number(env.PROXY_TIMEOUT_MS) || undefined : undefined + }; + } + + // Auth0 DCR config (optional) + let auth0Dcr: Auth0DcrConfig | null = null; + const mgmtDomain = env.MGMT_DOMAIN; + const mgmtClientId = env.MGMT_CLIENT_ID; + const mgmtClientSecret = env.MGMT_CLIENT_SECRET; + const logWebhookSecret = env.LOG_WEBHOOK_SECRET; + + if (mgmtDomain && mgmtClientId && mgmtClientSecret && logWebhookSecret) { + auth0Dcr = { + mgmtDomain, + clientId: mgmtClientId, + clientSecret: mgmtClientSecret, + logWebhookSecret, + googleConnectionName: env.GOOGLE_CONNECTION_NAME || "google-oauth2" + }; + } + + return { + oidc, + toolScopes, + functionsBase, + appOrigin, + proxy, + auth0Dcr + }; +} + + diff --git a/packages/proxyabl-core/src/index.ts b/packages/proxyabl-core/src/index.ts index e69de29..6b67587 100644 --- a/packages/proxyabl-core/src/index.ts +++ b/packages/proxyabl-core/src/index.ts @@ -0,0 +1,10 @@ +// packages/proxyabl-core/src/index.ts +export * from "./config"; +export * from "./oidc"; +export { + ProxyablAuthError, + verifyAccessToken, + assertToolScopes, + getRequiredScopesForTool, + type VerifiedAccessToken, +} from "./auth"; diff --git a/packages/proxyabl-core/src/oidc.ts b/packages/proxyabl-core/src/oidc.ts new file mode 100644 index 0000000..919ca1b --- /dev/null +++ b/packages/proxyabl-core/src/oidc.ts @@ -0,0 +1,100 @@ +// packages/proxyabl-core/src/oidc.ts +import { + createRemoteJWKSet, + jwtVerify, + type JWTPayload, + type JWTVerifyGetKey, +} from "jose"; +import type { ProxyablConfig } from "./config"; +import { trimTrailingSlashes } from "./config"; + + +export interface VerifiedAccessTokenPayload extends JWTPayload { + sub?: string; + scope?: string; + permissions?: string[]; // Auth0-style permissions array + [key: string]: unknown; +} + +export interface VerifiedAccessToken { + payload: VerifiedAccessTokenPayload; +} + +// Simple in-memory cache keyed by issuer (no trailing slash) +type IssuerKey = string; +const jwksCache = new Map(); + +function normalizeIssuer(issuer: string): string { + return trimTrailingSlashes(issuer); +} + +function getIssuerVariants(issuerRaw: string): string[] { + const base = normalizeIssuer(issuerRaw); + return [base, `${base}/`]; +} + +function getJwksForConfig(config: ProxyablConfig): JWTVerifyGetKey { + const issuerKey = normalizeIssuer(config.oidc.issuer); + + const existing = jwksCache.get(issuerKey); + if (existing) return existing; + + const jwksUri = + config.oidc.jwksUri ?? + `${normalizeIssuer(config.oidc.issuer)}/.well-known/jwks.json`; + + const remote = createRemoteJWKSet(new URL(jwksUri)); + jwksCache.set(issuerKey, remote); + return remote; +} + +/** + * Verify a raw access token using the ProxyablConfig OIDC settings. + * - Validates issuer (with/without trailing slash) + * - Validates audience if configured + */ +export async function verifyAccessToken( + token: string, + config: ProxyablConfig +): Promise { + const jwks = getJwksForConfig(config); + const issuers = getIssuerVariants(config.oidc.issuer); + + const verifyOpts: Parameters[2] = { + issuer: issuers, + }; + + if (config.oidc.audience) { + (verifyOpts as any).audience = config.oidc.audience; + } + + const { payload } = await jwtVerify(token, jwks, verifyOpts); + return { payload: payload as VerifiedAccessTokenPayload }; +} + +/** + * Extract a string subject ("sub") from a verified payload. + * Empty string if missing. + */ +export function extractSub(payload: VerifiedAccessTokenPayload): string { + return typeof payload.sub === "string" ? payload.sub : ""; +} + +/** + * Merge scopes from: + * - space-delimited "scope" claim + * - Auth0 "permissions" string[] + */ +export function extractScopes( + payload: VerifiedAccessTokenPayload +): string[] { + const scopeStr = + typeof payload.scope === "string" ? payload.scope : ""; + const scopeList = scopeStr.split(" ").filter(Boolean); + + const permissions = Array.isArray((payload as any).permissions) + ? ((payload as any).permissions as string[]) + : []; + + return Array.from(new Set([...scopeList, ...permissions])); +} diff --git a/packages/proxyabl-core/tsconfig.json b/packages/proxyabl-core/tsconfig.json index 081eede..d88c620 100644 --- a/packages/proxyabl-core/tsconfig.json +++ b/packages/proxyabl-core/tsconfig.json @@ -1,10 +1,12 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { - "jsx": "react-jsx", - "outDir": "../../dist/admin-ui", + "composite": true, + "outDir": "dist", "rootDir": "src", - "composite": true + "types": ["node"], + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" }, - "include": ["src"] + "include": ["src"], + "references": [] } diff --git a/packages/proxyabl/package.json b/packages/proxyabl/package.json index 580e10c..cc4a6da 100644 --- a/packages/proxyabl/package.json +++ b/packages/proxyabl/package.json @@ -1,7 +1,7 @@ { "name": "@gatewaystack/proxyabl", "version": "0.0.1", - "private": true, + "private": false, "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -9,12 +9,13 @@ ".": "./dist/index.js" }, "scripts": { - "build": "tsc -p tsconfig.json" + "build": "tsc -b" }, "dependencies": { "express": "^4.22.0", - "jose": "^5.9.6" - }, + "@gatewaystack/proxyabl-core": "0.0.1", + "@gatewaystack/request-context": "0.1.0" + }, "devDependencies": { "@types/express": "^4.17.21", "typescript": "^5.6.3", diff --git a/packages/proxyabl/src/auth.ts b/packages/proxyabl/src/auth.ts new file mode 100644 index 0000000..bc54109 --- /dev/null +++ b/packages/proxyabl/src/auth.ts @@ -0,0 +1,122 @@ +// packages/proxyabl/src/auth.ts +import type { Request } from "express"; +import type { JWTPayload } from "jose"; +import type { ProxyablConfig } from "@gatewaystack/proxyabl-core"; + +import { + verifyAccessToken, + assertToolScopes, + getRequiredScopesForTool, + ProxyablAuthError, + type VerifiedAccessToken, +} from "@gatewaystack/proxyabl-core"; + +import { + getGatewayContext, + type GatewayIdentity, +} from "@gatewaystack/request-context"; + +import { buildWwwAuthenticate } from "./oidc-helpers"; + +/** + * Parsed Authorization header for quick diagnostics. + */ +export interface AuthHeaderShape { + hasAuth: boolean; + token: string; + tokenShape: "jwt" | "opaque" | "none"; + len: number; +} + +/** + * Identity + scopes derived from a Request. + */ +export interface VerifiedRequestIdentity { + sub: string; + email?: string; + scopes: string[]; + payload: JWTPayload; +} + +/** + * Cheap parser for the Authorization header. + */ +export function readAuthHeader(req: Request): AuthHeaderShape { + const auth = req.header("authorization") || ""; + const hasAuth = auth.startsWith("Bearer "); + const token = hasAuth ? auth.slice(7) : ""; + const tokenShape = token.includes(".") + ? ("jwt" as const) + : token + ? ("opaque" as const) + : ("none" as const); + + return { hasAuth, token, tokenShape, len: token.length }; +} + +/** + * Verify a Bearer token from an Express Request using proxyabl-core's + * verifyAccessToken. Throws ProxyablAuthError with .status/.code and + * a `.www` property that can be used as WWW-Authenticate. + * + * ALSO: populates GatewayContext.identity so other layers can read it. + */ +export async function verifyBearerFromRequest( + config: ProxyablConfig, + req: Request, +): Promise { + const { hasAuth } = readAuthHeader(req); + + const ctx = getGatewayContext(); + const identity = ctx?.identity as GatewayIdentity | undefined; + + if (!identity || !identity.sub) { + // If there was a Bearer token but no identity, treat as invalid token. + // If there was no Bearer, treat as missing auth. + const code = hasAuth ? "INVALID_TOKEN" : "NO_AUTH"; + const message = hasAuth + ? "Invalid or unverified access token" + : "Missing Bearer token"; + + const err = new ProxyablAuthError(code, message, 401) as ProxyablAuthError & { + www?: string; + }; + err.www = buildWwwAuthenticate(config, req) + ', error="invalid_token"'; + throw err; + } + + const scopes = identity.scopes ?? []; + const payload = identity.raw as JWTPayload; + + return { + sub: identity.sub, + email: identity.email, + scopes, + payload, + }; +} + +/** + * Convenience helper: ensure the caller has the scopes required for `toolName`. + * Throws ProxyablAuthError(INSUFFICIENT_SCOPE) on failure. + */ +export function ensureToolScopesForRequest( + config: ProxyablConfig, + toolName: string, + scopes: string[], +): void { + // This just delegates to core's assertToolScopes, but keeps + // the wrapper name expressive in the router. + assertToolScopes(config, toolName, scopes); +} + +/** + * Retrieve the required scopes for a tool (if any) – useful for + * building MCP tool metadata, documentation, etc. + */ +export function getRequiredScopesForToolForConfig( + config: ProxyablConfig, + toolName: string, +): string[] { + return getRequiredScopesForTool(config, toolName); +} diff --git a/packages/proxyabl/src/index.ts b/packages/proxyabl/src/index.ts index e51ef33..c55ced2 100644 --- a/packages/proxyabl/src/index.ts +++ b/packages/proxyabl/src/index.ts @@ -1 +1,8 @@ +// packages/proxyabl/src/index.ts +import { configFromEnv, type ProxyablConfig } from "@gatewaystack/proxyabl-core"; +import { createProxyablRouter } from "./router"; + +export { createProxyablRouter, configFromEnv }; +export type { ProxyablConfig }; + export * from "./tool-gateway"; diff --git a/packages/proxyabl/src/oidc-helpers.ts b/packages/proxyabl/src/oidc-helpers.ts new file mode 100644 index 0000000..246fdec --- /dev/null +++ b/packages/proxyabl/src/oidc-helpers.ts @@ -0,0 +1,104 @@ +// packages/proxyabl/src/oidc-helpers.ts +import type { Request } from "express"; +import type { ProxyablConfig } from "@gatewaystack/proxyabl-core"; + +/** + * Normalize issuer: no trailing slash. + */ +export function getIssuer(config: ProxyablConfig): string { + const raw = config.oidc.issuer || ""; + let s = raw; + while (s.endsWith("/")) { + s = s.slice(0, -1); + } + return s; +} + + +export function getAudience(config: ProxyablConfig): string | undefined { + return config.oidc.audience; +} + +export function getJwksUri(config: ProxyablConfig): string { + const issuer = getIssuer(config); + return config.oidc.jwksUri ?? `${issuer}/.well-known/jwks.json`; +} + +/** + * Scopes that clients should request. + * We add some base OIDC scopes on top of required tool scopes. + */ +export function getScopesSupported(config: ProxyablConfig): string[] { + const base = ["openid", "email", "profile"]; + const required = getRequiredScopes(config); + return Array.from(new Set([...base, ...required])); +} + +/** + * Union of all required scopes across tools. + */ +export function getRequiredScopes(config: ProxyablConfig): string[] { + const toolScopes = config.toolScopes || {}; + const all: string[] = []; + + for (const scopes of Object.values(toolScopes)) { + if (Array.isArray(scopes)) { + for (const s of scopes) { + if (typeof s === "string" && s.trim().length > 0) { + all.push(s); + } + } + } + } + + return Array.from(new Set(all)); +} + +/** + * RFC 9728-style WWW-Authenticate header for this resource server. + */ +export function buildWwwAuthenticate( + config: ProxyablConfig, + req: Request +): string { + const xfProto = req.get("x-forwarded-proto") || req.protocol || "https"; + const xfHost = req.get("x-forwarded-host") || req.get("host") || ""; + const base = `${xfProto}://${xfHost}`; + const metaUrl = `${base}/.well-known/oauth-protected-resource`; + + const scopes = getScopesSupported(config).join(" "); + const audience = getAudience(config); + const resourceParam = audience ? `, resource="${audience}"` : ""; + + // RFC 9728: use resource_metadata + return `Bearer resource_metadata="${metaUrl}", scope="${scopes}"${resourceParam}`; +} + +// --- Small robust fetch with timeout + retry (ported from toolGatewayHandler) --- + +export async function fetchJsonWithRetry( + url: string, + tries = 3, + timeoutMs = 4000 +): Promise { + let lastErr: unknown; + for (let i = 0; i < tries; i++) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const r = await fetch(url, { + headers: { accept: "application/json" }, + signal: ctrl.signal, + } as any); + clearTimeout(t); + if (!r.ok) throw new Error(`http_${r.status}`); + return await r.json(); + } catch (e) { + lastErr = e; + await new Promise((res) => setTimeout(res, 150 * (i + 1))); + } finally { + clearTimeout(t); + } + } + throw lastErr instanceof Error ? lastErr : new Error(String(lastErr)); +} \ No newline at end of file diff --git a/packages/proxyabl/src/router.ts b/packages/proxyabl/src/router.ts new file mode 100644 index 0000000..2b73455 --- /dev/null +++ b/packages/proxyabl/src/router.ts @@ -0,0 +1,1235 @@ +import { Router, type Request, type Response, type NextFunction, type RequestHandler } from "express"; +import rateLimit from "express-rate-limit"; +import type { ProxyablConfig, ToolScopesConfig } from "@gatewaystack/proxyabl-core"; + +import { + buildWwwAuthenticate, + fetchJsonWithRetry, + getIssuer, + getAudience, + getScopesSupported, + getJwksUri, + getRequiredScopes, +} from "./oidc-helpers"; + +// 🔹 NEW: auth helpers +import { + verifyBearerFromRequest, + ensureToolScopesForRequest, +} from "./auth"; + +// 🔹 NEW: if you want a requestId like before +import { randomUUID } from "crypto"; + +function trimTrailingSlashes(input: string): string { + let out = input; + while (out.endsWith("/")) { + out = out.slice(0, -1); + } + return out; +} + +export function createProxyablRouter(config: ProxyablConfig): Router { + const router = Router(); + + const wellKnownRateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 100, + standardHeaders: true, + legacyHeaders: false, + }) as unknown as RequestHandler; + + const webhookRateLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 100, + standardHeaders: true, + legacyHeaders: false, + }) as unknown as RequestHandler; + + // ---- Well-known endpoints ---- + router.get("/.well-known/oauth-protected-resource", (req, res, next) => + wkProtectedResourceHandler(config, req, res, next) + ); + router.get("/.well-known/openid-configuration", (req, res, next) => + wkOpenIdConfigHandler(config, req, res, next) + ); + router.get( + "/.well-known/oauth-authorization-server", + wellKnownRateLimiter, + (req, res, next) => wkAuthzServerHandler(config, req, res, next) + ); + + // ---- MCP JSON-RPC ---- + router.post("/mcp", (req, res, next) => + mcpHandler(config, req, res, next) + ); + router.options("/mcp", (req, res, next) => + mcpHandler(config, req, res, next) + ); + + // ---- REST tool calls (POST /:toolName) ---- + router.post("/:toolName", (req, res, next) => + restToolHandler(config, req, res, next) + ); + + // ---- Reverse proxy (/proxy/* or custom prefix) ---- + if (config.proxy) { + const prefix = config.proxy.prefix || "/proxy"; + router.all(`${prefix}/*`, (req, res, next) => + proxyHandler(config, req, res, next) + ); + } + + // ---- Auth0 DCR webhook (optional) ---- + if (config.auth0Dcr) { + router.post( + "/auth0/logs", + webhookRateLimiter, + (req, res, next) => auth0LogWebhookHandler(config, req, res, next) + ); + } + + return router; +} + +// For now, these are minimal placeholders. +// Next steps: replace each with your real logic from toolGatewayHandler.ts +function wkProtectedResourceHandler( + config: ProxyablConfig, + req: Request, + res: Response, + _next: NextFunction +) { + const issuer = getIssuer(config); + const audience = getAudience(config); + + const pathOnly = (req.originalUrl || req.url || "").split("?")[0]; + + console.log("[proxyabl.wk] oauth-protected-resource", { + path: pathOnly, + method: req.method, + issuer, + }); + + console.log("[proxyabl.wk.cfg]", { + issuer: issuer ? "[configured]" : "[default]", + audience: audience ? "[set]" : null, + jwks_uri: getJwksUri(config) ? "[set]" : null, + required_scopes_count: getRequiredScopes(config).length, + }); + + try { + // Optional sanity check: try fetching issuer discovery, + // but we don't need the response to build our payload. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + fetchJsonWithRetry(`${issuer}/.well-known/openid-configuration`).catch((e) => { + console.warn("[proxyabl.wk] discovery check failed", { + message: (e as Error).message, + }); + }); + + const scopes_supported = getScopesSupported(config); + + const payload: any = { + authorization_servers: [issuer], + scopes_supported, + }; + if (audience) payload.resource = audience; + + res.setHeader("Content-Type", "application/json"); + res.status(200).send(payload); + } catch (e: any) { + console.error("[proxyabl.wk.error]", { + message: e?.message || String(e), + }); + res.status(502).json({ + error: "discovery_fetch_failed", + detail: e?.message || String(e), + }); + } +} + + +async function wkOpenIdConfigHandler( + config: ProxyablConfig, + req: Request, + res: Response, + _next: NextFunction +) { + const issuer = getIssuer(config); + const url = `${issuer}/.well-known/openid-configuration`; + + console.log("[proxyabl.wk] openid-configuration proxy", { + path: req.path, + method: req.method, + upstream: url, + }); + + try { + const doc = await fetchJsonWithRetry(url); + res.setHeader("Content-Type", "application/json"); + res.status(200).send(doc); + } catch (e: any) { + console.error("[proxyabl.wk.error]", { + message: e?.message || String(e), + }); + res.status(502).json({ + error: "discovery_fetch_failed", + detail: e?.message || String(e), + }); + } +} + + +async function wkAuthzServerHandler( + config: ProxyablConfig, + req: Request, + res: Response, + _next: NextFunction +) { + const issuer = getIssuer(config); + const url = `${issuer}/.well-known/oauth-authorization-server`; + + console.log("[proxyabl.wk] oauth-authorization-server proxy", { + path: req.path, + method: req.method, + upstream: url, + }); + + try { + const doc = await fetchJsonWithRetry(url); + res.setHeader("Content-Type", "application/json"); + res.status(200).send(doc); + } catch (e: any) { + console.error("[proxyabl.wk.error]", { + message: e?.message || String(e), + }); + res.status(502).json({ + error: "discovery_fetch_failed", + detail: e?.message || String(e), + }); + } +} + + +// ---- MCP JSON-RPC helpers ---- + +type JsonRpcReq = { + jsonrpc: "2.0"; + id?: string | number | null; + method: string; + params?: any; +}; + +type JsonRpcRes = + | { jsonrpc: "2.0"; id: string | number | null; result: any } + | { + jsonrpc: "2.0"; + id: string | number | null; + error: { code: number; message: string; data?: any }; + }; + +function jsonRpcResult(id: any, result: any): JsonRpcRes { + return { jsonrpc: "2.0", id: id ?? null, result }; +} + +function jsonRpcError( + id: any, + code: number, + message: string, + data?: any +): JsonRpcRes { + return { jsonrpc: "2.0", id: id ?? null, error: { code, message, data } }; +} + + +async function mcpHandler( + config: ProxyablConfig, + req: Request, + res: Response, + _next: NextFunction +) { + // We always speak JSON + res.setHeader("Content-Type", "application/json"); + + // OPTIONS → CORS preflight + if (req.method === "OPTIONS") { + res.status(204).end(); + return; + } + + // Only POST is allowed beyond this point + if (req.method !== "POST") { + res + .status(405) + .json({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "Method not allowed" } }); + return; + } + + // Parse JSON-RPC body + let rpc: JsonRpcReq; + try { + rpc = + typeof req.body === "string" + ? (JSON.parse(req.body) as JsonRpcReq) + : (req.body as JsonRpcReq); + } catch { + res.status(400).json(jsonRpcError(null, -32700, "Parse error")); + return; + } + + if (!rpc || rpc.jsonrpc !== "2.0" || typeof rpc.method !== "string") { + res + .status(400) + .json(jsonRpcError(rpc?.id ?? null, -32600, "Invalid Request")); + return; + } + + // --- 1) initialize → no auth required + if (rpc.method === "initialize") { + const result = { + protocolVersion: "2025-06-18", + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: "proxyabl-gateway", version: "0.1.0" }, + }; + res.status(200).json(jsonRpcResult(rpc.id ?? null, result)); + return; + } + + // --- 2) notifications/initialized → ack-only + if (rpc.method === "notifications/initialized") { + res.status(202).end(); + return; + } + + // From here on, methods are authenticated + // From here on, methods are authenticated + const functionsBaseRaw = config.functionsBase || ""; + const functionsBase = trimTrailingSlashes(functionsBaseRaw); + if (!functionsBase) { + res.status(500).json( + jsonRpcError(rpc.id ?? null, -32000, "functionsBase not configured") + ); + return; + } + + // Helper: verify bearer + scopes, shared by tools/list and tools/call + const ensureAuth = async (toolNameForScopes?: string) => { + try { + const identity = await verifyBearerFromRequest(config, req); + + if (toolNameForScopes) { + ensureToolScopesForRequest(config, toolNameForScopes, identity.scopes); + } + + return identity; + } catch (e: any) { + const status = Number(e?.status) || 401; + const www = e?.www || buildWwwAuthenticate(config, req); + res.setHeader("WWW-Authenticate", www); + res + .status(status) + .json( + jsonRpcError( + rpc.id ?? null, + -32001, + e?.message || "Unauthorized", + { code: e?.code || "AUTH_ERROR" } + ) + ); + return undefined; + } + }; + + // --- 3) tools/list + if (rpc.method === "tools/list") { + const identity = await ensureAuth(); // auth required, but no specific tool scopes yet + if (!identity) return; // response already sent + + const toolScopes: ToolScopesConfig = config.toolScopes ?? {}; + + const tools = Object.entries(toolScopes).map(([name, scopes]) => { + const required = Array.isArray(scopes) ? scopes : []; + + const inputSchema = { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + additionalProperties: true, + }; + + const securitySchemes = + required.length > 0 + ? [{ type: "oauth2" as const, scopes: required }] + : [{ type: "noauth" as const }]; + + return { + name, + title: name.replace(/([A-Z])/g, " $1").trim(), + description: name, + inputSchema, + input_schema: inputSchema, + requiredScopes: required, + securitySchemes, + executable: true, + }; + }); + + res.status(200).json( + jsonRpcResult(rpc.id ?? null, { + tools, + }) + ); + return; + } + + // --- 4) tools/call + if (rpc.method === "tools/call") { + const nameRaw = rpc.params?.name; + const args = rpc.params?.arguments ?? {}; + + if (typeof nameRaw !== "string") { + res + .status(400) + .json( + jsonRpcError( + rpc.id ?? null, + -32602, + "Invalid tool name", + { code: "INVALID_TOOL_NAME" } + ) + ); + return; + } + + let toolName: string; + try { + toolName = sanitizeToolName(config, nameRaw); + } catch (e: any) { + const status = Number(e?.status) || 400; + res.status(status).json( + jsonRpcError( + rpc.id ?? null, + -32602, + e?.message || "Invalid tool name", + { code: e?.code || "INVALID_TOOL_NAME" } + ) + ); + return; + } + + const identity = await ensureAuth(toolName); + if (!identity) return; // response already sent + + const requestId = req.header("x-request-id") || randomUUID(); + const url = `${functionsBase}/${toolName}`; + + const upstream = await fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + "x-request-id": requestId, + // later you can add x-user-uid / x-gateway-sig if you want + }, + body: JSON.stringify(args), + } as any); + + const ct = upstream.headers.get("content-type") || ""; + const payloadOut = ct.includes("json") + ? await upstream.json() + : await upstream.text(); + + if (!upstream.ok) { + res.status(200).json( + jsonRpcResult(rpc.id ?? null, { + isError: true, + content: [ + { + type: "error", + error: { status: upstream.status, body: payloadOut }, + }, + ], + }) + ); + return; + } + + res.status(200).json( + jsonRpcResult(rpc.id ?? null, { + isError: false, + content: [{ type: "output_json", json: payloadOut }], + }) + ); + return; + } + + // --- 5) Unknown method + res + .status(400) + .json( + jsonRpcError( + rpc.id ?? null, + -32601, + `Method not found: ${rpc.method}` + ) + ); +} + + +function toolNameFromPath(path: string): string | null { + const parts = path.split("/").filter(Boolean); + return parts.length ? parts[parts.length - 1] : null; +} + +/** + * Only allow "safe" tool names and require that they exist in config.toolScopes. + * Mirrors your old sanitizeFunctionName behavior. + */ +function sanitizeToolName(config: ProxyablConfig, raw: string): string { + if (!/^[a-zA-Z0-9_-]+$/.test(raw)) { + const err: any = new Error("INVALID_TOOL_NAME"); + err.status = 400; + err.code = "INVALID_TOOL_NAME"; + throw err; + } + + const toolScopes = config.toolScopes ?? {}; + if (!toolScopes[raw]) { + const err: any = new Error(`UNKNOWN_TOOL:${raw}`); + err.status = 404; + err.code = "UNKNOWN_TOOL"; + throw err; + } + + return raw; +} + +async function restToolHandler( + config: ProxyablConfig, + req: Request, + res: Response, + _next: NextFunction +) { + if (req.method !== "POST") { + res.status(405).json({ ok: false, error: { code: "METHOD_NOT_ALLOWED" } }); + return; + } + + const requestId = req.header("x-request-id") || randomUUID(); + const started = Date.now(); + + try { + // 1) Parse + sanitize tool name from path + const rawName = toolNameFromPath(req.path); + if (!rawName) { + res.status(400).json({ + ok: false, + error: { code: "NO_TOOL", message: "Missing tool name" }, + requestId, + }); + return; + } + + let toolName: string; + try { + toolName = sanitizeToolName(config, rawName); + } catch (e: any) { + const status = Number(e?.status) || 400; + res.status(status).json({ + ok: false, + error: { code: e?.code || "INVALID_TOOL_NAME", message: e?.message || "Invalid tool name" }, + requestId, + }); + return; + } + + // 2) Verify Bearer token + populate GatewayContext.identity + let identity; + try { + identity = await verifyBearerFromRequest(config, req); + } catch (e: any) { + const status = Number(e?.status) || 401; + const www = e?.www || buildWwwAuthenticate(config, req); + res.setHeader("WWW-Authenticate", www); + res.status(status).json({ + ok: false, + error: { code: e?.code || "AUTH_ERROR", message: e?.message || "Unauthorized" }, + requestId, + }); + return; + } + + // 3) Enforce tool scopes + try { + ensureToolScopesForRequest(config, toolName, identity.scopes); + } catch (e: any) { + const status = Number(e?.status) || 403; + res.status(status).json({ + ok: false, + error: { code: e?.code || "INSUFFICIENT_SCOPE", message: e?.message || "Insufficient scope" }, + requestId, + }); + return; + } + + // 4) Call upstream tool function + const baseRaw = config.functionsBase || ""; + const base = trimTrailingSlashes(baseRaw); + if (!base) { + res.status(500).json({ + ok: false, + error: { code: "MISSING_FUNCTIONS_BASE", message: "functionsBase not configured in ProxyablConfig" }, + requestId, + }); + return; + } + + const url = `${base}/${toolName}`; + + const upstream = await fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + "x-request-id": requestId, + // you can add additional headers here later (x-user-uid, x-gateway-sig, etc.) + }, + body: JSON.stringify(req.body ?? {}), + } as any); + + const ct = upstream.headers.get("content-type") || ""; + const payloadOut = ct.includes("json") ? await upstream.json() : await upstream.text(); + const elapsedMs = Date.now() - started; + + if (!upstream.ok) { + res.status(upstream.status).json({ + ok: false, + error: { + code: `FN_${upstream.status}`, + message: "Function error", + details: payloadOut, + }, + requestId, + elapsedMs, + sub: identity.sub, + }); + return; + } + + res.status(200).json({ + ok: true, + data: payloadOut, + requestId, + elapsedMs, + sub: identity.sub, + }); + } catch (e: any) { + const status = Number(e?.status) || 500; + console.error("[proxyabl.rest:error]", { + status, + message: e?.message || String(e), + }); + res.status(status).json({ + ok: false, + error: { code: e?.code || "GATEWAY_ERROR", message: e?.message || String(e) }, + requestId, + }); + } +} + +// ---- Reverse proxy helpers ---- + +function getProxyTargetUrl(config: ProxyablConfig): URL | null { + const target = config.proxy?.target; + if (!target) return null; + + try { + const u = new URL(target); + if (!/^https?:$/.test(u.protocol)) { + console.error("[proxyabl.proxy] PROXY target must be http or https"); + return null; + } + // Basic hostname sanity: only letters, digits, dot, dash + if (!/^[a-zA-Z0-9.-]+$/.test(u.hostname)) { + console.error("[proxyabl.proxy] invalid proxy hostname:", u.hostname); + return null; + } + return u; + } catch (e) { + console.error("[proxyabl.proxy] invalid proxy target:", target, e); + return null; + } +} + +/** + * Normalize a configured allowedPath into a safe prefix: + * - ensure leading slash + * - only allow [a-zA-Z0-9/_-] + */ +function normalizeAllowedPrefix(prefix: string): string | null { + if (typeof prefix !== "string") return null; + + let p = prefix.trim(); + if (!p) return null; + + // ensure leading slash + p = "/" + p.replace(/^\/+/, ""); + + // Only allow safe characters in paths + if (!/^\/[a-zA-Z0-9/_-]*$/.test(p)) { + console.warn("[proxyabl.proxy] ignoring invalid allowedPath:", prefix); + return null; + } + + return p; +} + +/** + * Get the allowlisted upstream path prefixes. + * If not provided or invalid, default to ["/"] (everything). + */ +function getProxyAllowedPaths(config: ProxyablConfig): string[] { + const fromConfig = config.proxy?.allowedPaths; + if (fromConfig && fromConfig.length) { + const cleaned = fromConfig + .map(normalizeAllowedPrefix) + .filter((p): p is string => Boolean(p)); + if (cleaned.length) return cleaned; + } + return ["/"]; +} + + +/** + * Normalize and sanitize the upstream path tail. + * Always returns a path (never a URL), forbids \"//\", \"://\", and \"..\". + */ +function sanitizeProxyPath(rawTail: string): string { + let p = rawTail || "/"; + + // Normalize: ensure leading slash + p = "/" + p.replace(/^\/+/, ""); + + // Block attempts to smuggle in full URLs or schemes + if (p.startsWith("//") || p.includes("://")) { + const err: any = new Error("INVALID_UPSTREAM_PATH"); + err.status = 400; + err.code = "INVALID_UPSTREAM_PATH"; + throw err; + } + + // Avoid .. path traversal + if (p.includes("..")) { + const err: any = new Error("INVALID_UPSTREAM_PATH"); + err.status = 400; + err.code = "INVALID_UPSTREAM_PATH"; + throw err; + } + + return p; +} + +/** + * Enforce that the path is under one of the allowed prefixes. + * - Exact match: "/foo" + * - Prefix match: "/foo/" matches "/foo/bar", "/foo/baz", etc. + */ +function enforceProxyPathAllowlist( + path: string, + allowedPrefixes: string[] +): void { + const ok = allowedPrefixes.some((prefix) => { + const pref = prefix.startsWith("/") ? prefix : `/${prefix}`; + if (pref.endsWith("/")) { + return path === pref.slice(0, -1) || path.startsWith(pref); + } + return path === pref || path.startsWith(pref + "/"); + }); + + if (!ok) { + const err: any = new Error("PROXY_PATH_NOT_ALLOWED"); + err.status = 403; + err.code = "PROXY_PATH_NOT_ALLOWED"; + throw err; + } +} + +async function proxyHandler( + config: ProxyablConfig, + req: Request, + res: Response, + _next: NextFunction +) { + const proxyCfg = config.proxy; + const baseUrl = getProxyTargetUrl(config); + + if (!proxyCfg || !baseUrl) { + res.status(501).json({ + ok: false, + error: "proxy_not_configured", + }); + return; + } + + try { + // 🔐 1) Authenticate the caller + const identity = await verifyBearerFromRequest(config, req); + const userId = identity.sub; + + const allowedPaths = getProxyAllowedPaths(config); + + // 2) Strip the gateway prefix and sanitize the tail + const prefix = proxyCfg.prefix || "/proxy"; + const rawTail = req.path.slice(prefix.length) || "/"; + const tail = sanitizeProxyPath(rawTail); + + // 3) Enforce allowlist BEFORE constructing URL + enforceProxyPathAllowlist(tail, allowedPaths); + + // 4) Build upstream URL anchored to validated baseUrl + const urlObj = new URL(tail, baseUrl); + + // Extra SSRF guard: ensure hostname + protocol still match the configured target + if (urlObj.hostname !== baseUrl.hostname || urlObj.protocol !== baseUrl.protocol) { + console.warn("[proxyabl.proxy] host/protocol mismatch after URL construction", { + baseHost: baseUrl.hostname, + baseProto: baseUrl.protocol, + urlHost: urlObj.hostname, + urlProto: urlObj.protocol, + }); + + res.status(400).json({ + ok: false, + error: "invalid_upstream_url", + detail: "Upstream URL host/protocol mismatch", + }); + return; + } + + // Copy original query params + for (const [k, v] of Object.entries(req.query)) { + if (Array.isArray(v)) { + v.forEach((x) => urlObj.searchParams.append(k, String(x))); + } else if (v != null) { + urlObj.searchParams.append(k, String(v)); + } + } + + // Inject user id into query if configured + if (proxyCfg.injectQuery) { + urlObj.searchParams.set(proxyCfg.injectQuery, userId); + } + + // 5) Build upstream headers + const headers: Record = {}; + for (const [k, v] of Object.entries(req.headers)) { + const key = k.toLowerCase(); + if ( + key === "host" || + key === "connection" || + key === "content-length" || + key === "transfer-encoding" || + key === "authorization" + ) { + continue; + } + headers[key] = Array.isArray(v) ? v.join(", ") : (v as string); + } + + // Inject user id into header if configured + if (proxyCfg.injectHeader) { + headers[proxyCfg.injectHeader.toLowerCase()] = userId; + } + + const method = req.method.toUpperCase(); + const hasBody = !["GET", "HEAD"].includes(method); + const body = + hasBody && req.body ? JSON.stringify(req.body) : undefined; + if (body && !headers["content-type"]) { + headers["content-type"] = "application/json"; + } + + // 6) Timeout + fetch + const controller = new AbortController(); + const timeoutMs = proxyCfg.timeoutMs ?? 5000; + const t = setTimeout(() => controller.abort(), timeoutMs); + + // codeql[js/server-side-request-forgery]: upstream URL is constrained by a validated baseUrl + strict path allowlist; tail is sanitized to disallow protocols, hostnames, and traversal. + const upstream = await fetch(urlObj.toString(), { + method, + headers, + body, + signal: controller.signal, + } as any); + + clearTimeout(t); + + // 7) Stream response back + res.status(upstream.status); + upstream.headers.forEach((val, key) => { + const k = key.toLowerCase(); + if (k === "content-length" || k === "transfer-encoding" || k === "connection") { + return; + } + res.setHeader(key, val); + }); + + const buf = Buffer.from(await upstream.arrayBuffer()); + res.end(buf); + } catch (e: any) { + if (e?.name === "AbortError") { + res.status(502).json({ error: "upstream_timeout" }); + return; + } + + const status = Number(e?.status) || 502; + console.error("[proxyabl.proxy.error]", { + status, + code: e?.code, + message: e?.message, + }); + + res.status(status).json({ error: e?.code || "proxy_failed" }); + } +} + +// ---- Auth0 DCR helpers ---- + +interface OAuthTokenRes { + access_token: string; + token_type?: string; + expires_in?: number; +} + +function getAuth0DcrConfig(config: ProxyablConfig) { + const dcr = config.auth0Dcr; + if (!dcr) { + throw new Error("auth0Dcr_not_configured"); + } + return dcr; +} + +function sanitizeMgmtClientId(id: string): string { + if (typeof id !== "string") { + throw new Error("invalid_client_id_type"); + } + // Auth0 client_ids are URL-safe base64-ish: letters, digits, - and _ + if (!/^[a-zA-Z0-9_-]+$/.test(id)) { + throw new Error("invalid_client_id_format"); + } + return id; +} + +function auth0MgmtUrl(dcrCfg: ReturnType, path: string): string { + const domain = (dcrCfg.mgmtDomain || "").trim(); + if (!domain) { + throw new Error("MGMT_DOMAIN_not_set"); + } + // Defensive: only allow hostname-style content in domain + if (!/^[a-zA-Z0-9.-]+$/.test(domain)) { + throw new Error(`invalid_MGMT_DOMAIN:${domain}`); + } + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + return `https://${domain}${normalizedPath}`; +} + +async function getMgmtToken( + config: ProxyablConfig +): Promise { + const dcrCfg = getAuth0DcrConfig(config); + + const r = await fetch( + auth0MgmtUrl(dcrCfg, "/oauth/token"), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + grant_type: "client_credentials", + client_id: dcrCfg.clientId, + client_secret: dcrCfg.clientSecret, + audience: auth0MgmtUrl(dcrCfg, "/api/v2/"), + }), + } as any + ); + + if (!r.ok) { + throw new Error(`mgmt_token_http_${r.status}`); + } + + const j = (await r.json()) as OAuthTokenRes; + if (!j?.access_token) { + throw new Error("mgmt_token_missing_access_token"); + } + return j.access_token; +} + + +async function promoteClient( + config: ProxyablConfig, + mgmtToken: string, + clientId: string +) { + const dcrCfg = getAuth0DcrConfig(config); + + const patchBody = { + app_type: "regular_web", + is_first_party: true, + token_endpoint_auth_method: "none", + grant_types: ["authorization_code", "refresh_token"], + }; + + const safeClientId = sanitizeMgmtClientId(clientId); + + const r = await fetch( + auth0MgmtUrl(dcrCfg, `/api/v2/clients/${encodeURIComponent(safeClientId)}`), + { + method: "PATCH", + headers: { + "content-type": "application/json", + authorization: `Bearer ${mgmtToken}`, + }, + body: JSON.stringify(patchBody), + } as any + ); + + if (!r.ok) { + console.error("[proxyabl.dcr.promoteClient:error]", { + status: r.status, + client_id_suffix: safeClientId.slice(-5), + }); + throw new Error(`promote_http_${r.status}`); + } +} + +async function enableGoogleForClient( + config: ProxyablConfig, + mgmtToken: string, + clientId: string +) { + const dcrCfg = getAuth0DcrConfig(config); + const connectionName = dcrCfg.googleConnectionName || "google-oauth2"; + + // 1) find the connection id for google-oauth2 + const rc = await fetch( + auth0MgmtUrl(dcrCfg, `/api/v2/connections?name=${encodeURIComponent(connectionName)}`), + { + headers: { authorization: `Bearer ${mgmtToken}` }, + } as any + ); + if (!rc.ok) throw new Error(`conn_lookup_http_${rc.status}`); + + const arr = (await rc.json()) as Array<{ id?: string; enabled_clients?: string[] }>; + const conn = Array.isArray(arr) ? arr[0] : null; + if (!conn || !conn.id) throw new Error(`connection_not_found:${connectionName}`); + + // 2) add clientId to enabled_clients (idempotent) + const enabled = new Set(Array.isArray(conn.enabled_clients) ? conn.enabled_clients : []); + enabled.add(clientId); + + const rp = await fetch( + auth0MgmtUrl(dcrCfg, `/api/v2/connections/${conn.id}`), + { + method: "PATCH", + headers: { + "content-type": "application/json", + authorization: `Bearer ${mgmtToken}`, + }, + body: JSON.stringify({ enabled_clients: Array.from(enabled) }), + } as any + ); + if (!rp.ok) { + console.error("[proxyabl.dcr.enableGoogleForClient:error]", { + status: rp.status, + client_id_suffix: clientId.slice(-5), + }); + throw new Error(`conn_patch_http_${rp.status}`); + } +} + +function unwrapAuth0Event(ev: any): any { + // Auth0 Log Streams often send { data: } + return ev && typeof ev === "object" && ev.data && typeof ev.data === "object" + ? ev.data + : ev; +} + +function evtPath(e: any): string { + return String( + e?.details?.request?.path || + e?.details?.request?.url || + e?.http?.request?.path || + "" + ).toLowerCase(); +} + +function isDcrEventRaw(ev: any): boolean { + const e = unwrapAuth0Event(ev); + const type = String(e?.type || "").toLowerCase(); // e.g., "sapi" + const desc = String(e?.description || "").toLowerCase(); // "dynamic client registration" + const path = evtPath(e); + const method = String(e?.details?.request?.method || e?.http?.method || "").toUpperCase(); + + return ( + // classic text + (type === "sapi" && desc.includes("dynamic client registration")) || + // OIDC DCR endpoint + path.includes("/oidc/register") || + // some tenants log it as client creation via Mgmt API + (method === "POST" && + (path === "/api/v2/clients" || path.endsWith("/api/v2/clients"))) + ); +} + +function extractClientIdFromDcrRaw(ev: any): string | null { + const e = unwrapAuth0Event(ev); + return ( + e?.details?.response?.body?.client_id ?? + e?.client_id ?? + e?.details?.request?.body?.client_id ?? + null + ); +} + +async function findNewestChatGPTClientId( + config: ProxyablConfig, + mgmtToken: string +): Promise { + const dcrCfg = getAuth0DcrConfig(config); + const url = + auth0MgmtUrl(dcrCfg, "/api/v2/clients") + + "?is_global=false&per_page=10&sort=created_at:-1" + + "&fields=client_id,name,created_at,app_type,grant_types,token_endpoint_auth_method" + + "&include_fields=true"; + + const r = await fetch(url, { + headers: { authorization: `Bearer ${mgmtToken}` }, + } as any); + + if (!r.ok) { + console.warn("[proxyabl.dcr] clients list failed", { status: r.status }); + return null; + } + + const arr = (await r.json()) as Array; + const now = Date.now(); + + for (const c of arr) { + const name = String(c?.name || ""); + const createdAt = Date.parse(c?.created_at || ""); + const within5min = isFinite(createdAt) && now - createdAt < 5 * 60 * 1000; + const looksLikeDcr = + name.toLowerCase().startsWith("chatgpt") || + name.toLowerCase().includes("chat gpt"); + const publicPkce = + c?.token_endpoint_auth_method === "none" && + Array.isArray(c?.grant_types) && + c.grant_types.includes("authorization_code"); + + if (within5min && looksLikeDcr && publicPkce && c?.client_id) { + return c.client_id as string; + } + } + + return null; +} + +async function auth0LogWebhookHandler( + config: ProxyablConfig, + req: Request, + res: Response, + _next: NextFunction +) { + try { + const dcrCfg = getAuth0DcrConfig(config); + + // 1) Shared-secret auth + const auth = req.header("authorization") || ""; + if (auth !== `Bearer ${dcrCfg.logWebhookSecret}`) { + res.status(401).json({ ok: false, error: "unauthorized" }); + return; + } + + // 2) Not configured → advertise clearly (nice for OSS quickstarts) + if ( + !dcrCfg.mgmtDomain || + !dcrCfg.clientId || + !dcrCfg.clientSecret + ) { + res.status(501).json({ + ok: false, + error: "not_configured", + detail: + "Set auth0Dcr.mgmtDomain, clientId, clientSecret to enable DCR promotion.", + }); + return; + } + + + // 3) Parse events + let parsed: unknown = req.body; + if (typeof parsed === "string") { + try { + parsed = JSON.parse(parsed); + } catch { + // ignore parse error, treat as a single opaque event + } + } + + const events: any[] = Array.isArray(parsed) + ? (parsed as any[]) + : [parsed as any]; + const dcrEvents = events.filter(isDcrEventRaw); + + if (dcrEvents.length === 0) { + res.status(200).json({ ok: true, filtered: true }); + return; + } + + // 4) Promote each DCR'ed client + const mgmtToken = await getMgmtToken(config); + + for (const raw of dcrEvents) { + let cid = extractClientIdFromDcrRaw(raw); + if (!cid) { + console.warn("[proxyabl.dcr] no client_id in event; attempting fallback lookup"); + cid = await findNewestChatGPTClientId(config, mgmtToken); + } + if (!cid) { + console.warn("[proxyabl.dcr] could not determine client_id; skipping event"); + continue; + } + + console.log("[proxyabl.dcr] promoting client", { client_id: cid }); + + try { + await promoteClient(config, mgmtToken, cid); + } catch (e: any) { + const msg = String(e?.message || ""); + if (msg.startsWith("promote_http_404")) { + console.warn( + "[proxyabl.dcr] 404 promoting client; retrying with fallback search" + ); + const alt = await findNewestChatGPTClientId(config, mgmtToken); + if (alt && alt !== cid) { + console.log("[proxyabl.dcr] retry promoting", { client_id: alt }); + await promoteClient(config, mgmtToken, alt); + cid = alt; + } else { + throw e; + } + } else { + throw e; + } + } + + await enableGoogleForClient(config, mgmtToken, cid); + console.log("[proxyabl.dcr] promoted+enabled", { client_id: cid }); + } + + res.status(200).json({ ok: true, promoted: dcrEvents.length }); + } catch (e: any) { + console.error("[proxyabl.dcr:error]", { + message: e?.message || String(e), + }); + res.status(500).json({ ok: false, error: "dcr_failed" }); + } +} + diff --git a/packages/proxyabl/src/tool-gateway.ts b/packages/proxyabl/src/tool-gateway.ts index 84cf1ae..a06e673 100644 --- a/packages/proxyabl/src/tool-gateway.ts +++ b/packages/proxyabl/src/tool-gateway.ts @@ -1,9 +1,7 @@ -// functions/src/handlers/toolGatewayHandler.ts import { Router } from "express"; import type { Request, Response } from "express"; import { randomUUID, createHmac } from "crypto"; -import rateLimit from "express-rate-limit"; - +import rateLimit, { ipKeyGenerator } from "express-rate-limit"; // ---------- Config -------- const FUNCTIONS_BASE = (process.env.FUNCTIONS_BASE ?? @@ -42,12 +40,14 @@ const DISCOVERY_MAX_PER_WINDOW = +(process.env.DISCOVERY_MAX_PER_WINDOW || 120); const keyFromReq = (req: any): string => { const xf = req.get?.("x-forwarded-for"); if (typeof xf === "string" && xf.length > 0) { - return xf.split(",")[0].trim(); + const ip = xf.split(",")[0].trim(); + return ipKeyGenerator(ip); } - return req.ip || "unknown"; + return ipKeyGenerator(req.ip); }; + // General limiter for tool calls (REST + MCP) const toolLimiter = rateLimit({ windowMs: RATE_WINDOW_MS, diff --git a/packages/proxyabl/tsconfig.json b/packages/proxyabl/tsconfig.json index bd231e6..5b3e5bc 100644 --- a/packages/proxyabl/tsconfig.json +++ b/packages/proxyabl/tsconfig.json @@ -1,9 +1,15 @@ { - "extends": "../../tsconfig.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "../../dist/proxyabl", - "composite": true + "outDir": "dist", + "composite": true, + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" }, - "include": ["src"] + "include": ["src"], + "references": [ + { "path": "../proxyabl-core" }, + { "path": "../request-context" } + ] } + diff --git a/packages/request-context/package.json b/packages/request-context/package.json index e0d6ea4..7fd18fc 100644 --- a/packages/request-context/package.json +++ b/packages/request-context/package.json @@ -3,7 +3,6 @@ "version": "0.1.0", "main": "dist/index.js", "types": "dist/index.d.ts", - "scripts": { - "build": "tsc -b" - } + "scripts": { "build": "tsc -b" } } + diff --git a/packages/request-context/src/index.ts b/packages/request-context/src/index.ts index de994cd..439311c 100644 --- a/packages/request-context/src/index.ts +++ b/packages/request-context/src/index.ts @@ -7,8 +7,13 @@ import type { GatewayRequestMeta, } from "./types"; +// New helper for "overrides" shape +type GatewayContextOverrides = Omit, "request"> & { + request?: Partial; +}; + export function createGatewayContext( - overrides: Partial = {} + overrides: GatewayContextOverrides = {} ): GatewayContext { const reqOverrides: Partial = overrides.request ?? {}; @@ -59,3 +64,45 @@ export function mergeGatewayContext( extras: { ...(base.extras ?? {}), ...(updates.extras ?? {}) }, }; } + +// ADD THIS BELOW mergeGatewayContext in packages/request-context/src/index.ts + +import { AsyncLocalStorage } from "async_hooks"; + +const gatewayAls = new AsyncLocalStorage(); + +/** + * Run a function with a fresh GatewayContext bound to the current async call chain. + * Typically called once per inbound HTTP request by identifiabl middleware. + */ +export function runWithGatewayContext( + overrides: GatewayContextOverrides, + fn: () => T +): T { + const ctx = createGatewayContext(overrides); + return gatewayAls.run(ctx, fn); +} + +/** + * Get the current GatewayContext (if any) for this async call chain. + * Returns undefined if called outside runWithGatewayContext. + */ +export function getGatewayContext(): GatewayContext | undefined { + return gatewayAls.getStore(); +} + +/** + * Merge updates into the current context in-place. + * (Useful for later layers like transformabl, validatabl, limitabl, etc.) + */ +export function updateGatewayContext( + updates: Partial +): void { + const current = gatewayAls.getStore(); + if (!current) return; + + const merged = mergeGatewayContext(current, updates); + + // Mutate in place so references held elsewhere stay valid. + Object.assign(current, merged); +} diff --git a/packages/request-context/tsconfig.json b/packages/request-context/tsconfig.json index f4558b1..039e90f 100644 --- a/packages/request-context/tsconfig.json +++ b/packages/request-context/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "composite": true, - "outDir": "dist" + "outDir": "dist", + "types": ["node"] }, "include": ["src"], "references": [] diff --git a/packages/validatabl-core/tsconfig.json b/packages/validatabl-core/tsconfig.json index 6f9d064..e623160 100644 --- a/packages/validatabl-core/tsconfig.json +++ b/packages/validatabl-core/tsconfig.json @@ -11,6 +11,7 @@ "strict": true, "esModuleInterop": true, "skipLibCheck": true, + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo", "baseUrl": ".", // 👈 relative to packages/identifiabl-core "paths": { diff --git a/packages/validatabl/package.json b/packages/validatabl/package.json index 6f43a1b..729ccba 100644 --- a/packages/validatabl/package.json +++ b/packages/validatabl/package.json @@ -11,6 +11,9 @@ "import": "./dist/index.js" } }, + "scripts": { + "build": "tsc -b" + }, "peerDependencies": { "@gatewaystack/validatabl-core": "*", "express": "^4" diff --git a/tsconfig.base.json b/tsconfig.base.json index 362444a..224638d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -14,29 +14,31 @@ "types": [], "baseUrl": ".", "paths": { - "@gatewaystack/identifiabl-core": ["packages/identifiabl-core/src"], - "@gatewaystack/identifiabl": ["packages/identifiabl/src"], + "@gatewaystack/identifiabl-core": ["packages/identifiabl-core/dist"], + "@gatewaystack/identifiabl": ["packages/identifiabl/dist"], - "@gatewaystack/transformabl-core": ["packages/transformabl-core/src"], - "@gatewaystack/transformabl": ["packages/transformabl/src"], + "@gatewaystack/transformabl-core": ["packages/transformabl-core/src"], + "@gatewaystack/transformabl": ["packages/transformabl/src"], - "@gatewaystack/validatabl-core": ["packages/validatabl-core/src/index.ts"], - "@gatewaystack/validatabl": ["packages/validatabl/src"], + "@gatewaystack/validatabl-core": ["packages/validatabl-core/dist"], + "@gatewaystack/validatabl": ["packages/validatabl/dist"], - "@gatewaystack/limitabl-core": ["packages/limitabl-core/src"], - "@gatewaystack/limitabl": ["packages/limitabl/src"], + "@gatewaystack/limitabl-core": ["packages/limitabl-core/src"], + "@gatewaystack/limitabl": ["packages/limitabl/src"], - "@gatewaystack/proxyabl-core": ["packages/proxyabl-core/src"], - "@gatewaystack/proxyabl": ["packages/proxyabl/src"], + "@gatewaystack/proxyabl-core": ["packages/proxyabl-core/dist"], + "@gatewaystack/proxyabl": ["packages/proxyabl/dist"], - "@gatewaystack/explicabl-core": ["packages/explicabl-core/src"], - "@gatewaystack/explicabl": ["packages/explicabl/src"], + "@gatewaystack/explicabl-core": ["packages/explicabl-core/src"], + "@gatewaystack/explicabl": ["packages/explicabl/src"], - "@gatewaystack/request-context": ["packages/request-context/src"], - "@gatewaystack/integrations": ["packages/integrations/src"], - "@gatewaystack/compat": ["packages/compat/src"] - } - }, + "@gatewaystack/request-context": ["packages/request-context/src/index.ts"], + "@gatewaystack/request-context/*": ["packages/request-context/src/*"], + + "@gatewaystack/integrations": ["packages/integrations/src"], + "@gatewaystack/compat": ["packages/compat/src"] + } + }, "exclude": ["dist", "build", "node_modules"] } diff --git a/tsconfig.json b/tsconfig.json index 0be0f43..141dfe0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,8 +27,28 @@ { "path": "packages/compat" } ], "compilerOptions": { - "composite": true, - "skipLibCheck": true + "composite": true, + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@gatewaystack/proxyabl-core": ["packages/proxyabl-core"], + "@gatewaystack/proxyabl": ["packages/proxyabl"], + + "@gatewaystack/identifiabl-core": ["packages/identifiabl-core"], + "@gatewaystack/identifiabl": ["packages/identifiabl"], + "@gatewaystack/transformabl-core": ["packages/transformabl-core"], + "@gatewaystack/transformabl": ["packages/transformabl"], + "@gatewaystack/validatabl-core": ["packages/validatabl-core"], + "@gatewaystack/validatabl": ["packages/validatabl"], + "@gatewaystack/limitabl-core": ["packages/limitabl-core"], + "@gatewaystack/limitabl": ["packages/limitabl"], + "@gatewaystack/explicabl-core": ["packages/explicabl-core"], + "@gatewaystack/explicabl": ["packages/explicabl"], + "@gatewaystack/request-context": ["packages/request-context"], + "@gatewaystack/integrations": ["packages/integrations"], + "@gatewaystack/compat": ["packages/compat"] + } + }, "exclude": ["**/*.config.ts", "**/*.config.js", "node_modules", "dist"] }