nestjs-keycloak-auth 1.0.1-alpha.0 → 1.0.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
@@ -1,375 +1,394 @@
1
- <div align="center">
2
-
3
- # NestJS Keycloak Auth
4
-
5
- ![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/bannaarr01/nestjs-keycloak-auth?style=for-the-badge)
6
- ![GitHub License](https://img.shields.io/github/license/bannaarr01/nestjs-keycloak-auth?style=for-the-badge)
7
-
8
- A bearer-only Keycloak authentication and authorization module for [NestJS](https://nestjs.com/). Uses standard OIDC discovery and has zero runtime dependency on `keycloak-connect`.
9
-
10
- </div>
11
-
12
- ## Features
13
-
14
- - Bearer-token API authentication and authorization for NestJS.
15
- - OIDC discovery — endpoints resolved from `.well-known/openid-configuration` (with fallback).
16
- - ONLINE and OFFLINE token validation (introspection + JWKS signature verification).
17
- - Algorithm allowlist — only RS, ES, and PS family algorithms are accepted during offline validation.
18
- - Per-realm `notBefore` revocation state for multi-tenant safety.
19
- - Resource/scope authorization via UMA (`@Resource`, `@Scopes`, `@ConditionalScopes`).
20
- - Role authorization (`@Roles`) with configurable role merge and match modes.
21
- - OIDC back-channel logout (`sid`/`sub` revocation).
22
- - Typed error hierarchy all library errors extend `KeycloakAuthError` for easy catching.
23
- - Compatible with [Fastify](https://github.com/fastify/fastify) platform.
24
-
25
- ## Runtime Scope (Important)
26
-
27
- - This package is designed for bearer-only API/server flows.
28
- - It does **not** implement browser/session middleware flows such as login redirects, auth-code callback exchange, session/cookie grant stores, or logout endpoints.
29
- - It implements Keycloak admin callback endpoints:
30
- - `POST /k_push_not_before` for realm `notBefore` revocation updates (used by OFFLINE token validation).
31
- - `POST /k_logout` for OIDC back-channel logout token handling (`sid`/`sub` revocation).
32
-
33
- ## Installation
34
-
35
- ### Yarn
36
-
37
- ```bash
38
- yarn add nestjs-keycloak-auth
39
- ```
40
-
41
- ### NPM
42
-
43
- ```bash
44
- npm install nestjs-keycloak-auth --save
45
- ```
46
-
47
- ## Getting Started
48
-
49
- ### Module registration
50
-
51
- Registering the module:
52
-
53
- ```typescript
54
- KeycloakAuthModule.register({
55
- authServerUrl: 'http://localhost:8080', // might be http://localhost:8080/auth for older keycloak versions
56
- realm: 'master',
57
- clientId: 'my-nestjs-app',
58
- secret: 'secret',
59
- bearerOnly: true,
60
- policyEnforcement: PolicyEnforcementMode.PERMISSIVE, // optional
61
- tokenValidation: TokenValidation.ONLINE, // optional
62
- });
63
- ```
64
-
65
- Async registration is also available:
66
-
67
- ```typescript
68
- KeycloakAuthModule.registerAsync({
69
- useExisting: KeycloakConfigService,
70
- imports: [ConfigModule],
71
- });
72
- ```
73
-
74
- #### KeycloakConfigService
75
-
76
- ```typescript
77
- import { Injectable } from '@nestjs/common';
78
- import {
79
- KeycloakAuthOptions,
80
- KeycloakAuthOptionsFactory,
81
- PolicyEnforcementMode,
82
- TokenValidation,
83
- } from 'nestjs-keycloak-auth';
84
-
85
- @Injectable()
86
- export class KeycloakConfigService implements KeycloakAuthOptionsFactory {
87
- createKeycloakAuthOptions(): KeycloakAuthOptions {
88
- return {
89
- // http://localhost:8080/auth for older keycloak versions
90
- authServerUrl: 'http://localhost:8080',
91
- realm: 'master',
92
- clientId: 'my-nestjs-app',
93
- secret: 'secret',
94
- bearerOnly: true,
95
- policyEnforcement: PolicyEnforcementMode.PERMISSIVE,
96
- tokenValidation: TokenValidation.ONLINE,
97
- };
98
- }
99
- }
100
- ```
101
-
102
- You can also register by just providing the `keycloak.json` path and an optional module configuration:
103
-
104
- ```typescript
105
- KeycloakAuthModule.register(`./keycloak.json`, {
106
- policyEnforcement: PolicyEnforcementMode.PERMISSIVE,
107
- tokenValidation: TokenValidation.ONLINE,
108
- });
109
- ```
110
-
111
- ### Guards
112
-
113
- Register any of the guards either globally, or scoped in your controller.
114
-
115
- #### Global registration using APP_GUARD token
116
-
117
- **_NOTE: These are in order, see https://docs.nestjs.com/guards#binding-guards for more information._**
118
-
119
- ```typescript
120
- providers: [
121
- {
122
- provide: APP_GUARD,
123
- useClass: AuthGuard,
124
- },
125
- {
126
- provide: APP_GUARD,
127
- useClass: ResourceGuard,
128
- },
129
- {
130
- provide: APP_GUARD,
131
- useClass: RoleGuard,
132
- },
133
- ];
134
- ```
135
-
136
- #### Scoped registration
137
-
138
- ```typescript
139
- @Controller('cats')
140
- @UseGuards(AuthGuard, ResourceGuard)
141
- export class CatsController {}
142
- ```
143
-
144
- ## What do these guards do?
145
-
146
- ### AuthGuard
147
-
148
- Adds an authentication guard, you can also have it scoped if you like (using regular `@UseGuards(AuthGuard)` in your controllers). By default, it will throw a 401 unauthorized when it is unable to verify the JWT token or `Bearer` header is missing.
149
-
150
- ### ResourceGuard
151
-
152
- Adds a resource guard, which is permissive by default (can be configured see [options](#nest-keycloak-options)). Only controllers annotated with `@Resource` and methods with `@Scopes` are handled by this guard.
153
-
154
- When `@EnforcerOptions()` is not provided, default claims are sent for authorization requests:
155
- - `http.uri`
156
- - `user.agent`
157
-
158
- **_NOTE: This guard is not necessary if you are using role-based authorization exclusively. You can use role guard exclusively for that._**
159
-
160
- ### RoleGuard
161
-
162
- Adds a role guard, **can only be used in conjunction with resource guard when enforcement policy is PERMISSIVE**, unless you only use role guard exclusively.
163
- Permissive by default. Used by controller methods annotated with `@Roles` (matching can be configured)
164
-
165
- ## Configuring controllers
166
-
167
- In your controllers, simply do:
168
-
169
- ```typescript
170
- import {
171
- Resource,
172
- Roles,
173
- Scopes,
174
- Public,
175
- RoleMatchingMode,
176
- } from 'nestjs-keycloak-auth';
177
- import { Controller, Get, Delete, Put, Post, Param } from '@nestjs/common';
178
- import { Product } from './product';
179
- import { ProductService } from './product.service';
180
-
181
- @Controller()
182
- @Resource(Product.name)
183
- export class ProductController {
184
- constructor(private service: ProductService) {}
185
-
186
- @Get()
187
- @Public()
188
- async findAll() {
189
- return await this.service.findAll();
190
- }
191
-
192
- @Get()
193
- @Roles({ roles: ['admin', 'other'] })
194
- async findAllBarcodes() {
195
- return await this.service.findAllBarcodes();
196
- }
197
-
198
- @Get(':code')
199
- @Scopes('View')
200
- async findByCode(@Param('code') code: string) {
201
- return await this.service.findByCode(code);
202
- }
203
-
204
- @Post()
205
- @Scopes('Create')
206
- @ConditionalScopes((request, token) => {
207
- if (token.hasRealmRole('sysadmin')) {
208
- return ['Overwrite'];
209
- }
210
- return [];
211
- })
212
- async create(@Body() product: Product) {
213
- return await this.service.create(product);
214
- }
215
-
216
- @Delete(':code')
217
- @Scopes('Delete')
218
- @Roles({ roles: ['admin', 'realm:sysadmin'], mode: RoleMatchingMode.ALL })
219
- async deleteByCode(@Param('code') code: string) {
220
- return await this.service.deleteByCode(code);
221
- }
222
-
223
- @Put(':code')
224
- @Scopes('Edit')
225
- async update(@Param('code') code: string, @Body() product: Product) {
226
- return await this.service.update(code, product);
227
- }
228
- }
229
- ```
230
-
231
- ## Decorators
232
-
233
- Here are the decorators you can use in your controllers.
234
-
235
- | Decorator | Description |
236
- | ------------------ | --------------------------------------------------------------------------------------------------------- |
237
- | @KeycloakUser | Retrieves the current Keycloak logged-in user. (must be per method, unless controller is request scoped.) |
238
- | @AccessToken | Retrieves the access token used in the request |
239
- | @ResolvedScopes | Retrieves the resolved scopes (used in @ConditionalScopes) |
240
- | @EnforcerOptions | Keycloak enforcer options. |
241
- | @Public | Allow any user to use the route. |
242
- | @Resource | Keycloak application resource name. |
243
- | @Scopes | Keycloak application scopes. |
244
- | @ConditionalScopes | Conditional keycloak application scopes. |
245
- | @Roles | Keycloak realm/application roles. |
246
- | @TokenScopes | Required OAuth scopes on the access token. |
247
-
248
- ## Multi tenant configuration
249
-
250
- Setting up for multi-tenant is configured as an option in your configuration:
251
-
252
- ```typescript
253
- {
254
- // Add /auth for older keycloak versions
255
- authServerUrl: 'http://localhost:8180/', // will be used as fallback
256
- clientId: 'nest-api', // will be used as fallback
257
- secret: 'fallback', // will be used as fallback
258
- multiTenant: {
259
- resolveAlways: true,
260
- realmResolver: (request) => {
261
- return request.get('host').split('.')[0];
262
- },
263
- realmSecretResolver: (realm, request) => {
264
- const secrets = { master: 'secret', slave: 'password' };
265
- return secrets[realm];
266
- },
267
- realmClientIdResolver: (realm, request) => {
268
- const clientIds = { master: 'angular-app', slave: 'vue-app' };
269
- return clientIds[realm];
270
- },
271
- // note to add /auth for older keycloak versions
272
- realmAuthServerUrlResolver: (realm, request) => {
273
- const authServerUrls = { master: 'https://master.local/', slave: 'https://slave.local/' };
274
- return authServerUrls[realm];
275
- }
276
- }
277
- }
278
- ```
279
-
280
- ## Admin callback endpoints
281
-
282
- This module mounts Keycloak admin callback endpoints:
283
-
284
- - `POST /k_push_not_before`
285
- - `POST /k_logout`
286
-
287
- Purpose:
288
-
289
- - Accepts signed Keycloak admin callbacks with action `PUSH_NOT_BEFORE`.
290
- - Updates token revocation cutoff (`notBefore`) used by OFFLINE validation.
291
- - Stores `notBefore` per realm URL, so one realm update does not affect another realm in multi-tenant setups.
292
- - Accepts signed OIDC back-channel logout tokens and revokes token usage by `sid` and/or `sub`.
293
-
294
- Realm resolution for callback verification:
295
-
296
- 1. `multiTenant.realmResolver(request)` when configured
297
- 2. Single-tenant configured realm (`realm`)
298
-
299
- `k_logout` here is callback-only revocation handling for bearer tokens. It does not add browser/session middleware flows.
300
-
301
- ## Error handling
302
-
303
- All errors thrown by the library extend `KeycloakAuthError`, so you can catch any library error with a single `instanceof` check:
304
-
305
- ```typescript
306
- import {
307
- KeycloakAuthError,
308
- KeycloakConfigError,
309
- KeycloakTokenError,
310
- KeycloakPermissionError,
311
- KeycloakAdminError,
312
- } from 'nestjs-keycloak-auth';
313
- ```
314
-
315
- | Error class | Code | When |
316
- | ------------------------ | --------------------------- | -------------------------------------------------------- |
317
- | `KeycloakAuthError` | `KEYCLOAK_AUTH_ERROR` | Base class — catches all library errors via `instanceof` |
318
- | `KeycloakConfigError` | `KEYCLOAK_CONFIG_ERROR` | Missing config, invalid options, file not found |
319
- | `KeycloakTokenError` | `KEYCLOAK_TOKEN_ERROR` | Malformed JWT, grant validation failure, JWKS key miss |
320
- | `KeycloakPermissionError`| `KEYCLOAK_PERMISSION_ERROR` | UMA permission check failure |
321
- | `KeycloakAdminError` | `KEYCLOAK_ADMIN_ERROR` | Admin callback signature/token verification failure |
322
-
323
- Guards continue to throw standard NestJS `UnauthorizedException` / `ForbiddenException` at the HTTP boundary.
324
-
325
- ## Testing
326
-
327
- ```bash
328
- npm test
329
- npm run test:cov
330
- ```
331
-
332
- Current test setup uses Jest + ts-jest and is configured to enforce 100% global coverage thresholds.
333
-
334
- ## Configuration options
335
-
336
- ### Nest Keycloak Options
337
-
338
- | Option | Description | Required | Default |
339
- | ----------------- | -------------------------------------------------------------------------- | -------- | ---------- |
340
- | policyEnforcement | Sets the policy enforcement mode | no | PERMISSIVE |
341
- | tokenValidation | Sets the token validation method | no | ONLINE |
342
- | multiTenant | Sets options for [multi-tenant configuration](#multi-tenant-configuration) | no | - |
343
- | roleMerge | Sets the merge mode for `@Roles` decorator | no | OVERRIDE |
344
-
345
- ### Common Keycloak Config Fields
346
-
347
- | Option | Description |
348
- | ------------------------------ | ------------------------------------------------------ |
349
- | `realm` | Realm name |
350
- | `clientId` / `client-id` | Client ID (or `resource`) |
351
- | `secret` / `credentials.secret` | Client secret (confidential clients) |
352
- | `authServerUrl` / `serverUrl` | Keycloak base URL |
353
- | `bearerOnly` / `bearer-only` | Marks bearer-only behavior |
354
- | `public` / `public-client` | Public client mode |
355
- | `realmPublicKey` | Static realm public key for OFFLINE validation |
356
- | `verifyTokenAudience` | Enables strict audience check in OFFLINE validation |
357
- | `minTimeBetweenJwksRequests` | JWKS retry throttle |
358
-
359
- ### Multi Tenant Options
360
-
361
- | Option | Description | Required | Default |
362
- | -------------------------- | --------------------------------------------------------------------------------------------------------- | -------- | ------- |
363
- | resolveAlways | Always resolve realm config instead of using cached values | no | false |
364
- | realmResolver | Resolves realm from request | yes | - |
365
- | realmSecretResolver | Resolves secret by realm (and optional request) | no | - |
366
- | realmAuthServerUrlResolver | Resolves auth server URL by realm (and optional request) | no | - |
367
- | realmClientIdResolver | Resolves client ID by realm (and optional request) | yes | - |
368
-
369
- ## Acknowledgements
370
-
371
- Inspired by [nest-keycloak-connect](https://github.com/ferrerojosh/nest-keycloak-connect) by [John Joshua Ferrer](https://github.com/ferrerojosh) and the official [keycloak-nodejs-connect](https://github.com/keycloak/keycloak-nodejs-connect) adapter.
372
-
373
- ## License
374
-
375
- [MIT](LICENSE)
1
+ <div align="center">
2
+
3
+ # NestJS Keycloak Auth
4
+
5
+ [![npm version](https://img.shields.io/npm/v/nestjs-keycloak-auth?style=for-the-badge)](https://www.npmjs.com/package/nestjs-keycloak-auth)
6
+ [![npm downloads](https://img.shields.io/npm/dw/nestjs-keycloak-auth?style=for-the-badge)](https://www.npmjs.com/package/nestjs-keycloak-auth)
7
+ [![GitHub Issues or Pull Requests](https://img.shields.io/github/issues/bannaarr01/nestjs-keycloak-auth?style=for-the-badge)](https://github.com/bannaarr01/nestjs-keycloak-auth/issues)
8
+ [![GitHub License](https://img.shields.io/github/license/bannaarr01/nestjs-keycloak-auth?style=for-the-badge)](https://github.com/bannaarr01/nestjs-keycloak-auth/blob/main/LICENSE)
9
+ [![codecov](https://codecov.io/gh/bannaarr01/nestjs-keycloak-auth/graph/badge.svg)](https://codecov.io/gh/bannaarr01/nestjs-keycloak-auth)
10
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/bannaarr01/nestjs-keycloak-auth/badge)](https://scorecard.dev/viewer/?uri=github.com/bannaarr01/nestjs-keycloak-auth)
11
+
12
+ A bearer-only Keycloak authentication and authorization module for [NestJS](https://nestjs.com/). Uses standard OIDC discovery and has zero runtime dependency on `keycloak-connect`.
13
+
14
+ </div>
15
+
16
+ ## Features
17
+
18
+ - Bearer-token API authentication and authorization for NestJS.
19
+ - OIDC discovery endpoints resolved from `.well-known/openid-configuration` (with fallback).
20
+ - ONLINE and OFFLINE token validation (introspection + JWKS signature verification).
21
+ - Algorithm allowlist only RS, ES, and PS family algorithms are accepted during offline validation.
22
+ - Per-realm `notBefore` revocation state for multi-tenant safety.
23
+ - Resource/scope authorization via UMA (`@Resource`, `@Scopes`, `@ConditionalScopes`).
24
+ - Role authorization (`@Roles`) with configurable role merge and match modes.
25
+ - OIDC back-channel logout (`sid`/`sub` revocation).
26
+ - Typed error hierarchy — all library errors extend `KeycloakAuthError` for easy catching.
27
+ - Compatible with [Fastify](https://github.com/fastify/fastify) platform.
28
+
29
+ ## Runtime Scope (Important)
30
+
31
+ - This package is designed for bearer-only API/server flows.
32
+ - It does **not** implement browser/session middleware flows such as login redirects, auth-code callback exchange, session/cookie grant stores, or logout endpoints.
33
+ - It implements Keycloak admin callback endpoints:
34
+ - `POST /k_push_not_before` for realm `notBefore` revocation updates (used by OFFLINE token validation).
35
+ - `POST /k_logout` for OIDC back-channel logout token handling (`sid`/`sub` revocation).
36
+
37
+ ## Installation
38
+
39
+ ### Yarn
40
+
41
+ ```bash
42
+ yarn add nestjs-keycloak-auth
43
+ ```
44
+
45
+ ### NPM
46
+
47
+ ```bash
48
+ npm install nestjs-keycloak-auth --save
49
+ ```
50
+
51
+ ## Getting Started
52
+
53
+ ### Module registration
54
+
55
+ Registering the module:
56
+
57
+ ```typescript
58
+ KeycloakAuthModule.register({
59
+ authServerUrl: 'http://localhost:8080', // might be http://localhost:8080/auth for older keycloak versions
60
+ realm: 'master',
61
+ clientId: 'my-nestjs-app',
62
+ secret: 'secret',
63
+ bearerOnly: true,
64
+ policyEnforcement: PolicyEnforcementMode.PERMISSIVE, // optional
65
+ tokenValidation: TokenValidation.ONLINE, // optional
66
+ });
67
+ ```
68
+
69
+ Async registration is also available:
70
+
71
+ ```typescript
72
+ KeycloakAuthModule.registerAsync({
73
+ useExisting: KeycloakConfigService,
74
+ imports: [ConfigModule],
75
+ });
76
+ ```
77
+
78
+ #### KeycloakConfigService
79
+
80
+ ```typescript
81
+ import { Injectable } from '@nestjs/common';
82
+ import {
83
+ KeycloakAuthOptions,
84
+ KeycloakAuthOptionsFactory,
85
+ PolicyEnforcementMode,
86
+ TokenValidation,
87
+ } from 'nestjs-keycloak-auth';
88
+
89
+ @Injectable()
90
+ export class KeycloakConfigService implements KeycloakAuthOptionsFactory {
91
+ createKeycloakAuthOptions(): KeycloakAuthOptions {
92
+ return {
93
+ // http://localhost:8080/auth for older keycloak versions
94
+ authServerUrl: 'http://localhost:8080',
95
+ realm: 'master',
96
+ clientId: 'my-nestjs-app',
97
+ secret: 'secret',
98
+ bearerOnly: true,
99
+ policyEnforcement: PolicyEnforcementMode.PERMISSIVE,
100
+ tokenValidation: TokenValidation.ONLINE,
101
+ };
102
+ }
103
+ }
104
+ ```
105
+
106
+ You can also register by just providing the `keycloak.json` path and an optional module configuration:
107
+
108
+ ```typescript
109
+ KeycloakAuthModule.register(`./keycloak.json`, {
110
+ policyEnforcement: PolicyEnforcementMode.PERMISSIVE,
111
+ tokenValidation: TokenValidation.ONLINE,
112
+ });
113
+ ```
114
+
115
+ ### Guards
116
+
117
+ Register any of the guards either globally, or scoped in your controller.
118
+
119
+ #### Global registration using APP_GUARD token
120
+
121
+ **_NOTE: These are in order, see https://docs.nestjs.com/guards#binding-guards for more information._**
122
+
123
+ ```typescript
124
+ providers: [
125
+ {
126
+ provide: APP_GUARD,
127
+ useClass: AuthGuard,
128
+ },
129
+ {
130
+ provide: APP_GUARD,
131
+ useClass: ResourceGuard,
132
+ },
133
+ {
134
+ provide: APP_GUARD,
135
+ useClass: RoleGuard,
136
+ },
137
+ ];
138
+ ```
139
+
140
+ #### Scoped registration
141
+
142
+ ```typescript
143
+ @Controller('cats')
144
+ @UseGuards(AuthGuard, ResourceGuard)
145
+ export class CatsController {}
146
+ ```
147
+
148
+ ## What do these guards do?
149
+
150
+ ### AuthGuard
151
+
152
+ Adds an authentication guard, you can also have it scoped if you like (using regular `@UseGuards(AuthGuard)` in your controllers). By default, it will throw a 401 unauthorized when it is unable to verify the JWT token or `Bearer` header is missing.
153
+
154
+ ### ResourceGuard
155
+
156
+ Adds a resource guard, which is permissive by default (can be configured see [options](#nest-keycloak-options)). Only controllers annotated with `@Resource` and methods with `@Scopes` are handled by this guard.
157
+
158
+ **_NOTE: This guard is not necessary if you are using role-based authorization exclusively. You can use role guard exclusively for that._**
159
+
160
+ ### RoleGuard
161
+
162
+ Adds a role guard, **can only be used in conjunction with resource guard when enforcement policy is PERMISSIVE**, unless you only use role guard exclusively.
163
+ Permissive by default. Used by controller methods annotated with `@Roles` (matching can be configured)
164
+
165
+ ## Configuring controllers
166
+
167
+ In your controllers, simply do:
168
+
169
+ ```typescript
170
+ import {
171
+ Resource,
172
+ Roles,
173
+ Scopes,
174
+ Public,
175
+ RoleMatchingMode,
176
+ } from 'nestjs-keycloak-auth';
177
+ import { Controller, Get, Delete, Put, Post, Param } from '@nestjs/common';
178
+ import { Product } from './product';
179
+ import { ProductService } from './product.service';
180
+
181
+ @Controller()
182
+ @Resource(Product.name)
183
+ export class ProductController {
184
+ constructor(private service: ProductService) {}
185
+
186
+ @Get()
187
+ @Public()
188
+ async findAll() {
189
+ return await this.service.findAll();
190
+ }
191
+
192
+ @Get()
193
+ @Roles({ roles: ['admin', 'other'] })
194
+ async findAllBarcodes() {
195
+ return await this.service.findAllBarcodes();
196
+ }
197
+
198
+ @Get(':code')
199
+ @Scopes('View')
200
+ async findByCode(@Param('code') code: string) {
201
+ return await this.service.findByCode(code);
202
+ }
203
+
204
+ @Post()
205
+ @Scopes('Create')
206
+ @ConditionalScopes((request, token) => {
207
+ if (token.hasRealmRole('sysadmin')) {
208
+ return ['Overwrite'];
209
+ }
210
+ return [];
211
+ })
212
+ async create(@Body() product: Product) {
213
+ return await this.service.create(product);
214
+ }
215
+
216
+ @Delete(':code')
217
+ @Scopes('Delete')
218
+ @Roles({ roles: ['admin', 'realm:sysadmin'], mode: RoleMatchingMode.ALL })
219
+ async deleteByCode(@Param('code') code: string) {
220
+ return await this.service.deleteByCode(code);
221
+ }
222
+
223
+ @Put(':code')
224
+ @Scopes('Edit')
225
+ async update(@Param('code') code: string, @Body() product: Product) {
226
+ return await this.service.update(code, product);
227
+ }
228
+ }
229
+ ```
230
+
231
+ ## Decorators
232
+
233
+ Here are the decorators you can use in your controllers.
234
+
235
+ | Decorator | Description |
236
+ | ------------------ | --------------------------------------------------------------------------------------------------------- |
237
+ | @KeycloakUser | Retrieves the current Keycloak logged-in user. (must be per method, unless controller is request scoped.) |
238
+ | @AccessToken | Retrieves the access token used in the request |
239
+ | @ResolvedScopes | Retrieves the resolved scopes (used in @ConditionalScopes) |
240
+ | @EnforcerOptions | Keycloak enforcer options. |
241
+ | @Public | Allow any user to use the route. |
242
+ | @Resource | Keycloak application resource name. |
243
+ | @Scopes | Keycloak application scopes. |
244
+ | @ConditionalScopes | Conditional keycloak application scopes. |
245
+ | @Roles | Keycloak realm/application roles. |
246
+ | @TokenScopes | Required OAuth scopes on the access token. |
247
+
248
+ ## Multi tenant configuration
249
+
250
+ Setting up for multi-tenant is configured as an option in your configuration:
251
+
252
+ ```typescript
253
+ {
254
+ // Add /auth for older keycloak versions
255
+ authServerUrl: 'http://localhost:8180/', // will be used as fallback
256
+ clientId: 'nest-api', // will be used as fallback
257
+ secret: 'fallback', // will be used as fallback
258
+ multiTenant: {
259
+ resolveAlways: true,
260
+ realmResolver: (request) => {
261
+ return request.get('host').split('.')[0];
262
+ },
263
+ realmSecretResolver: (realm, request) => {
264
+ const secrets = { master: 'secret', slave: 'password' };
265
+ return secrets[realm];
266
+ },
267
+ realmClientIdResolver: (realm, request) => {
268
+ const clientIds = { master: 'angular-app', slave: 'vue-app' };
269
+ return clientIds[realm];
270
+ },
271
+ // note to add /auth for older keycloak versions
272
+ realmAuthServerUrlResolver: (realm, request) => {
273
+ const authServerUrls = { master: 'https://master.local/', slave: 'https://slave.local/' };
274
+ return authServerUrls[realm];
275
+ }
276
+ }
277
+ }
278
+ ```
279
+
280
+ ## Admin callback endpoints
281
+
282
+ This module mounts Keycloak admin callback endpoints:
283
+
284
+ - `POST /k_push_not_before`
285
+ - `POST /k_logout`
286
+
287
+ Purpose:
288
+
289
+ - Accepts signed Keycloak admin callbacks with action `PUSH_NOT_BEFORE`.
290
+ - Updates token revocation cutoff (`notBefore`) used by OFFLINE validation.
291
+ - Stores `notBefore` per realm URL, so one realm update does not affect another realm in multi-tenant setups.
292
+ - Accepts signed OIDC back-channel logout tokens and revokes token usage by `sid` and/or `sub`.
293
+
294
+ Realm resolution for callback verification:
295
+
296
+ 1. `multiTenant.realmResolver(request)` when configured
297
+ 2. Single-tenant configured realm (`realm`)
298
+
299
+ `k_logout` here is callback-only revocation handling for bearer tokens. It does not add browser/session middleware flows.
300
+
301
+ ## Error handling
302
+
303
+ All errors thrown by the library extend `KeycloakAuthError`, so you can catch any library error with a single `instanceof` check:
304
+
305
+ ```typescript
306
+ import {
307
+ KeycloakAuthError,
308
+ KeycloakConfigError,
309
+ KeycloakTokenError,
310
+ KeycloakPermissionError,
311
+ KeycloakAdminError,
312
+ } from 'nestjs-keycloak-auth';
313
+ ```
314
+
315
+ | Error class | Code | When |
316
+ | ------------------------ | --------------------------- | -------------------------------------------------------- |
317
+ | `KeycloakAuthError` | `KEYCLOAK_AUTH_ERROR` | Base class — catches all library errors via `instanceof` |
318
+ | `KeycloakConfigError` | `KEYCLOAK_CONFIG_ERROR` | Missing config, invalid options, file not found |
319
+ | `KeycloakTokenError` | `KEYCLOAK_TOKEN_ERROR` | Malformed JWT, grant validation failure, JWKS key miss |
320
+ | `KeycloakPermissionError`| `KEYCLOAK_PERMISSION_ERROR` | UMA permission check failure |
321
+ | `KeycloakAdminError` | `KEYCLOAK_ADMIN_ERROR` | Admin callback signature/token verification failure |
322
+
323
+ Guards continue to throw standard NestJS `UnauthorizedException` / `ForbiddenException` at the HTTP boundary.
324
+
325
+ ## Example project
326
+
327
+ The [`example/`](example/) folder contains a complete working NestJS application with:
328
+
329
+ - Docker Compose setup (Keycloak + PostgreSQL + NestJS API)
330
+ - Pre-configured Keycloak realm exports (single tenant + two tenants)
331
+ - Postman collection for testing all endpoints
332
+ - Product CRUD controller demonstrating `@Resource`, `@Scopes`, `@ConditionalScopes`, `@Roles`, `@EnforcerOptions`, and UMA response modes
333
+
334
+ See [example/README.md](example/README.md) for setup and usage instructions.
335
+
336
+ ## Testing
337
+
338
+ ```bash
339
+ npm test
340
+ npm run test:cov
341
+ ```
342
+
343
+ Current test setup uses Jest + ts-jest and is configured to enforce 100% global coverage thresholds.
344
+
345
+ ## Configuration options
346
+
347
+ ### Nest Keycloak Options
348
+
349
+ | Option | Description | Required | Default |
350
+ | ----------------- | -------------------------------------------------------------------------- | -------- | ---------- |
351
+ | policyEnforcement | Sets the policy enforcement mode | no | PERMISSIVE |
352
+ | tokenValidation | Sets the token validation method | no | ONLINE |
353
+ | multiTenant | Sets options for [multi-tenant configuration](#multi-tenant-configuration) | no | - |
354
+ | roleMerge | Sets the merge mode for `@Roles` decorator | no | OVERRIDE |
355
+
356
+ ### Common Keycloak Config Fields
357
+
358
+ | Option | Description |
359
+ | ------------------------------ | ------------------------------------------------------ |
360
+ | `realm` | Realm name |
361
+ | `clientId` / `client-id` | Client ID (or `resource`) |
362
+ | `secret` / `credentials.secret` | Client secret (confidential clients) |
363
+ | `authServerUrl` / `serverUrl` | Keycloak base URL |
364
+ | `bearerOnly` / `bearer-only` | Marks bearer-only behavior |
365
+ | `public` / `public-client` | Public client mode |
366
+ | `realmPublicKey` | Static realm public key for OFFLINE validation |
367
+ | `verifyTokenAudience` | Enables strict audience check in OFFLINE validation |
368
+ | `minTimeBetweenJwksRequests` | JWKS retry throttle |
369
+
370
+ ### Multi Tenant Options
371
+
372
+ | Option | Description | Required | Default |
373
+ | -------------------------- | --------------------------------------------------------------------------------------------------------- | -------- | ------- |
374
+ | resolveAlways | Always resolve realm config instead of using cached values | no | false |
375
+ | realmResolver | Resolves realm from request | yes | - |
376
+ | realmSecretResolver | Resolves secret by realm (and optional request) | no | - |
377
+ | realmAuthServerUrlResolver | Resolves auth server URL by realm (and optional request) | no | - |
378
+ | realmClientIdResolver | Resolves client ID by realm (and optional request) | yes | - |
379
+
380
+ ## Contributing
381
+
382
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
383
+
384
+ ## Security
385
+
386
+ To report a vulnerability, see [SECURITY.md](SECURITY.md). Do not open a public issue.
387
+
388
+ ## Acknowledgements
389
+
390
+ Inspired by [nest-keycloak-connect](https://github.com/ferrerojosh/nest-keycloak-connect) by [John Joshua Ferrer](https://github.com/ferrerojosh) and the official [keycloak-nodejs-connect](https://github.com/keycloak/keycloak-nodejs-connect) adapter.
391
+
392
+ ## License
393
+
394
+ [MIT](LICENSE)
@@ -1,10 +1,25 @@
1
1
  import { RoleMatch } from '../constants';
2
+ import { RolesDecoratorInput } from '../types/roles-decorator-input.type';
2
3
  export declare const META_ROLES = "roles";
3
4
  export declare const META_ROLE_MATCHING_MODE = "role-matching-mode";
4
5
  /**
5
6
  * Keycloak user roles.
6
- * @param roles - the roles to match
7
+ *
8
+ * @example
9
+ * // Variadic strings
10
+ * \ @Roles('admin', 'basic')
11
+ *
12
+ * // Single string
13
+ * \ @Roles('admin')
14
+ *
15
+ * // Array of strings
16
+ * \ @Roles(['admin', 'basic'])
17
+ *
18
+ * // Options object with roles key
19
+ * \ @Roles({ roles: ['realm:admin', 'realm:basic'] })
20
+ *
21
+ * @param args - the roles to match. Accepts variadic strings, a single array, or an options object with a `roles` key.
7
22
  * @since 2.0.0
8
23
  */
9
- export declare const Roles: (...roles: string[]) => import("@nestjs/common").CustomDecorator<string>;
24
+ export declare const Roles: (...args: RolesDecoratorInput[]) => import("@nestjs/common").CustomDecorator<string>;
10
25
  export declare const RoleMatchingMode: (mode: RoleMatch) => import("@nestjs/common").CustomDecorator<string>;
@@ -4,12 +4,42 @@ exports.RoleMatchingMode = exports.Roles = exports.META_ROLE_MATCHING_MODE = exp
4
4
  const common_1 = require("@nestjs/common");
5
5
  exports.META_ROLES = 'roles';
6
6
  exports.META_ROLE_MATCHING_MODE = 'role-matching-mode';
7
+ function isRolesDecoratorOptions(value) {
8
+ return (typeof value === 'object' &&
9
+ value !== null &&
10
+ !Array.isArray(value) &&
11
+ Array.isArray(value.roles));
12
+ }
13
+ function normalizeRoles(args) {
14
+ if (args.length === 0)
15
+ return [];
16
+ const first = args[0];
17
+ if (isRolesDecoratorOptions(first))
18
+ return first.roles;
19
+ if (Array.isArray(first))
20
+ return first;
21
+ return args;
22
+ }
7
23
  /**
8
24
  * Keycloak user roles.
9
- * @param roles - the roles to match
25
+ *
26
+ * @example
27
+ * // Variadic strings
28
+ * \ @Roles('admin', 'basic')
29
+ *
30
+ * // Single string
31
+ * \ @Roles('admin')
32
+ *
33
+ * // Array of strings
34
+ * \ @Roles(['admin', 'basic'])
35
+ *
36
+ * // Options object with roles key
37
+ * \ @Roles({ roles: ['realm:admin', 'realm:basic'] })
38
+ *
39
+ * @param args - the roles to match. Accepts variadic strings, a single array, or an options object with a `roles` key.
10
40
  * @since 2.0.0
11
41
  */
12
- const Roles = (...roles) => (0, common_1.SetMetadata)(exports.META_ROLES, roles);
42
+ const Roles = (...args) => (0, common_1.SetMetadata)(exports.META_ROLES, normalizeRoles(args));
13
43
  exports.Roles = Roles;
14
44
  const RoleMatchingMode = (mode) => (0, common_1.SetMetadata)(exports.META_ROLE_MATCHING_MODE, mode);
15
45
  exports.RoleMatchingMode = RoleMatchingMode;
@@ -0,0 +1,3 @@
1
+ export interface RolesDecoratorOptions {
2
+ roles: string[];
3
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -37,6 +37,8 @@ export * from './controllers/keycloak-admin.controller';
37
37
  export * from './token/keycloak-token';
38
38
  export * from './token/keycloak-grant';
39
39
  export * from './types/conditional-scope.type';
40
+ export * from './types/roles-decorator-input.type';
41
+ export * from './interface/roles-decorator-options.interface';
40
42
  export * from './errors';
41
43
  export * from './util';
42
44
  export declare class KeycloakAuthModule {
@@ -80,6 +80,8 @@ __exportStar(require("./controllers/keycloak-admin.controller"), exports);
80
80
  __exportStar(require("./token/keycloak-token"), exports);
81
81
  __exportStar(require("./token/keycloak-grant"), exports);
82
82
  __exportStar(require("./types/conditional-scope.type"), exports);
83
+ __exportStar(require("./types/roles-decorator-input.type"), exports);
84
+ __exportStar(require("./interface/roles-decorator-options.interface"), exports);
83
85
  __exportStar(require("./errors"), exports);
84
86
  __exportStar(require("./util"), exports);
85
87
  let KeycloakAuthModule = KeycloakAuthModule_1 = class KeycloakAuthModule {
@@ -0,0 +1,2 @@
1
+ import { RolesDecoratorOptions } from '../interface/roles-decorator-options.interface';
2
+ export type RolesDecoratorInput = string | string[] | RolesDecoratorOptions;
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nestjs-keycloak-auth",
3
- "version": "1.0.1-alpha.0",
3
+ "version": "1.0.2",
4
4
  "description": "Keycloak authentication and authorization module for NestJS",
5
5
  "author": "B. Joshua Adedigba",
6
6
  "contributors": [
@@ -26,9 +26,25 @@
26
26
  },
27
27
  "keywords": [
28
28
  "nestjs",
29
+ "nest",
29
30
  "keycloak",
30
- "typescript"
31
+ "nest-keycloak",
32
+ "keycloak-nest",
33
+ "typescript",
34
+ "authentication",
35
+ "authorization",
36
+ "oauth2",
37
+ "openid-connect",
38
+ "oidc",
39
+ "rbac",
40
+ "guard",
41
+ "uma",
42
+ "multi-tenant",
43
+ "jwt",
44
+ "sso",
45
+ "access-control"
31
46
  ],
47
+ "homepage": "https://github.com/bannaarr01/nestjs-keycloak-auth#readme",
32
48
  "repository": {
33
49
  "type": "git",
34
50
  "url": "https://github.com/bannaarr01/nestjs-keycloak-auth"
@@ -64,7 +80,8 @@
64
80
  "@nestjs/common": "^11.0.6",
65
81
  "@nestjs/core": "^11.0.6",
66
82
  "@nestjs/graphql": "^13.0.2",
67
- "@types/express": "^4.17.21",
83
+ "@release-it/conventional-changelog": "^10.0.6",
84
+ "@types/express": "^5.0.6",
68
85
  "@types/jest": "^30.0.0",
69
86
  "@types/node": "^18.19.23",
70
87
  "axios": "^1.7.0",
@@ -72,7 +89,7 @@
72
89
  "class-validator": "0.14.1",
73
90
  "cpr": "3.0.1",
74
91
  "eslint": "^9.39.4",
75
- "eslint-config-prettier": "9.1.0",
92
+ "eslint-config-prettier": "10.1.8",
76
93
  "eslint-plugin-prettier": "5.1.3",
77
94
  "eslint-plugin-unused-imports": "^4.4.1",
78
95
  "graphql": "^16.10.0",
@@ -82,7 +99,7 @@
82
99
  "prettier": "3.2.5",
83
100
  "reflect-metadata": "0.2.1",
84
101
  "release-it": "^19.2.4",
85
- "rimraf": "3.0.2",
102
+ "rimraf": "6.1.3",
86
103
  "rxjs": "7.8.1",
87
104
  "ts-jest": "^29.4.6",
88
105
  "ts-node": "10.8.2",