@pharmadate/mcp 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +201 -2
- package/dist/auth/crypto.d.ts +24 -0
- package/dist/auth/crypto.js +110 -0
- package/dist/auth/login-page.d.ts +32 -0
- package/dist/auth/login-page.js +98 -0
- package/dist/auth/oauth-provider.d.ts +92 -0
- package/dist/auth/oauth-provider.js +208 -0
- package/dist/auth/redirect-trust.d.ts +26 -0
- package/dist/auth/redirect-trust.js +94 -0
- package/dist/client.d.ts +54 -0
- package/dist/client.js +131 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.js +28 -0
- package/dist/http.d.ts +79 -0
- package/dist/http.js +272 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +114 -0
- package/dist/limits.d.ts +74 -0
- package/dist/limits.js +152 -0
- package/dist/logger.d.ts +10 -0
- package/dist/logger.js +34 -0
- package/dist/operation-mode.d.ts +14 -0
- package/dist/operation-mode.js +10 -0
- package/dist/pharmacy-resolver.d.ts +54 -0
- package/dist/pharmacy-resolver.js +92 -0
- package/dist/principal.d.ts +30 -0
- package/dist/principal.js +85 -0
- package/dist/prompts.d.ts +3 -0
- package/dist/prompts.js +42 -0
- package/dist/resources.d.ts +15 -0
- package/dist/resources.js +58 -0
- package/dist/results.d.ts +42 -0
- package/dist/results.js +121 -0
- package/dist/scrub.d.ts +18 -0
- package/dist/scrub.js +49 -0
- package/dist/server-instructions.d.ts +8 -0
- package/dist/server-instructions.js +39 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +59 -0
- package/dist/tools/common.d.ts +9 -0
- package/dist/tools/common.js +225 -0
- package/dist/tools/index.d.ts +15 -0
- package/dist/tools/index.js +6 -0
- package/dist/tools/operate.d.ts +15 -0
- package/dist/tools/operate.js +24 -0
- package/dist/tools/partner.d.ts +2 -0
- package/dist/tools/partner.js +176 -0
- package/dist/tools/types.d.ts +39 -0
- package/dist/tools/types.js +3 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +15 -0
- package/package.json +75 -4
- package/server.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nubiia (https://nubiia.es)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,202 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @pharmadate/mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://mcptry.com/servers/es.pharmadatepedido/mcp)
|
|
4
|
+
|
|
5
|
+
Servidor [MCP](https://modelcontextprotocol.io) de Pharmadate. Permite a un asistente de IA (Claude, ChatGPT, Cursor, VS Code...)
|
|
6
|
+
consultar el catálogo, valorar y cursar pedidos y, si eres partner (TopFarma...), gestionar tus farmacias, **sobre la API v1 de
|
|
7
|
+
Pharmadate** (`/api-docs`). No accede a la base de datos: toda la autorización la hace la API.
|
|
8
|
+
|
|
9
|
+
> **Estado (fase 1):** partners y farmacias. Las herramientas de administración interna llegan en la fase 2.
|
|
10
|
+
> El paquete está en npm (`npx -y @pharmadate/mcp`) y el servidor remoto está activo en producción.
|
|
11
|
+
|
|
12
|
+
- Paquete npm: `@pharmadate/mcp` · Registro MCP: `es.pharmadatepedido/mcp`
|
|
13
|
+
- Remoto (Streamable HTTP): `https://www.pharmadatepedido.es/mcp`
|
|
14
|
+
- 19 herramientas; el **nombre de cada una es el `operationId`** del OpenAPI (`frontend/api-docs/openapi.yaml`).
|
|
15
|
+
|
|
16
|
+
## Qué puede hacer cada credencial
|
|
17
|
+
|
|
18
|
+
| Credencial | Prefijo | Herramientas |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| **Partner** | `pk_partner_...` | Las 19: las operativas (con el argumento `pharmacy`) y la gestión de sus farmacias |
|
|
21
|
+
| **Farmacia** | `pk_live_...` | 12: las operativas, sobre su propia farmacia (sin argumento `pharmacy`) |
|
|
22
|
+
|
|
23
|
+
### Operativas (12) — farmacia, o partner con `pharmacy`
|
|
24
|
+
|
|
25
|
+
| Herramienta | Tipo | Para qué |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| `ping` | lectura | Comprobar la credencial y ver quién eres |
|
|
28
|
+
| `getCatalog` | lectura | Buscar en el catálogo (`q` por nombre o laboratorio; 50 por página) |
|
|
29
|
+
| `getConditions` | lectura | Condiciones de un código nacional |
|
|
30
|
+
| `lookupConditions` | lectura | Condiciones de hasta 1000 códigos |
|
|
31
|
+
| `createQuote` | lectura | Valorar una cesta (no crea nada). **Siempre antes de pedir** |
|
|
32
|
+
| `createOrder` | escritura, idempotente | Pedido **REAL**. `external_ref` obligatoria |
|
|
33
|
+
| `listOrders`, `getOrder`, `getOrderModification` | lectura | Seguimiento de pedidos, faltas y modificaciones |
|
|
34
|
+
| `listOrderShipments`, `getOrderShipment`, `getShipment` | lectura | Envíos |
|
|
35
|
+
|
|
36
|
+
### Solo partner (7)
|
|
37
|
+
|
|
38
|
+
| Herramienta | Tipo | Para qué |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `listPharmacies`, `getPharmacy` | lectura | Tus farmacias (nunca devuelven la API key completa) |
|
|
41
|
+
| `createPharmacy` | escritura | Alta |
|
|
42
|
+
| `updatePharmacy` | escritura (destructiva) | Modificar datos |
|
|
43
|
+
| `deactivatePharmacy` | destructiva, idempotente | Baja lógica (se deshace con `reactivatePharmacy`) |
|
|
44
|
+
| `reactivatePharmacy` | escritura | Deshacer una baja (API key nueva) |
|
|
45
|
+
| `rotatePharmacyApiKey` | destructiva | Key nueva; **invalida la anterior en el acto** |
|
|
46
|
+
|
|
47
|
+
Cada herramienta lleva las *annotations* MCP (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) para que el cliente
|
|
48
|
+
pida confirmación en las que escriben. Además hay 4 *resources* (`pharmadate://docs/integration-guide`, `.../errors`, `.../openapi`,
|
|
49
|
+
`pharmadate://me`), 4 *prompts* (`placeOrder`, `orderStatus` y, para partners, `onboardPharmacy` y `recoverPharmacyAccess`) y
|
|
50
|
+
unas *instrucciones del servidor* con las reglas de uso.
|
|
51
|
+
|
|
52
|
+
## Conectarlo
|
|
53
|
+
|
|
54
|
+
### Claude Code (remoto)
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
claude mcp add --transport http pharmadate https://www.pharmadatepedido.es/mcp \
|
|
58
|
+
--header "Authorization: Bearer pk_partner_XXXXXXXX"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Sin `--header`, Claude Code arranca el login OAuth en el navegador (`/mcp` dentro de Claude Code).
|
|
62
|
+
|
|
63
|
+
### Claude Code / Claude Desktop (local, stdio)
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
claude mcp add pharmadate --env PHARMADATE_API_KEY=pk_partner_XXXXXXXX --env PHARMADATE_MCP_MODE=STANDARD -- npx -y @pharmadate/mcp
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Claude Desktop (`claude_desktop_config.json`):
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"mcpServers": {
|
|
74
|
+
"pharmadate": {
|
|
75
|
+
"command": "npx",
|
|
76
|
+
"args": ["-y", "@pharmadate/mcp"],
|
|
77
|
+
"env": {
|
|
78
|
+
"PHARMADATE_API_KEY": "pk_partner_XXXXXXXX",
|
|
79
|
+
"PHARMADATE_MCP_MODE": "STANDARD"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Cursor / VS Code
|
|
87
|
+
|
|
88
|
+
Cursor (`~/.cursor/mcp.json`) o VS Code (`.vscode/mcp.json`, clave `servers`):
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"mcpServers": {
|
|
93
|
+
"pharmadate": {
|
|
94
|
+
"url": "https://www.pharmadatepedido.es/mcp",
|
|
95
|
+
"headers": { "Authorization": "Bearer pk_partner_XXXXXXXX" }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Claude.ai y ChatGPT (OAuth)
|
|
102
|
+
|
|
103
|
+
*Ajustes > Conectores > Añadir conector personalizado* y la URL `https://www.pharmadatepedido.es/mcp`. El cliente abre una página de
|
|
104
|
+
Pharmadate donde **pegas tu API key**; no hace falta configurar ningún cliente OAuth (el registro es dinámico).
|
|
105
|
+
|
|
106
|
+
> **Consejo para partners:** crea una credencial dedicada «MCP» en `/partner.html` (`POST /api/partner/tokens`). Así la puedes revocar
|
|
107
|
+
> sin tocar tu integración.
|
|
108
|
+
|
|
109
|
+
## Variables de entorno (stdio)
|
|
110
|
+
|
|
111
|
+
| Variable | Obligatoria | Descripción |
|
|
112
|
+
|---|---|---|
|
|
113
|
+
| `PHARMADATE_API_KEY` | sí | Key de partner o de farmacia |
|
|
114
|
+
| `PHARMADATE_MCP_MODE` | no | `READ_ONLY` (**por defecto**: solo lectura y valoración) o `STANDARD` (pedidos y gestión de farmacias) |
|
|
115
|
+
| `PHARMADATE_PHARMACY_REF` | no | Solo partners: CIF de la farmacia por defecto (se verifica antes de usarla) |
|
|
116
|
+
| `PHARMADATE_API_URL` | no | Por defecto `https://www.pharmadatepedido.es/api` (la beta: `.../api` de su URL) |
|
|
117
|
+
|
|
118
|
+
El servidor remoto trabaja siempre en modo `STANDARD` (cada cliente pide confirmación por las *annotations*); el operador puede dejarlo
|
|
119
|
+
en `READ_ONLY` con `MCP_REMOTE_MODE`.
|
|
120
|
+
|
|
121
|
+
## Seguridad
|
|
122
|
+
|
|
123
|
+
- **La farmacia se resuelve antes de operar (punto crítico).** Con una key de partner, la API da de alta *automáticamente* la farmacia de
|
|
124
|
+
un CIF desconocido en cualquier ruta operativa. Por eso el MCP, con `pharmacy: { pharmacy_id }` (preferido) o `{ cif }`, la busca antes
|
|
125
|
+
entre las **tuyas** y activas; si no aparece (o es de otro partner, o está de baja) responde «farmacia no encontrada entre las tuyas» y
|
|
126
|
+
**no llama a la API operativa**. Solo entonces envía `X-Pharmacy-Ref`, y **nunca** `pharmacy` ni `pharmacy_ref` en el cuerpo.
|
|
127
|
+
- **Sin estado de negocio:** cada llamada se autoriza en la API. Un partner nunca ve las farmacias de otro.
|
|
128
|
+
- **Keys:** nunca van a logs ni a mensajes de error (se redactan por patrón). `createPharmacy`, `reactivatePharmacy` y `rotatePharmacyApiKey` solo
|
|
129
|
+
muestran el prefijo; con `reveal_api_key: true` la key completa va en un bloque aparte con `annotations.audience: ["user"]`. **Puede quedar en el
|
|
130
|
+
historial del chat**: úsalo cuando la necesites de verdad. Si no la revelas al reactivar o rotar, esa key no se muestra en ningún sitio.
|
|
131
|
+
- **Límites del MCP** (por credencial, en memoria del proceso): 30 escrituras/hora, 10 `createOrder`/hora y 500 líneas por quote o pedido; en los
|
|
132
|
+
endpoints sin autenticar, 30 peticiones/minuto por IP, y 20 credenciales inválidas por minuto y IP en `/mcp` (después, 429 sin llamar a la API). Los
|
|
133
|
+
contadores por IP tienen tope de tamaño. La API añade sus 120 peticiones/minuto.
|
|
134
|
+
- **Pedidos:** `createOrder` es real, firme e **idempotente por `external_ref`** (obligatoria): reintentar con la misma ref y la misma cesta no duplica.
|
|
135
|
+
Si se corta la llamada, el modelo debe reintentar con la **misma** ref.
|
|
136
|
+
- **Auditoría:** cada llamada queda en `api_audit` con `User-Agent: pharmadate-mcp/<versión> (remote|stdio; client=<nombre>)` y la IP real.
|
|
137
|
+
|
|
138
|
+
### OAuth 2.1 sin base de datos
|
|
139
|
+
|
|
140
|
+
`/.well-known/oauth-authorization-server`, `/register` (DCR), `/authorize`, `/token` y `/.well-known/oauth-protected-resource/mcp`, en la raíz.
|
|
141
|
+
El `code` (60 s), el access token (1 h) y el refresh token (30 días, tope de 90 días de sesión) son **la API key cifrada con AES-256-GCM** con
|
|
142
|
+
`MCP_OAUTH_SECRET` (más PKCE, cliente y caducidad); el `client_id` es la metadata del registro firmada con HMAC. **Revocar es rotar o revocar la key**:
|
|
143
|
+
cada llamada se valida contra la API y un refresh con la key revocada se rechaza. Cambiar `MCP_OAUTH_SECRET` cierra todas las sesiones.
|
|
144
|
+
|
|
145
|
+
**Registro abierto y phishing.** Cualquiera puede registrar un cliente (DCR) con la `redirect_uri` y el nombre que quiera y mandarte el enlace de
|
|
146
|
+
`/authorize`; si pegaras tu key, el `code` iría a su dirección. Por eso la página de login:
|
|
147
|
+
|
|
148
|
+
- enseña de forma destacada **«Tras conectar, volverás a: `<host>`»**;
|
|
149
|
+
- trata como **de confianza** solo los callbacks de Claude (`claude.ai`, `claude.com`), ChatGPT/OpenAI (`chatgpt.com`, `chat.openai.com`, `platform.openai.com`),
|
|
150
|
+
loopback (`127.0.0.1`, `localhost`, `[::1]`, con cualquier puerto, RFC 8252) y los de `MCP_ALLOWED_REDIRECTS`;
|
|
151
|
+
- con cualquier otro host sale un **aviso rojo** y una casilla obligatoria **«Reconozco esta aplicación y su dirección»**, que el servidor exige también en el `POST`;
|
|
152
|
+
- el nombre que el cliente dice tener **nunca titula la página** si no es de confianza: sale como «dice llamarse X (no verificado)»;
|
|
153
|
+
- recomienda usar **un token de partner dedicado al MCP**, no la key de tu integración.
|
|
154
|
+
|
|
155
|
+
## Despliegue (operador)
|
|
156
|
+
|
|
157
|
+
El MCP vive en `mcp/` y se monta **dentro de la app de Elastic Beanstalk**, detrás de `MCP_ENABLED=1`. Sin esa variable no se carga ni se expone nada.
|
|
158
|
+
|
|
159
|
+
| Variable (EB) | Descripción |
|
|
160
|
+
|---|---|
|
|
161
|
+
| `MCP_ENABLED=1` | Activa `POST /mcp` y las rutas OAuth |
|
|
162
|
+
| `MCP_PUBLIC_URL` | URL pública, p. ej. `https://www.pharmadatepedido.es` (issuer y recurso) |
|
|
163
|
+
| `MCP_OAUTH_SECRET` | Hex o base64 de **32+ bytes** aleatorios (`openssl rand -base64 48`, 64 caracteres). Uno corto o sin aleatoriedad (`xxxx…`) se rechaza y el MCP no se monta |
|
|
164
|
+
| `MCP_ALLOWED_REDIRECTS` | Opcional: callbacks OAuth de confianza además de Claude, ChatGPT/OpenAI y loopback; hosts u orígenes separados por comas (`app.socio.es,https://otro.es:8443`). Sin ella, la lista extra queda vacía |
|
|
165
|
+
| `MCP_ALLOWED_ORIGINS` | Opcional: orígenes de navegador extra (por defecto claude.ai, claude.com, chatgpt.com) |
|
|
166
|
+
| `MCP_ALLOWED_HOSTS` | Opcional: hostnames extra para la cabecera `Host` (protección contra DNS rebinding) |
|
|
167
|
+
| `MCP_REMOTE_MODE` | Opcional: `READ_ONLY` para un remoto de solo lectura |
|
|
168
|
+
| `MCP_PROXY_HOPS` | Opcional: proxies de confianza delante de la app para leer `X-Forwarded-For` (**2** con ALB + nginx, por defecto; **1** en la beta). La API v1 usa la misma cuenta (`backend/services/ipCliente.js`; también acepta `TRUSTED_PROXY_HOPS`, que manda sobre esta) |
|
|
169
|
+
|
|
170
|
+
Los workflows `deploy-prod.yml`, `deploy-dev.yml` y `deploy-beta.yml` compilan el MCP (`cd mcp && npm ci && npm run build`) y meten `mcp/dist` en el zip; las
|
|
171
|
+
dependencias (`@modelcontextprotocol/sdk`, `zod`, `pino`) están en `backend/package.json` porque EB solo instala las de producción. **La beta solo sirve HTTP**
|
|
172
|
+
(sin certificado): el OAuth exige HTTPS salvo en `localhost`, así que allí se activa además `MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL=true` (**nunca en producción**).
|
|
173
|
+
|
|
174
|
+
### Publicar (requiere aprobación)
|
|
175
|
+
|
|
176
|
+
1. `mcp/package.json` y `mcp/server.json` con la misma versión; tag `mcp-vX.Y.Z` → `.github/workflows/mcp-publish.yml` (npm con procedencia + registro MCP).
|
|
177
|
+
2. Secrets: `NPM_TOKEN` y `MCP_REGISTRY_DNS_PRIVATE_KEY`.
|
|
178
|
+
3. Namespace `es.pharmadatepedido/*` del registro: verificación por DNS. Genera un par Ed25519 (`openssl genpkey -algorithm Ed25519`), publica en GoDaddy el TXT
|
|
179
|
+
`pharmadatepedido.es TXT "v=MCPv1; k=ed25519; p=<clave pública base64>"` y guarda la privada (hex) en el secret. Detalles: documentación de `mcp-publisher`.
|
|
180
|
+
|
|
181
|
+
## Desarrollo
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
cd mcp && npm ci
|
|
185
|
+
npm test # unitarios: contrato con el OpenAPI, snapshots por rol, resolución de la farmacia, aislamiento, OAuth, límites
|
|
186
|
+
npm run build
|
|
187
|
+
npm run test:integration # backend real (SQLite de pruebas, requiere `cd backend && npm ci`) con MCP_ENABLED=1: HTTP, OAuth y stdio
|
|
188
|
+
npm run dev # stdio con tsx (PHARMADATE_API_KEY=...)
|
|
189
|
+
MCP_OAUTH_SECRET=$(openssl rand -hex 32) npm run dev -- --transport=http # HTTP local en :3333
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Probar con el inspector: `npx @modelcontextprotocol/inspector` y la URL `http://localhost:3333/mcp` (Streamable HTTP, cabecera `Authorization: Bearer <key>`).
|
|
193
|
+
|
|
194
|
+
Diseño completo y decisiones: [`docs/diseno_mcp.md`](../docs/diseno_mcp.md).
|
|
195
|
+
|
|
196
|
+
## Licencia
|
|
197
|
+
|
|
198
|
+
[MIT](LICENSE) © 2026 Nubiia.
|
|
199
|
+
|
|
200
|
+
## Desarrollado por
|
|
201
|
+
|
|
202
|
+
Desarrollado por [Nubiia](https://nubiia.es) · Contacto: samuel.fraga@nubiia.es
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type TokenKind = 'code' | 'access' | 'refresh';
|
|
2
|
+
export declare const MIN_SECRET_LENGTH = 32;
|
|
3
|
+
/** Entropia minima del secreto, en bytes (`openssl rand -base64 48` da 48; `openssl rand -hex 32` da 32). */
|
|
4
|
+
export declare const MIN_SECRET_BYTES = 32;
|
|
5
|
+
/**
|
|
6
|
+
* Comprueba `MCP_OAUTH_SECRET`: tiene que ser hex o base64 y, decodificado, de al menos {@link MIN_SECRET_BYTES} bytes, con
|
|
7
|
+
* caracteres variados (un `xxxxxxxx...` de 40 letras tiene longitud de sobra y entropia cero). Devuelve el motivo del rechazo, o
|
|
8
|
+
* `null` si vale.
|
|
9
|
+
*/
|
|
10
|
+
export declare function secretProblem(secret: unknown): string | null;
|
|
11
|
+
export interface DerivedKeys {
|
|
12
|
+
enc: Buffer;
|
|
13
|
+
mac: Buffer;
|
|
14
|
+
}
|
|
15
|
+
export declare function deriveKeys(secret: string): DerivedKeys;
|
|
16
|
+
/** Cifra un objeto JSON en un token opaco con el prefijo de su tipo. */
|
|
17
|
+
export declare function seal(kind: TokenKind, payload: object, keys: DerivedKeys): string;
|
|
18
|
+
/** Descifra y verifica. Devuelve `null` si el token esta manipulado, es de otro tipo o no es nuestro. */
|
|
19
|
+
export declare function open<T extends object>(kind: TokenKind, token: string, keys: DerivedKeys): T | null;
|
|
20
|
+
/** Si el texto parece un token del OAuth del MCP (de cualquier tipo). */
|
|
21
|
+
export declare function looksLikeMcpToken(token: string): boolean;
|
|
22
|
+
/** Firma un objeto: `base64url(json).base64url(hmac)`. Es el `client_id` del registro dinamico. */
|
|
23
|
+
export declare function sign(payload: object, keys: DerivedKeys): string;
|
|
24
|
+
export declare function verify<T extends object>(signed: string, keys: DerivedKeys): T | null;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cifrado y firma sin estado del OAuth del MCP.
|
|
3
|
+
*
|
|
4
|
+
* No hay base de datos: todo lo que el servidor necesita recordar viaja DENTRO del propio token, cifrado con
|
|
5
|
+
* AES-256-GCM (confidencialidad + integridad) con claves derivadas de `MCP_OAUTH_SECRET` por HKDF.
|
|
6
|
+
*
|
|
7
|
+
* - code (60 s) API key + PKCE + client + redirect_uri
|
|
8
|
+
* - access token (1 h) API key + client + scopes + resource
|
|
9
|
+
* - refresh token (30 d) API key + client + scopes + inicio de la cadena (tope 90 d)
|
|
10
|
+
* - client_id (DCR) metadata del registro firmada con HMAC-SHA256
|
|
11
|
+
*
|
|
12
|
+
* Cada tipo usa un AAD distinto: un code no se puede presentar como access token ni al reves.
|
|
13
|
+
*
|
|
14
|
+
* @module auth/crypto
|
|
15
|
+
*/
|
|
16
|
+
import { createCipheriv, createDecipheriv, createHmac, hkdfSync, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
17
|
+
const PREFIX = { code: 'pdc1.', access: 'pda1.', refresh: 'pdr1.' };
|
|
18
|
+
export const MIN_SECRET_LENGTH = 32;
|
|
19
|
+
/** Entropia minima del secreto, en bytes (`openssl rand -base64 48` da 48; `openssl rand -hex 32` da 32). */
|
|
20
|
+
export const MIN_SECRET_BYTES = 32;
|
|
21
|
+
/**
|
|
22
|
+
* Comprueba `MCP_OAUTH_SECRET`: tiene que ser hex o base64 y, decodificado, de al menos {@link MIN_SECRET_BYTES} bytes, con
|
|
23
|
+
* caracteres variados (un `xxxxxxxx...` de 40 letras tiene longitud de sobra y entropia cero). Devuelve el motivo del rechazo, o
|
|
24
|
+
* `null` si vale.
|
|
25
|
+
*/
|
|
26
|
+
export function secretProblem(secret) {
|
|
27
|
+
const ayuda = ' Genera uno con `openssl rand -base64 48`.';
|
|
28
|
+
if (typeof secret !== 'string' || secret.length < MIN_SECRET_LENGTH)
|
|
29
|
+
return `MCP_OAUTH_SECRET debe tener al menos ${MIN_SECRET_LENGTH} caracteres.${ayuda}`;
|
|
30
|
+
let bytes;
|
|
31
|
+
if (/^[0-9a-fA-F]+$/.test(secret)) {
|
|
32
|
+
bytes = secret.length % 2 === 0 ? secret.length / 2 : 0;
|
|
33
|
+
}
|
|
34
|
+
else if (/^[A-Za-z0-9+/_-]+={0,2}$/.test(secret)) {
|
|
35
|
+
bytes = Buffer.from(secret, 'base64').length;
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
return `MCP_OAUTH_SECRET debe ser hex o base64 (sin espacios ni otros simbolos).${ayuda}`;
|
|
39
|
+
}
|
|
40
|
+
if (bytes < MIN_SECRET_BYTES)
|
|
41
|
+
return `MCP_OAUTH_SECRET debe equivaler a al menos ${MIN_SECRET_BYTES} bytes aleatorios una vez decodificado (hex o base64).${ayuda}`;
|
|
42
|
+
if (new Set(secret).size < 10)
|
|
43
|
+
return `MCP_OAUTH_SECRET tiene muy pocos caracteres distintos: no parece aleatorio.${ayuda}`;
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
export function deriveKeys(secret) {
|
|
47
|
+
const problema = secretProblem(secret);
|
|
48
|
+
if (problema)
|
|
49
|
+
throw new Error(problema);
|
|
50
|
+
const salt = Buffer.from('pharmadate-mcp/oauth/v1');
|
|
51
|
+
return {
|
|
52
|
+
enc: Buffer.from(hkdfSync('sha256', secret, salt, 'enc', 32)),
|
|
53
|
+
mac: Buffer.from(hkdfSync('sha256', secret, salt, 'mac', 32)),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const b64u = (b) => b.toString('base64url');
|
|
57
|
+
/** Cifra un objeto JSON en un token opaco con el prefijo de su tipo. */
|
|
58
|
+
export function seal(kind, payload, keys) {
|
|
59
|
+
const iv = randomBytes(12);
|
|
60
|
+
const cipher = createCipheriv('aes-256-gcm', keys.enc, iv);
|
|
61
|
+
cipher.setAAD(Buffer.from(kind));
|
|
62
|
+
const data = Buffer.concat([cipher.update(JSON.stringify(payload), 'utf8'), cipher.final()]);
|
|
63
|
+
return PREFIX[kind] + b64u(Buffer.concat([iv, cipher.getAuthTag(), data]));
|
|
64
|
+
}
|
|
65
|
+
/** Descifra y verifica. Devuelve `null` si el token esta manipulado, es de otro tipo o no es nuestro. */
|
|
66
|
+
export function open(kind, token, keys) {
|
|
67
|
+
if (typeof token !== 'string' || !token.startsWith(PREFIX[kind]) || token.length > 8_192)
|
|
68
|
+
return null;
|
|
69
|
+
try {
|
|
70
|
+
const raw = Buffer.from(token.slice(PREFIX[kind].length), 'base64url');
|
|
71
|
+
if (raw.length < 12 + 16 + 2)
|
|
72
|
+
return null;
|
|
73
|
+
const decipher = createDecipheriv('aes-256-gcm', keys.enc, raw.subarray(0, 12));
|
|
74
|
+
decipher.setAAD(Buffer.from(kind));
|
|
75
|
+
decipher.setAuthTag(raw.subarray(12, 28));
|
|
76
|
+
const json = Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString('utf8');
|
|
77
|
+
return JSON.parse(json);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Si el texto parece un token del OAuth del MCP (de cualquier tipo). */
|
|
84
|
+
export function looksLikeMcpToken(token) {
|
|
85
|
+
return Object.values(PREFIX).some((p) => token.startsWith(p));
|
|
86
|
+
}
|
|
87
|
+
/** Firma un objeto: `base64url(json).base64url(hmac)`. Es el `client_id` del registro dinamico. */
|
|
88
|
+
export function sign(payload, keys) {
|
|
89
|
+
const body = b64u(Buffer.from(JSON.stringify(payload)));
|
|
90
|
+
const mac = createHmac('sha256', keys.mac).update(body).digest();
|
|
91
|
+
return `${body}.${b64u(mac)}`;
|
|
92
|
+
}
|
|
93
|
+
export function verify(signed, keys) {
|
|
94
|
+
if (typeof signed !== 'string' || signed.length > 8_192)
|
|
95
|
+
return null;
|
|
96
|
+
const i = signed.lastIndexOf('.');
|
|
97
|
+
if (i <= 0)
|
|
98
|
+
return null;
|
|
99
|
+
const body = signed.slice(0, i);
|
|
100
|
+
const mac = Buffer.from(signed.slice(i + 1), 'base64url');
|
|
101
|
+
const expected = createHmac('sha256', keys.mac).update(body).digest();
|
|
102
|
+
if (mac.length !== expected.length || !timingSafeEqual(mac, expected))
|
|
103
|
+
return null;
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pagina de login del OAuth del MCP: el usuario pega su API key de Pharmadate.
|
|
3
|
+
*
|
|
4
|
+
* HTML + CSS en linea, sin JavaScript. Todos los textos que vienen de fuera (nombre del cliente, errores) se escapan.
|
|
5
|
+
* La respuesta lleva `frame-ancestors 'none'` (anti-clickjacking) y `no-store`. No lleva `form-action`: Chrome lo aplica
|
|
6
|
+
* tambien a la redireccion posterior y bloquearia la vuelta al cliente (claude.ai, localhost...).
|
|
7
|
+
*
|
|
8
|
+
* @module auth/login-page
|
|
9
|
+
*/
|
|
10
|
+
export interface LoginPageParams {
|
|
11
|
+
clientId: string;
|
|
12
|
+
redirectUri: string;
|
|
13
|
+
codeChallenge: string;
|
|
14
|
+
scope?: string | undefined;
|
|
15
|
+
state?: string | undefined;
|
|
16
|
+
resource?: string | undefined;
|
|
17
|
+
/** Nombre que el cliente SE PONE al registrarse: no esta verificado. */
|
|
18
|
+
clientName?: string | undefined;
|
|
19
|
+
/** Host al que se vuelve tras conectar (se muestra de forma destacada). */
|
|
20
|
+
redirectHost: string;
|
|
21
|
+
/** `true` si el callback es de confianza ({@link classifyRedirect}); si no, aviso rojo y casilla obligatoria. */
|
|
22
|
+
trusted: boolean;
|
|
23
|
+
/** Callback de loopback: sin aviso ni casilla, pero el nombre del cliente tampoco esta verificado. */
|
|
24
|
+
loopback?: boolean | undefined;
|
|
25
|
+
errorMessage?: string | undefined;
|
|
26
|
+
}
|
|
27
|
+
/** Quita saltos de linea y caracteres de control del nombre autodeclarado (no puede disfrazarse de otra frase). */
|
|
28
|
+
export declare function cleanClientName(nombre: string | undefined): string;
|
|
29
|
+
export declare function escapeHtml(valor: string): string;
|
|
30
|
+
export declare const LOGIN_PATH = "/oauth/login";
|
|
31
|
+
export declare function renderLoginPage(p: LoginPageParams): string;
|
|
32
|
+
export declare function loginPageHeaders(): Record<string, string>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pagina de login del OAuth del MCP: el usuario pega su API key de Pharmadate.
|
|
3
|
+
*
|
|
4
|
+
* HTML + CSS en linea, sin JavaScript. Todos los textos que vienen de fuera (nombre del cliente, errores) se escapan.
|
|
5
|
+
* La respuesta lleva `frame-ancestors 'none'` (anti-clickjacking) y `no-store`. No lleva `form-action`: Chrome lo aplica
|
|
6
|
+
* tambien a la redireccion posterior y bloquearia la vuelta al cliente (claude.ai, localhost...).
|
|
7
|
+
*
|
|
8
|
+
* @module auth/login-page
|
|
9
|
+
*/
|
|
10
|
+
/** Quita saltos de linea y caracteres de control del nombre autodeclarado (no puede disfrazarse de otra frase). */
|
|
11
|
+
export function cleanClientName(nombre) {
|
|
12
|
+
// eslint-disable-next-line no-control-regex
|
|
13
|
+
return (nombre ?? '').replace(/[\u0000-\u001f\u007f\u2028\u2029\u202a-\u202e\u2066-\u2069]+/g, ' ').trim().slice(0, 100);
|
|
14
|
+
}
|
|
15
|
+
export function escapeHtml(valor) {
|
|
16
|
+
return valor.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
17
|
+
}
|
|
18
|
+
const hidden = (name, value) => value === undefined || value === '' ? '' : `<input type="hidden" name="${name}" value="${escapeHtml(value)}">`;
|
|
19
|
+
export const LOGIN_PATH = '/oauth/login';
|
|
20
|
+
export function renderLoginPage(p) {
|
|
21
|
+
const nombre = cleanClientName(p.clientName);
|
|
22
|
+
const destino = escapeHtml(p.redirectHost);
|
|
23
|
+
// Un cliente de confianza puede titular la pagina con su nombre; uno que no lo es, no: el nombre lo escribe quien lo registro.
|
|
24
|
+
// Solo un destino de confianza que NO es loopback (claude.ai, chatgpt.com...) puede titular la pagina con el nombre que dice tener
|
|
25
|
+
const nombreFiable = p.trusted && !p.loopback;
|
|
26
|
+
const cliente = nombreFiable ? escapeHtml(nombre || 'tu asistente de IA') : (p.loopback ? 'una aplicación local' : 'una aplicación externa');
|
|
27
|
+
const titular = nombreFiable
|
|
28
|
+
? ''
|
|
29
|
+
: `<p class="unverified">${nombre ? `Dice llamarse «${escapeHtml(nombre)}» <strong>(no verificado)</strong>.` : 'No ha indicado ningún nombre.'}</p>`;
|
|
30
|
+
const aviso = p.trusted
|
|
31
|
+
? ''
|
|
32
|
+
: `<div class="warn" role="alert"><strong>Atención: esta dirección no es de un asistente de confianza.</strong> No es Claude, ni ChatGPT, ni una aplicación de tu equipo. Si sigues, quien controle <strong>${destino}</strong> podrá consultar el catálogo, cursar pedidos y, si eres partner, gestionar tus farmacias con los permisos de tu API key. Continúa solo si has iniciado tú esta conexión y reconoces la aplicación.</div>`;
|
|
33
|
+
const reconocer = p.trusted
|
|
34
|
+
? ''
|
|
35
|
+
: `<label class="ack"><input type="checkbox" name="ack" value="1" required> Reconozco esta aplicación y su dirección (<strong>${destino}</strong>)</label>`;
|
|
36
|
+
const error = p.errorMessage ? `<div class="error" role="alert">${escapeHtml(p.errorMessage)}</div>` : '';
|
|
37
|
+
return `<!DOCTYPE html>
|
|
38
|
+
<html lang="es">
|
|
39
|
+
<head>
|
|
40
|
+
<meta charset="utf-8">
|
|
41
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
42
|
+
<meta name="robots" content="noindex">
|
|
43
|
+
<title>Conectar Pharmadate</title>
|
|
44
|
+
<style>
|
|
45
|
+
*{box-sizing:border-box}
|
|
46
|
+
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f1f5f9;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:#0f172a}
|
|
47
|
+
main{width:100%;max-width:420px;margin:16px;background:#fff;border-radius:14px;padding:32px 28px;box-shadow:0 10px 30px rgba(15,23,42,.12)}
|
|
48
|
+
h1{font-size:1.25rem;margin:0 0 6px}
|
|
49
|
+
p{margin:0 0 16px;color:#475569;font-size:.92rem;line-height:1.45}
|
|
50
|
+
label{display:block;font-weight:600;font-size:.85rem;margin-bottom:6px}
|
|
51
|
+
input[type=password]{width:100%;padding:11px 12px;border:1px solid #cbd5e1;border-radius:8px;font-size:.95rem;font-family:ui-monospace,Menlo,monospace}
|
|
52
|
+
input[type=password]:focus{outline:2px solid #2563eb;border-color:#2563eb}
|
|
53
|
+
button{margin-top:16px;width:100%;padding:12px;border:0;border-radius:8px;background:#0f172a;color:#fff;font-size:1rem;font-weight:600;cursor:pointer}
|
|
54
|
+
button:hover{background:#1e293b}
|
|
55
|
+
.error{background:#fef2f2;color:#991b1b;border:1px solid #fecaca;border-radius:8px;padding:10px 12px;margin-bottom:16px;font-size:.88rem}
|
|
56
|
+
.hint{font-size:.8rem;color:#64748b;margin-top:14px}
|
|
57
|
+
.dest{background:#f1f5f9;border:1px solid #cbd5e1;border-radius:8px;padding:10px 12px;margin-bottom:16px;font-size:.92rem;color:#0f172a;word-break:break-all}
|
|
58
|
+
.dest strong{font-size:1.02rem}
|
|
59
|
+
.warn{background:#fef2f2;color:#991b1b;border:2px solid #dc2626;border-radius:8px;padding:12px;margin-bottom:16px;font-size:.88rem;line-height:1.45}
|
|
60
|
+
.unverified{color:#991b1b;font-size:.88rem}
|
|
61
|
+
.ack{display:flex;gap:8px;align-items:flex-start;font-weight:600;font-size:.88rem;margin-top:14px;color:#991b1b}
|
|
62
|
+
.ack input{margin-top:3px}
|
|
63
|
+
.tip{background:#fffbeb;border:1px solid #fde68a;color:#78350f;border-radius:8px;padding:10px 12px;margin-top:14px;font-size:.82rem;line-height:1.45}
|
|
64
|
+
.brand{font-weight:700;letter-spacing:.02em;color:#0f172a;margin-bottom:18px}
|
|
65
|
+
</style>
|
|
66
|
+
</head>
|
|
67
|
+
<body>
|
|
68
|
+
<main>
|
|
69
|
+
<div class="brand">Pharmadate</div>
|
|
70
|
+
<h1>Conectar con ${cliente}</h1>
|
|
71
|
+
${titular}
|
|
72
|
+
<div class="dest">Tras conectar, volverás a: <strong>${destino}</strong></div>
|
|
73
|
+
<p>${nombreFiable ? cliente : 'Esta aplicación'} podrá consultar el catálogo, valorar y cursar pedidos y, si eres partner, gestionar tus farmacias, con los permisos de la API key que pegues aquí.</p>
|
|
74
|
+
${aviso}
|
|
75
|
+
${error}
|
|
76
|
+
<form method="post" action="${LOGIN_PATH}" autocomplete="off">
|
|
77
|
+
${hidden('client_id', p.clientId)}${hidden('redirect_uri', p.redirectUri)}${hidden('code_challenge', p.codeChallenge)}${hidden('scope', p.scope)}${hidden('state', p.state)}${hidden('resource', p.resource)}
|
|
78
|
+
<label for="api_key">API key de Pharmadate</label>
|
|
79
|
+
<input id="api_key" name="api_key" type="password" required placeholder="pk_partner_... o pk_live_..." spellcheck="false" autocapitalize="off">
|
|
80
|
+
${reconocer}
|
|
81
|
+
<button type="submit">Conectar</button>
|
|
82
|
+
</form>
|
|
83
|
+
<div class="tip"><strong>Usa un token de partner dedicado al MCP.</strong> Crea una credencial «MCP» propia en tu panel (/partner.html) y pega esa, no la que usa tu integración: así puedes revocarla en cualquier momento sin cortar nada más. Para desconectar basta con rotar o revocar la key.</div>
|
|
84
|
+
<p class="hint">Esta versión solo admite keys de partner o de farmacia.</p>
|
|
85
|
+
</main>
|
|
86
|
+
</body>
|
|
87
|
+
</html>`;
|
|
88
|
+
}
|
|
89
|
+
export function loginPageHeaders() {
|
|
90
|
+
return {
|
|
91
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
92
|
+
'Cache-Control': 'no-store',
|
|
93
|
+
'X-Frame-Options': 'DENY',
|
|
94
|
+
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'",
|
|
95
|
+
'Referrer-Policy': 'no-referrer',
|
|
96
|
+
'X-Content-Type-Options': 'nosniff',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proveedor OAuth 2.1 SIN ESTADO del MCP de Pharmadate.
|
|
3
|
+
*
|
|
4
|
+
* Estructura tomada de `mcp-nevent/src/auth/oauth-provider.ts`, sin las stores de MongoDB: no hay base de datos y el
|
|
5
|
+
* servidor no recuerda nada entre peticiones (funciona con varias instancias de Elastic Beanstalk detras del ALB).
|
|
6
|
+
*
|
|
7
|
+
* Flujo:
|
|
8
|
+
* 1. El cliente descubre `/.well-known/oauth-authorization-server` y se registra (`POST /register`, DCR). El
|
|
9
|
+
* `client_id` ES la metadata del registro firmada con HMAC ({@link StatelessClientsStore}).
|
|
10
|
+
* 2. `GET /authorize` -> pagina donde el usuario PEGA SU API KEY. `POST /oauth/login` la valida con `GET /api/v1/ping`.
|
|
11
|
+
* 3. Se emite un `code` (60 s): la key cifrada con AES-256-GCM + PKCE + client + redirect_uri.
|
|
12
|
+
* 4. `POST /token`: el SDK comprueba PKCE; aqui se canjea por un access token (1 h) y un refresh token (30 d).
|
|
13
|
+
* 5. Cada llamada a `/mcp` trae el access token; se descifra y la key se usa contra la API, que es quien autoriza.
|
|
14
|
+
*
|
|
15
|
+
* REVOCAR ES ROTAR LA KEY: no hay lista de tokens revocados. Una key rotada o revocada hace que cada llamada de la API
|
|
16
|
+
* (y cualquier refresco) devuelva 401, y el cliente vuelve a pedir el login. Por eso no se anuncia `revocation_endpoint`.
|
|
17
|
+
*
|
|
18
|
+
* El code no es de un solo uso (no hay estado donde marcarlo); lo acotan su vida de 60 s y PKCE (quien lo robe no tiene el
|
|
19
|
+
* `code_verifier`).
|
|
20
|
+
*
|
|
21
|
+
* @module auth/oauth-provider
|
|
22
|
+
*/
|
|
23
|
+
import type { Response } from 'express';
|
|
24
|
+
import type { OAuthServerProvider, AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider.js';
|
|
25
|
+
import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js';
|
|
26
|
+
import type { OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
|
|
27
|
+
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
|
|
28
|
+
import { type DerivedKeys } from './crypto.js';
|
|
29
|
+
export declare const SCOPE = "mcp:tools";
|
|
30
|
+
/** Mensaje de `verifyAccessToken` con un token caducado (el servidor lo distingue de uno falsificado). */
|
|
31
|
+
export declare const TOKEN_EXPIRED_MESSAGE = "Token caducado.";
|
|
32
|
+
export declare const CODE_TTL_S = 60;
|
|
33
|
+
export declare const ACCESS_TTL_S = 3600;
|
|
34
|
+
export declare const REFRESH_TTL_S: number;
|
|
35
|
+
export declare const CHAIN_MAX_S: number;
|
|
36
|
+
/** Resultado de comprobar una API key contra la API. */
|
|
37
|
+
export type KeyCheck = {
|
|
38
|
+
ok: true;
|
|
39
|
+
} | {
|
|
40
|
+
ok: false;
|
|
41
|
+
reason: 'invalid' | 'unavailable';
|
|
42
|
+
message: string;
|
|
43
|
+
};
|
|
44
|
+
export type KeyValidator = (apiKey: string) => Promise<KeyCheck>;
|
|
45
|
+
/** DCR sin base de datos: el client_id lleva la metadata firmada. Todos los clientes son publicos (PKCE, sin secreto). */
|
|
46
|
+
export declare class StatelessClientsStore implements OAuthRegisteredClientsStore {
|
|
47
|
+
private readonly keys;
|
|
48
|
+
constructor(keys: DerivedKeys);
|
|
49
|
+
getClient(clientId: string): OAuthClientInformationFull | undefined;
|
|
50
|
+
registerClient(client: Omit<OAuthClientInformationFull, 'client_id' | 'client_id_issued_at'>): OAuthClientInformationFull;
|
|
51
|
+
}
|
|
52
|
+
export interface OAuthProviderOptions {
|
|
53
|
+
secret: string;
|
|
54
|
+
/** Comprueba una API key (GET /api/v1/ping). */
|
|
55
|
+
validateKey: KeyValidator;
|
|
56
|
+
/** URL canonica del recurso MCP (p. ej. https://www.pharmadatepedido.es/mcp). */
|
|
57
|
+
resourceUrl: URL;
|
|
58
|
+
/** Callbacks de confianza ADEMAS de los de Claude, ChatGPT/OpenAI y loopback (`MCP_ALLOWED_REDIRECTS`): hosts u origenes. */
|
|
59
|
+
trustedRedirects?: string[];
|
|
60
|
+
}
|
|
61
|
+
export declare class PharmadateOAuthProvider implements OAuthServerProvider {
|
|
62
|
+
readonly clientsStore: StatelessClientsStore;
|
|
63
|
+
private readonly keys;
|
|
64
|
+
private readonly validateKey;
|
|
65
|
+
readonly resourceUrl: URL;
|
|
66
|
+
private readonly extraRedirects;
|
|
67
|
+
constructor(options: OAuthProviderOptions);
|
|
68
|
+
/** GET /authorize: muestra la pagina donde se pega la API key (con el destino y, si no es de confianza, el aviso). */
|
|
69
|
+
authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void>;
|
|
70
|
+
/**
|
|
71
|
+
* POST /oauth/login: valida la API key y, si es buena, redirige al cliente con un code.
|
|
72
|
+
* `redirect_uri` se vuelve a comprobar contra las registradas en el client_id (anti open-redirect), con la misma regla que
|
|
73
|
+
* `/authorize` (exacta, salvo el puerto en loopback). Si el callback no es de confianza hace falta `ack=1` (la casilla
|
|
74
|
+
* "Reconozco esta aplicacion y su direccion"): se exige AQUI, no solo en la pagina, y antes de mirar la API key.
|
|
75
|
+
*/
|
|
76
|
+
handleLoginPost(form: {
|
|
77
|
+
api_key?: string;
|
|
78
|
+
client_id?: string;
|
|
79
|
+
redirect_uri?: string;
|
|
80
|
+
code_challenge?: string;
|
|
81
|
+
scope?: string;
|
|
82
|
+
state?: string;
|
|
83
|
+
resource?: string;
|
|
84
|
+
ack?: string;
|
|
85
|
+
}, res: Response): Promise<void>;
|
|
86
|
+
challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<string>;
|
|
87
|
+
exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string, _codeVerifier?: string, redirectUri?: string, resource?: URL): Promise<OAuthTokens>;
|
|
88
|
+
exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise<OAuthTokens>;
|
|
89
|
+
/** Descifra un access token. Lo usa `requireBearerAuth` del SDK. */
|
|
90
|
+
verifyAccessToken(token: string): Promise<AuthInfo>;
|
|
91
|
+
private issue;
|
|
92
|
+
}
|