@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/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/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/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/tsconfig.tsbuildinfo +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/tsconfig.tsbuildinfo +1 -1
- package/package.json +12 -12
- package/src/model/service/apiKey/ApiKeyManager.ts +24 -3
- package/tests/cases/unit/apiKey/unpersistedApiKeyManager.test.ts +2 -3
- package/tests/cases/unit/apiKey/verifyAndProlongReplica.test.ts +170 -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",
|
|
@@ -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 {
|
|
@@ -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
|
})
|
|
@@ -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
|
+
})
|