@ballistix.digital/identity-connector 0.0.0-stage → 0.2.0

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 (71) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +630 -2
  3. package/dist/common/domain/enum/identityConnectorExceptionType.enum.d.ts +14 -0
  4. package/dist/common/domain/enum/identityConnectorExceptionType.enum.js +18 -0
  5. package/dist/common/domain/model/identity.d.ts +9 -0
  6. package/dist/common/domain/model/identity.js +2 -0
  7. package/dist/common/domain/model/identityProfile.d.ts +6 -0
  8. package/dist/common/domain/model/identityProfile.js +2 -0
  9. package/dist/index.d.ts +12 -0
  10. package/dist/index.js +23 -0
  11. package/dist/module/entra/domain/model/entraProviderOptions.d.ts +6 -0
  12. package/dist/module/entra/domain/model/entraProviderOptions.js +42 -0
  13. package/dist/module/entra/index.d.ts +2 -0
  14. package/dist/module/entra/index.js +7 -0
  15. package/dist/module/entra/service/entra.provider.d.ts +23 -0
  16. package/dist/module/entra/service/entra.provider.js +72 -0
  17. package/dist/module/identity/domain/exception/identityConnectorException.d.ts +5 -0
  18. package/dist/module/identity/domain/exception/identityConnectorException.js +48 -0
  19. package/dist/module/identity/domain/model/identityConnectorModuleOptions.d.ts +7 -0
  20. package/dist/module/identity/domain/model/identityConnectorModuleOptions.js +2 -0
  21. package/dist/module/identity/domain/model/identityProvider.d.ts +22 -0
  22. package/dist/module/identity/domain/model/identityProvider.js +17 -0
  23. package/dist/module/identity/domain/model/identityProviderOptions.d.ts +5 -0
  24. package/dist/module/identity/domain/model/identityProviderOptions.js +34 -0
  25. package/dist/module/identity/domain/model/identityProvidersToken.d.ts +1 -0
  26. package/dist/module/identity/domain/model/identityProvidersToken.js +4 -0
  27. package/dist/module/identity/domain/model/userStore.d.ts +10 -0
  28. package/dist/module/identity/domain/model/userStore.js +12 -0
  29. package/dist/module/identity/domain/validation/default.decorator.d.ts +1 -0
  30. package/dist/module/identity/domain/validation/default.decorator.js +10 -0
  31. package/dist/module/identity/domain/validation/trimmed.decorator.d.ts +2 -0
  32. package/dist/module/identity/domain/validation/trimmed.decorator.js +17 -0
  33. package/dist/module/identity/domain/validation/validateOptions.d.ts +2 -0
  34. package/dist/module/identity/domain/validation/validateOptions.js +44 -0
  35. package/dist/module/identity/identityConnector.module.d.ts +6 -0
  36. package/dist/module/identity/identityConnector.module.js +47 -0
  37. package/dist/module/identity/service/identityAuth.guard.d.ts +20 -0
  38. package/dist/module/identity/service/identityAuth.guard.js +117 -0
  39. package/dist/module/identity/service/identityManagement.d.ts +16 -0
  40. package/dist/module/identity/service/identityManagement.js +54 -0
  41. package/dist/module/identity/service/identityRuntime.d.ts +2 -0
  42. package/dist/module/identity/service/identityRuntime.js +6 -0
  43. package/dist/module/identity/service/identityRuntimeInternals.d.ts +11 -0
  44. package/dist/module/identity/service/identityRuntimeInternals.js +52 -0
  45. package/dist/module/identity/service/signIn.cache.d.ts +20 -0
  46. package/dist/module/identity/service/signIn.cache.js +102 -0
  47. package/dist/module/jwt/service/signedToken.verifier.d.ts +12 -0
  48. package/dist/module/jwt/service/signedToken.verifier.js +56 -0
  49. package/dist/module/zitadel/domain/model/zitadelManagementOptions.d.ts +4 -0
  50. package/dist/module/zitadel/domain/model/zitadelManagementOptions.js +31 -0
  51. package/dist/module/zitadel/domain/model/zitadelProviderOptions.d.ts +10 -0
  52. package/dist/module/zitadel/domain/model/zitadelProviderOptions.js +48 -0
  53. package/dist/module/zitadel/domain/model/zitadelServiceUserKey.d.ts +6 -0
  54. package/dist/module/zitadel/domain/model/zitadelServiceUserKey.js +42 -0
  55. package/dist/module/zitadel/domain/validation/isRsaPrivateKey.decorator.d.ts +2 -0
  56. package/dist/module/zitadel/domain/validation/isRsaPrivateKey.decorator.js +26 -0
  57. package/dist/module/zitadel/domain/validation/organizationIdList.decorator.d.ts +1 -0
  58. package/dist/module/zitadel/domain/validation/organizationIdList.decorator.js +19 -0
  59. package/dist/module/zitadel/index.d.ts +4 -0
  60. package/dist/module/zitadel/index.js +11 -0
  61. package/dist/module/zitadel/service/zitadel.provider.d.ts +40 -0
  62. package/dist/module/zitadel/service/zitadel.provider.js +266 -0
  63. package/dist/testing/index.d.ts +2 -0
  64. package/dist/testing/index.js +7 -0
  65. package/dist/testing/service/testIdentityProvider.mock.d.ts +42 -0
  66. package/dist/testing/service/testIdentityProvider.mock.js +118 -0
  67. package/dist/testing/service/userStore.contract.d.ts +17 -0
  68. package/dist/testing/service/userStore.contract.js +156 -0
  69. package/dist/types/index.d.ts +1 -0
  70. package/dist/types/index.js +5 -0
  71. package/package.json +138 -5
package/CHANGELOG.md ADDED
@@ -0,0 +1,6 @@
1
+ # Changelog
2
+
3
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
4
+ and the package follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+
6
+ ## Unreleased
package/README.md CHANGED
@@ -1,3 +1,631 @@
1
- # Temporary Holding Version
1
+ # @ballistix.digital/identity-connector
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A NestJS module that signs a user in with an OIDC access token and provisions
4
+ users at one or more identity providers, behind provider-agnostic contracts.
5
+ The app does not know which provider is behind it, and one app can use more
6
+ than one at the same time. The providers are Zitadel and Entra, each behind
7
+ an entry of its own.
8
+
9
+ This file is the consumer guide and the public contract. The pages that
10
+ describe how the package is built live in the
11
+ [Ballistix wiki](https://github.com/layeredprints/ballistix-okf-wiki/blob/main/general/libraries/identity-connector/).
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install @ballistix.digital/identity-connector @ballistix.digital/exception-mapper @ballistix.digital/exception-types class-validator class-transformer
17
+ ```
18
+
19
+ The providers verify tokens, and the Zitadel provider signs its service user's
20
+ assertion, with `jose`. Install it when the application imports `./zitadel` or
21
+ `./entra`:
22
+
23
+ ```bash
24
+ npm install jose@^6
25
+ ```
26
+
27
+ `jose` ships ES modules only. Node 24 loads it from CommonJS by itself, but
28
+ Jest's CommonJS runtime does not: a Jest setup that loads a provider entry
29
+ transforms `jose` to CommonJS (`transformIgnorePatterns:
30
+ ['/node_modules/(?!jose/)']`). Give `jose` a transformer of its own that only
31
+ rewrites the import syntax and keeps class fields as they are
32
+ (`useDefineForClassFields: true`): `jose`'s error classes break under the
33
+ `[[Set]]` semantics Ballistix packages compile with, and ts-jest keeps one
34
+ configuration per Jest project, so a second ts-jest entry does not help. This
35
+ repository's `test/module/common/esModule.transformer.js` is such a
36
+ transformer.
37
+
38
+ A browser build that reads the `./types` entry alone installs the package by
39
+ itself:
40
+
41
+ ```bash
42
+ npm install @ballistix.digital/identity-connector
43
+ ```
44
+
45
+ The package needs Node 24 or later and has no dependencies of its own.
46
+ Every peer dependency is optional, so npm installs none of them for you:
47
+ install in the application what the entries it imports need.
48
+
49
+ ## Entries
50
+
51
+ | Entry | Holds |
52
+ | --- | --- |
53
+ | `.` | The module, the runtime, the contracts, and `identityConnectorException` and `identityConnectorExceptionTypeOf` |
54
+ | `./zitadel` | The Zitadel provider |
55
+ | `./entra` | The Entra provider |
56
+ | `./testing` | The test doubles and the store contract |
57
+ | `./types` | The exception type enum, with no framework import |
58
+
59
+ ## Signing in
60
+
61
+ An app writes three classes and registers them. Each one extends a class of
62
+ the package, which holds the logic.
63
+
64
+ 1. Register `ExceptionModule` and the app's provider classes, one per identity
65
+ provider connection:
66
+
67
+ ```ts
68
+ @Module({
69
+ imports: [
70
+ ExceptionModule.forRoot(),
71
+ IdentityConnectorModule.forRoot({ identityProviders: [ComplianceZitadel] }),
72
+ ],
73
+ })
74
+ export class AppModule {}
75
+ ```
76
+
77
+ The module builds each provider once, with what its constructor injects,
78
+ so a global `ConfigModule` or a module in `imports` must provide it; every
79
+ guard and identity management then shares that instance. Two providers
80
+ with the same name stop the app at boot, since a store keeps an identity
81
+ under its provider's name, and so do two providers that list the same
82
+ issuer: a token names only its issuer, so with the same audience each would
83
+ accept the other's tokens. A missing `ExceptionModule` stops it too:
84
+ without its filter, every refusal would reach the client as a 500.
85
+
86
+ 2. Write the user store over the app's own table. It implements `UserStore`,
87
+ and the library never touches the table itself:
88
+
89
+ ```ts
90
+ @Injectable()
91
+ export class UserStoreService extends UserStore<User> {
92
+ findByIdentityId(identityId, provider, transaction) { /* find by the column of that provider, on the transaction when given */ }
93
+ async linkByEmail(identity) { /* one match by email, conditional link */ }
94
+ async create(identity) { /* only with createOnSignIn: return the user the identity already has, or insert one */ }
95
+ async sync(user, identity) { /* write roles, email, names; return the value for request.user */ }
96
+ toProfile(user, provider) { /* throw identityConnectorException(NO_EMAIL) when the user has none */ }
97
+ link(user, identityId, provider, transaction) { /* conditional: no other identity, no other user, by primary key */ }
98
+ }
99
+ ```
100
+
101
+ 3. Extend the guard, pass it the providers whose tokens it accepts, and put it
102
+ on the controllers. `request.user` is what `sync()` returned:
103
+
104
+ ```ts
105
+ @Injectable()
106
+ export class AuthGuard extends IdentityAuthGuard<User> {
107
+ constructor(runtime: IdentityRuntime, store: UserStoreService, zitadel: ComplianceZitadel) {
108
+ super(runtime, store, [zitadel]);
109
+ }
110
+ }
111
+
112
+ @UseGuards(AuthGuard)
113
+ ```
114
+
115
+ A guard accepts the tokens of the providers it passes to `super()`, at least
116
+ one, injected by the class it registered in `IdentityConnectorModule.forRoot()`;
117
+ a guard for employee routes passes only the Entra provider. A token goes to
118
+ the one among them that lists its issuer, before anything is verified, and a
119
+ token of another registered provider is refused as `PROVIDER_NOT_ACCEPTED`. A
120
+ guard without providers, or with a provider the module does not register,
121
+ stops the app at boot. A provider the app registers only when it is
122
+ configured is injected with `@Optional()` and passed only when it is set:
123
+
124
+ ```ts
125
+ constructor(runtime: IdentityRuntime, store: UserStoreService, entra: ComplianceEntra, @Optional() zitadel?: ComplianceZitadel) {
126
+ super(runtime, store, zitadel ? [entra, zitadel] : [entra]);
127
+ }
128
+ ```
129
+
130
+ ## Provisioning users
131
+
132
+ Provisioning gives one of the app's users an identity at the identity
133
+ provider, for example when the app invites a customer: the identity provider's
134
+ identity with the user's email, or a new one, linked to the user through the
135
+ store's `link()`. The app then sends its own mail with a link where the user
136
+ sets up how they sign in, such as a password.
137
+
138
+ Extend `IdentityManagement` once per place the app manages identities, such as
139
+ customers and employees. It injects the store and the provider class, and
140
+ passes the management options of that place: their shape is the provider's,
141
+ for Zitadel the organization new users land in.
142
+
143
+ ```ts
144
+ @Injectable()
145
+ export class CustomerIdentityManagement extends IdentityManagement<User, ComplianceZitadel> {
146
+ constructor(runtime: IdentityRuntime, store: UserStoreService, zitadel: ComplianceZitadel, config: ConfigService) {
147
+ super(runtime, store, zitadel, {
148
+ organizationId: config.get('ZITADEL_CUSTOMERS_ORG_ID'),
149
+ federatedOrganizationIds: [config.get('ZITADEL_VDL_ORG_ID')],
150
+ });
151
+ }
152
+ }
153
+ ```
154
+
155
+ Inject it where the domain needs it:
156
+
157
+ ```ts
158
+ if (!(await this.customers.isFederated(user))) {
159
+ await this.customers.provision(user, transaction);
160
+ // queue the app's own mail
161
+ }
162
+
163
+ // in the mail job, at send time, so the link is never stored
164
+ const link = await this.customers.createAccountSetupLink(user, { returnUrl });
165
+ ```
166
+
167
+ - **`isFederated(user)`** is true when the user's email signs in elsewhere,
168
+ for example employees whose domain belongs to an organization that signs in
169
+ through Entra. **Call it before `provision()`**: `provision()` does not check
170
+ it, and would give such a user an identity of its own.
171
+ - **`provision(user, transaction?)`** does nothing for a user already linked to
172
+ the provider. Otherwise it finds the identity by email or creates one, and
173
+ links it with `link(user, identityId, provider, transaction)`. The store's
174
+ `link()` decides: it refuses a user that has another identity of the
175
+ provider, and an identity another user has. When it refuses,
176
+ `provision()` reads the owner with
177
+ `findByIdentityId(identityId, provider, transaction)` to throw
178
+ `IDENTITY_ALREADY_LINKED` or `LINK_REFUSED`. The transaction reaches both
179
+ unchanged, so the store runs them on the connection the transaction holds:
180
+ with a pool of one connection, a read outside it would wait forever. The
181
+ guard calls `findByIdentityId` without a transaction. `provision()` returns
182
+ nothing and throws on failure. When it fails after the identity was
183
+ created, the next call finds that identity by email and links it, rather
184
+ than creating a second one.
185
+ - **`createAccountSetupLink(user, { returnUrl })`** asks the provider for a
186
+ link where the identity sets up how it signs in, such as a password.
187
+
188
+ A sign-in refused just before the user is provisioned is not remembered after
189
+ `provision()` links the identity, so the user signs in at once. That holds
190
+ when the guard and the identity management inject the same store instance:
191
+ the guard remembers sign-ins per store instance, so two store classes are two
192
+ caches, even over one table, and so is one class registered in two modules.
193
+ Register the store once, as a singleton: a request-scoped store gets a new
194
+ cache on every request, which remembers nothing and does not run parallel
195
+ first sign-ins of one identity one at a time. Calling `provision()` again for
196
+ a user already linked clears the refusal as well.
197
+
198
+ Each method reads the email and the identity id through the store's
199
+ `toProfile(user, provider)`, so the app never handles an identity id. Two
200
+ identity managements may inject one provider class, each with its own
201
+ management options. An identity management whose provider class is not
202
+ registered in `IdentityConnectorModule.forRoot()` stops the app at boot.
203
+
204
+ What the methods throw; [Exceptions](#exceptions) lists every type with its code and field:
205
+
206
+ | Type | Status | Exception | When |
207
+ | --- | --- | --- | --- |
208
+ | `NO_EMAIL` | 409 | `PreconditionFailedException` | `toProfile()` found no email on the user |
209
+ | `IDENTITY_ALREADY_LINKED` | 409 | `PreconditionFailedException` | `provision()`: the store's `link()` refused, and another user owns the identity with the user's email |
210
+ | `LINK_REFUSED` | 409 | `PreconditionFailedException` | `provision()`: the store's `link()` refused for another reason, such as a user with another identity of the provider |
211
+ | `USER_NOT_PROVISIONED` | 409 | `PreconditionFailedException` | `createAccountSetupLink()`: the user has no identity at this provider |
212
+ | `NOT_CONFIGURED` | 503 | `GenericErrorException` | The provider holds no management credentials. Catch it to skip provisioning, for example on a local machine |
213
+ | `NOT_SUPPORTED` | 501 | `GenericErrorException` | The provider cannot perform the call |
214
+ | `PROVIDER_UNREACHABLE` | 503 | `GenericErrorException` | The provider could not be reached |
215
+
216
+ A refusal of the provider that retrying does not fix, such as a rejected key,
217
+ is a plain `Error` with the provider's answer; the provider's section says
218
+ which.
219
+
220
+ ## Exceptions
221
+
222
+ The library has no exception class of its own. It throws the exceptions of
223
+ `@ballistix.digital/exception-types`, so the app's exception filter and its
224
+ frontends see the same classes and envelopes as for any other error. Each
225
+ type of `IdentityConnectorExceptionTypeEnum` reaches the client as:
226
+
227
+ | Type | Status | Code | Exception | Where the type sits | Thrown by |
228
+ | --- | --- | --- | --- | --- | --- |
229
+ | `NO_TOKEN` | 401 | `UNAUTHENTICATED` | `UnauthenticatedException` | `errors[0].message` | The guard: no `Bearer` token |
230
+ | `UNKNOWN_ISSUER` | 401 | `UNAUTHENTICATED` | `UnauthenticatedException` | `errors[0].message` | The guard: no registered provider lists the token's issuer |
231
+ | `PROVIDER_NOT_ACCEPTED` | 401 | `UNAUTHENTICATED` | `UnauthenticatedException` | `errors[0].message` | The guard: the provider that lists the token's issuer is registered, but not one the guard accepts |
232
+ | `INVALID_TOKEN` | 401 | `UNAUTHENTICATED` | `UnauthenticatedException` | `errors[0].message` | The guard and the providers: the token is not valid |
233
+ | `USER_NOT_LINKED` | 403 | `ACCESS_DENIED` | `AccessDeniedException` | `errors[0].message` | The guard: no user for the identity |
234
+ | `NO_EMAIL` | 409 | `PRECONDITION_FAILED` | `PreconditionFailedException` | `errors[0].condition` | The store's `toProfile()` |
235
+ | `IDENTITY_ALREADY_LINKED` | 409 | `PRECONDITION_FAILED` | `PreconditionFailedException` | `errors[0].condition` | `provision()` |
236
+ | `LINK_REFUSED` | 409 | `PRECONDITION_FAILED` | `PreconditionFailedException` | `errors[0].condition` | `provision()` |
237
+ | `USER_NOT_PROVISIONED` | 409 | `PRECONDITION_FAILED` | `PreconditionFailedException` | `errors[0].condition` | `createAccountSetupLink()` |
238
+ | `NOT_SUPPORTED` | 501 | `GENERIC_ERROR` | `GenericErrorException` | `errors[0].message` | A management call the provider cannot perform |
239
+ | `NOT_CONFIGURED` | 503 | `GENERIC_ERROR` | `GenericErrorException` | `errors[0].message` | A management call without credentials |
240
+ | `PROVIDER_UNREACHABLE` | 503 | `GENERIC_ERROR` | `GenericErrorException` | `errors[0].message` | A provider that cannot reach its identity provider |
241
+
242
+ A frontend that maps the library's refusals reads the type from the field the
243
+ table names; the code alone does not tell `USER_NOT_LINKED` apart from another
244
+ 403, nor a 401 of the library from another `UNAUTHENTICATED`.
245
+
246
+ - **To react to one case**, read the type with `identityConnectorExceptionTypeOf(error)`
247
+ and compare it; never compare the message or the class. It answers only
248
+ for an exception with the code and status the type is thrown with, and
249
+ `undefined` for anything else.
250
+
251
+ ```ts
252
+ try {
253
+ await this.customers.provision(user, transaction);
254
+ } catch (error) {
255
+ if (identityConnectorExceptionTypeOf(error) !== IdentityConnectorExceptionTypeEnum.NOT_CONFIGURED) throw error;
256
+ }
257
+ ```
258
+
259
+ - **To throw one**, from a store or from a provider the app writes, use
260
+ `identityConnectorException(type)`: it picks the class and status the type reaches
261
+ the client with, for example
262
+ `throw identityConnectorException(IdentityConnectorExceptionTypeEnum.NO_EMAIL)` in
263
+ `toProfile()`.
264
+
265
+ ## Zitadel
266
+
267
+ `ZitadelProvider` from `./zitadel` signs users in with access tokens of one
268
+ Zitadel instance. The app extends it once per connection:
269
+
270
+ ```ts
271
+ @Injectable()
272
+ export class ComplianceZitadel extends ZitadelProvider {
273
+ constructor(config: ConfigService) {
274
+ super({
275
+ name: 'zitadel',
276
+ issuer: config.get('ZITADEL_ISSUER'),
277
+ projectId: config.get('ZITADEL_PROJECT_ID'),
278
+ organizationIds: [config.get('ZITADEL_VDL_ORG_ID')],
279
+ roleOrganizationIds: [config.get('ZITADEL_VDL_ORG_ID')],
280
+ linkByEmail: true,
281
+ serviceUserKey: config.get('ZITADEL_SERVICE_USER_KEY')
282
+ ? JSON.parse(config.get('ZITADEL_SERVICE_USER_KEY'))
283
+ : undefined,
284
+ });
285
+ }
286
+ }
287
+ ```
288
+
289
+ | Option | Required | Meaning |
290
+ | --- | --- | --- |
291
+ | `name` | yes | The provider name; unique in the app |
292
+ | `issuer` | yes | The Zitadel instance URL: the one issuer it accepts and the base of its endpoints. A trailing slash is dropped |
293
+ | `projectId` | yes | The Zitadel project: the audience every token carries |
294
+ | `organizationIds` | yes, at least one | The organizations whose users may be linked or created at sign-in |
295
+ | `roleOrganizationIds` | yes, at least one | The organizations whose role grants count |
296
+ | `linkByEmail`, `createOnSignIn` | no, both off | As for every provider |
297
+ | `serviceUserKey` | no | The parsed key file of the service user that calls the management API. Left out, every management call throws `NOT_CONFIGURED`; `null` stops the app at boot |
298
+
299
+ Both organization lists are required and may not be empty. An empty list would
300
+ quietly let every organization in, or let every organization's role grants
301
+ count: another organization with a grant on the project could then hand its
302
+ users an elevated role. A connection with a missing or empty option stops the
303
+ app at boot with a message that names the provider and every failing option.
304
+
305
+ The app's Zitadel application must issue JWT access tokens. The provider
306
+ verifies each token against the instance's signing keys (RS256, this issuer,
307
+ this project as audience) and caches the keys, so a signed-in user costs no
308
+ call to Zitadel; an opaque access token cannot be verified and is refused as
309
+ not valid.
310
+
311
+ The keys are cached by `jose`'s `createRemoteJWKSet`. A token whose key is
312
+ cached is answered from the cache. Once the keys are ten minutes old, the next
313
+ token waits for a fresh set. A token with a key id the cache lacks fetches the
314
+ keys again when the last fetch that succeeded is 30 seconds old or more;
315
+ otherwise, it is refused as not valid (401). Every token waiting on a fetch
316
+ shares it. Tokens with made-up key ids therefore cost Zitadel at most one
317
+ request per 30 seconds and never lock out a user whose key is known. A key
318
+ Zitadel adds is accepted within 30 seconds, and a key it withdraws is refused
319
+ within ten minutes. A fetch gets 5 seconds; when the keys are needed and cannot
320
+ be fetched, the token fails with `PROVIDER_UNREACHABLE` (503) rather than as
321
+ not valid, so a user with a key Zitadel just added is not signed out. While
322
+ Zitadel's key endpoint is down, every sign-in fails this way once the keys are
323
+ ten minutes old.
324
+
325
+ On a first sign-in, when no user is linked yet, the provider calls Zitadel's
326
+ userinfo endpoint to complete the identity: email, whether Zitadel verified
327
+ it, and names. It refuses a user whose organization is not in
328
+ `organizationIds` with `USER_NOT_LINKED` (403). The frontend must request the
329
+ scope `urn:zitadel:iam:user:resourceowner` at sign-in: without it userinfo
330
+ leaves the organization out, and every first sign-in is refused. The provider
331
+ passes `emailVerified` on without checking it; a store that links by email
332
+ decides whether an unverified email may link.
333
+
334
+ ### Managing Zitadel users
335
+
336
+ With `serviceUserKey`, an `IdentityManagement` on this provider provisions
337
+ users at Zitadel. Its management options:
338
+
339
+ | Management option | Required | Meaning |
340
+ | --- | --- | --- |
341
+ | `organizationId` | yes | The organization `provision()` searches by email and creates new users in |
342
+ | `federatedOrganizationIds` | no | Organizations whose users sign in elsewhere: `isFederated()` is true for an email on one of their domains |
343
+
344
+ The service user is a machine user with a key of type JSON. It needs to search
345
+ and create users and create password codes in `organizationId`, and to read the
346
+ domains of `federatedOrganizationIds`. The provider exchanges an assertion
347
+ signed with its key for an access token (Zitadel's JWT profile grant) and
348
+ reuses that token until a minute before it expires.
349
+
350
+ - **`findByEmail`** searches `organizationId` alone, without regard to case.
351
+ Two users there with one email are an error rather than a pick.
352
+ - **`create`** makes a human user with a verified email and no password.
353
+ - **`createAccountSetupLink`** returns a link to the password page of
354
+ Zitadel's login (v2), with a password code Zitadel returns instead of mailing
355
+ it. The code is valid as long as the instance's setting for password reset
356
+ codes says, so ask for the link when the mail is sent; every call creates a
357
+ new one. The page reads no return address, so `returnUrl` is ignored.
358
+ - **`isFederated`** compares the email's domain with the domains of
359
+ `federatedOrganizationIds`, exactly: a subdomain does not count. It caches
360
+ each organization's domains for ten minutes, so a domain an admin adds
361
+ counts within that time.
362
+ - **Every email is sent trimmed and in lowercase.** Zitadel matches uppercase
363
+ letters badly in its user search and its domain discovery (Zitadel issue
364
+ #12025).
365
+
366
+ The management options are checked at boot when `serviceUserKey` is given: a
367
+ missing `organizationId` stops the app with a message that names the identity
368
+ management. Without the key they go unchecked, so an app on a local machine
369
+ may leave both unset; every management call then throws `NOT_CONFIGURED`,
370
+ which the app catches to skip provisioning.
371
+
372
+ A call Zitadel cannot answer, through a network failure, a timeout of 5
373
+ seconds, a 5xx, a 408 or a 429, is `PROVIDER_UNREACHABLE` (503). So is a 401:
374
+ it may only mean Zitadel ended the service user's token early, so the provider
375
+ drops that token and the next call fetches a new one; the app tries again, as
376
+ for any 503. Any other refusal, such as a key Zitadel rejects when the provider
377
+ asks for a token or a service user without a permission, is a plain `Error`
378
+ with Zitadel's status and answer: a fault in the configuration that retrying
379
+ does not fix, and that an app skipping `NOT_CONFIGURED` must not skip
380
+ silently.
381
+
382
+ ## Entra
383
+
384
+ `EntraProvider` from `./entra` signs users in with access tokens of one
385
+ Entra tenant, and with `acceptIdTokens` also with its ID tokens (see
386
+ [ID tokens](#id-tokens)); without that option an ID token is refused. The app
387
+ extends it once per tenant:
388
+
389
+ ```ts
390
+ @Injectable()
391
+ export class ComplianceEntra extends EntraProvider {
392
+ constructor(config: ConfigService) {
393
+ super({
394
+ name: 'entra',
395
+ tenantId: config.get('AZURE_AD_TENANT_ID'),
396
+ // Without ?., a missing variable fails here instead of in the provider's boot check.
397
+ audience: config.get('AZURE_AD_AUDIENCE')?.split(','),
398
+ createOnSignIn: true,
399
+ });
400
+ }
401
+ }
402
+ ```
403
+
404
+ | Option | Required | Meaning |
405
+ | --- | --- | --- |
406
+ | `name` | yes | The provider name; unique in the app |
407
+ | `tenantId` | yes | The GUID of the tenant. It gives the issuers and the URL of the signing keys. `common` and `organizations` name no single tenant and stop the app at boot |
408
+ | `audience` | yes | The application ID URI or client id every token carries, or a list of them. Each is trimmed |
409
+ | `linkByEmail` | no, off | Leave it off; see below |
410
+ | `createOnSignIn` | no, off | As for every provider |
411
+ | `acceptIdTokens` | no, off | Also accept an ID token of the tenant; see [ID tokens](#id-tokens) |
412
+
413
+ **One provider per tenant.** Both of the tenant's issuers belong to the
414
+ provider, and two providers may not list one issuer, so a second provider for
415
+ the same tenant stops the app at boot. Several app registrations of the tenant
416
+ share one provider through a list of audiences.
417
+
418
+ The provider accepts both of Entra's token versions: a v2.0 token, issued by
419
+ `login.microsoftonline.com/<tenant>/v2.0`, and a v1.0 token, issued by
420
+ `sts.windows.net/<tenant>/`. Which one Entra issues depends on the app
421
+ registration; nothing there has to change. It verifies each token against the
422
+ tenant's signing keys (RS256, one of those issuers, one of the audiences) and
423
+ caches the keys, as the Zitadel provider does, with the same times. It reads the tenant's keys: an
424
+ app registration with a claims-mapping policy gets its tokens signed with a
425
+ key of its own, which those keys do not list, so every token of it is refused
426
+ as not valid.
427
+
428
+ - **`Identity.id` is the `oid` claim**, the identity's object id. `sub` differs
429
+ per app registration, so a store that keeps the `sub` finds no user once
430
+ another app registration signs the same person in.
431
+ - **A token without `scp` is refused** with `INVALID_TOKEN` (401), unless it is
432
+ an ID token and `acceptIdTokens` is on. Only an access token issued to an
433
+ identity carries scopes; an app's own token, from the client credentials
434
+ grant, has an `oid` and may carry app roles, but belongs to no identity. An
435
+ app that calls the API with such a token is refused.
436
+ - **The email is the `email` claim**, which Entra adds only when the app
437
+ registration asks for it as an optional claim; without it the email is null.
438
+ The names are `given_name` and `family_name`: a v1.0 access token carries
439
+ them, a v2.0 one only as optional claims. For an access token, those
440
+ optional claims are set on the app registration of the API, not of the
441
+ frontend that asks for the token.
442
+ `preferred_username` and `upn` look like an email but need not be one and
443
+ can change, so the provider does not read them. `emailVerified` is always
444
+ false: Entra's tokens do not say.
445
+ - **Leave `linkByEmail` off.** The `email` claim is the identity's mail
446
+ attribute, which an admin sets and Entra does not verify; on a guest it can
447
+ be any address. A store that links by email would hand the user with that
448
+ address to whoever carries it. Turn it on only with a store that trusts the
449
+ tenant's mail attributes, and expect `emailVerified` to be false.
450
+ - **The roles are the app roles** in the `roles` claim.
451
+ - **A first sign-in needs no call to Entra**: the token carries all the
452
+ provider knows, so `readProfile` returns the identity as it is.
453
+
454
+ ### ID tokens
455
+
456
+ The proper setup is an access token for an API scope the app registration
457
+ exposes: the frontend asks for that scope, and the backend accepts what Entra
458
+ issued for it. Some frontends send their **ID token** as the bearer instead,
459
+ because they only ask for `openid profile email` and their access token is
460
+ one for Microsoft Graph. `acceptIdTokens: true` lets such a frontend keep
461
+ working while it moves to an API scope. An ID token's audience is the
462
+ frontend's client id, so `audience` lists that client id.
463
+
464
+ With the option on, a token without `scp` passes as an ID token only when both
465
+ hold:
466
+
467
+ - **It names no client.** Every Entra access token names the client it was
468
+ issued to, `azp` in a v2.0 token and `appid` in a v1.0 token, an app's own
469
+ token from the client credentials grant included. An ID token carries
470
+ neither. A token with `idtyp: "app"` is refused as well.
471
+ - **It names a person**, in `name`, `preferred_username` or `unique_name`. An
472
+ app's own token has none of them. An ID token with an `oid` does: Entra adds
473
+ the `oid` only with the `profile` scope, which brings them.
474
+
475
+ Signature, issuer, audience, lifetime and `oid` are checked as for an access
476
+ token, and the identity is read the same way. Delegated access tokens, with
477
+ `scp`, are still accepted.
478
+
479
+ What the option does not protect against:
480
+
481
+ - **An ID token is meant for the frontend, not for an API.** Anyone who gets
482
+ hold of a user's ID token for that client, from the browser's storage or a
483
+ log line, can call the API with it until it expires, usually after an hour.
484
+ It cannot be narrowed to this API, and Entra does not end it early when the
485
+ user signs out or loses access.
486
+ - **The rule rests on claims as Microsoft documents them today**, which it
487
+ does not promise to keep. Ask Entra for the `idtyp` optional claim on the
488
+ access tokens of the API's app registration: an app's own token then says
489
+ so explicitly.
490
+
491
+ Turn it off once the frontend sends an access token for the API's scope.
492
+
493
+ ### Managing Entra users
494
+
495
+ The management calls are not available yet. `findByEmail` and `create` throw
496
+ `NOT_CONFIGURED`, which an app that provisions users catches to skip
497
+ provisioning. `createAccountSetupLink` and `isFederated` throw `NOT_SUPPORTED`
498
+ (501): the tenant's IT hands employees their password, and an Entra identity never
499
+ signs in elsewhere.
500
+
501
+ ## Testing an app
502
+
503
+ `./testing` gives an app what it needs to test sign-in without an identity
504
+ provider:
505
+
506
+ ```ts
507
+ const provider = new TestIdentityProvider({ name: 'zitadel', issuers: ['https://test-issuer'] });
508
+
509
+ Test.createTestingModule({ imports: [AppModule] })
510
+ .overrideProvider(ComplianceZitadel)
511
+ .useValue(provider);
512
+
513
+ const token = provider.createToken({ id: 'zitadel-1', roles: ['ADMIN'] });
514
+ ```
515
+
516
+ Run the store contract against the app's own store, so a store that breaks a
517
+ rule the types cannot express fails in the app's CI:
518
+
519
+ ```ts
520
+ for (const check of userStoreContract(() => ({ store, providers: ['zitadel'], createUser, userId: (user) => user.id }))) {
521
+ it(check.name, check.run);
522
+ }
523
+ ```
524
+
525
+ ## Good to know
526
+
527
+ - **A change at the provider takes up to `signInCacheSeconds` to arrive.** The
528
+ guard verifies the token on every request, but for an identity it saw
529
+ recently it answers from its cache, without the store and without `sync()`.
530
+ A revoked role therefore stays on `request.user` until the entry expires: 60
531
+ seconds by default. Set `signInCacheSeconds: 0` to apply such a change on the
532
+ next request, at the cost of a store call on every request.
533
+ - **A refused sign-in stays refused for up to `signInCacheSeconds`.** The
534
+ guard remembers a `USER_NOT_LINKED` refusal too. `provision()` drops it when
535
+ it links the identity. A user the app links by other means is still refused
536
+ until the entry expires, unless `signInCacheSeconds` is 0, and so is a
537
+ sign-in that arrives between `provision()` and the commit of the app's
538
+ transaction; calling `provision()` again after the commit clears it.
539
+ - **`link()` keeps one identity to one user.** The library cannot tell two
540
+ user objects apart, so the store compares users by primary key and checks
541
+ within the write. On PostgreSQL, a conditional update does both at once,
542
+ with a unique index on the identity column behind it. The example runs it
543
+ in a nested `transaction()`, which TypeORM 0.3.22 turns into a savepoint
544
+ inside the app's transaction; a version without nested transactions throws
545
+ `TransactionAlreadyStartedError` there and needs a savepoint of its own
546
+ through the query runner:
547
+
548
+ ```ts
549
+ async link(user: User, identityId: string, provider: string, transaction?: EntityManager): Promise<boolean> {
550
+ try {
551
+ // Within the app's transaction this is a savepoint, so a unique violation undoes this update alone.
552
+ return await (transaction ?? this.manager).transaction(async (manager) => {
553
+ const result = await manager
554
+ .createQueryBuilder()
555
+ .update(User)
556
+ .set({ zitadelId: identityId })
557
+ .where('id = :userId', { userId: user.id })
558
+ .andWhere('("zitadelId" IS NULL OR "zitadelId" = :identityId)', { identityId })
559
+ .andWhere('NOT EXISTS (SELECT 1 FROM "user" other WHERE other."zitadelId" = :identityId AND other.id <> :userId)')
560
+ .execute();
561
+
562
+ return result.affected === 1;
563
+ });
564
+ } catch (error) {
565
+ // The identity column's own unique index: another transaction linked the identity at the same moment.
566
+ if (error instanceof QueryFailedError && error.driverError?.constraint === 'UQ_user_zitadelId') {
567
+ return false;
568
+ }
569
+ throw error;
570
+ }
571
+ }
572
+ ```
573
+
574
+ The unique index covers what the `NOT EXISTS` cannot: two transactions open
575
+ at the same time do not see each other's writes, so both could pass it.
576
+ Catching its violation turns it into a refusal rather than the database's
577
+ own error. Match the index by its name, so a violation of another unique
578
+ index on the table still surfaces as the data error it is; name it
579
+ yourself, such as `@Index('UQ_user_zitadelId', ['zitadelId'], { unique: true })`,
580
+ since TypeORM otherwise names it after a hash.
581
+
582
+ The savepoint keeps the app's transaction usable after a violation. Without
583
+ one, PostgreSQL refuses every further query of that transaction, so
584
+ `provision()`'s read of the owner fails, and `provision()` throws that
585
+ database error instead of `IDENTITY_ALREADY_LINKED`. A user object the app
586
+ loaded before another call linked that user links fine, since the update
587
+ checks the row, not the object.
588
+
589
+ MySQL refuses a subquery on the table an update writes, so a MySQL store
590
+ leaves out the `NOT EXISTS`, relies on the unique index alone and catches
591
+ its `ER_DUP_ENTRY` (errno 1062) instead of `23505`. The quoted table and
592
+ column names are the database's; match them to the entity's table and the
593
+ naming strategy.
594
+ - **Provision one user one call at a time.** Two `provision()` calls for the
595
+ same user that run at once can both miss the identity at the provider and
596
+ each create one. The first links its identity; the second gets
597
+ `LINK_REFUSED`, and its identity stays at the provider, linked to no one.
598
+ Run provisioning for a user from one place, such as the job that invites
599
+ them.
600
+ - **`create()` must be safe to run twice for one identity.** Within one app
601
+ instance the guard runs one sign-in per identity at a time, so parallel
602
+ requests of a new user wait for a single `create()`. Two app instances can
603
+ still each sign the same new identity in. Give the identity column a unique
604
+ index and return the existing user on a conflict; the store contract checks
605
+ it.
606
+ - **Treat `request.user` as read-only.** While an identity is cached, all its
607
+ requests share one `request.user` object. Keep per-request data elsewhere on
608
+ the request.
609
+ - **A guard injects its providers by the class the app registered.** It
610
+ accepts exactly the instances it passes to `super()`, so inject
611
+ `ComplianceZitadel`, the class listed in `forRoot()`, not a base class such as
612
+ `ZitadelProvider`, which Nest cannot inject. A test that replaces
613
+ `ComplianceZitadel` with `overrideProvider` replaces it in the guard too.
614
+ - **A provider tells an unreachable provider apart from a bad token.** The
615
+ guard passes an exception `identityConnectorException` made from
616
+ `authenticate()` on unchanged, so a provider throws
617
+ `identityConnectorException(PROVIDER_UNREACHABLE)` (503) when it cannot
618
+ reach its identity provider. Anything else `authenticate()` throws, another
619
+ exception of exception-types included, is a token that is not valid (401),
620
+ so a bad token never reaches the client as a 500 or a 404. This matters only
621
+ to an app that writes its own provider.
622
+ - **The guard links by email only an identity that has an email.** Whether the
623
+ email must be verified is the store's decision, in `linkByEmail()`.
624
+ - **A store implements what its guard's providers allow.** `linkByEmail()` and
625
+ `create()` return null unless the store overrides them, which refuses the
626
+ sign-in. When a provider the guard accepts has `linkByEmail` or
627
+ `createOnSignIn` and the store leaves that method out, the app stops at boot
628
+ with a message that names both. A guard that writes its own `onModuleInit`
629
+ calls `super.onModuleInit()`, or the check does not run.
630
+ - **The guard guards HTTP routes.** It reads the `Authorization` header of an
631
+ HTTP request.
@@ -0,0 +1,14 @@
1
+ export declare enum IdentityConnectorExceptionTypeEnum {
2
+ NO_TOKEN = "NO_TOKEN",
3
+ UNKNOWN_ISSUER = "UNKNOWN_ISSUER",
4
+ PROVIDER_NOT_ACCEPTED = "PROVIDER_NOT_ACCEPTED",
5
+ INVALID_TOKEN = "INVALID_TOKEN",
6
+ PROVIDER_UNREACHABLE = "PROVIDER_UNREACHABLE",
7
+ USER_NOT_LINKED = "USER_NOT_LINKED",
8
+ USER_NOT_PROVISIONED = "USER_NOT_PROVISIONED",
9
+ IDENTITY_ALREADY_LINKED = "IDENTITY_ALREADY_LINKED",
10
+ LINK_REFUSED = "LINK_REFUSED",
11
+ NO_EMAIL = "NO_EMAIL",
12
+ NOT_SUPPORTED = "NOT_SUPPORTED",
13
+ NOT_CONFIGURED = "NOT_CONFIGURED"
14
+ }
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.IdentityConnectorExceptionTypeEnum = void 0;
4
+ var IdentityConnectorExceptionTypeEnum;
5
+ (function (IdentityConnectorExceptionTypeEnum) {
6
+ IdentityConnectorExceptionTypeEnum["NO_TOKEN"] = "NO_TOKEN";
7
+ IdentityConnectorExceptionTypeEnum["UNKNOWN_ISSUER"] = "UNKNOWN_ISSUER";
8
+ IdentityConnectorExceptionTypeEnum["PROVIDER_NOT_ACCEPTED"] = "PROVIDER_NOT_ACCEPTED";
9
+ IdentityConnectorExceptionTypeEnum["INVALID_TOKEN"] = "INVALID_TOKEN";
10
+ IdentityConnectorExceptionTypeEnum["PROVIDER_UNREACHABLE"] = "PROVIDER_UNREACHABLE";
11
+ IdentityConnectorExceptionTypeEnum["USER_NOT_LINKED"] = "USER_NOT_LINKED";
12
+ IdentityConnectorExceptionTypeEnum["USER_NOT_PROVISIONED"] = "USER_NOT_PROVISIONED";
13
+ IdentityConnectorExceptionTypeEnum["IDENTITY_ALREADY_LINKED"] = "IDENTITY_ALREADY_LINKED";
14
+ IdentityConnectorExceptionTypeEnum["LINK_REFUSED"] = "LINK_REFUSED";
15
+ IdentityConnectorExceptionTypeEnum["NO_EMAIL"] = "NO_EMAIL";
16
+ IdentityConnectorExceptionTypeEnum["NOT_SUPPORTED"] = "NOT_SUPPORTED";
17
+ IdentityConnectorExceptionTypeEnum["NOT_CONFIGURED"] = "NOT_CONFIGURED";
18
+ })(IdentityConnectorExceptionTypeEnum || (exports.IdentityConnectorExceptionTypeEnum = IdentityConnectorExceptionTypeEnum = {}));