@adatechnology/auth-keycloak 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/README.md CHANGED
@@ -129,66 +129,153 @@ Resultado no log:
129
129
  [MyController.getToken][InMemoryCacheProvider.set] → token cached
130
130
  ```
131
131
 
132
- ### BearerTokenGuard autenticação B2B via introspecção
132
+ ### Contrato de headers (Kong API)
133
+
134
+ ```
135
+ Authorization: Bearer <service_token> → identidade do chamador (B2B)
136
+ X-Access-Token: <user_jwt> → token original do usuário (B2C)
137
+ ```
138
+
139
+ Kong valida o JWT do usuário via JWKS (local, zero chamadas ao Keycloak por request) e injeta os dois headers antes de encaminhar a request ao API.
140
+
141
+ ---
142
+
143
+ ### Guards
133
144
 
134
- Valida que o header `Authorization: Bearer <token>` contém um token ativo chamando
135
- `POST /token/introspect` no Keycloak. Use sempre em conjunto com `RolesGuard` em rotas B2B.
145
+ | Guard | Valida | Quando usar |
146
+ |---|---|---|
147
+ | `B2CGuard` | `X-Access-Token` presente | Rota exclusiva de usuários via Kong |
148
+ | `B2BGuard` | `Authorization` via introspection | Rota exclusiva de serviços internos |
149
+ | `ApiAuthGuard` | Detecta path e delega | Rota acessível pelos dois paths |
150
+ | `RolesGuard` | Roles no JWT correto | Sempre junto a um guard acima |
151
+ | `BearerTokenGuard` | `Authorization` via introspection | Nível baixo; prefira `B2BGuard` |
152
+
153
+ **Guard order matters** — guards de autenticação devem vir antes de `RolesGuard`.
154
+
155
+ ---
156
+
157
+ ### Decorators
158
+
159
+ #### `@AuthUser(param?)`
160
+ Extrai claim do token em `X-Access-Token`. Padrão: claim configurado em `userId` (default `sub`). Decodificação local, sem I/O.
136
161
 
137
162
  ```ts
138
- import { Controller, Headers, Post, UseGuards } from "@nestjs/common";
139
- import { BearerTokenGuard, Roles, RolesGuard } from "@adatechnology/auth-keycloak";
140
-
141
- @Controller("orders")
142
- export class OrdersController {
143
- @Post()
144
- @Roles("manage-requests")
145
- @UseGuards(BearerTokenGuard, RolesGuard)
146
- create(@Headers("x-user-id") keycloakId: string) {}
147
- }
163
+ @AuthUser() // sub (default)
164
+ @AuthUser('email') // claim único
165
+ @AuthUser(['preferred_username', 'email', 'sub']) // primeiro não-vazio
166
+ @AuthUser({ claim: 'email', header: 'x-user-jwt' }) // header customizado por rota
148
167
  ```
149
168
 
150
- **Por que dois guards em sequência?**
169
+ #### `@CallerToken(param?)`
170
+ Extrai claim do token em `Authorization`. Padrão: claim configurado em `callerId` (default `azp`).
151
171
 
152
- | Guard | Mecanismo | HTTP? | Falha |
153
- |---|---|---|---|
154
- | `BearerTokenGuard` | `POST /token/introspect` ao Keycloak | Sim | 401 — token inativo/expirado/forjado |
155
- | `RolesGuard` | Decode local do payload JWT | Não | 403 — permissão insuficiente |
172
+ ```ts
173
+ @CallerToken() // azp (default)
174
+ @CallerToken('sub') // claim único
175
+ @CallerToken(['client_id', 'azp']) // primeiro não-vazio
176
+ @CallerToken({ header: 'x-service-token', claim: 'azp' }) // header customizado
177
+ ```
156
178
 
157
- O `RolesGuard` sozinho **não é seguro** para autenticação: ele apenas decodifica o payload JWT
158
- sem verificar a assinatura, o que significa que um token forjado passaria. O `BearerTokenGuard`
159
- é quem garante autenticidade via introspecção.
179
+ #### `@AccessToken(header?)`
180
+ Retorna o JWT bruto do header B2C. Use quando precisar de claims não-string ou repassar o token.
160
181
 
161
- ### Autorização com @Roles
182
+ ```ts
183
+ @AccessToken() // header B2C padrão
184
+ @AccessToken('x-user-jwt') // header customizado
185
+ ```
186
+
187
+ #### `@Roles(...)`
188
+ Declara roles necessárias. Sempre usado com `RolesGuard`.
162
189
 
163
190
  ```ts
164
- import { Controller, Get, UseGuards } from "@nestjs/common";
165
- import { Roles, RolesGuard } from "@adatechnology/auth-keycloak";
166
-
167
- @Controller("secure")
168
- export class SecureController {
169
- @Get("public")
170
- @UseGuards(RolesGuard)
171
- public() {
172
- return { ok: true };
173
- }
191
+ @Roles('user-manager')
192
+ @Roles({ roles: ['admin', 'user-manager'], mode: 'any' }) // qualquer (default)
193
+ @Roles({ roles: ['admin', 'user-manager'], mode: 'all' }) // todas
194
+ ```
174
195
 
175
- @Get("admin")
176
- @UseGuards(RolesGuard)
177
- @Roles("admin")
178
- adminOnly() {
179
- return { ok: true };
180
- }
196
+ ---
181
197
 
182
- @Get("team")
183
- @UseGuards(RolesGuard)
184
- @Roles({ roles: ["manager", "lead"], mode: "all" }) // AND requer ambas as roles
185
- teamOnly() {
186
- return { ok: true };
187
- }
198
+ ### Exemplos de uso
199
+
200
+ **B2Cusuário via Kong:**
201
+ ```ts
202
+ @Get('me')
203
+ @Roles('user-manager')
204
+ @UseGuards(B2CGuard, RolesGuard)
205
+ async getMe(
206
+ @AuthUser() id: string,
207
+ @AuthUser('email') email: string,
208
+ @AuthUser(['preferred_username', 'email']) name: string,
209
+ @AccessToken() rawToken: string,
210
+ ) {
211
+ return { id, email, name };
188
212
  }
189
213
  ```
190
214
 
191
- O `RolesGuard` extrai roles de `realm_access.roles` e `resource_access[clientId].roles` do JWT.
215
+ **B2B serviço interno:**
216
+ ```ts
217
+ @Post('internal/notify')
218
+ @Roles('send-notifications')
219
+ @UseGuards(B2BGuard, RolesGuard)
220
+ async notify(
221
+ @CallerToken() caller: string, // 'domestic-backend-bff'
222
+ ) {
223
+ return { caller };
224
+ }
225
+ ```
226
+
227
+ **Ambos os paths:**
228
+ ```ts
229
+ @Get(':id')
230
+ @Roles('user-manager')
231
+ @UseGuards(ApiAuthGuard, RolesGuard)
232
+ async findById(
233
+ @Param('id') id: string,
234
+ @AuthUser() userId: string, // vazio no B2B-only path
235
+ @CallerToken() caller: string,
236
+ ) { ... }
237
+ ```
238
+
239
+ ---
240
+
241
+ ### Configuração de headers e claims
242
+
243
+ Configurável via env ou `forRoot()`. Prioridade: `forRoot()` > `process.env` > default.
244
+
245
+ **Env vars:**
246
+ ```env
247
+ KEYCLOAK_B2C_TOKEN_HEADER=x-access-token # header do user JWT
248
+ KEYCLOAK_B2B_TOKEN_HEADER=authorization # header do service token
249
+ KEYCLOAK_USER_ID_CLAIM=sub # claim(s) para user ID (comma-separated)
250
+ KEYCLOAK_CALLER_ID_CLAIM=azp # claim(s) para caller ID (comma-separated)
251
+ ```
252
+
253
+ **`forRoot()` (sobrescreve env):**
254
+ ```ts
255
+ KeycloakModule.forRoot({
256
+ ...
257
+ headers: { b2cToken: 'x-access-token', b2bToken: 'authorization' },
258
+ claims: {
259
+ userId: ['preferred_username', 'email', 'sub'], // string ou string[]
260
+ callerId: ['client_id', 'azp'],
261
+ },
262
+ })
263
+ ```
264
+
265
+ ---
266
+
267
+ ### BearerTokenGuard — autenticação B2B via introspecção
268
+
269
+ Valida `Authorization: Bearer <token>` chamando `POST /token/introspect` no Keycloak.
270
+
271
+ | Guard | Mecanismo | HTTP? | Falha |
272
+ |---|---|---|---|
273
+ | `BearerTokenGuard` | `POST /token/introspect` | Sim | 401 |
274
+ | `RolesGuard` | Decode local do payload | Não | 403 |
275
+
276
+ O `RolesGuard` sozinho **não é seguro** — ele decodifica sem verificar assinatura.
277
+
278
+ ---
192
279
 
193
280
  ### Tratamento de erros
194
281
 
@@ -213,6 +300,10 @@ try {
213
300
  | `KEYCLOAK_REALM` | `BACKEND` |
214
301
  | `KEYCLOAK_CLIENT_ID` | `backend-api` |
215
302
  | `KEYCLOAK_CLIENT_SECRET` | `backend-api-secret` |
303
+ | `KEYCLOAK_B2C_TOKEN_HEADER` | `x-access-token` |
304
+ | `KEYCLOAK_B2B_TOKEN_HEADER` | `authorization` |
305
+ | `KEYCLOAK_USER_ID_CLAIM` | `sub` |
306
+ | `KEYCLOAK_CALLER_ID_CLAIM` | `azp` |
216
307
 
217
308
  ### Notas
218
309
 
package/dist/index.d.ts CHANGED
@@ -44,6 +44,42 @@ interface KeycloakConfig {
44
44
  * determine how long to cache the access token instead of deriving TTL from the token's expires_in.
45
45
  */
46
46
  tokenCacheTtl?: number;
47
+ /**
48
+ * Header names for B2C and B2B token flows.
49
+ * Overrides process.env values when provided.
50
+ *
51
+ * Env equivalents:
52
+ * KEYCLOAK_B2C_TOKEN_HEADER (default: 'x-access-token')
53
+ * KEYCLOAK_B2B_TOKEN_HEADER (default: 'authorization')
54
+ */
55
+ headers?: {
56
+ /** Header carrying the user JWT forwarded by Kong. */
57
+ b2cToken?: string;
58
+ /** Header carrying the service account token. */
59
+ b2bToken?: string;
60
+ };
61
+ /**
62
+ * JWT claim names used to identify users and callers.
63
+ * Overrides process.env values when provided.
64
+ *
65
+ * Env equivalents:
66
+ * KEYCLOAK_USER_ID_CLAIM (default: 'sub')
67
+ * KEYCLOAK_CALLER_ID_CLAIM (default: 'azp')
68
+ */
69
+ claims?: {
70
+ /**
71
+ * Claim name(s) for the user identifier (from the B2C token).
72
+ * When an array, the first non-empty value found in the JWT is used.
73
+ * Env: KEYCLOAK_USER_ID_CLAIM (comma-separated). Default: 'sub'
74
+ */
75
+ userId?: string | string[];
76
+ /**
77
+ * Claim name(s) for the calling client identifier (from the B2B token).
78
+ * When an array, the first non-empty value found in the JWT is used.
79
+ * Env: KEYCLOAK_CALLER_ID_CLAIM (comma-separated). Default: 'azp'
80
+ */
81
+ callerId?: string | string[];
82
+ };
47
83
  }
48
84
  /**
49
85
  * Keycloak client interface
@@ -121,44 +157,101 @@ declare const KEYCLOAK_PROVIDER = "KEYCLOAK_PROVIDER";
121
157
 
122
158
  type RolesMode = "any" | "all";
123
159
  type RolesType = "realm" | "client" | "both";
160
+ interface TokenRolesOptions {
161
+ /** Header name where the JWT lives. E.g. 'x-access-token', 'authorization'. */
162
+ header: string;
163
+ /** Roles required from that token. */
164
+ roles: string[];
165
+ /** Match mode. Default: 'any' (at least one role must match). */
166
+ mode?: RolesMode;
167
+ /**
168
+ * If true, strips the 'Bearer ' prefix before decoding.
169
+ * Auto-detected when omitted: stripped when header is 'authorization'.
170
+ */
171
+ bearer?: boolean;
172
+ }
124
173
  type RolesOptions = {
125
174
  roles: string[];
126
175
  mode?: RolesMode;
127
176
  type?: RolesType;
128
177
  };
129
178
  /**
130
- * Decorator to declare required roles for a route or controller.
131
- * Accepts either a list of strings or a single options object.
132
- * Examples:
179
+ * Declares required roles without specifying the token source.
180
+ * RolesGuard resolves the token automatically:
181
+ * - X-Access-Token present → reads from user JWT (B2C)
182
+ * - Authorization only → reads from service JWT (B2B)
183
+ *
184
+ * @example
133
185
  * @Roles('admin')
134
- * @Roles('admin','editor')
135
- * @Roles(['admin','editor'])
136
- * @Roles({ roles: ['a','b'], mode: 'all', type: 'client' })
186
+ * @Roles('admin', 'editor')
187
+ * @Roles({ roles: ['admin', 'editor'], mode: 'all' })
137
188
  */
138
189
  declare function Roles(...args: Array<string | string[] | RolesOptions>): _nestjs_common.CustomDecorator<string>;
190
+ /**
191
+ * Declares roles that must be present in the **user JWT** (`X-Access-Token`).
192
+ * Checked independently from B2BRoles — both must pass when both are declared.
193
+ *
194
+ * Use when the route requires a specific user role regardless of which service called it.
195
+ *
196
+ * @example
197
+ * @B2CRoles('user-manager')
198
+ * @B2CRoles({ roles: ['admin', 'user-manager'], mode: 'all' })
199
+ */
200
+ declare function B2CRoles(...args: Array<string | string[] | RolesOptions>): _nestjs_common.CustomDecorator<string>;
201
+ /**
202
+ * Declares roles that must be present in the **service token** (`Authorization`).
203
+ * Checked independently from B2CRoles — both must pass when both are declared.
204
+ *
205
+ * Use when the route requires the calling service to have a specific role,
206
+ * regardless of which user triggered the request.
207
+ *
208
+ * @example
209
+ * @B2BRoles('manage-requests')
210
+ * @B2BRoles({ roles: ['manage-requests', 'send-notifications'], mode: 'any' })
211
+ */
212
+ declare function B2BRoles(...args: Array<string | string[] | RolesOptions>): _nestjs_common.CustomDecorator<string>;
213
+ /**
214
+ * Declares role requirements tied to a specific JWT header.
215
+ * Fully dynamic — works with any header that carries a JWT.
216
+ *
217
+ * Multiple uses are accumulated (AND logic): every @TokenRoles block must pass.
218
+ * Uses Reflector.getAllAndMerge internally, so stacking works correctly.
219
+ *
220
+ * @example
221
+ * // Verify user roles from X-Access-Token AND service roles from Authorization:
222
+ * @TokenRoles({ header: 'x-access-token', roles: ['user-manager'] })
223
+ * @TokenRoles({ header: 'authorization', roles: ['manage-requests'] })
224
+ * @UseGuards(B2BGuard, B2CGuard, RolesGuard)
225
+ *
226
+ * // Custom token header with ALL mode:
227
+ * @TokenRoles({ header: 'x-partner-token', roles: ['partner-admin', 'partner-api'], mode: 'all' })
228
+ * @UseGuards(RolesGuard)
229
+ */
230
+ declare function TokenRoles(options: TokenRolesOptions): _nestjs_common.CustomDecorator<string>;
139
231
 
140
232
  /**
141
- * Guard that checks whether the current request has the required roles.
233
+ * Guard that enforces role requirements declared by @Roles, @B2CRoles, and @B2BRoles.
142
234
  *
143
- * Supports two auth paths transparently:
235
+ * Three decorator modes:
144
236
  *
145
- * 1. **Kong path** (user-facing, preferred):
146
- * Kong validates the token, removes Authorization, and injects:
147
- * - `X-User-Id` — keycloak sub
148
- * - `X-User-Roles` — comma-separated realm roles
149
- * The guard reads roles from `X-User-Roles` header.
237
+ * @Roles('x') — auto-detect token source:
238
+ * X-Access-Token present read from user JWT (B2C)
239
+ * Authorization only → read from service JWT (B2B)
150
240
  *
151
- * 2. **B2B path** (service-to-service, e.g. BFF API):
152
- * The caller sends a service account JWT in the Authorization header.
153
- * The guard decodes the JWT locally and reads `realm_access.roles`.
241
+ * @B2CRoles('x') — always check the user JWT in X-Access-Token
242
+ * @B2BRoles('x') — always check the service JWT in Authorization
154
243
  *
155
- * Priority: Kong header JWT fallback.
244
+ * When @B2CRoles AND @B2BRoles are both declared, BOTH checks must pass.
245
+ * This allows expressing: "the calling service must have role X AND the user must have role Y".
156
246
  */
157
247
  declare class RolesGuard implements CanActivate {
158
248
  private readonly reflector;
159
249
  private readonly config?;
160
250
  constructor(reflector: Reflector, config?: KeycloakConfig);
161
- canActivate(context: ExecutionContext): boolean | Promise<boolean>;
251
+ canActivate(context: ExecutionContext): boolean;
252
+ private getMeta;
253
+ private extractRoles;
254
+ private assertRoles;
162
255
  private decodeJwtPayload;
163
256
  }
164
257
 
@@ -189,13 +282,18 @@ declare class B2BGuard implements CanActivate {
189
282
  }
190
283
 
191
284
  /**
192
- * B2C Guard — user-facing routes via Kong.
285
+ * B2C Guard — validates user context on routes that receive Kong-forwarded requests.
286
+ *
287
+ * Kong validates the user JWT (JWKS local) and injects:
288
+ * - `Authorization: Bearer <service_token>` — the calling service identity (B2B)
289
+ * - `X-Access-Token: <user_token>` — the original user token (B2C context)
290
+ * - `X-User-Id` — keycloak sub (extracted by Kong)
291
+ * - `X-User-Roles` — realm roles CSV (extracted by Kong)
193
292
  *
194
- * Kong validated the JWT (JWKS local), removed the Authorization header,
195
- * and injected X-User-Id + X-User-Roles. This guard simply asserts those
196
- * headers are present — it does NOT re-validate any token.
293
+ * This guard asserts the user context headers are present.
294
+ * It does NOT re-validate the user token that is Kong's responsibility.
197
295
  *
198
- * Use for routes that are ONLY called by end users through Kong.
296
+ * Pair with B2BGuard (or ApiAuthGuard) + RolesGuard for full auth.
199
297
  *
200
298
  * @example
201
299
  * ```ts
@@ -241,24 +339,79 @@ declare class ApiAuthGuard implements CanActivate {
241
339
  canActivate(context: ExecutionContext): Promise<boolean>;
242
340
  }
243
341
 
342
+ interface AuthUserOptions {
343
+ /**
344
+ * Override the header to read the B2C token from.
345
+ * Defaults to the configured B2C token header (env: KEYCLOAK_B2C_TOKEN_HEADER).
346
+ */
347
+ header?: string;
348
+ /**
349
+ * Claim name(s) to extract from the JWT payload.
350
+ * First non-empty value wins. Defaults to configured userId claim(s).
351
+ */
352
+ claim?: string | string[];
353
+ }
354
+ interface CallerTokenOptions {
355
+ /**
356
+ * Override the header to read the B2B token from.
357
+ * Defaults to the configured B2B token header (env: KEYCLOAK_B2B_TOKEN_HEADER).
358
+ */
359
+ header?: string;
360
+ /**
361
+ * Claim name(s) to extract from the JWT payload.
362
+ * First non-empty value wins. Defaults to configured callerId claim(s).
363
+ */
364
+ claim?: string | string[];
365
+ }
244
366
  /**
245
- * Parameter decorator that extracts the authenticated user's Keycloak ID
246
- * from the `X-User-Id` header injected by Kong after token validation.
367
+ * Extracts a claim from the user JWT forwarded by Kong in the B2C token header.
368
+ *
369
+ * Claims are decoded locally — no extra I/O.
247
370
  *
248
- * In the B2B path (service-to-service), the caller is responsible for
249
- * forwarding the same header.
371
+ * @param param - Claim name, array of claim names (first non-empty wins),
372
+ * or `{ header, claim }` to fully customize both.
250
373
  *
251
374
  * @example
252
375
  * ```ts
253
- * @Get('me')
254
- * @Roles('user-manager')
255
- * @UseGuards(RolesGuard)
256
- * async getMe(@AuthUser() keycloakId: string) {
257
- * return this.userService.getUserByKeycloakId(keycloakId);
258
- * }
376
+ * @AuthUser() // sub (default)
377
+ * @AuthUser('email') // single claim
378
+ * @AuthUser(['preferred_username', 'email']) // first non-empty
379
+ * @AuthUser({ claim: 'email', header: 'x-user-jwt' }) // custom header
380
+ * ```
381
+ */
382
+ declare const AuthUser: (...dataOrPipes: (string | string[] | AuthUserOptions | _nestjs_common.PipeTransform<any, any> | _nestjs_common.Type<_nestjs_common.PipeTransform<any, any>>)[]) => ParameterDecorator;
383
+ /**
384
+ * Extracts a claim from the service token in the B2B token header.
385
+ *
386
+ * Use this to identify the calling service.
387
+ *
388
+ * @param param - Claim name, array of claim names (first non-empty wins),
389
+ * or `{ header, claim }` to fully customize both.
390
+ *
391
+ * @example
392
+ * ```ts
393
+ * @CallerToken() // azp (default)
394
+ * @CallerToken('sub') // single claim
395
+ * @CallerToken(['client_id', 'azp']) // first non-empty
396
+ * @CallerToken({ header: 'x-service-token', claim: 'azp' }) // custom header
397
+ * ```
398
+ */
399
+ declare const CallerToken: (...dataOrPipes: (string | string[] | CallerTokenOptions | _nestjs_common.PipeTransform<any, any> | _nestjs_common.Type<_nestjs_common.PipeTransform<any, any>>)[]) => ParameterDecorator;
400
+ /**
401
+ * Extracts the raw B2C token header value (full user JWT string).
402
+ *
403
+ * Use this when you need the full token to pass downstream or inspect
404
+ * non-string claims (arrays, objects). For scalar claims prefer `@AuthUser(claim)`.
405
+ *
406
+ * @param header - Override the header name. Defaults to configured B2C token header.
407
+ *
408
+ * @example
409
+ * ```ts
410
+ * @AccessToken() // reads from default B2C header
411
+ * @AccessToken('x-user-jwt') // reads from custom header
259
412
  * ```
260
413
  */
261
- declare const AuthUser: (...dataOrPipes: unknown[]) => ParameterDecorator;
414
+ declare const AccessToken: (...dataOrPipes: (string | _nestjs_common.PipeTransform<any, any> | _nestjs_common.Type<_nestjs_common.PipeTransform<any, any>>)[]) => ParameterDecorator;
262
415
 
263
416
  declare class KeycloakError extends Error {
264
417
  readonly statusCode?: number;
@@ -271,4 +424,43 @@ declare class KeycloakError extends Error {
271
424
  });
272
425
  }
273
426
 
274
- export { ApiAuthGuard, AuthUser, B2BGuard, B2CGuard, BearerTokenGuard, KEYCLOAK_CLIENT, KEYCLOAK_CONFIG, KEYCLOAK_HTTP_INTERCEPTOR, KEYCLOAK_PROVIDER, type KeycloakClientInterface, type KeycloakConfig, KeycloakError, KeycloakModule, type KeycloakProviderInterface, type KeycloakTokenResponse, Roles, RolesGuard };
427
+ /**
428
+ * Runtime-configurable header names and JWT claim names for the B2B/B2C auth flow.
429
+ *
430
+ * Priority (highest → lowest):
431
+ * 1. KeycloakModule.forRoot({ headers, claims }) — programmatic config
432
+ * 2. process.env vars — set in .env files
433
+ * 3. Built-in defaults
434
+ *
435
+ * Environment variables:
436
+ * KEYCLOAK_B2C_TOKEN_HEADER — header for the user JWT (default: x-access-token)
437
+ * KEYCLOAK_B2B_TOKEN_HEADER — header for the service token (default: authorization)
438
+ * KEYCLOAK_USER_ID_CLAIM — claim(s) for user ID, comma-separated (default: sub)
439
+ * KEYCLOAK_CALLER_ID_CLAIM — claim(s) for caller ID, comma-separated (default: azp)
440
+ *
441
+ * Multiple claims example (first non-empty value wins):
442
+ * KEYCLOAK_USER_ID_CLAIM=preferred_username,email,sub
443
+ * KEYCLOAK_CALLER_ID_CLAIM=client_id,azp
444
+ */
445
+ interface TokenHeaderConfig {
446
+ /** Header name for the B2C user JWT forwarded by Kong. Default: 'x-access-token' */
447
+ b2cToken?: string;
448
+ /** Header name for the B2B service account token. Default: 'authorization' */
449
+ b2bToken?: string;
450
+ }
451
+ interface TokenClaimConfig {
452
+ /**
453
+ * Claim name(s) for the user identifier (from the B2C token).
454
+ * When an array, the first non-empty value found in the JWT is returned.
455
+ * Default: ['sub']
456
+ */
457
+ userId?: string | string[];
458
+ /**
459
+ * Claim name(s) for the caller/client identifier (from the B2B token).
460
+ * When an array, the first non-empty value found in the JWT is returned.
461
+ * Default: ['azp']
462
+ */
463
+ callerId?: string | string[];
464
+ }
465
+
466
+ export { AccessToken, ApiAuthGuard, AuthUser, type AuthUserOptions, B2BGuard, B2BRoles, B2CGuard, B2CRoles, BearerTokenGuard, CallerToken, type CallerTokenOptions, KEYCLOAK_CLIENT, KEYCLOAK_CONFIG, KEYCLOAK_HTTP_INTERCEPTOR, KEYCLOAK_PROVIDER, type KeycloakClientInterface, type KeycloakConfig, KeycloakError, KeycloakModule, type KeycloakProviderInterface, type KeycloakTokenResponse, Roles, RolesGuard, type TokenClaimConfig, type TokenHeaderConfig, TokenRoles, type TokenRolesOptions };
package/dist/index.js CHANGED
@@ -268,11 +268,15 @@ var require_dist = __commonJS({
268
268
  // src/index.ts
269
269
  var index_exports = {};
270
270
  __export(index_exports, {
271
+ AccessToken: () => AccessToken,
271
272
  ApiAuthGuard: () => ApiAuthGuard,
272
273
  AuthUser: () => AuthUser,
273
274
  B2BGuard: () => B2BGuard,
275
+ B2BRoles: () => B2BRoles,
274
276
  B2CGuard: () => B2CGuard,
277
+ B2CRoles: () => B2CRoles,
275
278
  BearerTokenGuard: () => BearerTokenGuard,
279
+ CallerToken: () => CallerToken,
276
280
  KEYCLOAK_CLIENT: () => KEYCLOAK_CLIENT,
277
281
  KEYCLOAK_CONFIG: () => KEYCLOAK_CONFIG,
278
282
  KEYCLOAK_HTTP_INTERCEPTOR: () => KEYCLOAK_HTTP_INTERCEPTOR,
@@ -280,7 +284,8 @@ __export(index_exports, {
280
284
  KeycloakError: () => KeycloakError,
281
285
  KeycloakModule: () => KeycloakModule,
282
286
  Roles: () => Roles,
283
- RolesGuard: () => RolesGuard
287
+ RolesGuard: () => RolesGuard,
288
+ TokenRoles: () => TokenRoles
284
289
  });
285
290
  module.exports = __toCommonJS(index_exports);
286
291
 
@@ -306,7 +311,7 @@ var KEYCLOAK_PROVIDER = "KEYCLOAK_PROVIDER";
306
311
  // package.json
307
312
  var package_default = {
308
313
  name: "@adatechnology/auth-keycloak",
309
- version: "0.1.1",
314
+ version: "0.1.2",
310
315
  publishConfig: {
311
316
  access: "public"
312
317
  },
@@ -757,7 +762,19 @@ var import_core = require("@nestjs/core");
757
762
  // src/roles.decorator.ts
758
763
  var import_common4 = require("@nestjs/common");
759
764
  var ROLES_META_KEY = "roles";
765
+ var B2C_ROLES_META_KEY = "roles:b2c";
766
+ var B2B_ROLES_META_KEY = "roles:b2b";
767
+ var TOKEN_ROLES_META_KEY = "roles:token";
760
768
  function Roles(...args) {
769
+ return (0, import_common4.SetMetadata)(ROLES_META_KEY, normalizeRolesOptions(args));
770
+ }
771
+ function B2CRoles(...args) {
772
+ return (0, import_common4.SetMetadata)(B2C_ROLES_META_KEY, normalizeRolesOptions(args));
773
+ }
774
+ function B2BRoles(...args) {
775
+ return (0, import_common4.SetMetadata)(B2B_ROLES_META_KEY, normalizeRolesOptions(args));
776
+ }
777
+ function normalizeRolesOptions(args) {
761
778
  let payload;
762
779
  if (args.length === 1 && typeof args[0] === "object" && !Array.isArray(args[0])) {
763
780
  payload = args[0];
@@ -769,76 +786,161 @@ function Roles(...args) {
769
786
  }
770
787
  payload.mode = payload.mode ?? "any";
771
788
  payload.type = payload.type ?? "both";
772
- return (0, import_common4.SetMetadata)(ROLES_META_KEY, payload);
789
+ return payload;
790
+ }
791
+ function TokenRoles(options) {
792
+ const normalized = {
793
+ ...options,
794
+ header: options.header.toLowerCase(),
795
+ mode: options.mode ?? "any",
796
+ // auto-detect bearer stripping: true when header is 'authorization'
797
+ bearer: options.bearer ?? options.header.toLowerCase() === "authorization"
798
+ };
799
+ return (0, import_common4.SetMetadata)(TOKEN_ROLES_META_KEY, [normalized]);
773
800
  }
774
801
 
775
802
  // src/roles.guard.ts
776
803
  var import_shared2 = __toESM(require_dist());
804
+
805
+ // src/keycloak.headers.ts
806
+ var state = {
807
+ headers: {
808
+ b2cToken: parseEnvHeader("KEYCLOAK_B2C_TOKEN_HEADER", "x-access-token"),
809
+ b2bToken: parseEnvHeader("KEYCLOAK_B2B_TOKEN_HEADER", "authorization")
810
+ },
811
+ claims: {
812
+ userId: parseEnvClaims("KEYCLOAK_USER_ID_CLAIM", ["sub"]),
813
+ callerId: parseEnvClaims("KEYCLOAK_CALLER_ID_CLAIM", ["azp"])
814
+ }
815
+ };
816
+ function configureTokenHeaders(cfg) {
817
+ if (cfg.b2cToken) state.headers.b2cToken = cfg.b2cToken.toLowerCase();
818
+ if (cfg.b2bToken) state.headers.b2bToken = cfg.b2bToken.toLowerCase();
819
+ }
820
+ function configureTokenClaims(cfg) {
821
+ if (cfg.userId) state.claims.userId = normalizeClaims(cfg.userId);
822
+ if (cfg.callerId) state.claims.callerId = normalizeClaims(cfg.callerId);
823
+ }
824
+ function getB2CTokenHeader() {
825
+ return state.headers.b2cToken;
826
+ }
827
+ function getB2BTokenHeader() {
828
+ return state.headers.b2bToken;
829
+ }
830
+ function getUserIdClaims() {
831
+ return state.claims.userId;
832
+ }
833
+ function getCallerIdClaims() {
834
+ return state.claims.callerId;
835
+ }
836
+ function parseEnvHeader(key, fallback) {
837
+ return (process.env[key] ?? fallback).toLowerCase();
838
+ }
839
+ function parseEnvClaims(key, fallback) {
840
+ const raw = process.env[key];
841
+ if (!raw) return fallback;
842
+ return raw.split(",").map((c) => c.trim()).filter(Boolean);
843
+ }
844
+ function normalizeClaims(value) {
845
+ if (Array.isArray(value)) return value.filter(Boolean);
846
+ return value.split(",").map((c) => c.trim()).filter(Boolean);
847
+ }
848
+
849
+ // src/roles.guard.ts
777
850
  var RolesGuard = class {
778
851
  constructor(reflector, config) {
779
852
  this.reflector = reflector;
780
853
  this.config = config;
781
854
  }
782
855
  canActivate(context) {
783
- var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j;
784
- const meta = this.reflector.get(ROLES_META_KEY, context.getHandler()) || this.reflector.get(ROLES_META_KEY, context.getClass());
785
- if (!meta || !meta.roles || meta.roles.length === 0) return true;
856
+ var _a, _b, _c, _d, _e, _f;
786
857
  const req = context.switchToHttp().getRequest();
787
- const required = meta.roles;
788
- const availableRoles = /* @__PURE__ */ new Set();
789
- const kongRolesHeader = ((_a = req.headers) == null ? void 0 : _a["x-user-roles"]) || ((_b = req.headers) == null ? void 0 : _b["X-User-Roles"]);
790
- if (kongRolesHeader) {
791
- kongRolesHeader.split(",").map((r) => r.trim()).filter(Boolean).forEach((r) => availableRoles.add(r));
792
- } else {
793
- const authHeader = ((_c = req.headers) == null ? void 0 : _c.authorization) || ((_d = req.headers) == null ? void 0 : _d.Authorization);
794
- const token = authHeader ? String(authHeader).split(" ")[1] : (_e = req.query) == null ? void 0 : _e.token;
795
- if (!token) {
796
- throw new import_shared2.BaseAppError({
797
- message: "Authorization token not provided",
798
- status: HTTP_STATUS.FORBIDDEN,
799
- code: ROLES_ERROR_CODE.MISSING_TOKEN,
800
- context: {}
801
- });
802
- }
803
- const payload = this.decodeJwtPayload(token);
804
- if (((_f = payload == null ? void 0 : payload.realm_access) == null ? void 0 : _f.roles) && Array.isArray(payload.realm_access.roles)) {
805
- payload.realm_access.roles.forEach((r) => availableRoles.add(r));
806
- }
807
- const clientId = (_h = (_g = this.config) == null ? void 0 : _g.credentials) == null ? void 0 : _h.clientId;
808
- if (clientId && ((_j = (_i = payload == null ? void 0 : payload.resource_access) == null ? void 0 : _i[clientId]) == null ? void 0 : _j.roles)) {
809
- payload.resource_access[clientId].roles.forEach(
810
- (r) => availableRoles.add(r)
811
- );
858
+ const b2cMeta = this.getMeta(B2C_ROLES_META_KEY, context);
859
+ const b2bMeta = this.getMeta(B2B_ROLES_META_KEY, context);
860
+ const genericMeta = this.getMeta(ROLES_META_KEY, context);
861
+ const tokenRules = this.reflector.getAllAndMerge(
862
+ TOKEN_ROLES_META_KEY,
863
+ [context.getHandler(), context.getClass()]
864
+ ) ?? [];
865
+ if (!b2cMeta && !b2bMeta && !genericMeta && tokenRules.length === 0) return true;
866
+ if (b2cMeta) {
867
+ const token = (_a = req.headers) == null ? void 0 : _a[getB2CTokenHeader()];
868
+ const roles = token ? this.extractRoles(token, "b2c") : /* @__PURE__ */ new Set();
869
+ this.assertRoles(roles, b2cMeta, "B2C (user)");
870
+ }
871
+ if (b2bMeta) {
872
+ const raw = (_b = req.headers) == null ? void 0 : _b[getB2BTokenHeader()];
873
+ const token = raw == null ? void 0 : raw.split(" ")[1];
874
+ const roles = token ? this.extractRoles(token, "b2b") : /* @__PURE__ */ new Set();
875
+ this.assertRoles(roles, b2bMeta, "B2B (service)");
876
+ }
877
+ if (genericMeta) {
878
+ const accessToken = (_c = req.headers) == null ? void 0 : _c[getB2CTokenHeader()];
879
+ if (accessToken) {
880
+ const roles = this.extractRoles(accessToken, "b2c");
881
+ this.assertRoles(roles, genericMeta, "B2C (user)");
882
+ } else {
883
+ const raw = (_d = req.headers) == null ? void 0 : _d[getB2BTokenHeader()];
884
+ const token = (raw == null ? void 0 : raw.split(" ")[1]) ?? ((_e = req.query) == null ? void 0 : _e.token);
885
+ if (!token) {
886
+ throw new import_shared2.BaseAppError({
887
+ message: "Authorization token not provided",
888
+ status: HTTP_STATUS.FORBIDDEN,
889
+ code: ROLES_ERROR_CODE.MISSING_TOKEN,
890
+ context: {}
891
+ });
892
+ }
893
+ const roles = this.extractRoles(token, "b2b");
894
+ this.assertRoles(roles, genericMeta, "B2B (service)");
812
895
  }
813
- if (meta.type === "both" && (payload == null ? void 0 : payload.resource_access)) {
814
- Object.values(payload.resource_access).forEach((entry) => {
815
- if ((entry == null ? void 0 : entry.roles) && Array.isArray(entry.roles)) {
816
- entry.roles.forEach((r) => availableRoles.add(r));
817
- }
818
- });
896
+ }
897
+ for (const rule of tokenRules) {
898
+ const raw = (_f = req.headers) == null ? void 0 : _f[rule.header];
899
+ const token = rule.bearer ? raw == null ? void 0 : raw.split(" ")[1] : raw;
900
+ const roles = token ? this.extractRoles(token, "b2c") : /* @__PURE__ */ new Set();
901
+ this.assertRoles(roles, { roles: rule.roles, mode: rule.mode ?? "any" }, `header:${rule.header}`);
902
+ }
903
+ return true;
904
+ }
905
+ // ── Helpers ───────────────────────────────────────────────────────────────
906
+ getMeta(key, ctx) {
907
+ return this.reflector.get(key, ctx.getHandler()) || this.reflector.get(key, ctx.getClass()) || void 0;
908
+ }
909
+ extractRoles(token, source) {
910
+ var _a, _b, _c, _d;
911
+ const payload = this.decodeJwtPayload(token);
912
+ const roles = /* @__PURE__ */ new Set();
913
+ if (((_a = payload == null ? void 0 : payload.realm_access) == null ? void 0 : _a.roles) && Array.isArray(payload.realm_access.roles)) {
914
+ payload.realm_access.roles.forEach((r) => roles.add(r));
915
+ }
916
+ if (source === "b2b" && (payload == null ? void 0 : payload.resource_access)) {
917
+ const clientId = (_c = (_b = this.config) == null ? void 0 : _b.credentials) == null ? void 0 : _c.clientId;
918
+ if (clientId && ((_d = payload.resource_access[clientId]) == null ? void 0 : _d.roles)) {
919
+ payload.resource_access[clientId].roles.forEach((r) => roles.add(r));
819
920
  }
820
921
  }
821
- const hasMatch = required.map((r) => availableRoles.has(r));
822
- const result = meta.mode === "all" ? hasMatch.every(Boolean) : hasMatch.some(Boolean);
823
- if (!result) {
922
+ return roles;
923
+ }
924
+ assertRoles(available, meta, label) {
925
+ const hasMatch = meta.roles.map((r) => available.has(r));
926
+ const passed = meta.mode === "all" ? hasMatch.every(Boolean) : hasMatch.some(Boolean);
927
+ if (!passed) {
824
928
  throw new import_shared2.BaseAppError({
825
- message: "Insufficient roles",
929
+ message: `Insufficient roles for ${label} token`,
826
930
  status: HTTP_STATUS.FORBIDDEN,
827
931
  code: ROLES_ERROR_CODE.INSUFFICIENT_ROLES,
828
- context: { required }
932
+ context: { required: meta.roles, source: label }
829
933
  });
830
934
  }
831
- return true;
832
935
  }
833
936
  decodeJwtPayload(token) {
834
937
  try {
835
938
  const parts = token.split(".");
836
939
  if (parts.length < 2) return {};
837
- const payload = parts[1];
940
+ const padded = parts[1].replace(/-/g, "+").replace(/_/g, "/");
838
941
  const BufferCtor = globalThis.Buffer;
839
942
  if (!BufferCtor) return {};
840
- const decoded = BufferCtor.from(payload, "base64").toString("utf8");
841
- return JSON.parse(decoded);
943
+ return JSON.parse(BufferCtor.from(padded, "base64").toString("utf8"));
842
944
  } catch {
843
945
  return {};
844
946
  }
@@ -854,6 +956,8 @@ RolesGuard = __decorateClass([
854
956
  // src/keycloak.module.ts
855
957
  var KeycloakModule = class {
856
958
  static forRoot(config, httpConfig) {
959
+ if (config.headers) configureTokenHeaders(config.headers);
960
+ if (config.claims) configureTokenClaims(config.claims);
857
961
  return {
858
962
  module: KeycloakModule,
859
963
  global: true,
@@ -929,12 +1033,12 @@ var import_common8 = require("@nestjs/common");
929
1033
  var import_shared3 = __toESM(require_dist());
930
1034
  var B2CGuard = class {
931
1035
  canActivate(context) {
932
- var _a, _b;
1036
+ var _a;
933
1037
  const request = context.switchToHttp().getRequest();
934
- const userId = ((_a = request.headers) == null ? void 0 : _a["x-user-id"]) ?? ((_b = request.headers) == null ? void 0 : _b["X-User-Id"]);
935
- if (userId) return true;
1038
+ const accessToken = (_a = request.headers) == null ? void 0 : _a[getB2CTokenHeader()];
1039
+ if (accessToken) return true;
936
1040
  throw new import_shared3.BaseAppError({
937
- message: "Missing Kong identity headers (X-User-Id). Route requires user authentication via Kong.",
1041
+ message: "Missing X-Access-Token header. Route requires Kong-forwarded user authentication.",
938
1042
  status: HTTP_STATUS.UNAUTHORIZED,
939
1043
  code: BEARER_ERROR_CODE.MISSING_TOKEN,
940
1044
  context: {}
@@ -954,18 +1058,18 @@ var ApiAuthGuard = class {
954
1058
  this.b2cGuard = b2cGuard;
955
1059
  }
956
1060
  async canActivate(context) {
957
- var _a, _b, _c, _d;
1061
+ var _a, _b;
958
1062
  const request = context.switchToHttp().getRequest();
959
- const authHeader = ((_a = request.headers) == null ? void 0 : _a.authorization) ?? ((_b = request.headers) == null ? void 0 : _b.Authorization);
1063
+ const accessToken = (_a = request.headers) == null ? void 0 : _a[getB2CTokenHeader()];
1064
+ if (accessToken) {
1065
+ return this.b2cGuard.canActivate(context);
1066
+ }
1067
+ const authHeader = (_b = request.headers) == null ? void 0 : _b[getB2BTokenHeader()];
960
1068
  if (authHeader == null ? void 0 : authHeader.toLowerCase().startsWith("bearer ")) {
961
1069
  return this.b2bGuard.canActivate(context);
962
1070
  }
963
- const userId = ((_c = request.headers) == null ? void 0 : _c["x-user-id"]) ?? ((_d = request.headers) == null ? void 0 : _d["X-User-Id"]);
964
- if (userId) {
965
- return this.b2cGuard.canActivate(context);
966
- }
967
1071
  throw new import_shared4.BaseAppError({
968
- message: "Unauthorized: missing Authorization header (B2B) or Kong identity headers (B2C)",
1072
+ message: "Unauthorized: missing X-Access-Token (Kong/B2C) or Authorization header (B2B)",
969
1073
  status: HTTP_STATUS.UNAUTHORIZED,
970
1074
  code: BEARER_ERROR_CODE.MISSING_TOKEN,
971
1075
  context: {}
@@ -979,20 +1083,102 @@ ApiAuthGuard = __decorateClass([
979
1083
  // src/auth-user.decorator.ts
980
1084
  var import_common10 = require("@nestjs/common");
981
1085
  var AuthUser = (0, import_common10.createParamDecorator)(
982
- (_data, ctx) => {
983
- var _a, _b;
1086
+ (param, ctx) => {
1087
+ var _a;
1088
+ const request = ctx.switchToHttp().getRequest();
1089
+ const { header, claims } = resolveB2CParam(param);
1090
+ const raw = (_a = request.headers) == null ? void 0 : _a[header];
1091
+ const token = Array.isArray(raw) ? raw[0] : raw;
1092
+ if (!token) return "";
1093
+ return decodeJwtClaims(String(token), claims) ?? "";
1094
+ }
1095
+ );
1096
+ var CallerToken = (0, import_common10.createParamDecorator)(
1097
+ (param, ctx) => {
1098
+ var _a;
1099
+ const request = ctx.switchToHttp().getRequest();
1100
+ const { header, claims } = resolveB2BParam(param);
1101
+ const raw = (_a = request.headers) == null ? void 0 : _a[header];
1102
+ const token = raw == null ? void 0 : raw.split(" ")[1];
1103
+ if (!token) return "";
1104
+ return decodeJwtClaims(token, claims) ?? "";
1105
+ }
1106
+ );
1107
+ var AccessToken = (0, import_common10.createParamDecorator)(
1108
+ (header, ctx) => {
1109
+ var _a;
984
1110
  const request = ctx.switchToHttp().getRequest();
985
- const raw = ((_a = request.headers) == null ? void 0 : _a["x-user-id"]) ?? ((_b = request.headers) == null ? void 0 : _b["X-User-Id"]);
1111
+ const h = (header == null ? void 0 : header.toLowerCase()) ?? getB2CTokenHeader();
1112
+ const raw = (_a = request.headers) == null ? void 0 : _a[h];
986
1113
  return Array.isArray(raw) ? raw[0] : raw ?? "";
987
1114
  }
988
1115
  );
1116
+ function resolveB2CParam(param) {
1117
+ var _a;
1118
+ if (!param) {
1119
+ return { header: getB2CTokenHeader(), claims: getUserIdClaims() };
1120
+ }
1121
+ if (typeof param === "string") {
1122
+ return { header: getB2CTokenHeader(), claims: [param] };
1123
+ }
1124
+ if (Array.isArray(param)) {
1125
+ return { header: getB2CTokenHeader(), claims: param };
1126
+ }
1127
+ return {
1128
+ header: ((_a = param.header) == null ? void 0 : _a.toLowerCase()) ?? getB2CTokenHeader(),
1129
+ claims: param.claim ? normalizeClaims2(param.claim) : getUserIdClaims()
1130
+ };
1131
+ }
1132
+ function resolveB2BParam(param) {
1133
+ var _a;
1134
+ if (!param) {
1135
+ return { header: getB2BTokenHeader(), claims: getCallerIdClaims() };
1136
+ }
1137
+ if (typeof param === "string") {
1138
+ return { header: getB2BTokenHeader(), claims: [param] };
1139
+ }
1140
+ if (Array.isArray(param)) {
1141
+ return { header: getB2BTokenHeader(), claims: param };
1142
+ }
1143
+ return {
1144
+ header: ((_a = param.header) == null ? void 0 : _a.toLowerCase()) ?? getB2BTokenHeader(),
1145
+ claims: param.claim ? normalizeClaims2(param.claim) : getCallerIdClaims()
1146
+ };
1147
+ }
1148
+ function normalizeClaims2(value) {
1149
+ if (Array.isArray(value)) return value.filter(Boolean);
1150
+ return value.split(",").map((c) => c.trim()).filter(Boolean);
1151
+ }
1152
+ function decodeJwtClaims(token, claims) {
1153
+ try {
1154
+ const parts = token.split(".");
1155
+ if (parts.length < 2) return void 0;
1156
+ const padded = parts[1].replace(/-/g, "+").replace(/_/g, "/");
1157
+ const BufferCtor = globalThis.Buffer;
1158
+ if (!BufferCtor) return void 0;
1159
+ const payload = JSON.parse(
1160
+ BufferCtor.from(padded, "base64").toString("utf8")
1161
+ );
1162
+ for (const claim of claims) {
1163
+ const value = payload[claim];
1164
+ if (typeof value === "string" && value.length > 0) return value;
1165
+ }
1166
+ return void 0;
1167
+ } catch {
1168
+ return void 0;
1169
+ }
1170
+ }
989
1171
  // Annotate the CommonJS export names for ESM import in node:
990
1172
  0 && (module.exports = {
1173
+ AccessToken,
991
1174
  ApiAuthGuard,
992
1175
  AuthUser,
993
1176
  B2BGuard,
1177
+ B2BRoles,
994
1178
  B2CGuard,
1179
+ B2CRoles,
995
1180
  BearerTokenGuard,
1181
+ CallerToken,
996
1182
  KEYCLOAK_CLIENT,
997
1183
  KEYCLOAK_CONFIG,
998
1184
  KEYCLOAK_HTTP_INTERCEPTOR,
@@ -1000,5 +1186,6 @@ var AuthUser = (0, import_common10.createParamDecorator)(
1000
1186
  KeycloakError,
1001
1187
  KeycloakModule,
1002
1188
  Roles,
1003
- RolesGuard
1189
+ RolesGuard,
1190
+ TokenRoles
1004
1191
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/auth-keycloak",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },