@adonis-agora/authkit-server 0.81.3 → 0.82.1
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/README.md +1 -1
- package/build/database/migrations/make_authkit_oidc_table.js +3 -0
- package/build/src/accounts/lucid_store/organizations.js +9 -1
- package/build/src/adapters/adapter_contract.d.ts +12 -0
- package/build/src/adapters/database_adapter.d.ts +10 -0
- package/build/src/adapters/database_adapter.js +174 -8
- package/build/src/adapters/redis_adapter.d.ts +1 -1
- package/build/src/adapters/redis_adapter.js +22 -11
- package/build/src/host/admin_sessions_service.js +46 -34
- package/build/src/host/persistent_rp_session.js +20 -1
- package/build/src/host/runtime_settings.d.ts +5 -3
- package/build/src/host/runtime_settings.js +34 -7
- package/build/src/host/ui-dist/assets/index-C1eSfG8B.js +67 -0
- package/build/src/host/ui-dist/index.html +1 -1
- package/build/src/pat/lucid_pat_store.js +9 -3
- package/build/src/pat/pat_store.d.ts +1 -0
- package/build/src/schema/ensure.d.ts +5 -0
- package/build/src/schema/ensure.js +130 -4
- package/package.json +15 -12
- package/skills/authkit-idp-setup/SKILL.md +6 -2
- package/build/src/host/ui-dist/assets/index-CIv26Mfg.js +0 -155
package/README.md
CHANGED
|
@@ -120,7 +120,7 @@ router.post('/auth/interaction/:uid/consent', [AuthInteractionController, 'conse
|
|
|
120
120
|
|
|
121
121
|
Escolha o backend no `config/authkit.ts`:
|
|
122
122
|
- `adapters.redis({ connection })` — requer `@adonisjs/redis` configurado.
|
|
123
|
-
- `adapters.database({ connection? })` — Lucid;
|
|
123
|
+
- `adapters.database({ connection? })` — Lucid; tabela `authkit_oidc_payloads`, criada pelo `ensureAuthkitSchema` no boot (com `schema: { autoManage: false }`, chame-o numa migração). A coluna indexada `account_id` mantém as buscas e revogações por conta em SQL; linhas antigas com `account_id` NULL continuam sendo encontradas. Postgres, MySQL e SQLite.
|
|
124
124
|
|
|
125
125
|
## Observabilidade
|
|
126
126
|
|
|
@@ -11,6 +11,9 @@ export default class extends BaseSchema {
|
|
|
11
11
|
table.string('uid', 255).nullable().index();
|
|
12
12
|
table.timestamp('expires_at', { useTz: true }).nullable();
|
|
13
13
|
table.primary(['model_name', 'id']);
|
|
14
|
+
table.string('account_id', 255).nullable();
|
|
15
|
+
table.index(['model_name', 'account_id'], 'authkit_oidc_payloads_account_idx');
|
|
16
|
+
table.index(['expires_at'], 'authkit_oidc_payloads_expires_idx');
|
|
14
17
|
});
|
|
15
18
|
}
|
|
16
19
|
async down() {
|
|
@@ -79,9 +79,17 @@ export function buildOrganizations(ctx) {
|
|
|
79
79
|
},
|
|
80
80
|
async listOrgsForAccount(accountId) {
|
|
81
81
|
const memberships = await MemberModel.query().where('account_id', accountId);
|
|
82
|
+
if (memberships.length === 0)
|
|
83
|
+
return [];
|
|
84
|
+
// One query for every org (by primary key, like `find`), instead of one per
|
|
85
|
+
// membership. The result keeps the memberships' order.
|
|
86
|
+
const ids = [...new Set(memberships.map((m) => m.organizationId))];
|
|
87
|
+
const orgs = new Map();
|
|
88
|
+
for (const org of await OrgModel.findMany(ids))
|
|
89
|
+
orgs.set(String(org.id), org);
|
|
82
90
|
const result = [];
|
|
83
91
|
for (const m of memberships) {
|
|
84
|
-
const org =
|
|
92
|
+
const org = orgs.get(String(m.organizationId));
|
|
85
93
|
if (org)
|
|
86
94
|
result.push({ ...toOrgSummary(org), role: m.role });
|
|
87
95
|
}
|
|
@@ -36,4 +36,16 @@ export interface OidcAdapter {
|
|
|
36
36
|
* (`new AdapterClass(model)`), então não recebe parâmetro.
|
|
37
37
|
*/
|
|
38
38
|
list?(): Promise<EnumeratedArtifact[]>;
|
|
39
|
+
/**
|
|
40
|
+
* Artefatos vivos deste model cujo `payload.accountId` é `accountId` — o mesmo
|
|
41
|
+
* resultado de `list()` filtrado pela conta, sem enumerar as outras contas.
|
|
42
|
+
* OPCIONAL: ausente, os consumidores caem em `list()` + filtro. Tem de ser completo
|
|
43
|
+
* (a revogação confia nele): um artefato que fique de fora continua valendo.
|
|
44
|
+
*/
|
|
45
|
+
listByAccount?(accountId: string): Promise<EnumeratedArtifact[]>;
|
|
46
|
+
/**
|
|
47
|
+
* Artefatos vivos deste model cujo `payload.grantId` está em `grantIds` — o
|
|
48
|
+
* mesmo resultado de `list()` filtrado pelos grants. OPCIONAL, como `listByAccount`.
|
|
49
|
+
*/
|
|
50
|
+
listByGrantIds?(grantIds: string[]): Promise<EnumeratedArtifact[]>;
|
|
39
51
|
}
|
|
@@ -19,4 +19,14 @@ export declare class DatabaseAdapter implements OidcAdapter {
|
|
|
19
19
|
* console admin para listar `Client` (CRUD) e `Session`/`Grant`/tokens.
|
|
20
20
|
*/
|
|
21
21
|
list(): Promise<EnumeratedArtifact[]>;
|
|
22
|
+
/**
|
|
23
|
+
* Artefatos vivos da conta, pelo índice `(model_name, account_id)`. As linhas com
|
|
24
|
+
* `account_id` NULL (gravadas antes da coluna existir, ou por uma versão anterior
|
|
25
|
+
* da lib durante um deploy) entram na leitura; o filtro final é sempre o
|
|
26
|
+
* `payload.accountId`, e as NULL que têm conta no payload são classificadas aqui,
|
|
27
|
+
* para não serem relidas na próxima busca.
|
|
28
|
+
*/
|
|
29
|
+
listByAccount(accountId: string): Promise<EnumeratedArtifact[]>;
|
|
30
|
+
/** Artefatos vivos que referenciam algum dos grants, pela coluna indexada `grant_id`. */
|
|
31
|
+
listByGrantIds(grantIds: string[]): Promise<EnumeratedArtifact[]>;
|
|
22
32
|
}
|
|
@@ -1,4 +1,31 @@
|
|
|
1
1
|
const TABLE = 'authkit_oidc_payloads';
|
|
2
|
+
/** Intervalo mínimo, por conexão, entre duas limpezas das linhas expiradas. */
|
|
3
|
+
const PRUNE_INTERVAL_MS = 10 * 60_000;
|
|
4
|
+
/** Quanto tempo a ausência da coluna `account_id` vale antes de re-checar. */
|
|
5
|
+
const ACCOUNT_COLUMN_RECHECK_MS = 60_000;
|
|
6
|
+
/** Tamanho máximo de uma lista `IN (...)`. */
|
|
7
|
+
const IN_CHUNK = 500;
|
|
8
|
+
const connectionStates = new WeakMap();
|
|
9
|
+
function connectionState(db) {
|
|
10
|
+
let state = connectionStates.get(db);
|
|
11
|
+
if (!state) {
|
|
12
|
+
state = { accountColumn: null, accountColumnRecheckAt: 0, nextPruneAt: 0 };
|
|
13
|
+
connectionStates.set(db, state);
|
|
14
|
+
}
|
|
15
|
+
return state;
|
|
16
|
+
}
|
|
17
|
+
/** Dialetos que gravam `expires_at` como texto ISO-8601 (comparado lexicograficamente). */
|
|
18
|
+
const TEXT_TIMESTAMP_DIALECTS = new Set([
|
|
19
|
+
'sqlite3',
|
|
20
|
+
'better-sqlite3',
|
|
21
|
+
'libsql',
|
|
22
|
+
]);
|
|
23
|
+
function chunks(items) {
|
|
24
|
+
const out = [];
|
|
25
|
+
for (let i = 0; i < items.length; i += IN_CHUNK)
|
|
26
|
+
out.push(items.slice(i, i + IN_CHUNK));
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
2
29
|
export class DatabaseAdapter {
|
|
3
30
|
name;
|
|
4
31
|
db;
|
|
@@ -9,10 +36,74 @@ export class DatabaseAdapter {
|
|
|
9
36
|
#query() {
|
|
10
37
|
return this.db.query().from(TABLE).where('model_name', this.name);
|
|
11
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* Valor de `expires_at` (gravação e comparação) para o dialeto da conexão. No
|
|
41
|
+
* SQLite a coluna guarda texto ISO-8601 (o driver gravaria um `Date` como número,
|
|
42
|
+
* que não se compara com o texto já gravado); nos demais vai um `Date`, que o
|
|
43
|
+
* driver serializa no formato do banco — o MySQL recusa o ISO com `Z`.
|
|
44
|
+
*/
|
|
45
|
+
#timestamp(ms) {
|
|
46
|
+
const state = connectionState(this.db);
|
|
47
|
+
if (state.dialect === undefined) {
|
|
48
|
+
// O factory passa o manager (`Database`) ou um `QueryClient` de conexão nomeada.
|
|
49
|
+
const db = this.db;
|
|
50
|
+
const client = typeof db.connection === 'function' ? db.connection() : db;
|
|
51
|
+
state.dialect = client?.dialect?.name ?? null;
|
|
52
|
+
}
|
|
53
|
+
const dialect = state.dialect;
|
|
54
|
+
return !dialect || TEXT_TIMESTAMP_DIALECTS.has(dialect)
|
|
55
|
+
? new Date(ms).toISOString()
|
|
56
|
+
: new Date(ms);
|
|
57
|
+
}
|
|
58
|
+
/** `#query()` restrito às linhas não expiradas. */
|
|
59
|
+
#liveQuery() {
|
|
60
|
+
const now = this.#timestamp(Date.now());
|
|
61
|
+
return this.#query().where((q) => q.whereNull('expires_at').orWhere('expires_at', '>', now));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* A coluna `account_id` existe? Probe por SELECT (searchPath-aware, como o
|
|
65
|
+
* `ensureAuthkitSchema`). "Presente" vale pela vida do processo; "ausente" é
|
|
66
|
+
* re-checado, para pegar o ALTER do boot que rodou depois do primeiro request.
|
|
67
|
+
*/
|
|
68
|
+
async #hasAccountColumn() {
|
|
69
|
+
const state = connectionState(this.db);
|
|
70
|
+
if (state.accountColumn === true)
|
|
71
|
+
return true;
|
|
72
|
+
if (state.accountColumn === false && state.accountColumnRecheckAt > Date.now())
|
|
73
|
+
return false;
|
|
74
|
+
try {
|
|
75
|
+
await this.db.query().from(TABLE).select('account_id').limit(1);
|
|
76
|
+
state.accountColumn = true;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
state.accountColumn = false;
|
|
80
|
+
state.accountColumnRecheckAt = Date.now() + ACCOUNT_COLUMN_RECHECK_MS;
|
|
81
|
+
}
|
|
82
|
+
return state.accountColumn;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Apaga as linhas expiradas de todos os models (no máximo uma vez por
|
|
86
|
+
* {@link PRUNE_INTERVAL_MS} por conexão). Linha expirada já é invisível para
|
|
87
|
+
* `find`/`list`; a limpeza só impede a tabela de crescer sem limite. Roda fora
|
|
88
|
+
* do caminho do request e nunca lança.
|
|
89
|
+
*/
|
|
90
|
+
#maybePrune() {
|
|
91
|
+
const state = connectionState(this.db);
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
if (state.nextPruneAt > now)
|
|
94
|
+
return;
|
|
95
|
+
state.nextPruneAt = now + PRUNE_INTERVAL_MS;
|
|
96
|
+
Promise.resolve()
|
|
97
|
+
.then(() => this.db
|
|
98
|
+
.query()
|
|
99
|
+
.from(TABLE)
|
|
100
|
+
.whereNotNull('expires_at')
|
|
101
|
+
.where('expires_at', '<=', this.#timestamp(now))
|
|
102
|
+
.delete())
|
|
103
|
+
.catch(() => { });
|
|
104
|
+
}
|
|
12
105
|
async upsert(id, payload, expiresIn) {
|
|
13
|
-
|
|
14
|
-
// independente do backend (SQLite/Postgres) e do formato nativo de timestamp.
|
|
15
|
-
const expiresAt = expiresIn ? new Date(Date.now() + expiresIn * 1000).toISOString() : null;
|
|
106
|
+
const expiresAt = expiresIn ? this.#timestamp(Date.now() + expiresIn * 1000) : null;
|
|
16
107
|
const row = {
|
|
17
108
|
id,
|
|
18
109
|
model_name: this.name,
|
|
@@ -22,13 +113,21 @@ export class DatabaseAdapter {
|
|
|
22
113
|
uid: payload.uid ?? null,
|
|
23
114
|
expires_at: expiresAt,
|
|
24
115
|
};
|
|
25
|
-
|
|
116
|
+
if (await this.#hasAccountColumn()) {
|
|
117
|
+
// NULL = "sem conta conhecida": as buscas por conta também leem essas linhas
|
|
118
|
+
// (e as classificam), então uma linha escrita sem a coluna nunca fica de fora.
|
|
119
|
+
const accountId = payload.accountId;
|
|
120
|
+
row.account_id = typeof accountId === 'string' && accountId ? accountId : null;
|
|
121
|
+
}
|
|
122
|
+
// Only whether the row exists matters: don't read the (possibly large) payload back.
|
|
123
|
+
const existing = await this.#query().where('id', id).select('id').first();
|
|
26
124
|
if (existing) {
|
|
27
125
|
await this.#query().where('id', id).update(row);
|
|
28
126
|
}
|
|
29
127
|
else {
|
|
30
128
|
await this.db.table(TABLE).insert(row);
|
|
31
129
|
}
|
|
130
|
+
this.#maybePrune();
|
|
32
131
|
}
|
|
33
132
|
async #parse(record) {
|
|
34
133
|
if (!record)
|
|
@@ -53,10 +152,10 @@ export class DatabaseAdapter {
|
|
|
53
152
|
const changed = await this.#query()
|
|
54
153
|
.where('id', id)
|
|
55
154
|
.where('payload', row.payload)
|
|
56
|
-
.where('expires_at', '>',
|
|
155
|
+
.where('expires_at', '>', this.#timestamp(now))
|
|
57
156
|
.update({
|
|
58
157
|
payload: JSON.stringify({ ...payload, exp: Math.floor(now / 1000) + expiresIn }),
|
|
59
|
-
expires_at:
|
|
158
|
+
expires_at: this.#timestamp(now + expiresIn * 1000),
|
|
60
159
|
});
|
|
61
160
|
if (Number(changed) > 0)
|
|
62
161
|
return true;
|
|
@@ -94,14 +193,81 @@ export class DatabaseAdapter {
|
|
|
94
193
|
* console admin para listar `Client` (CRUD) e `Session`/`Grant`/tokens.
|
|
95
194
|
*/
|
|
96
195
|
async list() {
|
|
97
|
-
|
|
196
|
+
return this.#toArtifacts(await this.#liveQuery().orderBy('id', 'asc'));
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Artefatos vivos da conta, pelo índice `(model_name, account_id)`. As linhas com
|
|
200
|
+
* `account_id` NULL (gravadas antes da coluna existir, ou por uma versão anterior
|
|
201
|
+
* da lib durante um deploy) entram na leitura; o filtro final é sempre o
|
|
202
|
+
* `payload.accountId`, e as NULL que têm conta no payload são classificadas aqui,
|
|
203
|
+
* para não serem relidas na próxima busca.
|
|
204
|
+
*/
|
|
205
|
+
async listByAccount(accountId) {
|
|
206
|
+
if (!(await this.#hasAccountColumn())) {
|
|
207
|
+
return (await this.list()).filter((r) => r.payload.accountId === accountId);
|
|
208
|
+
}
|
|
209
|
+
const rows = await this.#liveQuery()
|
|
210
|
+
.where((q) => q.where('account_id', accountId).orWhereNull('account_id'))
|
|
211
|
+
.orderBy('id', 'asc');
|
|
212
|
+
const unclassified = new Map();
|
|
213
|
+
const result = [];
|
|
214
|
+
for (const artifact of this.#parseRows(rows)) {
|
|
215
|
+
const owner = artifact.payload.accountId;
|
|
216
|
+
if (artifact.unclassified && typeof owner === 'string' && owner) {
|
|
217
|
+
const ids = unclassified.get(owner) ?? [];
|
|
218
|
+
ids.push(artifact.id);
|
|
219
|
+
unclassified.set(owner, ids);
|
|
220
|
+
}
|
|
221
|
+
if (owner === accountId)
|
|
222
|
+
result.push({ id: artifact.id, payload: artifact.payload });
|
|
223
|
+
}
|
|
224
|
+
await this.#classify(unclassified);
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
/** Artefatos vivos que referenciam algum dos grants, pela coluna indexada `grant_id`. */
|
|
228
|
+
async listByGrantIds(grantIds) {
|
|
229
|
+
const wanted = new Set(grantIds);
|
|
230
|
+
const result = [];
|
|
231
|
+
for (const chunk of chunks([...wanted])) {
|
|
232
|
+
const rows = await this.#liveQuery().whereIn('grant_id', chunk).orderBy('id', 'asc');
|
|
233
|
+
for (const artifact of this.#toArtifacts(rows)) {
|
|
234
|
+
if (wanted.has(artifact.payload.grantId))
|
|
235
|
+
result.push(artifact);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
async #classify(byAccount) {
|
|
241
|
+
for (const [accountId, ids] of byAccount) {
|
|
242
|
+
for (const chunk of chunks(ids)) {
|
|
243
|
+
try {
|
|
244
|
+
await this.#query()
|
|
245
|
+
.whereNull('account_id')
|
|
246
|
+
.whereIn('id', chunk)
|
|
247
|
+
.update({ account_id: accountId });
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// Classificar é só otimização: a linha segue NULL e é lida de novo.
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/** Linhas → artefatos, descartando as expiradas; `unclassified` = `account_id` NULL. */
|
|
256
|
+
#parseRows(rows) {
|
|
98
257
|
const now = Date.now();
|
|
99
258
|
const result = [];
|
|
100
259
|
for (const row of rows) {
|
|
101
260
|
if (row.expires_at && new Date(row.expires_at).getTime() <= now)
|
|
102
261
|
continue;
|
|
103
|
-
result.push({
|
|
262
|
+
result.push({
|
|
263
|
+
id: row.id,
|
|
264
|
+
payload: JSON.parse(row.payload),
|
|
265
|
+
unclassified: row.account_id == null,
|
|
266
|
+
});
|
|
104
267
|
}
|
|
105
268
|
return result;
|
|
106
269
|
}
|
|
270
|
+
#toArtifacts(rows) {
|
|
271
|
+
return this.#parseRows(rows).map(({ id, payload }) => ({ id, payload }));
|
|
272
|
+
}
|
|
107
273
|
}
|
|
@@ -19,7 +19,7 @@ export declare class RedisAdapter implements OidcAdapter {
|
|
|
19
19
|
* prefixo de chave do model (`<prefix>:<name>:*`). É limpo porque cada artefato
|
|
20
20
|
* é uma chave única já namespaceada por `prefix` + `name`; SCAN é não-bloqueante
|
|
21
21
|
* (cursor) ao contrário de KEYS. Usado pelo console admin (`Client`, `Session`,
|
|
22
|
-
* `Grant`, tokens).
|
|
22
|
+
* `Grant`, tokens). Cada página do SCAN é lida num único MGET.
|
|
23
23
|
*/
|
|
24
24
|
list(): Promise<EnumeratedArtifact[]>;
|
|
25
25
|
}
|
|
@@ -101,15 +101,23 @@ export class RedisAdapter {
|
|
|
101
101
|
return this.find(id);
|
|
102
102
|
}
|
|
103
103
|
async consume(id) {
|
|
104
|
-
const
|
|
105
|
-
|
|
104
|
+
const key = this.#key(id);
|
|
105
|
+
// GET + TTL in one round trip (a pipeline, read-only: nothing changes before the SET).
|
|
106
|
+
const results = await this.redis.pipeline().get(key).ttl(key).exec();
|
|
107
|
+
const [getError, data] = results?.[0] ?? [null, null];
|
|
108
|
+
if (getError)
|
|
109
|
+
throw getError;
|
|
110
|
+
if (!data)
|
|
106
111
|
return;
|
|
112
|
+
const [ttlError, ttl] = results?.[1] ?? [null, -1];
|
|
113
|
+
if (ttlError)
|
|
114
|
+
throw ttlError;
|
|
115
|
+
const found = JSON.parse(data);
|
|
107
116
|
found.consumed = Math.floor(Date.now() / 1000);
|
|
108
|
-
const ttl = await this.redis.ttl(this.#key(id));
|
|
109
117
|
if (ttl > 0)
|
|
110
|
-
await this.redis.set(
|
|
118
|
+
await this.redis.set(key, JSON.stringify(found), 'EX', ttl);
|
|
111
119
|
else
|
|
112
|
-
await this.redis.set(
|
|
120
|
+
await this.redis.set(key, JSON.stringify(found));
|
|
113
121
|
}
|
|
114
122
|
async destroy(id) {
|
|
115
123
|
await this.redis.del(this.#key(id));
|
|
@@ -128,24 +136,27 @@ export class RedisAdapter {
|
|
|
128
136
|
* prefixo de chave do model (`<prefix>:<name>:*`). É limpo porque cada artefato
|
|
129
137
|
* é uma chave única já namespaceada por `prefix` + `name`; SCAN é não-bloqueante
|
|
130
138
|
* (cursor) ao contrário de KEYS. Usado pelo console admin (`Client`, `Session`,
|
|
131
|
-
* `Grant`, tokens).
|
|
139
|
+
* `Grant`, tokens). Cada página do SCAN é lida num único MGET.
|
|
132
140
|
*/
|
|
133
141
|
async list() {
|
|
134
142
|
const prefix = `${this.prefix}:${this.name}:`;
|
|
135
143
|
const result = [];
|
|
136
144
|
let cursor = '0';
|
|
137
145
|
do {
|
|
138
|
-
const [next, keys] = await this.redis.scan(cursor, 'MATCH', `${prefix}*`, 'COUNT',
|
|
146
|
+
const [next, keys] = await this.redis.scan(cursor, 'MATCH', `${prefix}*`, 'COUNT', 1000);
|
|
139
147
|
cursor = next;
|
|
140
|
-
|
|
141
|
-
|
|
148
|
+
if (keys.length === 0)
|
|
149
|
+
continue;
|
|
150
|
+
const values = await this.redis.mget(...keys);
|
|
151
|
+
keys.forEach((key, i) => {
|
|
152
|
+
const data = values[i];
|
|
142
153
|
if (!data)
|
|
143
|
-
|
|
154
|
+
return;
|
|
144
155
|
result.push({
|
|
145
156
|
id: key.slice(prefix.length),
|
|
146
157
|
payload: JSON.parse(data),
|
|
147
158
|
});
|
|
148
|
-
}
|
|
159
|
+
});
|
|
149
160
|
} while (cursor !== '0');
|
|
150
161
|
result.sort((a, b) => a.id.localeCompare(b.id));
|
|
151
162
|
return result;
|
|
@@ -122,6 +122,32 @@ export class AdminSessionsService {
|
|
|
122
122
|
return [];
|
|
123
123
|
return adapter.list();
|
|
124
124
|
}
|
|
125
|
+
/** Artefatos do model da conta — pela busca por conta do adapter, quando existe. */
|
|
126
|
+
async #listModelOfAccount(model, accountId) {
|
|
127
|
+
const adapter = this.#adapter(model);
|
|
128
|
+
if (adapter.listByAccount)
|
|
129
|
+
return adapter.listByAccount(accountId);
|
|
130
|
+
if (!adapter.list)
|
|
131
|
+
return [];
|
|
132
|
+
return (await adapter.list()).filter((r) => r.payload.accountId === accountId);
|
|
133
|
+
}
|
|
134
|
+
/** Artefatos de um model token que referenciam algum dos grants. */
|
|
135
|
+
async #listTokensOfGrants(model, grantIds) {
|
|
136
|
+
if (grantIds.size === 0)
|
|
137
|
+
return [];
|
|
138
|
+
const adapter = this.#adapter(model);
|
|
139
|
+
if (adapter.listByGrantIds)
|
|
140
|
+
return adapter.listByGrantIds([...grantIds]);
|
|
141
|
+
if (!adapter.list)
|
|
142
|
+
return [];
|
|
143
|
+
return (await adapter.list()).filter((r) => grantIds.has(r.payload.grantId));
|
|
144
|
+
}
|
|
145
|
+
/** Grants da conta, exceto os espelhos `pgrant_` de Account Tokens do Poppy. */
|
|
146
|
+
async #accountGrants(accountId) {
|
|
147
|
+
// Os grants `pgrant_` são o espelho de um Account Token do Poppy (Bearer de MCP) — aparecem e
|
|
148
|
+
// são revogados como personal agent, não como app OIDC.
|
|
149
|
+
return (await this.#listModelOfAccount('Grant', accountId)).filter((r) => !r.id.startsWith('pgrant_'));
|
|
150
|
+
}
|
|
125
151
|
/**
|
|
126
152
|
* Conta TODAS as sessões ativas do IdP (todas as contas). 0 quando o adapter não
|
|
127
153
|
* enumera. Usado pelo dashboard de métricas.
|
|
@@ -132,10 +158,8 @@ export class AdminSessionsService {
|
|
|
132
158
|
}
|
|
133
159
|
/** Lista as sessões ativas da conta (vazio quando o adapter não enumera). */
|
|
134
160
|
async listSessions(accountId) {
|
|
135
|
-
const rows = await this.#
|
|
136
|
-
return rows
|
|
137
|
-
.filter((r) => r.payload.accountId === accountId)
|
|
138
|
-
.map((r) => ({
|
|
161
|
+
const rows = await this.#listModelOfAccount('Session', accountId);
|
|
162
|
+
return rows.map((r) => ({
|
|
139
163
|
id: r.id,
|
|
140
164
|
accountId,
|
|
141
165
|
loginTs: r.payload.loginTs,
|
|
@@ -183,14 +207,12 @@ export class AdminSessionsService {
|
|
|
183
207
|
* enumeração de cada model token), feitas só quando o adapter enumera.
|
|
184
208
|
*/
|
|
185
209
|
async listGrants(accountId) {
|
|
186
|
-
const
|
|
187
|
-
// Os grants `pgrant_` são o espelho de um Account Token do Poppy (Bearer de MCP) — aparecem e
|
|
188
|
-
// são revogados como personal agent, não como app OIDC.
|
|
189
|
-
const grants = rows.filter((r) => r.payload.accountId === accountId && !r.id.startsWith('pgrant_'));
|
|
210
|
+
const grants = await this.#accountGrants(accountId);
|
|
190
211
|
if (grants.length === 0)
|
|
191
212
|
return [];
|
|
192
|
-
const
|
|
193
|
-
const
|
|
213
|
+
const grantIds = new Set(grants.map((g) => g.id));
|
|
214
|
+
const atByGrant = await this.#countByGrant('AccessToken', grantIds);
|
|
215
|
+
const rtByGrant = await this.#countByGrant('RefreshToken', grantIds);
|
|
194
216
|
return grants.map((g) => ({
|
|
195
217
|
id: g.id,
|
|
196
218
|
accountId,
|
|
@@ -199,9 +221,9 @@ export class AdminSessionsService {
|
|
|
199
221
|
refreshTokens: rtByGrant.get(g.id) ?? 0,
|
|
200
222
|
}));
|
|
201
223
|
}
|
|
202
|
-
/** Conta artefatos de um model token agrupados por `grantId`. */
|
|
203
|
-
async #countByGrant(model) {
|
|
204
|
-
const rows = await this.#
|
|
224
|
+
/** Conta os artefatos de um model token dos grants, agrupados por `grantId`. */
|
|
225
|
+
async #countByGrant(model, grantIds) {
|
|
226
|
+
const rows = await this.#listTokensOfGrants(model, grantIds);
|
|
205
227
|
const map = new Map();
|
|
206
228
|
for (const r of rows) {
|
|
207
229
|
const gid = r.payload.grantId;
|
|
@@ -231,7 +253,7 @@ export class AdminSessionsService {
|
|
|
231
253
|
// Revogamos todos os grants da conta (que pertencem à conta, não à sessão
|
|
232
254
|
// específica). A sessão corrente gerará novos grants no próximo authorize.
|
|
233
255
|
// Isso garante que sessões antigas não mantenham tokens vivos.
|
|
234
|
-
const grants = await this
|
|
256
|
+
const grants = await this.#accountGrants(accountId);
|
|
235
257
|
const grantIds = new Set(grants.map((g) => g.id));
|
|
236
258
|
let accessTokens = 0;
|
|
237
259
|
let refreshTokens = 0;
|
|
@@ -279,7 +301,7 @@ export class AdminSessionsService {
|
|
|
279
301
|
for (const s of sessions) {
|
|
280
302
|
await sessionAdapter.destroy(s.id);
|
|
281
303
|
}
|
|
282
|
-
const grants = await this
|
|
304
|
+
const grants = await this.#accountGrants(accountId);
|
|
283
305
|
const grantIds = new Set(grants.map((g) => g.id));
|
|
284
306
|
let accessTokens = 0;
|
|
285
307
|
let refreshTokens = 0;
|
|
@@ -314,7 +336,7 @@ export class AdminSessionsService {
|
|
|
314
336
|
*/
|
|
315
337
|
async revokeClientGrants(accountId, clientId) {
|
|
316
338
|
const grantAdapter = this.#adapter('Grant');
|
|
317
|
-
const grants = (await this
|
|
339
|
+
const grants = (await this.#accountGrants(accountId)).filter((g) => g.payload.clientId === clientId);
|
|
318
340
|
const grantIds = new Set(grants.map((g) => g.id));
|
|
319
341
|
const accessTokens = await this.#destroyTokensOfGrants('AccessToken', grantIds);
|
|
320
342
|
const refreshTokens = await this.#destroyTokensOfGrants('RefreshToken', grantIds);
|
|
@@ -346,12 +368,10 @@ export class AdminSessionsService {
|
|
|
346
368
|
*/
|
|
347
369
|
async revokeOrgGrants(orgId, accountId) {
|
|
348
370
|
const grantAdapter = this.#adapter('Grant');
|
|
349
|
-
const rows =
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
return accountId === undefined || r.payload.accountId === accountId;
|
|
354
|
-
});
|
|
371
|
+
const rows = accountId === undefined
|
|
372
|
+
? await this.#listModel('Grant')
|
|
373
|
+
: await this.#listModelOfAccount('Grant', accountId);
|
|
374
|
+
const grants = rows.filter((r) => normalizeActiveOrg(r.payload.activeOrg)?.orgId === orgId);
|
|
355
375
|
if (grants.length === 0) {
|
|
356
376
|
return { sessions: 0, grants: 0, accessTokens: 0, refreshTokens: 0 };
|
|
357
377
|
}
|
|
@@ -375,17 +395,9 @@ export class AdminSessionsService {
|
|
|
375
395
|
/** Destrói (quando enumerável) os artefatos de um model token cujos grantId estão em `grantIds`. */
|
|
376
396
|
async #destroyTokensOfGrants(model, grantIds) {
|
|
377
397
|
const adapter = this.#adapter(model);
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
for (const r of rows) {
|
|
383
|
-
const gid = r.payload.grantId;
|
|
384
|
-
if (gid && grantIds.has(gid)) {
|
|
385
|
-
await adapter.destroy(r.id);
|
|
386
|
-
count++;
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
return count;
|
|
398
|
+
const rows = await this.#listTokensOfGrants(model, grantIds);
|
|
399
|
+
for (const r of rows)
|
|
400
|
+
await adapter.destroy(r.id);
|
|
401
|
+
return rows.length;
|
|
390
402
|
}
|
|
391
403
|
}
|
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
import { ACCOUNT_IDP_SESSION_KEY, readIdpSession } from './idp_session_bridge.js';
|
|
2
2
|
import { impersonationState } from './impersonation_session.js';
|
|
3
3
|
import { assertAccountEnabled } from './login_attempt.js';
|
|
4
|
+
/**
|
|
5
|
+
* How long (seconds) a renewal stays good enough: a linked RP whose IdP session was
|
|
6
|
+
* renewed less than this ago, with the current TTL, is not renewed again. Without
|
|
7
|
+
* it every authenticated request wrote the IdP session row and re-sent its cookie.
|
|
8
|
+
*/
|
|
9
|
+
const RENEW_RESOLUTION_SEC = 60;
|
|
10
|
+
/**
|
|
11
|
+
* The linked RP's IdP session already expires between `ttl - RENEW_RESOLUTION_SEC`
|
|
12
|
+
* and `ttl` from now, i.e. it was renewed with this TTL moments ago. Renewing again
|
|
13
|
+
* would only move the expiry by those few seconds. A session that expires LATER
|
|
14
|
+
* than `now + ttl` (the TTL was lowered) is still renewed, which shortens it.
|
|
15
|
+
* Restoration (no local login yet) always renews.
|
|
16
|
+
*/
|
|
17
|
+
function renewedRecently(idp, ttl, current, linkedUid) {
|
|
18
|
+
if (!current || linkedUid !== idp.uid || typeof idp.exp !== 'number')
|
|
19
|
+
return false;
|
|
20
|
+
const remaining = idp.exp - Date.now() / 1000;
|
|
21
|
+
return remaining <= ttl && remaining > ttl - RENEW_RESOLUTION_SEC;
|
|
22
|
+
}
|
|
4
23
|
/** Same-host RP restoration: the signed IdP cookie is the persistent credential. */
|
|
5
24
|
export async function ensureRpSession(ctx, sessionKey) {
|
|
6
25
|
const service = await ctx.containerResolver.make('authkit.server');
|
|
@@ -39,7 +58,7 @@ export async function ensureRpSession(ctx, sessionKey) {
|
|
|
39
58
|
}
|
|
40
59
|
const adapter = service.provider.Session.adapter;
|
|
41
60
|
const ttl = service.sessionTtlHolder.rememberSec;
|
|
42
|
-
if (adapter.renewSession) {
|
|
61
|
+
if (adapter.renewSession && !renewedRecently(idp, ttl, current, linkedUid)) {
|
|
43
62
|
if (!(await adapter.renewSession(idp.id, ttl))) {
|
|
44
63
|
if (linkedUid)
|
|
45
64
|
ctx.session.clear();
|
|
@@ -108,10 +108,9 @@ export declare class RuntimeSettings implements SettingsCapability {
|
|
|
108
108
|
private readonly db;
|
|
109
109
|
private readonly ttlMs;
|
|
110
110
|
private readonly connectionName;
|
|
111
|
-
private
|
|
112
|
-
/** null = não foi verificado ainda; false = tabela ausente; true = presente */
|
|
113
|
-
private tablePresent;
|
|
111
|
+
private readonly shared;
|
|
114
112
|
constructor(db: any, opts?: RuntimeSettingsOptions);
|
|
113
|
+
private get cache();
|
|
115
114
|
/**
|
|
116
115
|
* Retorna o objeto de conexão Lucid correto.
|
|
117
116
|
* Quando `connectionName` está definido, usa `db.connection(name)`;
|
|
@@ -128,6 +127,9 @@ export declare class RuntimeSettings implements SettingsCapability {
|
|
|
128
127
|
* detecta a tabela corretamente independente do schema.
|
|
129
128
|
*
|
|
130
129
|
* Fail-safe: qualquer erro → false (tabela considerada ausente, sem lançar).
|
|
130
|
+
* "Presente" vale pela vida do processo; "ausente" é re-verificado após o TTL,
|
|
131
|
+
* para que uma migração rodada com o app no ar (ou um erro transitório) não
|
|
132
|
+
* desligue as settings até o restart.
|
|
131
133
|
*/
|
|
132
134
|
private hasTable;
|
|
133
135
|
getSetting(key: string, orgId?: string | null): Promise<unknown | null>;
|
|
@@ -57,6 +57,24 @@ import { isSettingLocked, SettingLockedError } from './config_locks.js';
|
|
|
57
57
|
export function supportsSettings(obj) {
|
|
58
58
|
return !!obj && typeof obj.getSetting === 'function';
|
|
59
59
|
}
|
|
60
|
+
const sharedStates = new WeakMap();
|
|
61
|
+
function sharedStateFor(db, connection) {
|
|
62
|
+
const fresh = () => ({ cache: new Map(), tablePresent: null, tableAbsentUntil: 0 });
|
|
63
|
+
if (!db || (typeof db !== 'object' && typeof db !== 'function'))
|
|
64
|
+
return fresh();
|
|
65
|
+
let byConnection = sharedStates.get(db);
|
|
66
|
+
if (!byConnection) {
|
|
67
|
+
byConnection = new Map();
|
|
68
|
+
sharedStates.set(db, byConnection);
|
|
69
|
+
}
|
|
70
|
+
const name = connection ?? '';
|
|
71
|
+
let state = byConnection.get(name);
|
|
72
|
+
if (!state) {
|
|
73
|
+
state = fresh();
|
|
74
|
+
byConnection.set(name, state);
|
|
75
|
+
}
|
|
76
|
+
return state;
|
|
77
|
+
}
|
|
60
78
|
/** Chave de cache: combina key + escopo de org (null = global). */
|
|
61
79
|
function cacheKey(key, orgId) {
|
|
62
80
|
return orgId ? `${key}\x00${orgId}` : key;
|
|
@@ -70,13 +88,15 @@ export class RuntimeSettings {
|
|
|
70
88
|
db;
|
|
71
89
|
ttlMs;
|
|
72
90
|
connectionName;
|
|
73
|
-
|
|
74
|
-
/** null = não foi verificado ainda; false = tabela ausente; true = presente */
|
|
75
|
-
tablePresent = null;
|
|
91
|
+
shared;
|
|
76
92
|
constructor(db, opts = {}) {
|
|
77
93
|
this.db = db;
|
|
78
94
|
this.ttlMs = opts.ttlMs ?? 15_000;
|
|
79
95
|
this.connectionName = opts.connection;
|
|
96
|
+
this.shared = sharedStateFor(db, opts.connection);
|
|
97
|
+
}
|
|
98
|
+
get cache() {
|
|
99
|
+
return this.shared.cache;
|
|
80
100
|
}
|
|
81
101
|
/**
|
|
82
102
|
* Retorna o objeto de conexão Lucid correto.
|
|
@@ -96,17 +116,24 @@ export class RuntimeSettings {
|
|
|
96
116
|
* detecta a tabela corretamente independente do schema.
|
|
97
117
|
*
|
|
98
118
|
* Fail-safe: qualquer erro → false (tabela considerada ausente, sem lançar).
|
|
119
|
+
* "Presente" vale pela vida do processo; "ausente" é re-verificado após o TTL,
|
|
120
|
+
* para que uma migração rodada com o app no ar (ou um erro transitório) não
|
|
121
|
+
* desligue as settings até o restart.
|
|
99
122
|
*/
|
|
100
123
|
async hasTable() {
|
|
101
|
-
|
|
102
|
-
|
|
124
|
+
const shared = this.shared;
|
|
125
|
+
if (shared.tablePresent === true)
|
|
126
|
+
return true;
|
|
127
|
+
if (shared.tablePresent === false && shared.tableAbsentUntil > Date.now())
|
|
128
|
+
return false;
|
|
103
129
|
try {
|
|
104
130
|
await this.conn().from('auth_settings').select('key').limit(1);
|
|
105
|
-
|
|
131
|
+
shared.tablePresent = true;
|
|
106
132
|
return true;
|
|
107
133
|
}
|
|
108
134
|
catch {
|
|
109
|
-
|
|
135
|
+
shared.tablePresent = false;
|
|
136
|
+
shared.tableAbsentUntil = Date.now() + this.ttlMs;
|
|
110
137
|
return false;
|
|
111
138
|
}
|
|
112
139
|
}
|