@envisiongroup/create-alakazam 0.1.1 → 0.1.2
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@envisiongroup/create-alakazam",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "CLI para generar proyectos Mastra cliente sobre @envisiongroup/alakazam (agents hub con auth, storage y rutas ya cableadas).",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -37,6 +37,9 @@ AUTH_DISABLED=false
|
|
|
37
37
|
# true → bypass de auth solo para Mastra Studio (same-origin, sin Bearer).
|
|
38
38
|
# Recomendado en local. En producción debe quedar sin setear (o false).
|
|
39
39
|
STUDIO_AUTH_BYPASS=true
|
|
40
|
+
# ¿Llamadas servicio-a-servicio (M2M) sin usuario interactivo? No uses estos
|
|
41
|
+
# flags: ver las variantes comentadas en src/mastra/index.ts (endpoints
|
|
42
|
+
# públicos puntuales / provider app-only en src/mastra/auth/).
|
|
40
43
|
|
|
41
44
|
# ── CORS ────────────────────────────────────────────────────────────────────
|
|
42
45
|
# Lista de orígenes permitidos separados por coma (tu SPA en local):
|
|
@@ -19,6 +19,10 @@ import type { RoleRule } from '@envisiongroup/alakazam';
|
|
|
19
19
|
* { when: { provider: 'google', domain: 'envisiongroup.com' }, grant: 'employee' },
|
|
20
20
|
* // Email directo → rol (útil para admins puntuales):
|
|
21
21
|
* { when: { email: 'admin@envisiongroup.com' }, grant: 'hub-admin' },
|
|
22
|
+
* // Servicio M2M (app-only Entra, provider 'entra-service' del ejemplo
|
|
23
|
+
* // `auth/service-identity-provider.ts`; el `user` es el Object ID del
|
|
24
|
+
* // service principal llamante):
|
|
25
|
+
* { when: { provider: 'entra-service', user: '<OBJECT-ID-DEL-SP>' }, grant: 'mi-rol-servicio' },
|
|
22
26
|
* ```
|
|
23
27
|
*
|
|
24
28
|
* Requiere que la App Registration de la API tenga configurado
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EJEMPLO: autenticación servicio-a-servicio (M2M) junto al login de usuario.
|
|
3
|
+
*
|
|
4
|
+
* Por default el hub solo acepta tokens de USUARIO delegados de Entra ID
|
|
5
|
+
* (flujo interactivo del SPA, claim `scp` con `access_as_user`). Este archivo
|
|
6
|
+
* muestra cómo aceptar ADEMÁS tokens **app-only** emitidos por Entra para
|
|
7
|
+
* otra aplicación (client credentials flow), de modo que un servicio X
|
|
8
|
+
* (un backend, un job, una Azure Function, …) pueda invocar agents con su
|
|
9
|
+
* propia identidad, sin usuario interactivo y SIN desactivar la auth.
|
|
10
|
+
*
|
|
11
|
+
* ─── ¿Por qué UN provider compuesto y no dos providers? ────────────────────
|
|
12
|
+
*
|
|
13
|
+
* Con varios providers, el hub elige cuál valida cada token por su claim
|
|
14
|
+
* `iss` (routing multi-IdP). Pero un token app-only del MISMO tenant tiene
|
|
15
|
+
* exactamente el mismo `iss` que un token de usuario — el routing no puede
|
|
16
|
+
* distinguirlos. Por eso este provider se registra como ÚNICO provider y
|
|
17
|
+
* discrimina por la FORMA del token:
|
|
18
|
+
*
|
|
19
|
+
* - token SIN claim `scp` (o con `idtyp: 'app'`) → app-only: se valida
|
|
20
|
+
* aquí (firma + issuer + audience + app role requerido).
|
|
21
|
+
* - cualquier otro token → se delega intacto al provider de usuario de
|
|
22
|
+
* siempre (`EntraIdentityProvider`), conservando la semántica 401/403
|
|
23
|
+
* original para el SPA.
|
|
24
|
+
*
|
|
25
|
+
* ─── Alta en Microsoft Entra (una vez, por servicio llamante) ──────────────
|
|
26
|
+
*
|
|
27
|
+
* 1. En la App Registration DE ESTA API (la de `AZURE_API_CLIENT_ID`):
|
|
28
|
+
* "App roles" → crear rol con `allowedMemberTypes: ['Applications']`,
|
|
29
|
+
* value `Hub.Invoke` (o el que pases como `requiredAppRole`).
|
|
30
|
+
* 2. En la App Registration DEL SERVICIO llamante: "API permissions" →
|
|
31
|
+
* "Application permissions" → agregar el rol `Hub.Invoke` de tu API →
|
|
32
|
+
* conceder admin consent.
|
|
33
|
+
* 3. El servicio pide token con client credentials:
|
|
34
|
+
* POST https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token
|
|
35
|
+
* grant_type=client_credentials
|
|
36
|
+
* client_id=<app-del-servicio>&client_secret=<...>
|
|
37
|
+
* scope=api://<AZURE_API_CLIENT_ID>/.default
|
|
38
|
+
* y llama al hub con `Authorization: Bearer <token>`.
|
|
39
|
+
*
|
|
40
|
+
* ─── Identidad resultante y roles ──────────────────────────────────────────
|
|
41
|
+
*
|
|
42
|
+
* El `subject` de la identidad es el Object ID del service principal llamante
|
|
43
|
+
* (claim `sub`; fallback `azp`). Ese subject pasa a ser el
|
|
44
|
+
* `MASTRA_RESOURCE_ID_KEY` de la request: memoria, threads y ownership de
|
|
45
|
+
* runs quedan aislados POR SERVICIO, igual que por usuario.
|
|
46
|
+
*
|
|
47
|
+
* La identidad se reporta con `provider: 'entra-service'` para que el
|
|
48
|
+
* `roleResolver` pueda otorgar roles distintos a servicios que a usuarios
|
|
49
|
+
* (ver `access/role-rules.ts`):
|
|
50
|
+
*
|
|
51
|
+
* { when: { provider: 'entra-service', user: '<OBJECT-ID-DEL-SP>' }, grant: 'mi-rol-servicio' },
|
|
52
|
+
*
|
|
53
|
+
* Nota: si lo que buscas es NO exigir credencial alguna (red privada, dev),
|
|
54
|
+
* no necesitas un provider — usa los flags `AUTH_DISABLED` /
|
|
55
|
+
* `config.publicPaths` (ver los comentarios de `src/mastra/index.ts`).
|
|
56
|
+
*/
|
|
57
|
+
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
58
|
+
import {
|
|
59
|
+
AuthError,
|
|
60
|
+
entraIdentityProvider,
|
|
61
|
+
type EntraIdentityProvider,
|
|
62
|
+
type HubIdentity,
|
|
63
|
+
type IdentityProvider,
|
|
64
|
+
} from '@envisiongroup/alakazam';
|
|
65
|
+
|
|
66
|
+
/** Nombre del provider en la identidad resultante (lo usan las RoleRules). */
|
|
67
|
+
export const ENTRA_SERVICE_PROVIDER_NAME = 'entra-service' as const;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* JWKS del tenant (mismo endpoint que el flujo de usuario). `new URL` no
|
|
71
|
+
* puede fallar con un tenant sano, pero validamos para reportar config
|
|
72
|
+
* rota como error de servidor y no como excepción suelta en el arranque.
|
|
73
|
+
*/
|
|
74
|
+
const buildTenantJwks = (tenantId: string) => {
|
|
75
|
+
try {
|
|
76
|
+
return createRemoteJWKSet(
|
|
77
|
+
new URL(
|
|
78
|
+
`https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`,
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
throw new AuthError(
|
|
83
|
+
`Invalid tenant id for JWKS URL: ${error instanceof Error ? error.message : error}`,
|
|
84
|
+
500,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type EntraServiceIdentityProviderOptions = {
|
|
90
|
+
/** Tenant de Entra ID. Default: env AZURE_API_TENANT_ID. */
|
|
91
|
+
tenantId?: string;
|
|
92
|
+
/**
|
|
93
|
+
* Audience exigido: la App Registration de esta API.
|
|
94
|
+
* Default: env AZURE_API_CLIENT_ID (se acepta GUID y `api://<GUID>`,
|
|
95
|
+
* igual que en el flujo de usuario).
|
|
96
|
+
*/
|
|
97
|
+
apiClientId?: string;
|
|
98
|
+
/**
|
|
99
|
+
* App role (application permission) que el token del servicio debe
|
|
100
|
+
* incluir en su claim `roles`. Default: 'Hub.Invoke'.
|
|
101
|
+
*/
|
|
102
|
+
requiredAppRole?: string;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export class EntraServiceIdentityProvider implements IdentityProvider {
|
|
106
|
+
/**
|
|
107
|
+
* Se registra como provider único: el routing por `iss` no entra en
|
|
108
|
+
* juego (con un solo provider el hub lo usa directamente). El nombre
|
|
109
|
+
* del provider de USUARIOS sigue siendo 'entra' en sus identidades;
|
|
110
|
+
* las identidades app-only salen de aquí con 'entra-service'.
|
|
111
|
+
*/
|
|
112
|
+
readonly provider = 'entra';
|
|
113
|
+
readonly issuers: readonly string[];
|
|
114
|
+
|
|
115
|
+
readonly #apiClientId: string;
|
|
116
|
+
readonly #requiredAppRole: string;
|
|
117
|
+
readonly #jwks: ReturnType<typeof createRemoteJWKSet>;
|
|
118
|
+
readonly #userProvider: EntraIdentityProvider;
|
|
119
|
+
|
|
120
|
+
constructor(options: EntraServiceIdentityProviderOptions = {}) {
|
|
121
|
+
const tenantId =
|
|
122
|
+
options.tenantId ?? process.env.AZURE_API_TENANT_ID ?? '';
|
|
123
|
+
this.#apiClientId =
|
|
124
|
+
options.apiClientId ?? process.env.AZURE_API_CLIENT_ID ?? '';
|
|
125
|
+
this.#requiredAppRole = options.requiredAppRole ?? 'Hub.Invoke';
|
|
126
|
+
|
|
127
|
+
// Mismos issuers y JWKS que el flujo de usuario (mismo tenant):
|
|
128
|
+
// v2.0 y v1.0, según `accessTokenAcceptedVersion` de cada app.
|
|
129
|
+
this.issuers = tenantId
|
|
130
|
+
? [
|
|
131
|
+
`https://login.microsoftonline.com/${tenantId}/v2.0`,
|
|
132
|
+
`https://sts.windows.net/${tenantId}/`,
|
|
133
|
+
]
|
|
134
|
+
: [];
|
|
135
|
+
this.#jwks = buildTenantJwks(tenantId);
|
|
136
|
+
this.#userProvider = entraIdentityProvider(
|
|
137
|
+
options.tenantId ? { tenantId: options.tenantId } : {},
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async validateToken(token: string): Promise<HubIdentity> {
|
|
142
|
+
const serviceIdentity = await this.#tryValidateServiceToken(token);
|
|
143
|
+
if (serviceIdentity) {
|
|
144
|
+
return serviceIdentity;
|
|
145
|
+
}
|
|
146
|
+
// Token de usuario delegado (o token inválido): flujo de siempre,
|
|
147
|
+
// que produce los mismos 401/403 que sin este provider.
|
|
148
|
+
return this.#userProvider.validateToken(token);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Valida el token como app-only (client credentials). Devuelve `null`
|
|
153
|
+
* cuando el token NO es app-only (tiene `scp` → es de usuario, o no
|
|
154
|
+
* verifica → el flujo de usuario reportará el error adecuado).
|
|
155
|
+
*/
|
|
156
|
+
async #tryValidateServiceToken(token: string): Promise<HubIdentity | null> {
|
|
157
|
+
if (!this.#apiClientId || this.issuers.length === 0) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let payload;
|
|
162
|
+
try {
|
|
163
|
+
({ payload } = await jwtVerify(token, this.#jwks, {
|
|
164
|
+
issuer: [...this.issuers],
|
|
165
|
+
audience: [this.#apiClientId, `api://${this.#apiClientId}`],
|
|
166
|
+
}));
|
|
167
|
+
} catch {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Los tokens de usuario delegados SIEMPRE traen `scp` (scopes);
|
|
172
|
+
// los app-only traen `roles` (app roles) y, en v2.0, `idtyp: 'app'`.
|
|
173
|
+
const isAppOnly =
|
|
174
|
+
payload.idtyp === 'app' || typeof payload.scp !== 'string';
|
|
175
|
+
if (!isAppOnly) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const roles = Array.isArray(payload.roles)
|
|
180
|
+
? payload.roles.filter(
|
|
181
|
+
(role: unknown): role is string => typeof role === 'string',
|
|
182
|
+
)
|
|
183
|
+
: [];
|
|
184
|
+
if (!roles.includes(this.#requiredAppRole)) {
|
|
185
|
+
throw new AuthError(
|
|
186
|
+
`Token missing required app role: ${this.#requiredAppRole}`,
|
|
187
|
+
403,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// En tokens app-only, `sub` es el Object ID del service principal
|
|
192
|
+
// llamante; `azp` es el Client ID de su app (fallback).
|
|
193
|
+
const subject = payload.sub ?? (payload.azp as string | undefined);
|
|
194
|
+
if (!subject) {
|
|
195
|
+
throw new AuthError('Token missing sub claim', 401);
|
|
196
|
+
}
|
|
197
|
+
const tenantId = payload.tid as string | undefined;
|
|
198
|
+
if (!tenantId) {
|
|
199
|
+
throw new AuthError('Token missing tid claim', 401);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
provider: ENTRA_SERVICE_PROVIDER_NAME,
|
|
204
|
+
subject,
|
|
205
|
+
email: '', // app-only: no hay email de usuario
|
|
206
|
+
principals: [
|
|
207
|
+
{ kind: 'user', id: subject },
|
|
208
|
+
{ kind: 'domain', id: tenantId },
|
|
209
|
+
],
|
|
210
|
+
raw: payload,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export const entraServiceIdentityProvider = (
|
|
216
|
+
options: EntraServiceIdentityProviderOptions = {},
|
|
217
|
+
): EntraServiceIdentityProvider => new EntraServiceIdentityProvider(options);
|
|
@@ -45,3 +45,54 @@ export const mastra = await createAgentsHub({
|
|
|
45
45
|
roleResolver: staticRoleResolver(ROLE_RULES),
|
|
46
46
|
},
|
|
47
47
|
});
|
|
48
|
+
|
|
49
|
+
// --- Autenticación: variantes para clientes sin usuario interactivo ---------
|
|
50
|
+
// Por defecto, todo lo bajo `/api/*` (rutas nativas de Mastra) y `/app/*`
|
|
51
|
+
// (rutas custom) exige un Bearer token de usuario de Entra ID. El hub
|
|
52
|
+
// soporta tres variantes para escenarios distintos (p. ej. que un servicio
|
|
53
|
+
// X — otro backend, un job, una Function— llame a los agents):
|
|
54
|
+
//
|
|
55
|
+
// A) APAGADO TOTAL — solo emergencias/debug, NUNCA en producción.
|
|
56
|
+
// En `.env`: AUTH_DISABLED=true
|
|
57
|
+
// Ninguna ruta exige token y el catálogo (`/app/agents/catalog`) lista
|
|
58
|
+
// TODOS los recursos sin filtrar por roles. No hay usuario autenticado:
|
|
59
|
+
// se degrada el aislamiento por usuario (threads/runs sin ownership,
|
|
60
|
+
// pinned agents, uploads/downloads). No requiere AZURE_API_*.
|
|
61
|
+
//
|
|
62
|
+
// B) ENDPOINTS PÚBLICOS PUNTUALES — un caller sin token accede solo a lo
|
|
63
|
+
// que declares; el resto del hub sigue exigiendo Entra. Es el mismo
|
|
64
|
+
// patrón que usa `/app/files` (donde el downloadId opaco es el credential):
|
|
65
|
+
//
|
|
66
|
+
// export const mastra = await createAgentsHub({
|
|
67
|
+
// // ... agents, tools, workflows, auth, ...
|
|
68
|
+
// config: {
|
|
69
|
+
// // Path exacto público (p. ej. el stream nativo de UN agente):
|
|
70
|
+
// publicPaths: ['/api/agents/weather-agent/stream'],
|
|
71
|
+
// // …o prefijos completos (todo lo del agente):
|
|
72
|
+
// // publicPathPrefixes: ['/api/agents/weather-agent/'],
|
|
73
|
+
// },
|
|
74
|
+
// });
|
|
75
|
+
//
|
|
76
|
+
// Ojo: lo que publiques así queda SIN credencial — compensa a nivel de
|
|
77
|
+
// red (VNet, firewall, API Management) si lo expones fuera de localhost.
|
|
78
|
+
//
|
|
79
|
+
// C) SERVICIO A SERVICIO CON CREDENCIAL (recomendado para M2M) — el servicio
|
|
80
|
+
// X se autentica con client credentials de Entra (token app-only) y el hub
|
|
81
|
+
// lo valida como una identidad propia, con roles y aislamiento por
|
|
82
|
+
// servicio. Implementación de ejemplo lista para adaptar en
|
|
83
|
+
// `./auth/service-identity-provider.ts` (incluye el alta paso a paso en
|
|
84
|
+
// Entra). Se registra como provider ÚNICO — él mismo distingue tokens
|
|
85
|
+
// app-only de tokens de usuario del mismo tenant:
|
|
86
|
+
//
|
|
87
|
+
// import { entraServiceIdentityProvider } from './auth/service-identity-provider';
|
|
88
|
+
// ...
|
|
89
|
+
// auth: {
|
|
90
|
+
// providers: [entraServiceIdentityProvider()],
|
|
91
|
+
// roleResolver: staticRoleResolver(ROLE_RULES),
|
|
92
|
+
// },
|
|
93
|
+
//
|
|
94
|
+
// Roles para la identidad del servicio: ver `./access/role-rules.ts`.
|
|
95
|
+
//
|
|
96
|
+
// No uses STUDIO_AUTH_BYPASS para esto: ese flag es solo para Mastra Studio
|
|
97
|
+
// en local (un caller server-to-server sin header Origin también pasaría el
|
|
98
|
+
// bypass — en producción debe quedar sin setear).
|