feat(sso-gateway): add OIDC JWT gateway and proxy to micro-services

- New service smart-ide-sso-gateway (port 37148): JWKS verify, /health,
  /v1/token/verify, /v1/upstreams, /proxy/<key>/...
- CORS on JSON responses when SSO_CORS_ORIGIN is set; optional empty
  bearer for langextract upstream
- Docs: feature, API, repo index; wire sso-docv-enso and services scope
- Extend config/services.local.env.example with OIDC and gateway vars
This commit is contained in:
Nicolas Cantu 2026-04-03 22:42:44 +02:00
parent f482b0e2b8
commit 68cb5737c5
19 changed files with 673 additions and 1 deletions

View File

@ -56,6 +56,15 @@ REPOS_DEVTOOLS_URL=http://127.0.0.1:37140
# Orchestrator → tools bridge (for intent resolution hints)
TOOLS_BRIDGE_URL=http://127.0.0.1:37147
# --- smart-ide-sso-gateway (OIDC user token → proxy to micro-services) ---
SSO_GATEWAY_HOST=127.0.0.1
SSO_GATEWAY_PORT=37148
# SSO_CORS_ORIGIN=
# Required when running the gateway: docv / Enso issuer URL
OIDC_ISSUER=
# OIDC_AUDIENCE=
# OIDC_JWKS_URI=
# Ollama / AnythingLLM (orchestrator)
OLLAMA_URL=http://127.0.0.1:11434
ANYTHINGLLM_BASE_URL=

View File

@ -15,6 +15,7 @@ Documentation des **API HTTP** exposées par les services sous [`services/`](../
| **smart_ide-orchestrator** | Bearer (spécification) | `37145` (spécification) | [orchestrator.md](./orchestrator.md) |
| **anythingllm-devtools** | Bearer | `37146` | [anythingllm-devtools-api.md](./anythingllm-devtools-api.md) |
| **smart-ide-tools-bridge** | Bearer (sauf `/health`) | `37147` | [smart-ide-tools-bridge-api.md](./smart-ide-tools-bridge-api.md) |
| **smart-ide-sso-gateway** | Bearer utilisateur OIDC (sauf `/health`) | `37148` | [sso-gateway-api.md](./sso-gateway-api.md) |
| **docv** (externe) | selon dépôt Enso | selon déploiement | [docv.md](./docv.md) |
**OpenAPI** : FastAPI expose une spec interactive pour **langextract-api** (`/docs`) et **local-office** (`/docs`) une fois le service démarré.

View File

@ -0,0 +1,52 @@
# API — smart-ide-sso-gateway
Écoute par défaut : **`127.0.0.1:37148`**. Configuration : `services/smart-ide-sso-gateway/.env.example`, agrégat [config/services.local.env.example](../../config/services.local.env.example).
## Authentification
| Route | Auth utilisateur |
|-------|------------------|
| `GET /health` | Aucune |
| `OPTIONS *` | Aucune (préflight CORS si `SSO_CORS_ORIGIN` défini) |
| Toutes les autres | `Authorization: Bearer <access_token>` OIDC (docv / Enso) |
## Endpoints
### `GET /health`
Réponse `200` : `{ "status": "ok", "service": "smart-ide-sso-gateway" }`.
### `GET /v1/token/verify`
Vérifie le Bearer utilisateur. Réponse `200` : `{ "valid": true, "claims": { ... } }` avec un sous-ensemble des claims (`sub`, `iss`, `aud`, `exp`, `iat`, `email`, `name`, `preferred_username`).
### `GET /v1/upstreams`
Liste les clés de proxy disponibles : `{ "upstreams": [ "orchestrator", ... ] }`.
### Proxy — `ANY /proxy/<upstream_key>/<path>`
- **`<upstream_key>`** : voir liste ci-dessus (`repos_devtools`, `orchestrator`, etc.).
- **`<path>`** : chemin transmis tel quel à lURL de base du service (ex. `/proxy/orchestrator/v1/...``http://ORCHESTRATOR_HOST:PORT/v1/...`).
- **Corps** : relayé pour les méthodes avec body (limite `SSO_GATEWAY_MAX_BODY_BYTES`, défaut 32 MiB).
- **Réponses derreur** : `401` si Bearer utilisateur absent ou invalide ; `404` si clé inconnue ; `503` si `local_office` est ciblé sans `LOCAL_OFFICE_API_KEY`.
Len-tête `Authorization` utilisateur nest **pas** transmis à lamont ; il est remplacé par le jeton de service configuré. Voir [sso-gateway-service.md](../features/sso-gateway-service.md).
## Variables denvironnement (passerelle)
| Variable | Rôle |
|----------|------|
| `OIDC_ISSUER` | Obligatoire — URL de lissuer OpenID |
| `OIDC_AUDIENCE` | Optionnel — audience attendue du JWT |
| `OIDC_JWKS_URI` | Optionnel — URI JWKS explicite |
| `SSO_GATEWAY_HOST` / `SSO_GATEWAY_PORT` | Bind HTTP |
| `SSO_CORS_ORIGIN` | Si défini, en-têtes CORS sur les réponses |
| `SSO_GATEWAY_MAX_BODY_BYTES` | Taille max du corps en entrée |
Les jetons et hôtes des micro-services : mêmes noms que dans `config/services.local.env.example`.
## Voir aussi
- [sso-docv-enso.md](../features/sso-docv-enso.md)
- [README du service](../../services/smart-ide-sso-gateway/README.md)

View File

@ -33,6 +33,7 @@ Vue densemble et index complet : **[repo/README.md](./repo/README.md)**. Règ
| [repo/service-carbonyl.md](./repo/service-carbonyl.md) | Carbonyl (navigateur terminal), prévisualisation test |
| [repo/service-pageindex.md](./repo/service-pageindex.md) | PageIndex (index vectorless, définition sémantique documents) |
| [repo/service-smart-ide-tools-bridge.md](./repo/service-smart-ide-tools-bridge.md) | Pont HTTP IDE + sous-modules CLI |
| [repo/service-smart-ide-sso-gateway.md](./repo/service-smart-ide-sso-gateway.md) | Passerelle OIDC utilisateur → micro-services |
| [repo/service-chandra.md](./repo/service-chandra.md) | Chandra OCR (PDF / images structurés) |
| [repo/extension-anythingllm-workspaces.md](./repo/extension-anythingllm-workspaces.md) | Extension AnythingLLM IDE (supprimée ; voir anythingllm-devtools) |

View File

@ -12,6 +12,7 @@ Permettre au **front web** de la plateforme `smart_ide` (déployé par environne
| **docv / IdP Enso** | Émet `id_token` / `access_token`, expose JWKS |
| **Front SPA** | Échange code OAuth (PKCE recommandé), stocke session |
| **Backend API** (orchestrateur ou BFF) | Valide JWT (signature JWKS, `iss`, `aud`, `exp`), mappe rôles → droits policy |
| **smart-ide-sso-gateway** (monorepo) | Point dentrée optionnel : même validation JWT + proxy vers les micro-services avec jetons techniques — [sso-gateway-service.md](./sso-gateway-service.md) |
## Flux (authorization code + PKCE)
@ -49,6 +50,7 @@ Un **client OAuth par env** (test / pprod / prod) ou un seul client avec **claim
## Références internes
- [sso-gateway-service.md](./sso-gateway-service.md) — passerelle OIDC → API internes
- [platform-target.md](../platform-target.md) — matrice test / pprod / prod
- [deployment-target.md](../deployment-target.md) — TLS, pas de HTTP de contournement

View File

@ -0,0 +1,32 @@
# Passerelle SSO — accès utilisateur aux API `smart_ide`
## Rôle
Le service **`smart-ide-sso-gateway`** (`services/smart-ide-sso-gateway/`) centralise la **validation OIDC** (jeton utilisateur émis par **docv / Enso**) et le **proxy** vers les micro-services du monorepo avec leurs **jetons techniques**.
- Le **navigateur** ou un **BFF** présente un `access_token` utilisateur (`Authorization: Bearer`).
- La passerelle vérifie la signature (**JWKS**), `iss`, éventuellement `aud`, puis appelle lamont avec le **Bearer de service** ou la **clé API** attendue par chaque cible.
Les appels **machine à machine** sans contexte utilisateur (scripts, `ia_dev`) restent possibles **en direct** vers chaque service, comme avant : la passerelle est une **option** pour les parcours « utilisateur authentifié par docv ».
## Lien avec docv / Enso
Le flux **authorization code + PKCE** et le rôle d**IdP** restent décrits dans [sso-docv-enso.md](./sso-docv-enso.md). La passerelle ne remplace pas docv : elle **consomme** les `access_token` déjà obtenus par le front ou un back de confiance.
## Amonts proxy
Les clés exposées par `GET /v1/upstreams` et utilisées dans `/proxy/<clé>/...` sont : `orchestrator`, `repos_devtools`, `ia_dev_gateway`, `anythingllm_devtools`, `tools_bridge`, `langextract`, `regex_search`, `claw_proxy`, `local_office`. Les variables denvironnement sont les mêmes que pour le reste de la plateforme ([`config/services.local.env.example`](../../config/services.local.env.example)).
## En-têtes vers lamont
En plus de lauthentification de service, la passerelle peut envoyer :
- `X-OIDC-Sub` — claim `sub` du JWT utilisateur ;
- `X-OIDC-Email` — claim `email` si présent.
Les services amont peuvent sen servir pour du **journal** ou des **règles fines** ; la **politique dautorisation** métier reste de leur responsabilité.
## Documentation détaillée
- [API/sso-gateway-api.md](../API/sso-gateway-api.md)
- [services/smart-ide-sso-gateway/README.md](../../services/smart-ide-sso-gateway/README.md)

View File

@ -41,6 +41,7 @@ Toute la documentation **opérationnelle** qui vivait auparavant sous des `READM
| [service-pageindex.md](./service-pageindex.md) | PageIndex (index sémantique vectorless), sous-module amont |
| [service-chandra.md](./service-chandra.md) | Chandra OCR, sous-module amont |
| [service-smart-ide-tools-bridge.md](./service-smart-ide-tools-bridge.md) | Pont HTTP IDE + outils sous-modules |
| [service-smart-ide-sso-gateway.md](./service-smart-ide-sso-gateway.md) | Passerelle OIDC utilisateur → micro-services |
| [extension-anythingllm-workspaces.md](./extension-anythingllm-workspaces.md) | Extension AnythingLLM IDE (supprimée ; anythingllm-devtools) |
Les **spécifications** détaillées (contrats HTTP, sécurité, orchestration) restent dans [../API/README.md](../API/README.md) et [../features/](../features/).

View File

@ -0,0 +1,16 @@
# Service smart-ide-sso-gateway (`services/smart-ide-sso-gateway/`)
Passerelle HTTP : validation **JWT utilisateur** (issuer docv / Enso) et **proxy** vers les micro-services `smart_ide` avec authentification **technique** par service.
## Configuration locale
- Fichier agrégé : **[`config/services.local.env.example`](../../config/services.local.env.example)** — y ajouter `OIDC_ISSUER` (et optionnellement `SSO_GATEWAY_*`, `SSO_CORS_ORIGIN`) lorsque la passerelle est utilisée.
- Service : **`services/smart-ide-sso-gateway/.env.example`**.
## Exploitation
Voir **[`services/smart-ide-sso-gateway/README.md`](../../services/smart-ide-sso-gateway/README.md)**, **[`docs/features/sso-gateway-service.md`](../features/sso-gateway-service.md)** et **[`docs/API/sso-gateway-api.md`](../API/sso-gateway-api.md)**.
## Voir aussi
- [sso-docv-enso.md](../features/sso-docv-enso.md)

View File

@ -27,6 +27,7 @@ Tous écoutent en principe sur **`127.0.0.1`** ; ports par défaut : [API/README
| **claw-harness-api** (proxy) | **Proxy HTTP** vers un exécuteur claw-code amont | Outillage modèles alternatifs en dev | **Optionnel** si politique projet autorise ce proxy en interne |
| **local-office** | **Manipulation programmatique** de fichiers Office (docx, etc.), clé `X-API-Key` | Tests / génération de documents depuis lIDE | **Possible** pour génération batch ou transformations Office côté serveur applicatif (hors UI ONLYOFFICE) |
| **smart-ide-tools-bridge** | **Registre** des URLs locales ; **jobs** Carbonyl / PageIndex / Chandra (chemins validés sous `SMART_IDE_MONOREPO_ROOT`) | Découverte des services ; lancer OCR / index / plan terminal depuis lIDE | **Non prévu pour le trafic utilisateur final** : jobs longs, liés au poste / au monorepo ; les **backs produit** doivent préférer leurs propres pipelines (OCR, index) en prod si besoin |
| **smart-ide-sso-gateway** | **Validation JWT** docv / Enso + **proxy** vers les micro-services avec jetons techniques | Alternative au BFF maison pour un front ou un outil qui appelle déjà lIdP | **Possible** quand le produit veut un seul point dentrée HTTP local vers la plateforme IA sous identité utilisateur OIDC ; ne remplace pas les appels M2M directs — [features/sso-gateway-service.md](./features/sso-gateway-service.md) |
## 3. Outils CLI et sous-modules (sans listener HTTP dédié)
@ -53,12 +54,13 @@ Tous écoutent en principe sur **`127.0.0.1`** ; ports par défaut : [API/README
## 6. Rôles respectifs (rappel)
- **IDE** : orchestrateur (intentions), tools-bridge (registre + jobs outils), devtools AnythingLLM, repos-devtools, regex search, gateway, accès directs Ollama / AnythingLLM selon lUX.
- **IDE** : orchestrateur (intentions), tools-bridge (registre + jobs outils), devtools AnythingLLM, repos-devtools, regex search, ia-dev-gateway, **smart-ide-sso-gateway** (OIDC utilisateur → micro-services, si activé), accès directs Ollama / AnythingLLM selon lUX.
- **Backends applicatifs** : consommer en priorité les services **stables et documentés** pour lIA générique (**langextract**, **AnythingLLM**, **Ollama**, éventuellement **orchestrateur**) ; éviter de dépendre du **tools-bridge** pour la charge utilisateur ; respecter périmètres fichiers (`REGEX_SEARCH_ROOT`, SSH / données déployées — [remote-deployed-data-ssh.md](./features/remote-deployed-data-ssh.md)).
## Voir aussi
- [config/services.local.env.example](../config/services.local.env.example)
- [repo/service-smart-ide-tools-bridge.md](./repo/service-smart-ide-tools-bridge.md)
- [repo/service-smart-ide-sso-gateway.md](./repo/service-smart-ide-sso-gateway.md)
- [platform-target.md](./platform-target.md)
- [deployment-target.md](./deployment-target.md)

View File

@ -44,6 +44,8 @@ Services dappoint sur **`127.0.0.1`** (souvent auth **Bearer**) : Git devtool
**smart-ide-tools-bridge** (`services/smart-ide-tools-bridge/`) : **API** locale (registre + Carbonyl / PageIndex / Chandra) — [API/smart-ide-tools-bridge-api.md](../API/smart-ide-tools-bridge-api.md).
**smart-ide-sso-gateway** (`services/smart-ide-sso-gateway/`) : validation **JWT** docv / Enso et **proxy** vers les autres micro-services avec jetons techniques — [features/sso-gateway-service.md](./features/sso-gateway-service.md), [API/sso-gateway-api.md](./API/sso-gateway-api.md).
**Configuration locale** : [config/services.local.env.example](../config/services.local.env.example).
## Documentation liée

View File

@ -0,0 +1,51 @@
# smart-ide-sso-gateway — copy to .env, do not commit .env
# Or merge into config/services.local.env (see repo root config/services.local.env.example)
SSO_GATEWAY_HOST=127.0.0.1
SSO_GATEWAY_PORT=37148
# Optional: browser SPA origin for CORS on JSON and proxied responses
# SSO_CORS_ORIGIN=https://app.example.test
# SSO_GATEWAY_MAX_BODY_BYTES=33554432
# Required: docv / Enso OpenID issuer URL (no trailing slash issues tolerated)
OIDC_ISSUER=https://docv.example.test
# Optional: validate access_token audience
# OIDC_AUDIENCE=smart-ide-gateway
# Optional: override JWKS URL (otherwise discovery or {issuer}/.well-known/jwks.json)
# OIDC_JWKS_URI=https://docv.example.test/.well-known/jwks.json
# Same tokens as other services — gateway injects them toward upstreams
ORCHESTRATOR_HOST=127.0.0.1
ORCHESTRATOR_PORT=37145
ORCHESTRATOR_TOKEN=
REPOS_DEVTOOLS_HOST=127.0.0.1
REPOS_DEVTOOLS_PORT=37140
REPOS_DEVTOOLS_TOKEN=
IA_DEV_GATEWAY_HOST=127.0.0.1
IA_DEV_GATEWAY_PORT=37144
IA_DEV_GATEWAY_TOKEN=
ANYTHINGLLM_DEVTOOLS_HOST=127.0.0.1
ANYTHINGLLM_DEVTOOLS_PORT=37146
ANYTHINGLLM_DEVTOOLS_TOKEN=
TOOLS_BRIDGE_HOST=127.0.0.1
TOOLS_BRIDGE_PORT=37147
TOOLS_BRIDGE_TOKEN=
LANGEXTRACT_API_HOST=127.0.0.1
LANGEXTRACT_API_PORT=37141
LANGEXTRACT_SERVICE_TOKEN=
REGEX_SEARCH_HOST=127.0.0.1
REGEX_SEARCH_PORT=37143
REGEX_SEARCH_TOKEN=
CLAW_PROXY_HOST=127.0.0.1
CLAW_PROXY_PORT=37142
CLAW_PROXY_TOKEN=
LOCAL_OFFICE_URL=http://127.0.0.1:8000
LOCAL_OFFICE_API_KEY=

View File

@ -0,0 +1,2 @@
node_modules/
dist/

View File

@ -0,0 +1,30 @@
# smart-ide-sso-gateway
HTTP gateway that validates **user** access tokens from the docv / Enso OIDC issuer, then proxies requests to internal `smart_ide` micro-services using each services **technical** credentials (Bearer or `X-API-Key`).
## Responsibilities
- Verify `Authorization: Bearer <access_token>` with JWKS (`OIDC_ISSUER`, optional `OIDC_AUDIENCE`, optional `OIDC_JWKS_URI`).
- Expose `GET /health` without auth.
- Expose `GET /v1/token/verify` and `GET /v1/upstreams` with user Bearer.
- Proxy `ANY /proxy/<upstream_key>/<path>` to the configured upstream, replacing the user token with the service token and adding `X-OIDC-Sub` / `X-OIDC-Email` when present in the JWT.
## Run
```bash
cd services/smart-ide-sso-gateway
cp .env.example .env # edit OIDC_ISSUER and service tokens
set -a && source .env && set +a
npm ci
npm run build
npm start
```
Default listen: `http://127.0.0.1:37148`.
Upstream URLs and tokens reuse the same environment variables as the rest of the monorepo (`ORCHESTRATOR_*`, `TOOLS_BRIDGE_*`, `LOCAL_OFFICE_URL` / `LOCAL_OFFICE_API_KEY`, etc.). See `src/upstreams.ts`.
## Documentation
- Feature: [`docs/features/sso-gateway-service.md`](../../docs/features/sso-gateway-service.md)
- API: [`docs/API/sso-gateway-api.md`](../../docs/API/sso-gateway-api.md)

View File

@ -0,0 +1,63 @@
{
"name": "@4nk/smart-ide-sso-gateway",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@4nk/smart-ide-sso-gateway",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"jose": "^5.9.6"
},
"devDependencies": {
"@types/node": "^20.11.0",
"typescript": "^5.3.3"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@types/node": {
"version": "20.19.39",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

View File

@ -0,0 +1,23 @@
{
"name": "@4nk/smart-ide-sso-gateway",
"version": "0.1.0",
"private": true,
"description": "OIDC JWT validation (docv/Enso IdP) and authenticated proxy to smart_ide micro-services.",
"license": "MIT",
"type": "module",
"main": "dist/server.js",
"scripts": {
"build": "tsc -p ./",
"start": "node dist/server.js"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"jose": "^5.9.6"
},
"devDependencies": {
"@types/node": "^20.11.0",
"typescript": "^5.3.3"
}
}

View File

@ -0,0 +1,35 @@
import * as jose from "jose";
export const discoverJwksUri = async (issuer: string): Promise<string> => {
const explicit = process.env.OIDC_JWKS_URI?.trim();
if (explicit) {
return explicit;
}
const base = issuer.replace(/\/$/, "");
const r = await fetch(`${base}/.well-known/openid-configuration`);
if (r.ok) {
const j = (await r.json()) as { jwks_uri?: string };
if (j.jwks_uri) {
return j.jwks_uri;
}
}
return `${base}/.well-known/jwks.json`;
};
export type VerifyFn = (token: string) => Promise<jose.JWTPayload>;
export const createVerify = (
jwksUri: string,
issuer: string,
audience: string | undefined,
): VerifyFn => {
const JWKS = jose.createRemoteJWKSet(new URL(jwksUri));
return async (token: string) => {
const { payload } = await jose.jwtVerify(token, JWKS, {
issuer,
audience: audience ? audience : undefined,
clockTolerance: 30,
});
return payload;
};
};

View File

@ -0,0 +1,238 @@
import * as http from "node:http";
import type { JWTPayload } from "jose";
import { discoverJwksUri, createVerify, type VerifyFn } from "./oidc.js";
import { listUpstreamKeys, resolveUpstream, type UpstreamAuth } from "./upstreams.js";
const HOST = process.env.SSO_GATEWAY_HOST ?? "127.0.0.1";
const PORT = Number(process.env.SSO_GATEWAY_PORT ?? "37148");
const MAX_BODY_BYTES = Number(process.env.SSO_GATEWAY_MAX_BODY_BYTES ?? "33554432");
const CORS_ORIGIN = process.env.SSO_CORS_ORIGIN?.trim() ?? "";
const corsHeaders = (): Record<string, string> => {
if (!CORS_ORIGIN) {
return {};
}
return {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Authorization, Content-Type",
"Access-Control-Max-Age": "86400",
};
};
const applyCors = (res: http.ServerResponse): void => {
const h = corsHeaders();
for (const [k, v] of Object.entries(h)) {
res.setHeader(k, v);
}
};
const json = (res: http.ServerResponse, status: number, body: unknown): void => {
applyCors(res);
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(body));
};
const readBearer = (req: http.IncomingMessage): string | null => {
const raw = req.headers.authorization ?? "";
const m = /^Bearer\s+(.+)$/i.exec(raw);
return m?.[1]?.trim() ?? null;
};
const readBodyBuffer = async (req: http.IncomingMessage): Promise<Buffer> => {
const chunks: Buffer[] = [];
let total = 0;
for await (const chunk of req) {
const b = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
total += b.length;
if (total > MAX_BODY_BYTES) {
throw new Error(`Request body exceeds ${MAX_BODY_BYTES} bytes`);
}
chunks.push(b);
}
return Buffer.concat(chunks);
};
const hopByHop = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
"host",
]);
const buildForwardHeaders = (
req: http.IncomingMessage,
serviceAuth: UpstreamAuth,
payload: JWTPayload,
): Headers => {
const out = new Headers();
for (const [k, v] of Object.entries(req.headers)) {
if (!v) {
continue;
}
const lk = k.toLowerCase();
if (hopByHop.has(lk)) {
continue;
}
if (lk === "authorization") {
continue;
}
out.set(k, Array.isArray(v) ? v.join(", ") : v);
}
if (serviceAuth.kind === "bearer") {
if (serviceAuth.token) {
out.set("Authorization", `Bearer ${serviceAuth.token}`);
}
} else if (serviceAuth.value) {
out.set(serviceAuth.name, serviceAuth.value);
}
const sub = payload.sub;
if (typeof sub === "string" && sub.length > 0) {
out.set("X-OIDC-Sub", sub);
}
const email = payload.email;
if (typeof email === "string" && email.length > 0) {
out.set("X-OIDC-Email", email);
}
return out;
};
const responseHopByHop = new Set([
"connection",
"keep-alive",
"transfer-encoding",
"content-encoding",
]);
const proxyToUpstream = async (
req: http.IncomingMessage,
res: http.ServerResponse,
targetUrl: string,
headers: Headers,
body: Buffer,
): Promise<void> => {
const method = req.method ?? "GET";
const init: RequestInit = {
method,
headers,
redirect: "manual",
};
if (method !== "GET" && method !== "HEAD" && body.length > 0) {
init.body = new Uint8Array(body);
}
const out = await fetch(targetUrl, init);
applyCors(res);
res.statusCode = out.status;
for (const [k, v] of out.headers) {
if (responseHopByHop.has(k.toLowerCase())) {
continue;
}
res.setHeader(k, v);
}
const buf = Buffer.from(await out.arrayBuffer());
res.end(buf);
};
const publicClaims = (payload: JWTPayload): Record<string, unknown> => {
const out: Record<string, unknown> = {};
for (const k of ["sub", "iss", "aud", "exp", "iat", "email", "name", "preferred_username"]) {
if (payload[k] !== undefined) {
out[k] = payload[k];
}
}
return out;
};
const main = async (): Promise<void> => {
const issuer = process.env.OIDC_ISSUER?.trim();
if (!issuer) {
console.error("smart-ide-sso-gateway: set OIDC_ISSUER (docv / Enso IdP issuer URL).");
process.exit(1);
}
const audience = process.env.OIDC_AUDIENCE?.trim();
const jwksUri = await discoverJwksUri(issuer);
console.error(`smart-ide-sso-gateway: JWKS URI ${jwksUri}`);
const verify: VerifyFn = createVerify(jwksUri, issuer, audience || undefined);
const server = http.createServer((req, res) => {
void (async () => {
try {
if (req.method === "OPTIONS") {
res.writeHead(204, corsHeaders());
res.end();
return;
}
const url = new URL(req.url ?? "/", `http://${HOST}`);
const pathname = url.pathname;
if (req.method === "GET" && (pathname === "/health" || pathname === "/health/")) {
json(res, 200, { status: "ok", service: "smart-ide-sso-gateway" });
return;
}
const token = readBearer(req);
if (!token) {
json(res, 401, { error: "Missing Authorization: Bearer <access_token>" });
return;
}
let payload: JWTPayload;
try {
payload = await verify(token);
} catch {
json(res, 401, { error: "Invalid or expired token" });
return;
}
if (req.method === "GET" && pathname === "/v1/token/verify") {
json(res, 200, { valid: true, claims: publicClaims(payload) });
return;
}
if (req.method === "GET" && pathname === "/v1/upstreams") {
json(res, 200, { upstreams: listUpstreamKeys() });
return;
}
const proxyMatch = /^\/proxy\/([^/]+)(\/.*)?$/.exec(pathname);
if (!proxyMatch || !req.method) {
json(res, 404, { error: "Not found" });
return;
}
const key = proxyMatch[1];
const rest = proxyMatch[2] ?? "/";
const upstream = resolveUpstream(key);
if (!upstream) {
json(res, 404, { error: `Unknown upstream: ${key}` });
return;
}
if (upstream.auth.kind === "header" && !upstream.auth.value) {
json(res, 503, { error: `API key not configured for upstream ${key} (LOCAL_OFFICE_API_KEY)` });
return;
}
const body = await readBodyBuffer(req);
const targetUrl = `${upstream.baseUrl}${rest}${url.search}`;
const headers = buildForwardHeaders(req, upstream.auth, payload);
await proxyToUpstream(req, res, targetUrl, headers, body);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (!res.headersSent) {
json(res, 400, { error: msg });
}
}
})();
});
server.listen(PORT, HOST, () => {
console.error(`smart-ide-sso-gateway listening on http://${HOST}:${PORT}`);
});
};
void main();

View File

@ -0,0 +1,96 @@
export type UpstreamAuth =
| { kind: "bearer"; token: string }
| { kind: "header"; name: string; value: string };
export type UpstreamTarget = {
baseUrl: string;
auth: UpstreamAuth;
};
const trimSlash = (s: string): string => s.replace(/\/+$/, "");
const env = (k: string, d: string): string => process.env[k]?.trim() ?? d;
export const resolveUpstream = (key: string): UpstreamTarget | null => {
switch (key) {
case "orchestrator":
return {
baseUrl: trimSlash(
`http://${env("ORCHESTRATOR_HOST", "127.0.0.1")}:${env("ORCHESTRATOR_PORT", "37145")}`,
),
auth: { kind: "bearer", token: env("ORCHESTRATOR_TOKEN", "") },
};
case "repos_devtools":
return {
baseUrl: trimSlash(
`http://${env("REPOS_DEVTOOLS_HOST", "127.0.0.1")}:${env("REPOS_DEVTOOLS_PORT", "37140")}`,
),
auth: { kind: "bearer", token: env("REPOS_DEVTOOLS_TOKEN", "") },
};
case "ia_dev_gateway":
return {
baseUrl: trimSlash(
`http://${env("IA_DEV_GATEWAY_HOST", "127.0.0.1")}:${env("IA_DEV_GATEWAY_PORT", "37144")}`,
),
auth: { kind: "bearer", token: env("IA_DEV_GATEWAY_TOKEN", "") },
};
case "anythingllm_devtools":
return {
baseUrl: trimSlash(
`http://${env("ANYTHINGLLM_DEVTOOLS_HOST", "127.0.0.1")}:${env("ANYTHINGLLM_DEVTOOLS_PORT", "37146")}`,
),
auth: { kind: "bearer", token: env("ANYTHINGLLM_DEVTOOLS_TOKEN", "") },
};
case "tools_bridge":
return {
baseUrl: trimSlash(
`http://${env("TOOLS_BRIDGE_HOST", "127.0.0.1")}:${env("TOOLS_BRIDGE_PORT", "37147")}`,
),
auth: { kind: "bearer", token: env("TOOLS_BRIDGE_TOKEN", "") },
};
case "langextract":
return {
baseUrl: trimSlash(
`http://${env("LANGEXTRACT_API_HOST", "127.0.0.1")}:${env("LANGEXTRACT_API_PORT", "37141")}`,
),
auth: { kind: "bearer", token: env("LANGEXTRACT_SERVICE_TOKEN", "") },
};
case "regex_search":
return {
baseUrl: trimSlash(
`http://${env("REGEX_SEARCH_HOST", "127.0.0.1")}:${env("REGEX_SEARCH_PORT", "37143")}`,
),
auth: { kind: "bearer", token: env("REGEX_SEARCH_TOKEN", "") },
};
case "claw_proxy":
return {
baseUrl: trimSlash(
`http://${env("CLAW_PROXY_HOST", "127.0.0.1")}:${env("CLAW_PROXY_PORT", "37142")}`,
),
auth: { kind: "bearer", token: env("CLAW_PROXY_TOKEN", "") },
};
case "local_office":
return {
baseUrl: trimSlash(env("LOCAL_OFFICE_URL", "http://127.0.0.1:8000")),
auth: {
kind: "header",
name: "X-API-Key",
value: env("LOCAL_OFFICE_API_KEY", ""),
},
};
default:
return null;
}
};
export const listUpstreamKeys = (): string[] => [
"orchestrator",
"repos_devtools",
"ia_dev_gateway",
"anythingllm_devtools",
"tools_bridge",
"langextract",
"regex_search",
"claw_proxy",
"local_office",
];

View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"skipLibCheck": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"declaration": false
},
"include": ["src/**/*.ts"]
}