@pawells/nestjs-auth 1.0.0-dev.4c8c698

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.
Files changed (194) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +602 -0
  3. package/build/LICENSE +21 -0
  4. package/build/README.md +602 -0
  5. package/build/admin/client/client.d.ts +82 -0
  6. package/build/admin/client/client.d.ts.map +1 -0
  7. package/build/admin/client/client.js +157 -0
  8. package/build/admin/client/client.js.map +1 -0
  9. package/build/admin/client/errors/base-error.d.ts +58 -0
  10. package/build/admin/client/errors/base-error.d.ts.map +1 -0
  11. package/build/admin/client/errors/base-error.js +100 -0
  12. package/build/admin/client/errors/base-error.js.map +1 -0
  13. package/build/admin/client/errors/index.d.ts +2 -0
  14. package/build/admin/client/errors/index.d.ts.map +1 -0
  15. package/build/admin/client/errors/index.js +2 -0
  16. package/build/admin/client/errors/index.js.map +1 -0
  17. package/build/admin/client/index.d.ts +6 -0
  18. package/build/admin/client/index.d.ts.map +1 -0
  19. package/build/admin/client/index.js +11 -0
  20. package/build/admin/client/index.js.map +1 -0
  21. package/build/admin/client/services/authentication.service.d.ts +54 -0
  22. package/build/admin/client/services/authentication.service.d.ts.map +1 -0
  23. package/build/admin/client/services/authentication.service.js +99 -0
  24. package/build/admin/client/services/authentication.service.js.map +1 -0
  25. package/build/admin/client/services/base-service.d.ts +39 -0
  26. package/build/admin/client/services/base-service.d.ts.map +1 -0
  27. package/build/admin/client/services/base-service.js +107 -0
  28. package/build/admin/client/services/base-service.js.map +1 -0
  29. package/build/admin/client/services/client.service.d.ts +86 -0
  30. package/build/admin/client/services/client.service.d.ts.map +1 -0
  31. package/build/admin/client/services/client.service.js +193 -0
  32. package/build/admin/client/services/client.service.js.map +1 -0
  33. package/build/admin/client/services/event.service.d.ts +84 -0
  34. package/build/admin/client/services/event.service.d.ts.map +1 -0
  35. package/build/admin/client/services/event.service.js +155 -0
  36. package/build/admin/client/services/event.service.js.map +1 -0
  37. package/build/admin/client/services/federated-identity.service.d.ts +89 -0
  38. package/build/admin/client/services/federated-identity.service.d.ts.map +1 -0
  39. package/build/admin/client/services/federated-identity.service.js +120 -0
  40. package/build/admin/client/services/federated-identity.service.js.map +1 -0
  41. package/build/admin/client/services/group.service.d.ts +52 -0
  42. package/build/admin/client/services/group.service.d.ts.map +1 -0
  43. package/build/admin/client/services/group.service.js +105 -0
  44. package/build/admin/client/services/group.service.js.map +1 -0
  45. package/build/admin/client/services/identity-provider.service.d.ts +47 -0
  46. package/build/admin/client/services/identity-provider.service.d.ts.map +1 -0
  47. package/build/admin/client/services/identity-provider.service.js +86 -0
  48. package/build/admin/client/services/identity-provider.service.js.map +1 -0
  49. package/build/admin/client/services/index.d.ts +11 -0
  50. package/build/admin/client/services/index.d.ts.map +1 -0
  51. package/build/admin/client/services/index.js +11 -0
  52. package/build/admin/client/services/index.js.map +1 -0
  53. package/build/admin/client/services/realm.service.d.ts +41 -0
  54. package/build/admin/client/services/realm.service.d.ts.map +1 -0
  55. package/build/admin/client/services/realm.service.js +80 -0
  56. package/build/admin/client/services/realm.service.js.map +1 -0
  57. package/build/admin/client/services/role.service.d.ts +45 -0
  58. package/build/admin/client/services/role.service.d.ts.map +1 -0
  59. package/build/admin/client/services/role.service.js +92 -0
  60. package/build/admin/client/services/role.service.js.map +1 -0
  61. package/build/admin/client/services/user.service.d.ts +84 -0
  62. package/build/admin/client/services/user.service.d.ts.map +1 -0
  63. package/build/admin/client/services/user.service.js +216 -0
  64. package/build/admin/client/services/user.service.js.map +1 -0
  65. package/build/admin/client/types/config.types.d.ts +59 -0
  66. package/build/admin/client/types/config.types.d.ts.map +1 -0
  67. package/build/admin/client/types/config.types.js +13 -0
  68. package/build/admin/client/types/config.types.js.map +1 -0
  69. package/build/admin/client/types/event.types.d.ts +176 -0
  70. package/build/admin/client/types/event.types.d.ts.map +1 -0
  71. package/build/admin/client/types/event.types.js +2 -0
  72. package/build/admin/client/types/event.types.js.map +1 -0
  73. package/build/admin/client/types/index.d.ts +4 -0
  74. package/build/admin/client/types/index.d.ts.map +1 -0
  75. package/build/admin/client/types/index.js +4 -0
  76. package/build/admin/client/types/index.js.map +1 -0
  77. package/build/admin/client/types/keycloak.types.d.ts +169 -0
  78. package/build/admin/client/types/keycloak.types.d.ts.map +1 -0
  79. package/build/admin/client/types/keycloak.types.js +2 -0
  80. package/build/admin/client/types/keycloak.types.js.map +1 -0
  81. package/build/admin/client/utils/index.d.ts +2 -0
  82. package/build/admin/client/utils/index.d.ts.map +1 -0
  83. package/build/admin/client/utils/index.js +2 -0
  84. package/build/admin/client/utils/index.js.map +1 -0
  85. package/build/admin/client/utils/retry.d.ts +40 -0
  86. package/build/admin/client/utils/retry.d.ts.map +1 -0
  87. package/build/admin/client/utils/retry.js +72 -0
  88. package/build/admin/client/utils/retry.js.map +1 -0
  89. package/build/admin/config/keycloak.config.d.ts +33 -0
  90. package/build/admin/config/keycloak.config.d.ts.map +1 -0
  91. package/build/admin/config/keycloak.config.js +2 -0
  92. package/build/admin/config/keycloak.config.js.map +1 -0
  93. package/build/admin/config/keycloak.defaults.d.ts +11 -0
  94. package/build/admin/config/keycloak.defaults.d.ts.map +1 -0
  95. package/build/admin/config/keycloak.defaults.js +60 -0
  96. package/build/admin/config/keycloak.defaults.js.map +1 -0
  97. package/build/admin/health/keycloak.health.d.ts +13 -0
  98. package/build/admin/health/keycloak.health.d.ts.map +1 -0
  99. package/build/admin/health/keycloak.health.js +54 -0
  100. package/build/admin/health/keycloak.health.js.map +1 -0
  101. package/build/admin/index.d.ts +10 -0
  102. package/build/admin/index.d.ts.map +1 -0
  103. package/build/admin/index.js +9 -0
  104. package/build/admin/index.js.map +1 -0
  105. package/build/admin/keycloak-admin.interfaces.d.ts +45 -0
  106. package/build/admin/keycloak-admin.interfaces.d.ts.map +1 -0
  107. package/build/admin/keycloak-admin.interfaces.js +2 -0
  108. package/build/admin/keycloak-admin.interfaces.js.map +1 -0
  109. package/build/admin/keycloak-admin.module.d.ts +23 -0
  110. package/build/admin/keycloak-admin.module.d.ts.map +1 -0
  111. package/build/admin/keycloak-admin.module.js +101 -0
  112. package/build/admin/keycloak-admin.module.js.map +1 -0
  113. package/build/admin/keycloak.constants.d.ts +16 -0
  114. package/build/admin/keycloak.constants.d.ts.map +1 -0
  115. package/build/admin/keycloak.constants.js +16 -0
  116. package/build/admin/keycloak.constants.js.map +1 -0
  117. package/build/admin/permissions/index.d.ts +2 -0
  118. package/build/admin/permissions/index.d.ts.map +1 -0
  119. package/build/admin/permissions/index.js +2 -0
  120. package/build/admin/permissions/index.js.map +1 -0
  121. package/build/admin/permissions/keycloak-admin.permissions.d.ts +45 -0
  122. package/build/admin/permissions/keycloak-admin.permissions.d.ts.map +1 -0
  123. package/build/admin/permissions/keycloak-admin.permissions.js +68 -0
  124. package/build/admin/permissions/keycloak-admin.permissions.js.map +1 -0
  125. package/build/admin/services/keycloak-admin.service.d.ts +64 -0
  126. package/build/admin/services/keycloak-admin.service.d.ts.map +1 -0
  127. package/build/admin/services/keycloak-admin.service.js +152 -0
  128. package/build/admin/services/keycloak-admin.service.js.map +1 -0
  129. package/build/decorators/auth-decorators.d.ts +217 -0
  130. package/build/decorators/auth-decorators.d.ts.map +1 -0
  131. package/build/decorators/auth-decorators.js +251 -0
  132. package/build/decorators/auth-decorators.js.map +1 -0
  133. package/build/decorators/context-utils.d.ts +101 -0
  134. package/build/decorators/context-utils.d.ts.map +1 -0
  135. package/build/decorators/context-utils.js +178 -0
  136. package/build/decorators/context-utils.js.map +1 -0
  137. package/build/decorators/graphql-auth-decorators.d.ts +144 -0
  138. package/build/decorators/graphql-auth-decorators.d.ts.map +1 -0
  139. package/build/decorators/graphql-auth-decorators.js +152 -0
  140. package/build/decorators/graphql-auth-decorators.js.map +1 -0
  141. package/build/decorators/index.d.ts +5 -0
  142. package/build/decorators/index.d.ts.map +1 -0
  143. package/build/decorators/index.js +4 -0
  144. package/build/decorators/index.js.map +1 -0
  145. package/build/guards/index.d.ts +4 -0
  146. package/build/guards/index.d.ts.map +1 -0
  147. package/build/guards/index.js +4 -0
  148. package/build/guards/index.js.map +1 -0
  149. package/build/guards/jwt-auth.guard.d.ts +52 -0
  150. package/build/guards/jwt-auth.guard.d.ts.map +1 -0
  151. package/build/guards/jwt-auth.guard.js +97 -0
  152. package/build/guards/jwt-auth.guard.js.map +1 -0
  153. package/build/guards/permission.guard.d.ts +37 -0
  154. package/build/guards/permission.guard.d.ts.map +1 -0
  155. package/build/guards/permission.guard.js +73 -0
  156. package/build/guards/permission.guard.js.map +1 -0
  157. package/build/guards/role.guard.d.ts +33 -0
  158. package/build/guards/role.guard.d.ts.map +1 -0
  159. package/build/guards/role.guard.js +69 -0
  160. package/build/guards/role.guard.js.map +1 -0
  161. package/build/index.d.ts +92 -0
  162. package/build/index.d.ts.map +1 -0
  163. package/build/index.js +98 -0
  164. package/build/index.js.map +1 -0
  165. package/build/keycloak/index.d.ts +7 -0
  166. package/build/keycloak/index.d.ts.map +1 -0
  167. package/build/keycloak/index.js +5 -0
  168. package/build/keycloak/index.js.map +1 -0
  169. package/build/keycloak/keycloak.constants.d.ts +2 -0
  170. package/build/keycloak/keycloak.constants.d.ts.map +1 -0
  171. package/build/keycloak/keycloak.constants.js +2 -0
  172. package/build/keycloak/keycloak.constants.js.map +1 -0
  173. package/build/keycloak/keycloak.interfaces.d.ts +12 -0
  174. package/build/keycloak/keycloak.interfaces.d.ts.map +1 -0
  175. package/build/keycloak/keycloak.interfaces.js +2 -0
  176. package/build/keycloak/keycloak.interfaces.js.map +1 -0
  177. package/build/keycloak/keycloak.module.d.ts +56 -0
  178. package/build/keycloak/keycloak.module.d.ts.map +1 -0
  179. package/build/keycloak/keycloak.module.js +104 -0
  180. package/build/keycloak/keycloak.module.js.map +1 -0
  181. package/build/keycloak/keycloak.types.d.ts +60 -0
  182. package/build/keycloak/keycloak.types.d.ts.map +1 -0
  183. package/build/keycloak/keycloak.types.js +2 -0
  184. package/build/keycloak/keycloak.types.js.map +1 -0
  185. package/build/keycloak/services/jwks-cache.service.d.ts +64 -0
  186. package/build/keycloak/services/jwks-cache.service.d.ts.map +1 -0
  187. package/build/keycloak/services/jwks-cache.service.js +176 -0
  188. package/build/keycloak/services/jwks-cache.service.js.map +1 -0
  189. package/build/keycloak/services/keycloak-token-validation.service.d.ts +88 -0
  190. package/build/keycloak/services/keycloak-token-validation.service.d.ts.map +1 -0
  191. package/build/keycloak/services/keycloak-token-validation.service.js +243 -0
  192. package/build/keycloak/services/keycloak-token-validation.service.js.map +1 -0
  193. package/build/package.json +72 -0
  194. package/package.json +93 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aaron Wells
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,602 @@
1
+ # NestJS Authentication Module
2
+
3
+ [![GitHub Release](https://img.shields.io/github/v/release/PhillipAWells/nestjs-common)](https://github.com/PhillipAWells/nestjs-common/releases)
4
+ [![CI](https://github.com/PhillipAWells/nestjs-common/actions/workflows/ci.yml/badge.svg)](https://github.com/PhillipAWells/nestjs-common/actions/workflows/ci.yml)
5
+ [![npm version](https://img.shields.io/npm/v/@pawells/nestjs-auth.svg?style=flat)](https://www.npmjs.com/package/@pawells/nestjs-auth)
6
+ [![Node](https://img.shields.io/badge/node-%3E%3D24-brightgreen)](https://nodejs.org)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
8
+ [![GitHub Sponsors](https://img.shields.io/github/sponsors/PhillipAWells?style=social)](https://github.com/sponsors/PhillipAWells)
9
+
10
+ Keycloak integration library for NestJS resource servers. Validates Keycloak-issued access tokens (online introspection by default; offline JWKS opt-in), enforces role and permission guards on HTTP and GraphQL routes, and provides a typed Admin REST API client for user management, federated identity, and event polling.
11
+
12
+ This package does **not** issue tokens, manage passwords, or run login flows — those are Keycloak's responsibility.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Installation](#installation)
17
+ - [Quick Start](#quick-start)
18
+ - [KeycloakModule](#keycloakmodule)
19
+ - [Token Validation Modes](#token-validation-modes)
20
+ - [Guards](#guards)
21
+ - [Decorators](#decorators)
22
+ - [KeycloakAdminModule](#keycloakadminmodule)
23
+ - [Federated Identity](#federated-identity)
24
+ - [Event Polling](#event-polling)
25
+ - [Keycloak Client Configuration](#keycloak-client-configuration)
26
+ - [Security Notes](#security-notes)
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ yarn add @pawells/nestjs-auth
32
+ ```
33
+
34
+ ### Peer Dependencies
35
+
36
+ | Package | Version | Required |
37
+ |---|---|---|
38
+ | `@nestjs/common` | `>=10.0.0` | Yes |
39
+ | `@nestjs/core` | `>=10.0.0` | Yes |
40
+ | `@nestjs/jwt` | `>=10.0.0` | Yes |
41
+ | `@nestjs/terminus` | `>=10.0.0` | Yes |
42
+ | `joi` | `>=17.0.0` | Yes |
43
+ | `@nestjs/graphql` | `>=12.0.0` | Yes — required to use GraphQL decorators |
44
+ | `jwks-rsa` | `>=3.0.0` | No — required only for offline (JWKS) validation mode |
45
+
46
+ ## Quick Start
47
+
48
+ ```typescript
49
+ import { Module } from '@nestjs/common';
50
+ import { APP_GUARD } from '@nestjs/core';
51
+ import { ConfigModule, ConfigService } from '@nestjs/config';
52
+ import {
53
+ KeycloakModule,
54
+ KeycloakAdminModule,
55
+ JwtAuthGuard,
56
+ } from '@pawells/nestjs-auth';
57
+
58
+ @Module({
59
+ imports: [
60
+ KeycloakModule.forRootAsync({
61
+ imports: [ConfigModule],
62
+ inject: [ConfigService],
63
+ useFactory: (config: ConfigService) => ({
64
+ authServerUrl: config.get('KEYCLOAK_AUTH_SERVER_URL'),
65
+ realm: config.get('KEYCLOAK_REALM'),
66
+ clientId: config.get('KEYCLOAK_CLIENT_ID'),
67
+ clientSecret: config.get('KEYCLOAK_CLIENT_SECRET'),
68
+ }),
69
+ }),
70
+ KeycloakAdminModule.forRootAsync({
71
+ imports: [ConfigModule],
72
+ inject: [ConfigService],
73
+ useFactory: (config: ConfigService) => ({
74
+ enabled: config.get('KEYCLOAK_ADMIN_ENABLED') === 'true',
75
+ baseUrl: config.get('KEYCLOAK_BASE_URL'),
76
+ realmName: config.get('KEYCLOAK_REALM'),
77
+ credentials: {
78
+ type: 'clientCredentials',
79
+ clientId: config.get('KEYCLOAK_ADMIN_CLIENT_ID'),
80
+ clientSecret: config.get('KEYCLOAK_ADMIN_CLIENT_SECRET'),
81
+ },
82
+ }),
83
+ }),
84
+ ],
85
+ providers: [
86
+ {
87
+ provide: APP_GUARD,
88
+ useClass: JwtAuthGuard,
89
+ },
90
+ ],
91
+ })
92
+ export class AppModule {}
93
+ ```
94
+
95
+ ## KeycloakModule
96
+
97
+ `KeycloakModule` configures token validation for the service. It provides `KeycloakTokenValidationService` to all modules via its exports.
98
+
99
+ ### Options
100
+
101
+ | Field | Type | Default | Description |
102
+ |---|---|---|---|
103
+ | `authServerUrl` | `string` | — | Keycloak realm base URL, e.g. `https://auth.example.com/realms/myrealm` |
104
+ | `realm` | `string` | — | Keycloak realm name |
105
+ | `clientId` | `string` | — | This service's Keycloak client ID — used for audience validation and client role extraction |
106
+ | `validationMode` | `'online' \| 'offline'` | `'online'` | Token validation strategy — see [Token Validation Modes](#token-validation-modes) |
107
+ | `clientSecret` | `string` | — | Client secret for the introspection endpoint. Required when `validationMode` is `'online'` (the default) |
108
+ | `jwksCacheTtlMs` | `number` | `300000` | JWKS public key cache TTL in milliseconds. Used in offline mode only |
109
+ | `issuer` | `string` | `authServerUrl` | Expected `iss` claim value. Must match exactly. Defaults to `authServerUrl` |
110
+
111
+ ### forRoot
112
+
113
+ ```typescript
114
+ KeycloakModule.forRoot({
115
+ authServerUrl: 'https://auth.example.com/realms/myrealm',
116
+ realm: 'myrealm',
117
+ clientId: 'my-service',
118
+ clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
119
+ });
120
+ ```
121
+
122
+ ### forRootAsync
123
+
124
+ ```typescript
125
+ KeycloakModule.forRootAsync({
126
+ imports: [ConfigModule],
127
+ inject: [ConfigService],
128
+ useFactory: (config: ConfigService) => ({
129
+ authServerUrl: config.get('KEYCLOAK_AUTH_SERVER_URL'),
130
+ realm: config.get('KEYCLOAK_REALM'),
131
+ clientId: config.get('KEYCLOAK_CLIENT_ID'),
132
+ clientSecret: config.get('KEYCLOAK_CLIENT_SECRET'),
133
+ }),
134
+ });
135
+ ```
136
+
137
+ ## Token Validation Modes
138
+
139
+ ### Online (default)
140
+
141
+ Validates each token by calling Keycloak's introspection endpoint (`/protocol/openid-connect/token/introspect`). The introspection response is authoritative: it detects revoked tokens and expired sessions immediately.
142
+
143
+ - Requires `clientSecret`
144
+ - Adds a network round-trip per request
145
+ - **Recommended for most deployments**
146
+
147
+ ### Offline (opt-in)
148
+
149
+ Validates the JWT signature locally using Keycloak's JWKS endpoint. Public keys are fetched once and cached.
150
+
151
+ - Does not detect revocation — a revoked token remains valid until its `exp` claim passes
152
+ - No network hop after the initial key fetch
153
+ - Validates `exp`, `iss`, and `aud` claims locally
154
+ - Requires the `jwks-rsa` peer dependency
155
+ - Set `validationMode: 'offline'` to enable
156
+
157
+ ```typescript
158
+ KeycloakModule.forRoot({
159
+ authServerUrl: 'https://auth.example.com/realms/myrealm',
160
+ realm: 'myrealm',
161
+ clientId: 'my-service',
162
+ validationMode: 'offline',
163
+ jwksCacheTtlMs: 600000, // 10 minutes
164
+ });
165
+ ```
166
+
167
+ Use offline mode only when request throughput makes per-request introspection impractical and token lifetimes are short enough to bound the revocation window.
168
+
169
+ ## Guards
170
+
171
+ ### JwtAuthGuard
172
+
173
+ Validates the Keycloak access token on every incoming request. Extracts the `Bearer` token from the `Authorization` header, calls `KeycloakTokenValidationService.validateToken`, and attaches the resolved `KeycloakUser` to `request.user`.
174
+
175
+ Routes decorated with `@Public()` bypass the guard entirely.
176
+
177
+ **Register globally (recommended):**
178
+
179
+ ```typescript
180
+ import { APP_GUARD } from '@nestjs/core';
181
+ import { JwtAuthGuard } from '@pawells/nestjs-auth';
182
+
183
+ // In your AppModule providers array:
184
+ {
185
+ provide: APP_GUARD,
186
+ useClass: JwtAuthGuard,
187
+ }
188
+ ```
189
+
190
+ **Per-route:**
191
+
192
+ ```typescript
193
+ import { UseGuards } from '@nestjs/common';
194
+ import { JwtAuthGuard } from '@pawells/nestjs-auth';
195
+
196
+ @UseGuards(JwtAuthGuard)
197
+ @Controller('profile')
198
+ export class ProfileController {}
199
+ ```
200
+
201
+ ### RoleGuard
202
+
203
+ Checks whether the authenticated user holds at least one of the roles listed in `@Roles()`. Roles are matched against the union of `realm_access.roles` and `resource_access[clientId].roles` from the token.
204
+
205
+ ```typescript
206
+ import { UseGuards } from '@nestjs/common';
207
+ import { RoleGuard, Roles } from '@pawells/nestjs-auth';
208
+
209
+ @UseGuards(RoleGuard)
210
+ @Controller('admin')
211
+ export class AdminController {
212
+ @Roles('admin', 'moderator')
213
+ @Get('users')
214
+ listUsers() {
215
+ return [];
216
+ }
217
+ }
218
+ ```
219
+
220
+ ### PermissionGuard
221
+
222
+ Checks whether the authenticated user holds at least one of the values listed in `@Permissions()`, resolved against the same role arrays as `RoleGuard`.
223
+
224
+ ```typescript
225
+ import { UseGuards } from '@nestjs/common';
226
+ import { PermissionGuard, Permissions } from '@pawells/nestjs-auth';
227
+
228
+ @UseGuards(PermissionGuard)
229
+ @Controller('documents')
230
+ export class DocumentsController {
231
+ @Permissions('document.write')
232
+ @Post()
233
+ createDocument(@Body() dto: CreateDocumentDto) {
234
+ return {};
235
+ }
236
+ }
237
+ ```
238
+
239
+ ## Decorators
240
+
241
+ ### HTTP Decorators
242
+
243
+ | Decorator | Type | Description |
244
+ |---|---|---|
245
+ | `@Auth()` | Method | Marks the route as requiring authentication (sets `isPublic: false`) |
246
+ | `@Public()` | Method | Marks the route as public — `JwtAuthGuard` skips validation |
247
+ | `@Roles(...roles)` | Method | Specifies role requirements for `RoleGuard` |
248
+ | `@Permissions(...permissions)` | Method | Specifies permission requirements for `PermissionGuard` |
249
+ | `@CurrentUser(property?)` | Parameter | Injects the `KeycloakUser` from `request.user`, or a specific property if `property` is given |
250
+ | `@AuthToken()` | Parameter | Injects the raw Bearer token string from the `Authorization` header |
251
+
252
+ ```typescript
253
+ import { Controller, Get } from '@nestjs/common';
254
+ import { Auth, Public, Roles, CurrentUser, AuthToken } from '@pawells/nestjs-auth';
255
+ import type { KeycloakUser } from '@pawells/nestjs-auth';
256
+
257
+ @Controller('me')
258
+ export class ProfileController {
259
+ @Public()
260
+ @Get('ping')
261
+ ping() {
262
+ return 'pong';
263
+ }
264
+
265
+ @Auth()
266
+ @Get()
267
+ getProfile(@CurrentUser() user: KeycloakUser) {
268
+ return user;
269
+ }
270
+
271
+ @Roles('admin')
272
+ @Get('token')
273
+ getToken(@AuthToken() token: string) {
274
+ return { token };
275
+ }
276
+
277
+ @Get('id')
278
+ getId(@CurrentUser('id') userId: string) {
279
+ return { userId };
280
+ }
281
+ }
282
+ ```
283
+
284
+ ### GraphQL Decorators
285
+
286
+ The GraphQL variants are aliases of the HTTP decorators, pre-configured for the GraphQL execution context.
287
+
288
+ | Decorator | Equivalent to | Notes |
289
+ |---|---|---|
290
+ | `@GraphQLAuth()` | `@Auth()` | Marks GraphQL resolver as requiring authentication |
291
+ | `@GraphQLPublic()` | `@Public()` | Marks GraphQL resolver as public |
292
+ | `@GraphQLRoles(...roles)` | `@Roles(...roles)` | Specifies role requirements |
293
+ | `@GraphQLCurrentUser(property?)` | `@CurrentUser(property?, { contextType: 'graphql' })` | Injects user from GraphQL context |
294
+ | `@GraphQLUser(property?)` | `@GraphQLCurrentUser(property?)` | Alias |
295
+ | `@GraphQLAuthToken()` | `@AuthToken({ contextType: 'graphql' })` | Injects Bearer token from GraphQL context |
296
+ | `@GraphQLContextParam()` | — | Injects the full GraphQL context object |
297
+
298
+ ```typescript
299
+ import { Resolver, Query, Mutation } from '@nestjs/graphql';
300
+ import {
301
+ GraphQLAuth,
302
+ GraphQLPublic,
303
+ GraphQLRoles,
304
+ GraphQLCurrentUser,
305
+ GraphQLAuthToken,
306
+ } from '@pawells/nestjs-auth';
307
+ import type { KeycloakUser } from '@pawells/nestjs-auth';
308
+
309
+ @Resolver()
310
+ export class UserResolver {
311
+ @GraphQLPublic()
312
+ @Query(() => String)
313
+ async health(): Promise<string> {
314
+ return 'ok';
315
+ }
316
+
317
+ @GraphQLAuth()
318
+ @Query(() => String)
319
+ async me(@GraphQLCurrentUser() user: KeycloakUser): Promise<string> {
320
+ return user.id;
321
+ }
322
+
323
+ @GraphQLRoles('admin')
324
+ @Query(() => [String])
325
+ async listUsers(): Promise<string[]> {
326
+ return [];
327
+ }
328
+
329
+ @Mutation(() => Boolean)
330
+ async validateToken(@GraphQLAuthToken() token: string): Promise<boolean> {
331
+ return !!token;
332
+ }
333
+ }
334
+ ```
335
+
336
+ ## KeycloakAdminModule
337
+
338
+ `KeycloakAdminModule` provides a typed client for the Keycloak Admin REST API. It is registered as a global module — import it once in `AppModule` and inject `KeycloakAdminService` anywhere.
339
+
340
+ ### Options (KeycloakAdminConfig)
341
+
342
+ | Field | Type | Default | Description |
343
+ |---|---|---|---|
344
+ | `enabled` | `boolean` | `false` | When `false` the client is not initialized — useful for disabling in test environments |
345
+ | `baseUrl` | `string` | `'http://localhost:8080'` | Keycloak server base URL (not realm-specific) |
346
+ | `realmName` | `string` | `'master'` | Target realm for all Admin API calls |
347
+ | `credentials.type` | `'password' \| 'clientCredentials'` | `'password'` | Authentication method |
348
+ | `credentials.username` | `string` | — | Admin username (password auth only) |
349
+ | `credentials.password` | `string` | — | Admin password (password auth only) |
350
+ | `credentials.clientId` | `string` | — | Service account client ID (clientCredentials auth only) |
351
+ | `credentials.clientSecret` | `string` | — | Service account client secret (clientCredentials auth only) |
352
+ | `timeout` | `number` | `30000` | Request timeout in milliseconds |
353
+ | `retry.maxRetries` | `number` | `3` | Maximum retry attempts on transient failures |
354
+ | `retry.retryDelay` | `number` | `1000` | Delay between retries in milliseconds |
355
+
356
+ ### forRoot
357
+
358
+ ```typescript
359
+ KeycloakAdminModule.forRoot({
360
+ enabled: process.env.KEYCLOAK_ADMIN_ENABLED === 'true',
361
+ baseUrl: 'https://auth.example.com',
362
+ realmName: 'myrealm',
363
+ credentials: {
364
+ type: 'clientCredentials',
365
+ clientId: process.env.KEYCLOAK_ADMIN_CLIENT_ID,
366
+ clientSecret: process.env.KEYCLOAK_ADMIN_CLIENT_SECRET,
367
+ },
368
+ });
369
+ ```
370
+
371
+ ### forRootAsync
372
+
373
+ ```typescript
374
+ KeycloakAdminModule.forRootAsync({
375
+ imports: [ConfigModule],
376
+ inject: [ConfigService],
377
+ useFactory: (config: ConfigService) => ({
378
+ enabled: config.get('KEYCLOAK_ADMIN_ENABLED') === 'true',
379
+ baseUrl: config.get('KEYCLOAK_BASE_URL'),
380
+ realmName: config.get('KEYCLOAK_REALM'),
381
+ credentials: {
382
+ type: 'clientCredentials',
383
+ clientId: config.get('KEYCLOAK_ADMIN_CLIENT_ID'),
384
+ clientSecret: config.get('KEYCLOAK_ADMIN_CLIENT_SECRET'),
385
+ },
386
+ }),
387
+ });
388
+ ```
389
+
390
+ ### KeycloakAdminService
391
+
392
+ Inject `KeycloakAdminService` and access the sub-services via its properties.
393
+
394
+ | Property | Service | Responsibility |
395
+ |---|---|---|
396
+ | `.users` | `UserService` | Create, read, update, delete users; assign roles and groups |
397
+ | `.roles` | `RoleService` | Manage realm and client roles |
398
+ | `.realms` | `RealmService` | Realm-level configuration and queries |
399
+ | `.clients` | `ClientService` | Manage clients and client scopes |
400
+ | `.groups` | `GroupService` | Create and manage groups; add/remove members |
401
+ | `.identityProviders` | `IdentityProviderService` | Manage identity provider configurations |
402
+ | `.authentication` | `AuthenticationService` | Manage authentication flows |
403
+ | `.federatedIdentity` | `FederatedIdentityService` | Link and unlink external provider identities — see [Federated Identity](#federated-identity) |
404
+ | `.events` | `EventService` | Query admin and access events — see [Event Polling](#event-polling) |
405
+
406
+ ```typescript
407
+ import { Injectable } from '@nestjs/common';
408
+ import { KeycloakAdminService } from '@pawells/nestjs-auth';
409
+
410
+ @Injectable()
411
+ export class UserManagementService {
412
+ constructor(private readonly keycloak: KeycloakAdminService) {}
413
+
414
+ async createUser(email: string, firstName: string): Promise<void> {
415
+ await this.keycloak.users.create({
416
+ email,
417
+ firstName,
418
+ enabled: true,
419
+ });
420
+ }
421
+
422
+ async assignRole(userId: string, roleName: string): Promise<void> {
423
+ await this.keycloak.users.assignRole(userId, roleName);
424
+ }
425
+ }
426
+ ```
427
+
428
+ Call `keycloakAdminService.isEnabled()` before calling sub-services if the module may be disabled in the current environment.
429
+
430
+ ## Federated Identity
431
+
432
+ `KeycloakAdminService.federatedIdentity` manages links between Keycloak user accounts and external identity providers.
433
+
434
+ | Method | Signature | Description |
435
+ |---|---|---|
436
+ | `list` | `(userId: string) => Promise<FederatedIdentityLink[]>` | Returns all provider links for a user |
437
+ | `link` | `(userId: string, provider: string, link: { userId: string; userName: string }) => Promise<void>` | Links an external provider identity to a Keycloak user |
438
+ | `unlink` | `(userId: string, provider: string) => Promise<void>` | Removes a provider link from a Keycloak user |
439
+
440
+ `link` performs a pre-flight `list` check and throws `ConflictError` if a link for the same provider and external user ID already exists. This is a workaround for [Keycloak issue #34608](https://github.com/keycloak/keycloak/issues/34608), which can create duplicate federated identity records.
441
+
442
+ ```typescript
443
+ import { Injectable } from '@nestjs/common';
444
+ import { KeycloakAdminService, ConflictError } from '@pawells/nestjs-auth';
445
+
446
+ @Injectable()
447
+ export class IdentityLinkingService {
448
+ constructor(private readonly keycloak: KeycloakAdminService) {}
449
+
450
+ async linkGoogleAccount(
451
+ keycloakUserId: string,
452
+ googleUserId: string,
453
+ googleEmail: string,
454
+ ): Promise<void> {
455
+ try {
456
+ await this.keycloak.federatedIdentity.link(keycloakUserId, 'google', {
457
+ userId: googleUserId,
458
+ userName: googleEmail,
459
+ });
460
+ } catch (error) {
461
+ if (error instanceof ConflictError) {
462
+ // Already linked — treat as a no-op or surface to the caller
463
+ return;
464
+ }
465
+ throw error;
466
+ }
467
+ }
468
+
469
+ async listLinks(keycloakUserId: string) {
470
+ return this.keycloak.federatedIdentity.list(keycloakUserId);
471
+ }
472
+ }
473
+ ```
474
+
475
+ ## Event Polling
476
+
477
+ `KeycloakAdminService.events` queries Keycloak's event log for both admin (resource mutation) and access (login, logout, token) events.
478
+
479
+ ### Methods
480
+
481
+ | Method | Signature | Description |
482
+ |---|---|---|
483
+ | `getAdminEvents` | `(query?: AdminEventQuery) => Promise<KeycloakAdminEvent[]>` | Returns admin events matching the query |
484
+ | `getAccessEvents` | `(query?: AccessEventQuery) => Promise<KeycloakAccessEvent[]>` | Returns access events matching the query |
485
+
486
+ ### AdminEventQuery Fields
487
+
488
+ | Field | Type | Description |
489
+ |---|---|---|
490
+ | `operationTypes` | `('CREATE' \| 'UPDATE' \| 'DELETE' \| 'ACTION')[]` | Filter by operation type |
491
+ | `resourceTypes` | `string[]` | Filter by resource type (e.g. `['USER']`) |
492
+ | `resourcePath` | `string` | Filter by resource path prefix |
493
+ | `dateFrom` | `Date` | Earliest event timestamp (inclusive) |
494
+ | `dateTo` | `Date` | Latest event timestamp (inclusive) |
495
+ | `first` | `number` | Pagination offset |
496
+ | `max` | `number` | Maximum results to return |
497
+
498
+ `AccessEventQuery` supports the same date and pagination fields plus `type` (string array), `client`, and `user`.
499
+
500
+ ### KeycloakAdminEvent Fields
501
+
502
+ | Field | Type | Notes |
503
+ |---|---|---|
504
+ | `time` | `number` | Unix timestamp in milliseconds |
505
+ | `realmId` | `string` | Realm identifier |
506
+ | `operationType` | `'CREATE' \| 'UPDATE' \| 'DELETE' \| 'ACTION'` | Type of operation |
507
+ | `resourceType` | `string` | Resource category, e.g. `USER`, `GROUP` |
508
+ | `resourcePath` | `string` | Path to the affected resource |
509
+ | `representation` | `string \| undefined` | JSON-encoded resource snapshot. Present on CREATE and UPDATE only. Must be parsed with `JSON.parse()` before use |
510
+ | `authDetails` | `object \| undefined` | Actor details: `realmId`, `clientId`, `userId`, `ipAddress` |
511
+
512
+ ### Checkpoint Cursor Pattern
513
+
514
+ Keycloak does not provide a persistent event cursor. To avoid re-processing events or missing events between polls, track the `time` of the most recently processed event and pass it as `dateFrom` on subsequent polls. Use a page size (`max`) that fits within your Keycloak event retention window — events older than the retention period are purged and will be lost if polling falls behind.
515
+
516
+ ```typescript
517
+ import { Injectable } from '@nestjs/common';
518
+ import { Cron } from '@nestjs/schedule';
519
+ import { KeycloakAdminService } from '@pawells/nestjs-auth';
520
+
521
+ @Injectable()
522
+ export class EventSyncService {
523
+ private lastProcessedTime: Date = new Date(Date.now() - 60_000);
524
+
525
+ constructor(private readonly keycloak: KeycloakAdminService) {}
526
+
527
+ @Cron('*/30 * * * * *') // every 30 seconds
528
+ async pollAdminEvents(): Promise<void> {
529
+ const events = await this.keycloak.events.getAdminEvents({
530
+ dateFrom: this.lastProcessedTime,
531
+ operationTypes: ['CREATE', 'UPDATE', 'DELETE'],
532
+ resourceTypes: ['USER'],
533
+ max: 100,
534
+ });
535
+
536
+ for (const event of events) {
537
+ await this.processEvent(event);
538
+ const eventTime = new Date(event.time);
539
+ if (eventTime > this.lastProcessedTime) {
540
+ this.lastProcessedTime = eventTime;
541
+ }
542
+ }
543
+ }
544
+
545
+ private async processEvent(event: any): Promise<void> {
546
+ // Handle the event
547
+ }
548
+ }
549
+ ```
550
+
551
+ ## Keycloak Client Configuration
552
+
553
+ Three Keycloak clients are typically required when using this package.
554
+
555
+ ### React SPA (public client)
556
+
557
+ - **Client type**: Public
558
+ - **Authentication flow**: Standard flow enabled
559
+ - **PKCE**: Required — set Code Challenge Method to `S256`
560
+ - **Valid redirect URIs**: Your frontend origin(s)
561
+
562
+ ### NestJS resource server (confidential client)
563
+
564
+ This is the client whose `clientId` and `clientSecret` you provide to `KeycloakModule`. It is not used to authenticate users — it authenticates the service itself for introspection calls.
565
+
566
+ - **Client type**: Confidential
567
+ - **Service accounts**: Not required
568
+ - **Client authentication**: Enabled
569
+ - Introspection requires a client secret — keep `validationMode: 'online'` unless you have a specific reason to use offline mode
570
+
571
+ If you are using offline (JWKS) validation exclusively, a confidential client is not required for token validation. The JWKS endpoint is public.
572
+
573
+ ### Admin API caller (confidential service account)
574
+
575
+ This is the client whose credentials you provide to `KeycloakAdminModule`.
576
+
577
+ - **Client type**: Confidential
578
+ - **Service accounts**: Enabled
579
+ - **Required service account roles** (assigned in the `realm-management` client):
580
+ - `manage-users` — create, update, delete users and assign roles
581
+ - `manage-identity-providers` — link and unlink federated identities
582
+ - `view-events` — read admin and access events
583
+
584
+ ## Security Notes
585
+
586
+ **Online introspection is the recommended validation mode.** It is authoritative: a revoked Keycloak session is rejected immediately, regardless of token expiry.
587
+
588
+ **Offline JWKS validation does not detect revocation.** A token that has been revoked in Keycloak (e.g. by logging out or disabling the user) continues to pass offline validation until its `exp` claim expires. Only use offline mode when throughput requirements make per-request introspection impractical, and mitigate the revocation window by setting short token lifetimes in Keycloak (5 minutes or less).
589
+
590
+ **Federated identity deduplication (Keycloak #34608).** Keycloak's Admin API can create duplicate federated identity records if `addToFederatedIdentity` is called concurrently for the same user and provider. The `FederatedIdentityService.link` method guards against this with a pre-flight check, but the check is not atomic. Under high concurrency, implement external coordination (e.g. a distributed lock) if duplicate links are not tolerable.
591
+
592
+ **Event polling and retention windows.** Keycloak purges events based on a configurable retention period. If your poll interval exceeds the retention window — or if polling stops and then resumes — events will be permanently lost. Poll at a frequency significantly shorter than the retention window, and align the retention window with your operational requirements in the Keycloak realm settings (`Admin Console > Realm Settings > Events`).
593
+
594
+ ## Related Packages
595
+
596
+ - **[@pawells/nestjs-shared](https://www.npmjs.com/package/@pawells/nestjs-shared)** — Foundation: filters, guards, interceptors, logging, CSRF, error handling
597
+ - **[@pawells/nestjs-graphql](https://www.npmjs.com/package/@pawells/nestjs-graphql)** — GraphQL module with Redis subscriptions, DataLoaders, and WebSocket auth
598
+ - **[@pawells/nestjs-open-telemetry](https://www.npmjs.com/package/@pawells/nestjs-open-telemetry)** — OpenTelemetry tracing and metrics integration
599
+
600
+ ## License
601
+
602
+ MIT
package/build/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aaron Wells
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.