@contember/engine-tenant-api 2.2.0-alpha.7 → 2.2.0-alpha.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/development/src/TenantContainer.cjs +5 -2
- package/dist/development/src/TenantContainer.cjs.map +1 -1
- package/dist/development/src/TenantContainer.js +5 -2
- package/dist/development/src/TenantContainer.js.map +1 -1
- package/dist/development/src/model/commands/apiKey/ProlongApiKeyCommand.cjs +5 -8
- package/dist/development/src/model/commands/apiKey/ProlongApiKeyCommand.cjs.map +1 -1
- package/dist/development/src/model/commands/apiKey/ProlongApiKeyCommand.js +5 -8
- package/dist/development/src/model/commands/apiKey/ProlongApiKeyCommand.js.map +1 -1
- package/dist/development/src/model/commands/authLog/CreateAuthLogEntryCommand.cjs.map +1 -1
- package/dist/development/src/model/commands/authLog/CreateAuthLogEntryCommand.js.map +1 -1
- package/dist/development/src/model/service/IDPSignInManager.cjs +23 -15
- package/dist/development/src/model/service/IDPSignInManager.cjs.map +1 -1
- package/dist/development/src/model/service/IDPSignInManager.js +23 -15
- package/dist/development/src/model/service/IDPSignInManager.js.map +1 -1
- package/dist/development/src/model/service/apiKey/ApiKeyManager.cjs +16 -3
- package/dist/development/src/model/service/apiKey/ApiKeyManager.cjs.map +1 -1
- package/dist/development/src/model/service/apiKey/ApiKeyManager.js +16 -3
- package/dist/development/src/model/service/apiKey/ApiKeyManager.js.map +1 -1
- package/dist/development/src/model/service/idp/IdpSessionRevalidator.cjs +42 -31
- package/dist/development/src/model/service/idp/IdpSessionRevalidator.cjs.map +1 -1
- package/dist/development/src/model/service/idp/IdpSessionRevalidator.js +42 -31
- package/dist/development/src/model/service/idp/IdpSessionRevalidator.js.map +1 -1
- package/dist/production/src/TenantContainer.cjs +5 -2
- package/dist/production/src/TenantContainer.cjs.map +1 -1
- package/dist/production/src/TenantContainer.js +5 -2
- package/dist/production/src/TenantContainer.js.map +1 -1
- package/dist/production/src/model/commands/apiKey/ProlongApiKeyCommand.cjs +5 -8
- package/dist/production/src/model/commands/apiKey/ProlongApiKeyCommand.cjs.map +1 -1
- package/dist/production/src/model/commands/apiKey/ProlongApiKeyCommand.js +5 -8
- package/dist/production/src/model/commands/apiKey/ProlongApiKeyCommand.js.map +1 -1
- package/dist/production/src/model/commands/authLog/CreateAuthLogEntryCommand.cjs.map +1 -1
- package/dist/production/src/model/commands/authLog/CreateAuthLogEntryCommand.js.map +1 -1
- package/dist/production/src/model/service/IDPSignInManager.cjs +23 -15
- package/dist/production/src/model/service/IDPSignInManager.cjs.map +1 -1
- package/dist/production/src/model/service/IDPSignInManager.js +23 -15
- package/dist/production/src/model/service/IDPSignInManager.js.map +1 -1
- package/dist/production/src/model/service/apiKey/ApiKeyManager.cjs +16 -3
- package/dist/production/src/model/service/apiKey/ApiKeyManager.cjs.map +1 -1
- package/dist/production/src/model/service/apiKey/ApiKeyManager.js +16 -3
- package/dist/production/src/model/service/apiKey/ApiKeyManager.js.map +1 -1
- package/dist/production/src/model/service/idp/IdpSessionRevalidator.cjs +42 -31
- package/dist/production/src/model/service/idp/IdpSessionRevalidator.cjs.map +1 -1
- package/dist/production/src/model/service/idp/IdpSessionRevalidator.js +42 -31
- package/dist/production/src/model/service/idp/IdpSessionRevalidator.js.map +1 -1
- package/dist/tests/cases/integration/mocked/sql/sqlTransaction.d.ts +5 -0
- package/dist/tests/cases/integration/mocked/sql/sqlTransaction.d.ts.map +1 -1
- package/dist/tests/cases/unit/apiKey/verifyAndProlongReplica.test.d.ts +2 -0
- package/dist/tests/cases/unit/apiKey/verifyAndProlongReplica.test.d.ts.map +1 -0
- package/dist/tests/src/IdPMock.d.ts.map +1 -1
- package/dist/tests/src/testTenant.d.ts.map +1 -1
- package/dist/tests/tsconfig.tsbuildinfo +1 -1
- package/dist/types/TenantContainer.d.ts +2 -0
- package/dist/types/TenantContainer.d.ts.map +1 -1
- package/dist/types/model/commands/apiKey/ProlongApiKeyCommand.d.ts +5 -3
- package/dist/types/model/commands/apiKey/ProlongApiKeyCommand.d.ts.map +1 -1
- package/dist/types/model/commands/authLog/CreateAuthLogEntryCommand.d.ts +1 -0
- package/dist/types/model/commands/authLog/CreateAuthLogEntryCommand.d.ts.map +1 -1
- package/dist/types/model/service/IDPSignInManager.d.ts +3 -1
- package/dist/types/model/service/IDPSignInManager.d.ts.map +1 -1
- package/dist/types/model/service/apiKey/ApiKeyManager.d.ts +1 -0
- package/dist/types/model/service/apiKey/ApiKeyManager.d.ts.map +1 -1
- package/dist/types/model/service/idp/IdpSessionRevalidator.d.ts +3 -1
- package/dist/types/model/service/idp/IdpSessionRevalidator.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +12 -12
- package/src/TenantContainer.ts +7 -2
- package/src/model/commands/apiKey/ProlongApiKeyCommand.ts +10 -11
- package/src/model/commands/authLog/CreateAuthLogEntryCommand.ts +1 -0
- package/src/model/service/IDPSignInManager.ts +26 -14
- package/src/model/service/apiKey/ApiKeyManager.ts +24 -3
- package/src/model/service/idp/IdpSessionRevalidator.ts +43 -28
- package/tests/cases/integration/mocked/signInIdpMutation.test.ts +100 -1
- package/tests/cases/integration/mocked/sql/sqlTransaction.ts +16 -0
- package/tests/cases/unit/apiKey/prolongApiKeyCommand.test.ts +19 -9
- package/tests/cases/unit/apiKey/unpersistedApiKeyManager.test.ts +2 -3
- package/tests/cases/unit/apiKey/verifyAndProlongReplica.test.ts +170 -0
- package/tests/cases/unit/idp/idpSessionRevalidator.test.ts +37 -4
- package/tests/src/IdPMock.ts +2 -0
- 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.
|
|
3
|
+
"version": "2.2.0-alpha.9",
|
|
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.
|
|
30
|
-
"@contember/config-loader": "2.2.0-alpha.
|
|
31
|
-
"@contember/database": "2.2.0-alpha.
|
|
32
|
-
"@contember/database-migrations": "2.2.0-alpha.
|
|
33
|
-
"@contember/dic": "2.2.0-alpha.
|
|
34
|
-
"@contember/graphql-utils": "2.2.0-alpha.
|
|
35
|
-
"@contember/logger": "2.2.0-alpha.
|
|
36
|
-
"@contember/schema": "2.2.0-alpha.
|
|
37
|
-
"@contember/schema-utils": "2.2.0-alpha.
|
|
38
|
-
"@contember/typesafe": "2.2.0-alpha.
|
|
29
|
+
"@contember/authorization": "2.2.0-alpha.9",
|
|
30
|
+
"@contember/config-loader": "2.2.0-alpha.9",
|
|
31
|
+
"@contember/database": "2.2.0-alpha.9",
|
|
32
|
+
"@contember/database-migrations": "2.2.0-alpha.9",
|
|
33
|
+
"@contember/dic": "2.2.0-alpha.9",
|
|
34
|
+
"@contember/graphql-utils": "2.2.0-alpha.9",
|
|
35
|
+
"@contember/logger": "2.2.0-alpha.9",
|
|
36
|
+
"@contember/schema": "2.2.0-alpha.9",
|
|
37
|
+
"@contember/schema-utils": "2.2.0-alpha.9",
|
|
38
|
+
"@contember/typesafe": "2.2.0-alpha.9",
|
|
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.
|
|
49
|
+
"@contember/database-tester": "2.2.0-alpha.9",
|
|
50
50
|
"@graphql-tools/schema": "^10.0.31",
|
|
51
51
|
"@types/mustache": "^4.2.6",
|
|
52
52
|
"@types/node": "^24.12.0",
|
package/src/TenantContainer.ts
CHANGED
|
@@ -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(
|
|
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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
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
|
|
79
|
+
private shouldUpdateTracking(now: Date): boolean {
|
|
78
80
|
if (!this.requestInfo) {
|
|
79
81
|
return false
|
|
80
82
|
}
|
|
81
|
-
const
|
|
82
|
-
if (!
|
|
83
|
+
const lastUsedAt = this.tracking?.lastUsedAt
|
|
84
|
+
if (!lastUsedAt) {
|
|
83
85
|
return true
|
|
84
86
|
}
|
|
85
|
-
|
|
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
|
|
@@ -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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
|
@@ -45,10 +45,11 @@ export class ApiKeyManager {
|
|
|
45
45
|
return new ResponseOk(unpersistedResult)
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
const
|
|
49
|
-
if (
|
|
48
|
+
const fetchedApiKey = await this.fetchApiKeyByToken(dbContext, readDbContext, token)
|
|
49
|
+
if (fetchedApiKey === null) {
|
|
50
50
|
return new ResponseError(VerifyErrorCode.NOT_FOUND, 'API key was not found')
|
|
51
51
|
}
|
|
52
|
+
const apiKeyRow = fetchedApiKey.row
|
|
52
53
|
|
|
53
54
|
if (apiKeyRow.disabled_at !== null) {
|
|
54
55
|
return new ResponseError(
|
|
@@ -120,8 +121,9 @@ export class ApiKeyManager {
|
|
|
120
121
|
// response (revocation lands on the next request); in `blocking` mode it can revoke
|
|
121
122
|
// the current request. No-op for password sessions and IdPs without revalidation.
|
|
122
123
|
// `effectiveInfo` is passed so the audit entries record the originating request's IP/UA.
|
|
124
|
+
// The idp_session row is written together with the api_key, so it is read from where the key was found.
|
|
123
125
|
if (this.idpSessionRevalidator) {
|
|
124
|
-
const outcome = await this.idpSessionRevalidator.revalidate(dbContext,
|
|
126
|
+
const outcome = await this.idpSessionRevalidator.revalidate(dbContext, fetchedApiKey.sourceDbContext, apiKeyRow, effectiveInfo)
|
|
125
127
|
if (outcome === 'revoked') {
|
|
126
128
|
return new ResponseError(VerifyErrorCode.DISABLED, 'IdP session was revoked')
|
|
127
129
|
}
|
|
@@ -158,6 +160,20 @@ export class ApiKeyManager {
|
|
|
158
160
|
)
|
|
159
161
|
}
|
|
160
162
|
|
|
163
|
+
private async fetchApiKeyByToken(dbContext: DatabaseContext, readDbContext: DatabaseContext, token: string): Promise<FetchedApiKey | null> {
|
|
164
|
+
const query = new ApiKeyByTokenQuery(token)
|
|
165
|
+
const replicaRow = await readDbContext.queryHandler.fetch(query)
|
|
166
|
+
if (replicaRow !== null) {
|
|
167
|
+
return { row: replicaRow, sourceDbContext: readDbContext }
|
|
168
|
+
}
|
|
169
|
+
if (readDbContext.client.connection === dbContext.client.connection) {
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
// A token issued moments ago (sign-in, createSessionToken) may not have reached the read replica yet.
|
|
173
|
+
const primaryRow = await dbContext.queryHandler.fetch(query)
|
|
174
|
+
return primaryRow !== null ? { row: primaryRow, sourceDbContext: dbContext } : null
|
|
175
|
+
}
|
|
176
|
+
|
|
161
177
|
async createSessionApiKey(
|
|
162
178
|
dbContext: DatabaseContext,
|
|
163
179
|
identityId: string,
|
|
@@ -298,6 +314,11 @@ export class ApiKeyManager {
|
|
|
298
314
|
}
|
|
299
315
|
}
|
|
300
316
|
|
|
317
|
+
type FetchedApiKey = {
|
|
318
|
+
row: ApiKeyRow
|
|
319
|
+
sourceDbContext: DatabaseContext
|
|
320
|
+
}
|
|
321
|
+
|
|
301
322
|
export type VerifyResponse = Response<VerifyResult, VerifyErrorCode>
|
|
302
323
|
|
|
303
324
|
export class VerifyResult {
|
|
@@ -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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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('
|
|
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('
|
|
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
|
})
|
|
@@ -122,7 +122,7 @@ test('verifyAndProlong falls through to the database for a non-matching token',
|
|
|
122
122
|
const manager = new ApiKeyManager(new ApiKeyService(), new AuthPolicyResolver(), new AuthLogService(), unpersisted)
|
|
123
123
|
|
|
124
124
|
const tokenHash = computeTokenHash('some-db-token')
|
|
125
|
-
const
|
|
125
|
+
const dbContext = createDbContext([
|
|
126
126
|
{
|
|
127
127
|
sql:
|
|
128
128
|
'select "api_key"."id", "api_key"."type", "api_key"."identity_id", "api_key"."disabled_at", "api_key"."expires_at", "identity"."roles", "api_key"."expiration", "person"."id" as "person_id", "api_key"."last_ip", "api_key"."last_user_agent", "api_key"."last_used_at", "api_key"."trust_forwarded_info", "api_key"."issued_at", "api_key"."idle_timeout", "api_key"."max_expires_at" from "tenant"."api_key" inner join "tenant"."identity" as "identity" on "api_key"."identity_id" = "identity"."id" left join "tenant"."person" as "person" on "person"."identity_id" = "identity"."id" where "token_hash" = ?',
|
|
@@ -130,8 +130,7 @@ test('verifyAndProlong falls through to the database for a non-matching token',
|
|
|
130
130
|
response: { rows: [] },
|
|
131
131
|
},
|
|
132
132
|
])
|
|
133
|
-
const dbContext = createDbContext([])
|
|
134
133
|
|
|
135
|
-
const response = await manager.verifyAndProlong(dbContext,
|
|
134
|
+
const response = await manager.verifyAndProlong(dbContext, dbContext, 'some-db-token')
|
|
136
135
|
expect(response.ok).toBe(false)
|
|
137
136
|
})
|