@contember/engine-tenant-api 2.2.0-alpha.8 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contember/engine-tenant-api",
3
- "version": "2.2.0-alpha.8",
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.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",
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.8",
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",
@@ -45,10 +45,11 @@ export class ApiKeyManager {
45
45
  return new ResponseOk(unpersistedResult)
46
46
  }
47
47
 
48
- const apiKeyRow = await readDbContext.queryHandler.fetch(new ApiKeyByTokenQuery(token))
49
- if (apiKeyRow === null) {
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, readDbContext, apiKeyRow, effectiveInfo)
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 {
@@ -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 readDbContext = createDbContext([
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, readDbContext, 'some-db-token')
134
+ const response = await manager.verifyAndProlong(dbContext, dbContext, 'some-db-token')
136
135
  expect(response.ok).toBe(false)
137
136
  })
@@ -0,0 +1,170 @@
1
+ import { expect, test } from 'bun:test'
2
+ import {
3
+ ApiKey,
4
+ ApiKeyManager,
5
+ ApiKeyService,
6
+ AuthLogService,
7
+ AuthPolicyResolver,
8
+ computeTokenHash,
9
+ DatabaseContext,
10
+ IDPClaimSyncService,
11
+ IDPHandlerRegistry,
12
+ IdpSessionRevalidator,
13
+ Providers,
14
+ VerifyErrorCode,
15
+ } from '../../../../src/index.js'
16
+ import { Connection } from '@contember/database'
17
+ import { createConnectionMock, ExpectedQuery } from '@contember/database-tester'
18
+ import { createLogger, TestLoggerHandler } from '@contember/logger'
19
+ import { emptySchema } from '@contember/schema-utils'
20
+
21
+ const TOKEN = '0000000000000000000000000000000000000000'
22
+ const NOW = new Date('2026-05-21T12:00:00Z')
23
+
24
+ const providers: Providers = {
25
+ bcrypt: () => Promise.resolve('x'),
26
+ bcryptCompare: () => Promise.resolve(true),
27
+ now: () => NOW,
28
+ randomBytes: () => Promise.resolve(Buffer.alloc(0)),
29
+ uuid: () => '00000000-0000-0000-0000-000000000000',
30
+ decrypt: () => {
31
+ throw new Error('not supported')
32
+ },
33
+ encrypt: () => {
34
+ throw new Error('not supported')
35
+ },
36
+ encryptionEnabled: false,
37
+ hash: () => Buffer.alloc(0),
38
+ }
39
+
40
+ const apiKeyRow = {
41
+ id: 'api-key-id',
42
+ type: ApiKey.Type.SESSION,
43
+ identity_id: 'identity-id',
44
+ disabled_at: null,
45
+ expires_at: new Date(NOW.getTime() + 30 * 60_000),
46
+ roles: ['editor'],
47
+ expiration: 30,
48
+ person_id: 'person-id',
49
+ last_ip: null,
50
+ last_user_agent: null,
51
+ last_used_at: null,
52
+ trust_forwarded_info: false,
53
+ issued_at: null,
54
+ idle_timeout: null,
55
+ max_expires_at: null,
56
+ }
57
+
58
+ const selectByToken = (rows: (typeof apiKeyRow)[]): ExpectedQuery => ({
59
+ sql:
60
+ `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" = ?`,
61
+ parameters: [computeTokenHash(TOKEN)],
62
+ response: { rows },
63
+ })
64
+
65
+ // The prolong UPDATE runs detached on the primary once a key is accepted.
66
+ const prolongUpdate: ExpectedQuery = {
67
+ sql: `update "tenant"."api_key" set "expires_at" = ? where "id" = ?`,
68
+ parameters: [(val: unknown) => val instanceof Date, 'api-key-id'],
69
+ response: { rowCount: 1 },
70
+ }
71
+
72
+ const selectIdpSession = (rows: Record<string, unknown>[]): ExpectedQuery => ({
73
+ sql:
74
+ `select "idp_session"."id", "idp_session"."identity_provider_id", "idp_session"."idp_session_id", "idp_session"."tokens", "idp_session"."tokens_version", "idp_session"."idp_expires_at", "idp_session"."token_obtained_at", "idp_session"."last_validated_at", "idp_session"."created_at", "identity_provider"."type" as "provider_type", "identity_provider"."configuration" as "provider_configuration", "identity_provider"."disabled_at" as "provider_disabled_at" from "tenant"."idp_session" inner join "tenant"."identity_provider" as "identity_provider" on "idp_session"."identity_provider_id" = "identity_provider"."id" where "api_key_id" = ?`,
75
+ parameters: ['api-key-id'],
76
+ response: { rows },
77
+ })
78
+
79
+ const disableUpdate: ExpectedQuery = {
80
+ sql: `update "tenant"."api_key" set "disabled_at" = ? where "id" = ?`,
81
+ parameters: [(val: unknown) => val instanceof Date, 'api-key-id'],
82
+ response: { rowCount: 1 },
83
+ }
84
+
85
+ const authLogInsert: ExpectedQuery = {
86
+ sql:
87
+ `insert into "tenant"."person_auth_log" ("id", "invoked_by_id", "person_id", "type", "success", "error_code", "identity_provider_id", "metadata", "event_data") values (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
88
+ response: { rowCount: 1 },
89
+ }
90
+
91
+ const createDbContext = (connection: Connection.ConnectionType) =>
92
+ new DatabaseContext(connection.createClient('tenant', { module: 'tenant' }), providers)
93
+
94
+ const verify = async (primary: Connection.ConnectionType, replica: Connection.ConnectionType, idpSessionRevalidator?: IdpSessionRevalidator) => {
95
+ const manager = new ApiKeyManager(new ApiKeyService(), new AuthPolicyResolver(), new AuthLogService(), undefined, idpSessionRevalidator)
96
+ const response = await manager.verifyAndProlong(createDbContext(primary), createDbContext(replica), TOKEN)
97
+ // Let the setImmediate-scheduled prolong run before the connection mocks are torn down.
98
+ await new Promise(resolve => setImmediate(resolve))
99
+ return response
100
+ }
101
+
102
+ test('a key not yet replicated is found on the primary', async () => {
103
+ const primary = createConnectionMock([selectByToken([apiKeyRow]), prolongUpdate])
104
+ const replica = createConnectionMock([selectByToken([])])
105
+
106
+ const response = await verify(primary, replica)
107
+ expect(response.ok).toBe(true)
108
+ if (response.ok) {
109
+ expect(response.result.apiKeyId).toBe('api-key-id')
110
+ }
111
+ })
112
+
113
+ test('a key found on the replica does not query the primary', async () => {
114
+ const primary = createConnectionMock([prolongUpdate])
115
+ const replica = createConnectionMock([selectByToken([apiKeyRow])])
116
+
117
+ const response = await verify(primary, replica)
118
+ expect(response.ok).toBe(true)
119
+ })
120
+
121
+ test('a key missing on both the replica and the primary is not found', async () => {
122
+ const primary = createConnectionMock([selectByToken([])])
123
+ const replica = createConnectionMock([selectByToken([])])
124
+
125
+ const response = await verify(primary, replica)
126
+ expect(response.ok).toBe(false)
127
+ if (!response.ok) {
128
+ expect(response.error).toBe(VerifyErrorCode.NOT_FOUND)
129
+ }
130
+ })
131
+
132
+ test('without a replica, a missing key is looked up only once', async () => {
133
+ const connection = createConnectionMock([selectByToken([])])
134
+
135
+ const response = await verify(connection, connection)
136
+ expect(response.ok).toBe(false)
137
+ })
138
+
139
+ test('a key found only on the primary reads its IdP session from the primary', async () => {
140
+ const disabledIdpSession = {
141
+ id: 'idp-session-id',
142
+ identity_provider_id: 'idp-id',
143
+ idp_session_id: null,
144
+ tokens: null,
145
+ tokens_version: null,
146
+ idp_expires_at: null,
147
+ token_obtained_at: NOW,
148
+ last_validated_at: NOW,
149
+ created_at: NOW,
150
+ provider_type: 'oidc',
151
+ provider_configuration: { revalidation: { enabled: true } },
152
+ provider_disabled_at: NOW,
153
+ }
154
+ const primary = createConnectionMock([selectByToken([apiKeyRow]), selectIdpSession([disabledIdpSession]), disableUpdate, authLogInsert])
155
+ const replica = createConnectionMock([selectByToken([]), selectIdpSession([])])
156
+ const logHandler = new TestLoggerHandler()
157
+ const idpSessionRevalidator = new IdpSessionRevalidator(
158
+ new IDPHandlerRegistry(),
159
+ new IDPClaimSyncService({ getSchema: () => Promise.resolve(emptySchema) }),
160
+ createLogger(logHandler),
161
+ )
162
+
163
+ const response = await verify(primary, replica, idpSessionRevalidator)
164
+ expect(response.ok).toBe(false)
165
+ if (!response.ok) {
166
+ expect(response.error).toBe(VerifyErrorCode.DISABLED)
167
+ }
168
+ // The revocation audit entry is best-effort and only logged on failure.
169
+ expect(logHandler.messages).toEqual([])
170
+ })