@contember/engine-tenant-api 2.2.0-alpha.6 → 2.2.0-alpha.8

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 (77) hide show
  1. package/dist/development/src/TenantContainer.cjs +5 -2
  2. package/dist/development/src/TenantContainer.cjs.map +1 -1
  3. package/dist/development/src/TenantContainer.js +5 -2
  4. package/dist/development/src/TenantContainer.js.map +1 -1
  5. package/dist/development/src/model/commands/apiKey/ProlongApiKeyCommand.cjs +5 -8
  6. package/dist/development/src/model/commands/apiKey/ProlongApiKeyCommand.cjs.map +1 -1
  7. package/dist/development/src/model/commands/apiKey/ProlongApiKeyCommand.js +5 -8
  8. package/dist/development/src/model/commands/apiKey/ProlongApiKeyCommand.js.map +1 -1
  9. package/dist/development/src/model/commands/authLog/CreateAuthLogEntryCommand.cjs.map +1 -1
  10. package/dist/development/src/model/commands/authLog/CreateAuthLogEntryCommand.js.map +1 -1
  11. package/dist/development/src/model/commands/idp/ClaimIdpRevalidationCommand.cjs +3 -7
  12. package/dist/development/src/model/commands/idp/ClaimIdpRevalidationCommand.cjs.map +1 -1
  13. package/dist/development/src/model/commands/idp/ClaimIdpRevalidationCommand.js +3 -7
  14. package/dist/development/src/model/commands/idp/ClaimIdpRevalidationCommand.js.map +1 -1
  15. package/dist/development/src/model/service/IDPSignInManager.cjs +23 -15
  16. package/dist/development/src/model/service/IDPSignInManager.cjs.map +1 -1
  17. package/dist/development/src/model/service/IDPSignInManager.js +23 -15
  18. package/dist/development/src/model/service/IDPSignInManager.js.map +1 -1
  19. package/dist/development/src/model/service/idp/IdpSessionRevalidator.cjs +42 -31
  20. package/dist/development/src/model/service/idp/IdpSessionRevalidator.cjs.map +1 -1
  21. package/dist/development/src/model/service/idp/IdpSessionRevalidator.js +42 -31
  22. package/dist/development/src/model/service/idp/IdpSessionRevalidator.js.map +1 -1
  23. package/dist/production/src/TenantContainer.cjs +5 -2
  24. package/dist/production/src/TenantContainer.cjs.map +1 -1
  25. package/dist/production/src/TenantContainer.js +5 -2
  26. package/dist/production/src/TenantContainer.js.map +1 -1
  27. package/dist/production/src/model/commands/apiKey/ProlongApiKeyCommand.cjs +5 -8
  28. package/dist/production/src/model/commands/apiKey/ProlongApiKeyCommand.cjs.map +1 -1
  29. package/dist/production/src/model/commands/apiKey/ProlongApiKeyCommand.js +5 -8
  30. package/dist/production/src/model/commands/apiKey/ProlongApiKeyCommand.js.map +1 -1
  31. package/dist/production/src/model/commands/authLog/CreateAuthLogEntryCommand.cjs.map +1 -1
  32. package/dist/production/src/model/commands/authLog/CreateAuthLogEntryCommand.js.map +1 -1
  33. package/dist/production/src/model/commands/idp/ClaimIdpRevalidationCommand.cjs +3 -7
  34. package/dist/production/src/model/commands/idp/ClaimIdpRevalidationCommand.cjs.map +1 -1
  35. package/dist/production/src/model/commands/idp/ClaimIdpRevalidationCommand.js +3 -7
  36. package/dist/production/src/model/commands/idp/ClaimIdpRevalidationCommand.js.map +1 -1
  37. package/dist/production/src/model/service/IDPSignInManager.cjs +23 -15
  38. package/dist/production/src/model/service/IDPSignInManager.cjs.map +1 -1
  39. package/dist/production/src/model/service/IDPSignInManager.js +23 -15
  40. package/dist/production/src/model/service/IDPSignInManager.js.map +1 -1
  41. package/dist/production/src/model/service/idp/IdpSessionRevalidator.cjs +42 -31
  42. package/dist/production/src/model/service/idp/IdpSessionRevalidator.cjs.map +1 -1
  43. package/dist/production/src/model/service/idp/IdpSessionRevalidator.js +42 -31
  44. package/dist/production/src/model/service/idp/IdpSessionRevalidator.js.map +1 -1
  45. package/dist/tests/cases/integration/mocked/sql/sqlTransaction.d.ts +5 -0
  46. package/dist/tests/cases/integration/mocked/sql/sqlTransaction.d.ts.map +1 -1
  47. package/dist/tests/cases/unit/idp/claimIdpRevalidation.test.d.ts +2 -0
  48. package/dist/tests/cases/unit/idp/claimIdpRevalidation.test.d.ts.map +1 -0
  49. package/dist/tests/src/IdPMock.d.ts.map +1 -1
  50. package/dist/tests/src/testTenant.d.ts.map +1 -1
  51. package/dist/tests/tsconfig.tsbuildinfo +1 -1
  52. package/dist/types/TenantContainer.d.ts +2 -0
  53. package/dist/types/TenantContainer.d.ts.map +1 -1
  54. package/dist/types/model/commands/apiKey/ProlongApiKeyCommand.d.ts +5 -3
  55. package/dist/types/model/commands/apiKey/ProlongApiKeyCommand.d.ts.map +1 -1
  56. package/dist/types/model/commands/authLog/CreateAuthLogEntryCommand.d.ts +1 -0
  57. package/dist/types/model/commands/authLog/CreateAuthLogEntryCommand.d.ts.map +1 -1
  58. package/dist/types/model/commands/idp/ClaimIdpRevalidationCommand.d.ts.map +1 -1
  59. package/dist/types/model/service/IDPSignInManager.d.ts +3 -1
  60. package/dist/types/model/service/IDPSignInManager.d.ts.map +1 -1
  61. package/dist/types/model/service/idp/IdpSessionRevalidator.d.ts +3 -1
  62. package/dist/types/model/service/idp/IdpSessionRevalidator.d.ts.map +1 -1
  63. package/dist/types/tsconfig.tsbuildinfo +1 -1
  64. package/package.json +12 -12
  65. package/src/TenantContainer.ts +7 -2
  66. package/src/model/commands/apiKey/ProlongApiKeyCommand.ts +10 -11
  67. package/src/model/commands/authLog/CreateAuthLogEntryCommand.ts +1 -0
  68. package/src/model/commands/idp/ClaimIdpRevalidationCommand.ts +8 -7
  69. package/src/model/service/IDPSignInManager.ts +26 -14
  70. package/src/model/service/idp/IdpSessionRevalidator.ts +43 -28
  71. package/tests/cases/integration/mocked/signInIdpMutation.test.ts +100 -1
  72. package/tests/cases/integration/mocked/sql/sqlTransaction.ts +16 -0
  73. package/tests/cases/unit/apiKey/prolongApiKeyCommand.test.ts +19 -9
  74. package/tests/cases/unit/idp/claimIdpRevalidation.test.ts +50 -0
  75. package/tests/cases/unit/idp/idpSessionRevalidator.test.ts +37 -4
  76. package/tests/src/IdPMock.ts +2 -0
  77. package/tests/src/testTenant.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contember/engine-tenant-api",
3
- "version": "2.2.0-alpha.6",
3
+ "version": "2.2.0-alpha.8",
4
4
  "license": "Apache-2.0",
5
5
  "main": "./dist/production/index.js",
6
6
  "typings": "./dist/types/index.d.ts",
@@ -26,16 +26,16 @@
26
26
  "generate": "gql-gen --config graphql.codegen.yml"
27
27
  },
28
28
  "dependencies": {
29
- "@contember/authorization": "2.2.0-alpha.6",
30
- "@contember/config-loader": "2.2.0-alpha.6",
31
- "@contember/database": "2.2.0-alpha.6",
32
- "@contember/database-migrations": "2.2.0-alpha.6",
33
- "@contember/dic": "2.2.0-alpha.6",
34
- "@contember/graphql-utils": "2.2.0-alpha.6",
35
- "@contember/logger": "2.2.0-alpha.6",
36
- "@contember/schema": "2.2.0-alpha.6",
37
- "@contember/schema-utils": "2.2.0-alpha.6",
38
- "@contember/typesafe": "2.2.0-alpha.6",
29
+ "@contember/authorization": "2.2.0-alpha.8",
30
+ "@contember/config-loader": "2.2.0-alpha.8",
31
+ "@contember/database": "2.2.0-alpha.8",
32
+ "@contember/database-migrations": "2.2.0-alpha.8",
33
+ "@contember/dic": "2.2.0-alpha.8",
34
+ "@contember/graphql-utils": "2.2.0-alpha.8",
35
+ "@contember/logger": "2.2.0-alpha.8",
36
+ "@contember/schema": "2.2.0-alpha.8",
37
+ "@contember/schema-utils": "2.2.0-alpha.8",
38
+ "@contember/typesafe": "2.2.0-alpha.8",
39
39
  "@graphql-tools/utils": "^11.0.0",
40
40
  "graphql-tag": "^2.12.6",
41
41
  "jose": "^6.2.1",
@@ -46,7 +46,7 @@
46
46
  "otpauth": "^9.5.0"
47
47
  },
48
48
  "devDependencies": {
49
- "@contember/database-tester": "2.2.0-alpha.6",
49
+ "@contember/database-tester": "2.2.0-alpha.8",
50
50
  "@graphql-tools/schema": "^10.0.31",
51
51
  "@types/mustache": "^4.2.6",
52
52
  "@types/node": "^24.12.0",
@@ -1,3 +1,4 @@
1
+ import { Logger } from '@contember/logger'
1
2
  import { AccessEvaluator, Authorizator } from '@contember/authorization'
2
3
  import { Connection, DatabaseConfig } from '@contember/database'
3
4
  import { Builder } from '@contember/dic'
@@ -148,6 +149,7 @@ export interface TenantContainerArgs {
148
149
  projectInitializer: ProjectInitializer
149
150
  tenantCredentials: TenantCredentials
150
151
  cryptoProviders: Pick<Providers, 'encrypt' | 'decrypt' | 'encryptionEnabled'>
152
+ logger: Logger
151
153
  }
152
154
 
153
155
  export class TenantContainerFactory {
@@ -199,7 +201,10 @@ export class TenantContainerFactory {
199
201
  return idpRegistry
200
202
  })
201
203
  .addService('idpClaimSyncService', ({ projectSchemaResolver }) => new IDPClaimSyncService(projectSchemaResolver))
202
- .addService('idpSessionRevalidator', ({ idpRegistry, idpClaimSyncService }) => new IdpSessionRevalidator(idpRegistry, idpClaimSyncService))
204
+ .addService(
205
+ 'idpSessionRevalidator',
206
+ ({ idpRegistry, idpClaimSyncService }) => new IdpSessionRevalidator(idpRegistry, idpClaimSyncService, args.logger),
207
+ )
203
208
  .addService('backchannelLogoutManager', ({ idpRegistry }) => new BackchannelLogoutManager(idpRegistry))
204
209
  .addService('unpersistedApiKeyManager', () =>
205
210
  UnpersistedApiKeyManager.createForRootTokens({
@@ -255,7 +260,7 @@ export class TenantContainerFactory {
255
260
  )
256
261
  .addService(
257
262
  'idpSignInManager',
258
- ({ apiKeyManager, idpRegistry, idpClaimSyncService }) => new IDPSignInManager(apiKeyManager, idpRegistry, idpClaimSyncService),
263
+ ({ apiKeyManager, idpRegistry, idpClaimSyncService }) => new IDPSignInManager(apiKeyManager, idpRegistry, idpClaimSyncService, args.logger),
259
264
  )
260
265
  .addService('idpManager', ({ idpRegistry, projectSchemaResolver }) => new IDPManager(idpRegistry, projectSchemaResolver))
261
266
  .addService('personIdentityProviderManager', () => new PersonIdentityProviderManager())
@@ -4,9 +4,11 @@ import { ApiKeyHelper } from './ApiKeyHelper.js'
4
4
  import { QueryBuilder, UpdateBuilder } from '@contember/database'
5
5
 
6
6
  /**
7
- * Tracking writes (`last_used_at`) are throttled to at most once per this window, so
8
- * an idle check must add this slack to avoid prematurely expiring an active session
9
- * whose `last_used_at` is intentionally stale (see {@link ApiKeyManager.verifyAndProlong}).
7
+ * Tracking writes (`last_ip`, `last_user_agent`, `last_used_at`) are throttled to at most once per
8
+ * this window, even when the IP or User-Agent changes: a key shared by several service replicas
9
+ * alternates between their addresses, and writing every change makes its row a lock hotspot.
10
+ * An idle check must add this slack to avoid prematurely expiring an active session whose
11
+ * `last_used_at` is intentionally stale (see {@link ApiKeyManager.verifyAndProlong}).
10
12
  */
11
13
  export const PROLONG_THROTTLE_MS = 60_000
12
14
 
@@ -51,7 +53,7 @@ export class ProlongApiKeyCommand implements Command<void> {
51
53
 
52
54
  const requestIp = this.requestInfo?.ip || null
53
55
  const requestUserAgent = this.requestInfo?.userAgent || null
54
- const updateTracking = this.shouldUpdateTracking(now, requestIp, requestUserAgent)
56
+ const updateTracking = this.shouldUpdateTracking(now)
55
57
 
56
58
  if (!updateExpiration && !updateTracking) {
57
59
  return
@@ -74,17 +76,14 @@ export class ProlongApiKeyCommand implements Command<void> {
74
76
  await qb.execute(db)
75
77
  }
76
78
 
77
- private shouldUpdateTracking(now: Date, requestIp: string | null, requestUserAgent: string | null): boolean {
79
+ private shouldUpdateTracking(now: Date): boolean {
78
80
  if (!this.requestInfo) {
79
81
  return false
80
82
  }
81
- const last = this.tracking
82
- if (!last || last.lastUsedAt === null) {
83
+ const lastUsedAt = this.tracking?.lastUsedAt
84
+ if (!lastUsedAt) {
83
85
  return true
84
86
  }
85
- if (requestIp !== last.lastIp || requestUserAgent !== last.lastUserAgent) {
86
- return true
87
- }
88
- return now.getTime() - last.lastUsedAt.getTime() >= PROLONG_THROTTLE_MS
87
+ return now.getTime() - lastUsedAt.getTime() >= PROLONG_THROTTLE_MS
89
88
  }
90
89
  }
@@ -42,6 +42,7 @@ namespace CreateAuthLogEntryCommand {
42
42
  invokedById?: string
43
43
  personInputIdentifier?: string
44
44
  personId?: string
45
+ /** A `person_token` id (password reset, e-mail verification, passwordless). FK-checked, so never an `api_key` id: put that in `eventData`. */
45
46
  personTokenId?: string
46
47
  success: boolean
47
48
  errorCode?: string
@@ -1,3 +1,4 @@
1
+ import { UpdateBuilder } from '@contember/database'
1
2
  import { Command } from '../Command.js'
2
3
 
3
4
  /**
@@ -17,12 +18,12 @@ export class ClaimIdpRevalidationCommand implements Command<boolean> {
17
18
  }
18
19
 
19
20
  async execute({ db }: Command.Args): Promise<boolean> {
20
- const result = await db.query(
21
- `UPDATE "idp_session"
22
- SET "last_validated_at" = now()
23
- WHERE "id" = ? AND "last_validated_at" <= now() - ?::interval`,
24
- [this.id, this.interval],
25
- )
26
- return (result.rowCount ?? 0) > 0
21
+ const qb = UpdateBuilder.create()
22
+ .table('idp_session')
23
+ .where({ id: this.id })
24
+ .where(expr => expr.raw('"last_validated_at" <= now() - ?::interval', this.interval))
25
+ .values({ last_validated_at: expr => expr.raw('now()') })
26
+
27
+ return (await qb.execute(db)) > 0
27
28
  }
28
29
  }
@@ -13,12 +13,14 @@ import { IdentityProviderRow } from '../queries/idp/types.js'
13
13
  import { AuthLogService } from './AuthLogService.js'
14
14
  import { ClaimMappingAudit, IDPClaimSyncService } from './idp/IDPClaimSyncService.js'
15
15
  import { ClaimMapping, parseClaimMapping } from './idp/ClaimMapping.js'
16
+ import { Logger } from '@contember/logger'
16
17
 
17
18
  class IDPSignInManager {
18
19
  constructor(
19
20
  private readonly apiKeyManager: ApiKeyManager,
20
21
  private readonly idpRegistry: IDPHandlerRegistry,
21
22
  private readonly claimSyncService: IDPClaimSyncService,
23
+ private readonly logger: Logger,
22
24
  ) {}
23
25
 
24
26
  async signInIDP(
@@ -101,7 +103,8 @@ class IDPSignInManager {
101
103
  let claimMappingFailed = false
102
104
  try {
103
105
  mapping = parseClaimMapping(provider.configuration)
104
- } catch {
106
+ } catch (e) {
107
+ this.logger.warn(e, { message: 'IdP sign-in: claim mapping configuration is invalid', identityProviderId: provider.id })
105
108
  claimMappingFailed = true
106
109
  }
107
110
  let claimMappingAudit: ClaimMappingAudit | null = null
@@ -146,19 +149,28 @@ class IDPSignInManager {
146
149
  // non-revalidated session. Audit the downgrade (errorCode `encryption_disabled`)
147
150
  // so the operator can see that a session they expected to be continuously
148
151
  // re-validated is in fact not protected, instead of failing closed at sign-in.
149
- await db.commandBus.execute(
150
- new CreateAuthLogEntryCommand({
151
- type: 'idp_session_revalidation_failed',
152
- invokedById: personRow.identity_id,
153
- personId: personRow.id,
154
- identityProviderId: provider.id,
155
- personTokenId: apiKeyId,
156
- success: true,
157
- errorCode: 'encryption_disabled',
158
- ipAddress: requestInfo?.ip,
159
- userAgent: requestInfo?.userAgent,
160
- }),
161
- )
152
+ // Best-effort, in a savepoint: a failed insert would otherwise abort the whole
153
+ // sign-in transaction, and a lost audit row must not block the sign-in.
154
+ try {
155
+ await db.commandBus.transaction(bus =>
156
+ bus.execute(
157
+ new CreateAuthLogEntryCommand({
158
+ type: 'idp_session_revalidation_failed',
159
+ invokedById: personRow.identity_id,
160
+ personId: personRow.id,
161
+ identityProviderId: provider.id,
162
+ success: true,
163
+ errorCode: 'encryption_disabled',
164
+ ipAddress: requestInfo?.ip,
165
+ userAgent: requestInfo?.userAgent,
166
+ eventData: { apiKeyId },
167
+ }),
168
+ )
169
+ )
170
+ } catch (e) {
171
+ this.logger.error(e, { message: 'IdP sign-in: writing the encryption_disabled audit entry failed', identityProviderId: provider.id })
172
+ // the savepoint is rolled back, the sign-in transaction stays usable
173
+ }
162
174
  }
163
175
  }
164
176
 
@@ -10,6 +10,7 @@ import { CreateAuthLogEntryCommand } from '../../commands/authLog/CreateAuthLogE
10
10
  import { REVALIDATION_DEFAULT_FALLBACK_INTERVAL, REVALIDATION_DEFAULT_MIN_INTERVAL, REVALIDATION_DEFAULT_SOFT_THRESHOLD } from './IDPRevalidation.js'
11
11
  import { IdentityProviderHandler, RevalidationResult } from './IdentityProviderHandler.js'
12
12
  import { IDPClaimSyncService } from './IDPClaimSyncService.js'
13
+ import { Logger } from '@contember/logger'
13
14
  import { parseClaimMapping } from './ClaimMapping.js'
14
15
  import { JSONValue } from '@contember/schema'
15
16
 
@@ -46,6 +47,7 @@ export class IdpSessionRevalidator {
46
47
  constructor(
47
48
  private readonly idpRegistry: IDPHandlerRegistry,
48
49
  private readonly claimSyncService: IDPClaimSyncService,
50
+ private readonly logger: Logger,
49
51
  ) {
50
52
  }
51
53
 
@@ -88,7 +90,8 @@ export class IdpSessionRevalidator {
88
90
  let claimed: boolean
89
91
  try {
90
92
  claimed = await dbContext.commandBus.execute(new ClaimIdpRevalidationCommand(row.id, decision.claimInterval))
91
- } catch {
93
+ } catch (e) {
94
+ this.logger.warn(e, { message: 'IdP session revalidation: claim failed', idpSessionId: row.id })
92
95
  // invalid interval string or transient DB error — fail open, retry next request
93
96
  return 'valid'
94
97
  }
@@ -106,7 +109,9 @@ export class IdpSessionRevalidator {
106
109
  // here; otherwise it becomes an unhandledRejection and can take the worker down. The
107
110
  // session is simply kept and re-tried on a later request (the claim floor throttles).
108
111
  setImmediate(() => {
109
- this.runRevalidation(dbContext, handler, row, apiKeyRow, requestInfo).catch(() => {})
112
+ this.runRevalidation(dbContext, handler, row, apiKeyRow, requestInfo).catch(e => {
113
+ this.logger.error(e, { message: 'IdP session revalidation: background run failed', idpSessionId: row.id })
114
+ })
110
115
  })
111
116
  return 'valid'
112
117
  }
@@ -146,7 +151,8 @@ export class IdpSessionRevalidator {
146
151
  let config: {}
147
152
  try {
148
153
  config = handler.validateConfiguration(row.providerConfiguration)
149
- } catch {
154
+ } catch (e) {
155
+ this.logger.warn(e, { message: 'IdP session revalidation: stored configuration is invalid', idpSessionId: row.id })
150
156
  // corrupt stored config — must NOT revoke; keep the session and retry later. Audit
151
157
  // it so the operator can tell "vouched for" apart from "silently broken" (without an
152
158
  // entry, a config typo disables re-validation invisibly for the life of every session).
@@ -157,7 +163,8 @@ export class IdpSessionRevalidator {
157
163
  let result: RevalidationResult
158
164
  try {
159
165
  result = await handler.revalidate!(config, row.session)
160
- } catch {
166
+ } catch (e) {
167
+ this.logger.warn(e, { message: 'IdP session revalidation: identity provider call failed', idpSessionId: row.id })
161
168
  // transient failure (network / IdP down) — must NOT revoke; keep the session and retry
162
169
  // on a later request (the claim floor prevents hammering the IdP). Audited (throttled
163
170
  // to one entry per claim window) so a prolonged IdP outage is visible rather than a
@@ -185,7 +192,8 @@ export class IdpSessionRevalidator {
185
192
  try {
186
193
  await dbContext.commandBus.execute(new UpdateIdpSessionCommand(row.id, result.idpSession))
187
194
  await this.logRevalidated(dbContext, apiKeyRow, row, requestInfo)
188
- } catch {
195
+ } catch (e) {
196
+ this.logger.error(e, { message: 'IdP session revalidation: persisting the rotated session failed', idpSessionId: row.id })
189
197
  // fall through — session stays valid, persist retried on a later request
190
198
  }
191
199
  }
@@ -240,7 +248,8 @@ export class IdpSessionRevalidator {
240
248
  if (droppedUnsafeRules) {
241
249
  await this.logRoleMapping(dbContext, apiKeyRow, row, 'idp_role_mapping_failed', undefined, requestInfo)
242
250
  }
243
- } catch {
251
+ } catch (e) {
252
+ this.logger.warn(e, { message: 'IdP session revalidation: claim mapping sync failed', idpSessionId: row.id })
244
253
  // Strictly fail-open: a malformed mapping or a transient apply error must never revoke or
245
254
  // fail an already-vouched-for session. The transaction rolls back any partial apply.
246
255
  await this.logRoleMapping(dbContext, apiKeyRow, row, 'idp_role_mapping_failed', undefined, requestInfo)
@@ -266,14 +275,14 @@ export class IdpSessionRevalidator {
266
275
  // (consistent with the project_membership_* audit events).
267
276
  targetPersonId: apiKeyRow.person_id ?? undefined,
268
277
  identityProviderId: row.identityProviderId,
269
- personTokenId: apiKeyRow.id,
270
278
  success: true,
271
279
  eventData,
272
280
  ipAddress: requestInfo?.ip,
273
281
  userAgent: requestInfo?.userAgent,
274
282
  }),
275
283
  )
276
- } catch {
284
+ } catch (e) {
285
+ this.logger.error(e, { message: `IdP session revalidation: writing the ${type} audit entry failed`, idpSessionId: row.id })
277
286
  // best-effort audit — never fail the request because the audit write failed
278
287
  }
279
288
  }
@@ -285,10 +294,10 @@ export class IdpSessionRevalidator {
285
294
  invokedById: apiKeyRow.identity_id,
286
295
  personId: apiKeyRow.person_id ?? undefined,
287
296
  identityProviderId: row.identityProviderId,
288
- personTokenId: apiKeyRow.id,
289
297
  success: true,
290
298
  ipAddress: requestInfo?.ip,
291
299
  userAgent: requestInfo?.userAgent,
300
+ eventData: { apiKeyId: apiKeyRow.id },
292
301
  }),
293
302
  )
294
303
  }
@@ -314,16 +323,17 @@ export class IdpSessionRevalidator {
314
323
  invokedById: apiKeyRow.identity_id,
315
324
  personId: apiKeyRow.person_id ?? undefined,
316
325
  identityProviderId: row.identityProviderId,
317
- personTokenId: apiKeyRow.id,
318
326
  // not a security failure (the session is kept) — a fail-open marker the operator
319
327
  // can alert on. `success: true` keeps it out of the failed-login funnels.
320
328
  success: true,
321
329
  errorCode,
322
330
  ipAddress: requestInfo?.ip,
323
331
  userAgent: requestInfo?.userAgent,
332
+ eventData: { apiKeyId: apiKeyRow.id },
324
333
  }),
325
334
  )
326
- } catch {
335
+ } catch (e) {
336
+ this.logger.error(e, { message: 'IdP session revalidation: writing the idp_session_revalidation_failed audit entry failed', idpSessionId: row.id })
327
337
  // best-effort observability — never fail the request because the audit write failed
328
338
  }
329
339
  }
@@ -336,23 +346,28 @@ export class IdpSessionRevalidator {
336
346
  requestInfo?: ApiKeyRequestInfo,
337
347
  ): Promise<void> {
338
348
  await dbContext.commandBus.execute(new DisableApiKeyCommand(apiKeyRow.id))
339
- await dbContext.commandBus.execute(
340
- new CreateAuthLogEntryCommand({
341
- type: 'idp_session_revoked',
342
- invokedById: apiKeyRow.identity_id,
343
- personId: apiKeyRow.person_id ?? undefined,
344
- identityProviderId: row.identityProviderId,
345
- personTokenId: apiKeyRow.id,
346
- success: false,
347
- errorCode: reason,
348
- ipAddress: requestInfo?.ip,
349
- userAgent: requestInfo?.userAgent,
350
- // the action payload belongs in `event_data` (where every other audit event puts
351
- // it and where the admin UI / AuthLogQueryResolver reads it); `metadata` is for
352
- // transport context (forwarder IP/UA).
353
- eventData: { reason },
354
- }),
355
- )
349
+ try {
350
+ await dbContext.commandBus.execute(
351
+ new CreateAuthLogEntryCommand({
352
+ type: 'idp_session_revoked',
353
+ invokedById: apiKeyRow.identity_id,
354
+ personId: apiKeyRow.person_id ?? undefined,
355
+ identityProviderId: row.identityProviderId,
356
+ success: false,
357
+ errorCode: reason,
358
+ ipAddress: requestInfo?.ip,
359
+ userAgent: requestInfo?.userAgent,
360
+ // the action payload belongs in `event_data` (where every other audit event puts
361
+ // it and where the admin UI / AuthLogQueryResolver reads it); `metadata` is for
362
+ // transport context (forwarder IP/UA).
363
+ eventData: { reason, apiKeyId: apiKeyRow.id },
364
+ }),
365
+ )
366
+ } catch (e) {
367
+ this.logger.error(e, { message: 'IdP session revalidation: writing the idp_session_revoked audit entry failed', idpSessionId: row.id })
368
+ // best-effort audit: the key is already disabled, so the request must still be answered
369
+ // as revoked rather than fail on the audit write
370
+ }
356
371
  }
357
372
 
358
373
  private resolveConfig(configuration: Record<string, unknown>): ResolvedRevalidationConfig {
@@ -2,7 +2,8 @@ import { executeTenantTest } from '../../../src/testTenant.js'
2
2
  import { testUuid } from '../../../src/testUuid.js'
3
3
  import { expect, test } from 'bun:test'
4
4
  import { signInIDP } from './gql/signInIdp.js'
5
- import { sqlTransaction } from './sql/sqlTransaction.js'
5
+ import { sqlNestedTransaction, sqlTransaction } from './sql/sqlTransaction.js'
6
+ import { SQL } from '../../../src/tags.js'
6
7
  import { getIdpBySlugSql } from './sql/getIdpBySlugSql.js'
7
8
  import { getPersonByIdpSql } from './sql/getPersonByIdpSql.js'
8
9
  import { createSessionKeySql } from './sql/createSessionKeySql.js'
@@ -100,6 +101,104 @@ test('signs in idp with existing identity', async () => {
100
101
  })
101
102
  })
102
103
 
104
+ // Re-validation on, tokens returned, no encryption key: the token-bearing session is not stored and the
105
+ // downgrade is audited. The entry must not carry the api_key id in person_token_id (FK to person_token,
106
+ // #964); it goes to event_data. The insert runs in its own savepoint so a failure cannot abort sign-in.
107
+ test('signs in idp without an encryption key and audits encryption_disabled without person_token_id', async () => {
108
+ const externalIdentifier = 'abcd'
109
+ const email = 'john@doe.com'
110
+ const identityId = testUuid(2)
111
+ const personId = testUuid(7)
112
+ const projectId = testUuid(10)
113
+ const apiKeyId = testUuid(1)
114
+ const idpId = testUuid(20)
115
+ await executeTenantTest({
116
+ query: signInIDP({
117
+ identityProvider: 'mock',
118
+ idpResponse: {
119
+ url: 'test',
120
+ },
121
+ redirectUrl: 'test',
122
+ sessionData: {},
123
+ }),
124
+ executes: [
125
+ ...sqlTransaction(
126
+ getIdpBySlugSql({
127
+ slug: 'mock',
128
+ response: {
129
+ id: idpId,
130
+ autoSignUp: false,
131
+ exclusive: false,
132
+ configuration: {
133
+ externalIdentifier,
134
+ idpSession: { tokens: { refresh_token: 'r' } },
135
+ },
136
+ disabledAt: null,
137
+ initReturnsConfig: false,
138
+ requireVerifiedEmail: false,
139
+ assumeEmailVerified: false,
140
+ disableLocalAuthentication: false,
141
+ slug: 'mock',
142
+ type: 'mock',
143
+ },
144
+ }),
145
+ getPersonByIdpSql({
146
+ externalIdentifier,
147
+ identityProviderId: idpId,
148
+ response: {
149
+ email,
150
+ password: '123',
151
+ identityId,
152
+ personId,
153
+ roles: [],
154
+ },
155
+ }),
156
+ getConfigSql(),
157
+ getIdentityByIdSql({ identityId }),
158
+ getAuthPoliciesSql(),
159
+ createSessionKeySql({
160
+ apiKeyId,
161
+ identityId,
162
+ }),
163
+ ...sqlNestedTransaction({
164
+ sql:
165
+ SQL`insert into "tenant"."person_auth_log" ("id", "invoked_by_id", "person_id", "type", "success", "error_code", "ip_address", "identity_provider_id", "metadata", "event_data")
166
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
167
+ parameters: [
168
+ () => true,
169
+ identityId,
170
+ personId,
171
+ 'idp_session_revalidation_failed',
172
+ true,
173
+ 'encryption_disabled',
174
+ '',
175
+ idpId,
176
+ () => true,
177
+ { apiKeyId },
178
+ ],
179
+ response: { rowCount: 1 },
180
+ }),
181
+ ),
182
+ getIdentityProjectsSql({ identityId: identityId, projectId: projectId }),
183
+ selectMembershipsSql({
184
+ identityId: identityId,
185
+ projectId,
186
+ membershipsResponse: [],
187
+ }),
188
+ ],
189
+ return: (response: any) => {
190
+ expect(response.data.signInIDP.ok).toBe(true)
191
+ expect(response.data.signInIDP.result.token).toBe('0000000000000000000000000000000000000000')
192
+ },
193
+ expectedAuthLog: {
194
+ type: 'idp_login',
195
+ response: expect.objectContaining({
196
+ ok: true,
197
+ }),
198
+ },
199
+ })
200
+ })
201
+
103
202
  test('does NOT link by e-mail when provider requires a verified e-mail and the claim is unverified', async () => {
104
203
  const externalIdentifier = 'abcd'
105
204
  const email = 'john@doe.com'
@@ -15,3 +15,19 @@ export const sqlTransaction = (...queries: ExpectedQuery[]): ExpectedQuery[] =>
15
15
  response: { rowCount: 1 },
16
16
  },
17
17
  ]
18
+
19
+ /**
20
+ * A nested `client.transaction()` inside an open transaction. Postgres runs it as a savepoint; the
21
+ * connection mock renders every transaction as BEGIN/COMMIT and sets no isolation level for it.
22
+ */
23
+ export const sqlNestedTransaction = (...queries: ExpectedQuery[]): ExpectedQuery[] => [
24
+ {
25
+ sql: SQL`BEGIN;`,
26
+ response: { rowCount: 1 },
27
+ },
28
+ ...queries,
29
+ {
30
+ sql: SQL`COMMIT;`,
31
+ response: { rowCount: 1 },
32
+ },
33
+ ]
@@ -99,7 +99,7 @@ test('throttles last_* within window for same IP+UA', async () => {
99
99
  })
100
100
  })
101
101
 
102
- test('bypasses throttle when IP changes', async () => {
102
+ test('throttles last_* within window when IP changes', async () => {
103
103
  const now = new Date('2026-05-12T12:00:00Z')
104
104
  const currentExpiration = new Date('2026-05-12T12:29:30Z')
105
105
  await exec({
@@ -111,6 +111,22 @@ test('bypasses throttle when IP changes', async () => {
111
111
  lastUserAgent: 'curl',
112
112
  lastUsedAt: new Date('2026-05-12T11:59:50Z'),
113
113
  },
114
+ expectedQueries: [],
115
+ })
116
+ })
117
+
118
+ test('writes changed IP once the window has passed', async () => {
119
+ const now = new Date('2026-05-12T12:00:00Z')
120
+ const currentExpiration = new Date('2026-05-12T12:29:30Z')
121
+ await exec({
122
+ now,
123
+ currentExpiration,
124
+ requestInfo: { ip: '198.51.100.7', userAgent: 'curl' },
125
+ tracking: {
126
+ lastIp: '203.0.113.5',
127
+ lastUserAgent: 'curl',
128
+ lastUsedAt: new Date('2026-05-12T11:58:59Z'),
129
+ },
114
130
  expectedQueries: [
115
131
  {
116
132
  sql: `update "tenant"."api_key" set "last_ip" = ?, "last_user_agent" = ?, "last_used_at" = ? where "id" = ?`,
@@ -157,7 +173,7 @@ test('A19: unaffected when max_expires_at is null (today behavior)', async () =>
157
173
  })
158
174
  })
159
175
 
160
- test('bypasses throttle when User-Agent changes', async () => {
176
+ test('throttles last_* within window when User-Agent changes', async () => {
161
177
  const now = new Date('2026-05-12T12:00:00Z')
162
178
  const currentExpiration = new Date('2026-05-12T12:29:30Z')
163
179
  await exec({
@@ -169,12 +185,6 @@ test('bypasses throttle when User-Agent changes', async () => {
169
185
  lastUserAgent: 'curl',
170
186
  lastUsedAt: new Date('2026-05-12T11:59:50Z'),
171
187
  },
172
- expectedQueries: [
173
- {
174
- sql: `update "tenant"."api_key" set "last_ip" = ?, "last_user_agent" = ?, "last_used_at" = ? where "id" = ?`,
175
- parameters: ['203.0.113.5', 'browser/2', now, 'api-key-id'],
176
- response: { rowCount: 1 },
177
- },
178
- ],
188
+ expectedQueries: [],
179
189
  })
180
190
  })
@@ -0,0 +1,50 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { createConnectionMock, type ExpectedQuery } from '@contember/database-tester'
3
+ import { DatabaseContext, type Providers } from '../../../../src/index.js'
4
+ import { ClaimIdpRevalidationCommand } from '../../../../src/model/commands/idp/ClaimIdpRevalidationCommand.js'
5
+
6
+ const providers: Providers = {
7
+ bcrypt: async value => value,
8
+ bcryptCompare: async () => true,
9
+ now: () => new Date('2026-05-26T12:00:00Z'),
10
+ randomBytes: async length => Buffer.alloc(length),
11
+ uuid: () => '00000000-0000-0000-0000-000000000000',
12
+ decrypt: async value => ({ value, needsReEncrypt: false }),
13
+ encrypt: async value => ({ value, version: 1 }),
14
+ encryptionEnabled: true,
15
+ hash: value => Buffer.from(value.toString()),
16
+ }
17
+
18
+ const execute = async (expectedQuery: ExpectedQuery): Promise<boolean> => {
19
+ const expectedQueries = [expectedQuery]
20
+ const connection = createConnectionMock(expectedQueries)
21
+ const db = new DatabaseContext(connection.createClient('tenant', { module: 'tenant' }), providers)
22
+
23
+ const claimed = await db.commandBus.execute(new ClaimIdpRevalidationCommand('idp-session-1', '30 seconds'))
24
+
25
+ expect(expectedQueries).toHaveLength(0)
26
+ return claimed
27
+ }
28
+
29
+ // The table must be schema-qualified: the tenant schema is not on the connection's search_path,
30
+ // so an unqualified name makes the claim fail on every request, and the caller treats that as
31
+ // "somebody else holds the claim" — re-validation then never runs at all.
32
+ test('the claim is issued against the qualified tenant table', async () => {
33
+ const claimed = await execute({
34
+ sql: 'update "tenant"."idp_session" set "last_validated_at" = now() where "id" = ? and "last_validated_at" <= now() - ?::interval',
35
+ parameters: ['idp-session-1', '30 seconds'],
36
+ response: { rowCount: 1 },
37
+ })
38
+
39
+ expect(claimed).toBe(true)
40
+ })
41
+
42
+ test('a session still inside the throttle window is not claimed', async () => {
43
+ const claimed = await execute({
44
+ sql: 'update "tenant"."idp_session" set "last_validated_at" = now() where "id" = ? and "last_validated_at" <= now() - ?::interval',
45
+ parameters: ['idp-session-1', '30 seconds'],
46
+ response: { rowCount: 0 },
47
+ })
48
+
49
+ expect(claimed).toBe(false)
50
+ })