diff --git a/config/services.local.env.example b/config/services.local.env.example index 97c7e60..de67e03 100644 --- a/config/services.local.env.example +++ b/config/services.local.env.example @@ -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= diff --git a/docs/API/README.md b/docs/API/README.md index 0a72f0c..8511da6 100644 --- a/docs/API/README.md +++ b/docs/API/README.md @@ -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é. diff --git a/docs/API/sso-gateway-api.md b/docs/API/sso-gateway-api.md new file mode 100644 index 0000000..f450480 --- /dev/null +++ b/docs/API/sso-gateway-api.md @@ -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 ` 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//` + +- **``** : voir liste ci-dessus (`repos_devtools`, `orchestrator`, etc.). +- **``** : chemin transmis tel quel à l’URL 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 d’erreur** : `401` si Bearer utilisateur absent ou invalide ; `404` si clé inconnue ; `503` si `local_office` est ciblé sans `LOCAL_OFFICE_API_KEY`. + +L’en-tête `Authorization` utilisateur n’est **pas** transmis à l’amont ; il est remplacé par le jeton de service configuré. Voir [sso-gateway-service.md](../features/sso-gateway-service.md). + +## Variables d’environnement (passerelle) + +| Variable | Rôle | +|----------|------| +| `OIDC_ISSUER` | Obligatoire — URL de l’issuer 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) diff --git a/docs/README.md b/docs/README.md index e7ec65b..d0673a6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,6 +33,7 @@ Vue d’ensemble 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) | diff --git a/docs/features/sso-docv-enso.md b/docs/features/sso-docv-enso.md index 413aeb1..9a617d4 100644 --- a/docs/features/sso-docv-enso.md +++ b/docs/features/sso-docv-enso.md @@ -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 d’entré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 diff --git a/docs/features/sso-gateway-service.md b/docs/features/sso-gateway-service.md new file mode 100644 index 0000000..ae82caf --- /dev/null +++ b/docs/features/sso-gateway-service.md @@ -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 l’amont 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//...` sont : `orchestrator`, `repos_devtools`, `ia_dev_gateway`, `anythingllm_devtools`, `tools_bridge`, `langextract`, `regex_search`, `claw_proxy`, `local_office`. Les variables d’environnement 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 l’amont + +En plus de l’authentification 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 s’en servir pour du **journal** ou des **règles fines** ; la **politique d’autorisation** 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) diff --git a/docs/repo/README.md b/docs/repo/README.md index 256dd52..e534e65 100644 --- a/docs/repo/README.md +++ b/docs/repo/README.md @@ -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/). diff --git a/docs/repo/service-smart-ide-sso-gateway.md b/docs/repo/service-smart-ide-sso-gateway.md new file mode 100644 index 0000000..cf41e2f --- /dev/null +++ b/docs/repo/service-smart-ide-sso-gateway.md @@ -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) diff --git a/docs/services-functional-scope.md b/docs/services-functional-scope.md index cbd3bdc..df1a909 100644 --- a/docs/services-functional-scope.md +++ b/docs/services-functional-scope.md @@ -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 l’IDE | **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 l’IDE | **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à l’IdP | **Possible** quand le produit veut un seul point d’entré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 l’UX. +- **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 l’UX. - **Backends applicatifs** : consommer en priorité les services **stables et documentés** pour l’IA 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) diff --git a/docs/services.md b/docs/services.md index 852911c..f6866d0 100644 --- a/docs/services.md +++ b/docs/services.md @@ -44,6 +44,8 @@ Services d’appoint 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 diff --git a/services/smart-ide-sso-gateway/.env.example b/services/smart-ide-sso-gateway/.env.example new file mode 100644 index 0000000..4983a13 --- /dev/null +++ b/services/smart-ide-sso-gateway/.env.example @@ -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= diff --git a/services/smart-ide-sso-gateway/.gitignore b/services/smart-ide-sso-gateway/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/services/smart-ide-sso-gateway/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/services/smart-ide-sso-gateway/README.md b/services/smart-ide-sso-gateway/README.md new file mode 100644 index 0000000..179a925 --- /dev/null +++ b/services/smart-ide-sso-gateway/README.md @@ -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 service’s **technical** credentials (Bearer or `X-API-Key`). + +## Responsibilities + +- Verify `Authorization: Bearer ` 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//` 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) diff --git a/services/smart-ide-sso-gateway/package-lock.json b/services/smart-ide-sso-gateway/package-lock.json new file mode 100644 index 0000000..9536bfe --- /dev/null +++ b/services/smart-ide-sso-gateway/package-lock.json @@ -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" + } + } +} diff --git a/services/smart-ide-sso-gateway/package.json b/services/smart-ide-sso-gateway/package.json new file mode 100644 index 0000000..16370cc --- /dev/null +++ b/services/smart-ide-sso-gateway/package.json @@ -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" + } +} diff --git a/services/smart-ide-sso-gateway/src/oidc.ts b/services/smart-ide-sso-gateway/src/oidc.ts new file mode 100644 index 0000000..1efd321 --- /dev/null +++ b/services/smart-ide-sso-gateway/src/oidc.ts @@ -0,0 +1,35 @@ +import * as jose from "jose"; + +export const discoverJwksUri = async (issuer: string): Promise => { + 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; + +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; + }; +}; diff --git a/services/smart-ide-sso-gateway/src/server.ts b/services/smart-ide-sso-gateway/src/server.ts new file mode 100644 index 0000000..f4744a6 --- /dev/null +++ b/services/smart-ide-sso-gateway/src/server.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + const out: Record = {}; + 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 => { + 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 " }); + 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(); diff --git a/services/smart-ide-sso-gateway/src/upstreams.ts b/services/smart-ide-sso-gateway/src/upstreams.ts new file mode 100644 index 0000000..81a41bd --- /dev/null +++ b/services/smart-ide-sso-gateway/src/upstreams.ts @@ -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", +]; diff --git a/services/smart-ide-sso-gateway/tsconfig.json b/services/smart-ide-sso-gateway/tsconfig.json new file mode 100644 index 0000000..ae73f32 --- /dev/null +++ b/services/smart-ide-sso-gateway/tsconfig.json @@ -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"] +}