@adonis-agora/authkit-server 0.58.3 → 0.59.0

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.
@@ -165,7 +165,11 @@ export function lucidAccountStore(Model, options = {}) {
165
165
  return encrypter.decrypt(stored);
166
166
  },
167
167
  toAccount: (row) => ({
168
- id: row.id,
168
+ // `String(...)`: o `id` do AuthAccount vira o claim `sub`, que a spec OIDC
169
+ // exige que seja string. Tabelas adotadas em brownfield quase sempre têm
170
+ // `id` INTEGER auto-increment, e sem a coerção o número vazava para o
171
+ // token (e para todo call-site tipado como string).
172
+ id: String(row.id),
169
173
  email: row.email,
170
174
  globalRoles: row.globalRoles ?? [],
171
175
  name: row.fullName ?? undefined,
@@ -251,6 +255,16 @@ export function lucidAccountStore(Model, options = {}) {
251
255
  */
252
256
  __mfaIssuer: mfaIssuer,
253
257
  __webauthn: options.webauthn,
258
+ /**
259
+ * O model Lucid por trás deste store — exposto para o `authkit:doctor`
260
+ * inspecionar `$columnsDefinitions` e avisar sobre colunas ausentes ANTES
261
+ * que o fluxo correspondente quebre em produção (ver
262
+ * `doctor/checks.ts#checkAccountStoreColumns`). Importa sobretudo em
263
+ * adoção brownfield, onde o model aponta para uma tabela `users` que já
264
+ * existia e não passou pelas migrations do authkit.
265
+ * NÃO faz parte do contrato AccountStore.
266
+ */
267
+ __model: Model,
254
268
  };
255
269
  // Histórico de senhas: capability-probed via tabela `auth_password_history`.
256
270
  // A versão síncrona não pode fazer o probe de DB, então a capability fica
@@ -287,7 +301,8 @@ export async function lucidAccountStoreAsync(Model, options = {}) {
287
301
  sealSecret: (s) => s,
288
302
  openSecret: (s) => s ?? null,
289
303
  toAccount: (row) => ({
290
- id: row.id,
304
+ // Ver a nota em `toAccount` acima: `sub` precisa ser string.
305
+ id: String(row.id),
291
306
  email: row.email,
292
307
  globalRoles: row.globalRoles ?? [],
293
308
  name: row.fullName ?? undefined,
@@ -21,6 +21,16 @@ export interface AuditEvent {
21
21
  clientId?: string | null;
22
22
  /** Impersonation: quem agiu (o admin). */
23
23
  actorId?: string | null;
24
+ /**
25
+ * Organização (tenant) a que o evento pertence, quando houver. Campo de
26
+ * PRIMEIRA CLASSE — e não uma chave de `metadata` — de propósito: `metadata`
27
+ * é livre e por isso é DROPADO na projeção que vai para o barramento de
28
+ * diagnostics (ver `redactAuditEventForDiagnostics`), enquanto os ids internos
29
+ * opacos (`accountId`/`actorId`/`clientId`/`orgId`) são preservados. É esse
30
+ * campo que permite a um consumidor do barramento — p.ex. o provisioning do
31
+ * `@adonis-agora/authz` — saber QUAL tenant provisionar sem receber PII.
32
+ */
33
+ orgId?: string | null;
24
34
  ip?: string | null;
25
35
  metadata?: Record<string, unknown>;
26
36
  }
@@ -57,6 +57,19 @@ export declare function checkClients(input: DoctorInput): Finding;
57
57
  export declare function checkAdapterVolatility(input: DoctorInput): Finding | null;
58
58
  /** accountStore presente + quais capacidades implementa. */
59
59
  export declare function checkAccountStore(input: DoctorInput): Finding[];
60
+ /**
61
+ * Valida que o model por trás do account store tem as colunas que o store
62
+ * escreve. Existe por causa da adoção brownfield: quando o host aponta o
63
+ * AuthKit para a tabela `users` que ele JÁ tinha, as colunas dos mixins
64
+ * (`password_reset_token`, `email_verification_token`, …) não existem, o app
65
+ * sobe normalmente, e a falha só aparece quando alguém clica em "esqueci minha
66
+ * senha" — em produção. Este check antecipa isso para o boot.
67
+ *
68
+ * Silencioso quando não há o que inspecionar: um store custom (não-Lucid) não
69
+ * expõe `__model`, e nesse caso o contrato é responsabilidade de quem o
70
+ * implementou.
71
+ */
72
+ export declare function checkAccountStoreColumns(input: DoctorInput): Finding[];
60
73
  /** session provider configurado + warn se cookie store com tokenSets grandes. */
61
74
  export declare function checkSession(input: DoctorInput): Finding[];
62
75
  /** Hint de exceções de CSRF do shield para o mountPath. */
@@ -134,6 +134,84 @@ export function checkAccountStore(input) {
134
134
  });
135
135
  return findings;
136
136
  }
137
+ /**
138
+ * Propriedades do model que o account store Lucid escreve/lê, agrupadas pelo
139
+ * FLUXO que deixa de funcionar quando a coluna não existe. Agrupar por fluxo (e
140
+ * não listar colunas soltas) é o que torna o achado acionável: "falta
141
+ * `password_reset_token`" não diz nada a quem não conhece o interior da lib;
142
+ * "o reset de senha vai quebrar" diz.
143
+ */
144
+ const REQUIRED_COLUMN_GROUPS = [
145
+ { flow: 'identity (every flow)', properties: ['email'] },
146
+ { flow: 'password login', properties: ['password'] },
147
+ { flow: 'role claims / admin', properties: ['globalRoles'] },
148
+ {
149
+ flow: 'password reset',
150
+ properties: ['passwordResetToken', 'passwordResetExpiresAt'],
151
+ },
152
+ {
153
+ flow: 'email verification',
154
+ properties: ['emailVerifiedAt', 'emailVerificationToken'],
155
+ },
156
+ ];
157
+ /**
158
+ * Propriedades OPCIONAIS: a capability correspondente é detectada pela presença
159
+ * da coluna, então ausência é configuração válida (feature desligada) e nunca
160
+ * um erro — só vale reportar para que a ausência seja uma escolha, e não uma
161
+ * surpresa.
162
+ */
163
+ const OPTIONAL_COLUMN_GROUPS = [
164
+ { capability: 'profile (name/avatar)', properties: ['fullName', 'avatarUrl'] },
165
+ { capability: 'disable/enable account', properties: ['disabledAt'] },
166
+ { capability: 'password expiration', properties: ['passwordChangedAt'] },
167
+ ];
168
+ /**
169
+ * Valida que o model por trás do account store tem as colunas que o store
170
+ * escreve. Existe por causa da adoção brownfield: quando o host aponta o
171
+ * AuthKit para a tabela `users` que ele JÁ tinha, as colunas dos mixins
172
+ * (`password_reset_token`, `email_verification_token`, …) não existem, o app
173
+ * sobe normalmente, e a falha só aparece quando alguém clica em "esqueci minha
174
+ * senha" — em produção. Este check antecipa isso para o boot.
175
+ *
176
+ * Silencioso quando não há o que inspecionar: um store custom (não-Lucid) não
177
+ * expõe `__model`, e nesse caso o contrato é responsabilidade de quem o
178
+ * implementou.
179
+ */
180
+ export function checkAccountStoreColumns(input) {
181
+ const store = input.authkitConfig?.accountStore;
182
+ const model = store?.__model;
183
+ const definitions = model?.$columnsDefinitions;
184
+ if (!definitions || typeof definitions.get !== 'function')
185
+ return [];
186
+ /** Nome da coluna real (o que o DBA procura), com fallback à propriedade. */
187
+ const columnOf = (property) => definitions.get(property)?.columnName ?? property;
188
+ const missing = (properties) => properties.filter((p) => !definitions.has(p));
189
+ const findings = [];
190
+ for (const { flow, properties } of REQUIRED_COLUMN_GROUPS) {
191
+ const absent = missing(properties);
192
+ if (absent.length === 0)
193
+ continue;
194
+ const named = absent.map((p) => `\`${p}\` (column \`${columnOf(p)}\`)`).join(', ');
195
+ findings.push({
196
+ level: 'error',
197
+ message: `accountStore model (${model.name ?? 'model'}) is missing ${named} — ${flow} will fail at runtime. Add the column(s) with a migration, or map an existing one with @column({ columnName: '…' }).`,
198
+ });
199
+ }
200
+ const off = OPTIONAL_COLUMN_GROUPS.filter((g) => missing(g.properties).length > 0);
201
+ if (off.length > 0) {
202
+ findings.push({
203
+ level: 'ok',
204
+ message: `Capabilities off (column absent): ${off.map((g) => g.capability).join(', ')}.`,
205
+ });
206
+ }
207
+ if (findings.every((f) => f.level === 'ok')) {
208
+ findings.unshift({
209
+ level: 'ok',
210
+ message: `accountStore model (${model.name ?? 'model'}) has every required column.`,
211
+ });
212
+ }
213
+ return findings;
214
+ }
137
215
  /** session provider configurado + warn se cookie store com tokenSets grandes. */
138
216
  export function checkSession(input) {
139
217
  if (!input.peers.session) {
@@ -845,6 +923,7 @@ export function runAllChecks(input) {
845
923
  if (volatility)
846
924
  findings.push(volatility);
847
925
  findings.push(...checkAccountStore(input));
926
+ findings.push(...checkAccountStoreColumns(input));
848
927
  findings.push(...checkSession(input));
849
928
  findings.push(checkShield(input));
850
929
  findings.push(checkAlly(input));
@@ -56,9 +56,13 @@ export declare function signWebhookBody(body: string, secret: string): string;
56
56
  * e-mail). Nenhum data provider do dashboard lê `metadata`, então dropá-lo é
57
57
  * seguro;
58
58
  * - MANTÉM `type` (a família do evento — o que os providers agregam) e os ids
59
- * internos opacos `accountId`/`actorId`/`clientId` (correlação de subject/actor
60
- * no dashboard; NÃO são PII direta e, sem `email`/`ip`/`metadata` e com a linha
61
- * da conta já deletada, não são reidentificáveis).
59
+ * internos opacos `accountId`/`actorId`/`clientId`/`orgId` (correlação de
60
+ * subject/actor/tenant no dashboard; NÃO são PII direta e, sem `email`/`ip`/
61
+ * `metadata` e com a linha da conta já deletada, não são reidentificáveis).
62
+ * O `orgId` está aqui porque é o que torna o barramento UTILIZÁVEL para
63
+ * provisioning multi-tenant (o `@adonis-agora/authz` escuta
64
+ * `agora:authkit:organization.*` e precisa saber qual tenant escopar) —
65
+ * sem ele o consumidor recebia o tipo do evento e mais nada.
62
66
  *
63
67
  * Assim o Telescope nunca armazena PII bruta e a deleção de conta não precisa de uma
64
68
  * etapa de purge cross-lib. Os ramos `onEvent`/`webhook` (integrações que o host
@@ -20,6 +20,7 @@ export function buildWebhookBody(event) {
20
20
  accountId: event.accountId ?? null,
21
21
  email: event.email ?? null,
22
22
  clientId: event.clientId ?? null,
23
+ orgId: event.orgId ?? null,
23
24
  ip: event.ip ?? null,
24
25
  metadata: event.metadata ?? {},
25
26
  ts: new Date().toISOString(),
@@ -50,9 +51,13 @@ export function signWebhookBody(body, secret) {
50
51
  * e-mail). Nenhum data provider do dashboard lê `metadata`, então dropá-lo é
51
52
  * seguro;
52
53
  * - MANTÉM `type` (a família do evento — o que os providers agregam) e os ids
53
- * internos opacos `accountId`/`actorId`/`clientId` (correlação de subject/actor
54
- * no dashboard; NÃO são PII direta e, sem `email`/`ip`/`metadata` e com a linha
55
- * da conta já deletada, não são reidentificáveis).
54
+ * internos opacos `accountId`/`actorId`/`clientId`/`orgId` (correlação de
55
+ * subject/actor/tenant no dashboard; NÃO são PII direta e, sem `email`/`ip`/
56
+ * `metadata` e com a linha da conta já deletada, não são reidentificáveis).
57
+ * O `orgId` está aqui porque é o que torna o barramento UTILIZÁVEL para
58
+ * provisioning multi-tenant (o `@adonis-agora/authz` escuta
59
+ * `agora:authkit:organization.*` e precisa saber qual tenant escopar) —
60
+ * sem ele o consumidor recebia o tipo do evento e mais nada.
56
61
  *
57
62
  * Assim o Telescope nunca armazena PII bruta e a deleção de conta não precisa de uma
58
63
  * etapa de purge cross-lib. Os ramos `onEvent`/`webhook` (integrações que o host
@@ -65,6 +70,7 @@ export function redactAuditEventForDiagnostics(event) {
65
70
  accountId: event.accountId ?? null,
66
71
  actorId: event.actorId ?? null,
67
72
  clientId: event.clientId ?? null,
73
+ orgId: event.orgId ?? null,
68
74
  };
69
75
  }
70
76
  /**
@@ -110,6 +110,7 @@ export class AdminOrgsService {
110
110
  accountId: input.ownerAccountId,
111
111
  actorId: actor.actorId,
112
112
  ip: actor.ip,
113
+ orgId: org.id,
113
114
  metadata: { slug: org.slug, ...(actor.source ? { actor: actor.source } : {}) },
114
115
  });
115
116
  return org;
@@ -137,6 +138,7 @@ export class AdminOrgsService {
137
138
  type: 'organization.updated',
138
139
  actorId: actor.actorId,
139
140
  ip: actor.ip,
141
+ orgId,
140
142
  metadata: { orgId, ...(actor.source ? { actor: actor.source } : {}) },
141
143
  });
142
144
  return updated;
@@ -158,6 +160,7 @@ export class AdminOrgsService {
158
160
  type: 'organization.deleted',
159
161
  actorId: actor.actorId,
160
162
  ip: actor.ip,
163
+ orgId,
161
164
  metadata: { orgId, slug: existing.slug, ...(actor.source ? { actor: actor.source } : {}) },
162
165
  });
163
166
  return { ok: true };
@@ -182,6 +185,7 @@ export class AdminOrgsService {
182
185
  type: 'organization.member_added',
183
186
  actorId: actor.actorId,
184
187
  ip: actor.ip,
188
+ orgId,
185
189
  metadata: {
186
190
  orgId,
187
191
  accountId: input.accountId,
@@ -209,6 +213,7 @@ export class AdminOrgsService {
209
213
  type: 'organization.member_removed',
210
214
  actorId: actor.actorId,
211
215
  ip: actor.ip,
216
+ orgId,
212
217
  metadata: {
213
218
  orgId,
214
219
  targetAccountId: accountId,
@@ -239,6 +244,7 @@ export class AdminOrgsService {
239
244
  type: 'organization.member_role_changed',
240
245
  actorId: actor.actorId,
241
246
  ip: actor.ip,
247
+ orgId,
242
248
  metadata: {
243
249
  orgId,
244
250
  targetAccountId: accountId,
@@ -301,6 +307,7 @@ export class AdminOrgsService {
301
307
  type: 'organization.invitation_sent',
302
308
  actorId: actor.actorId,
303
309
  ip: actor.ip,
310
+ orgId,
304
311
  metadata: {
305
312
  orgId,
306
313
  email: input.email,
@@ -327,6 +334,7 @@ export class AdminOrgsService {
327
334
  type: 'organization.invitation_revoked',
328
335
  actorId: actor.actorId,
329
336
  ip: actor.ip,
337
+ orgId,
330
338
  metadata: {
331
339
  orgId,
332
340
  invitationId,
@@ -72,8 +72,13 @@ export default class AccountOrgsController {
72
72
  if (!name || !slug)
73
73
  return response.redirect(accountPath('orgs'));
74
74
  try {
75
- await store.createOrg({ name, slug, ownerAccountId: accountId });
76
- await cfg.audit?.record({ type: 'organization.created', accountId, metadata: { slug } });
75
+ const org = await store.createOrg({ name, slug, ownerAccountId: accountId });
76
+ await cfg.audit?.record({
77
+ type: 'organization.created',
78
+ accountId,
79
+ orgId: org.id,
80
+ metadata: { slug },
81
+ });
77
82
  }
78
83
  catch {
79
84
  // slug duplicado ou outro erro — redireciona sem mensagem de erro específica
@@ -112,6 +117,7 @@ export default class AccountOrgsController {
112
117
  await cfg.audit?.record({
113
118
  type: 'organization.switched',
114
119
  accountId,
120
+ orgId,
115
121
  metadata: { orgId, orgSlug: org.slug },
116
122
  });
117
123
  return response.redirect(accountPath('orgs'));
@@ -140,6 +146,7 @@ export default class AccountOrgsController {
140
146
  await cfg.audit?.record({
141
147
  type: 'organization.member_removed',
142
148
  accountId,
149
+ orgId: params.id,
143
150
  metadata: { orgId: params.id, self: true },
144
151
  });
145
152
  }
@@ -217,6 +224,7 @@ export default class AccountOrgsController {
217
224
  await cfg.audit?.record({
218
225
  type: 'organization.invitation_sent',
219
226
  accountId,
227
+ orgId: params.id,
220
228
  metadata: { orgId: params.id, email, role },
221
229
  });
222
230
  return response.redirect(accountPath('orgs'));
@@ -270,6 +278,7 @@ export default class AccountOrgsController {
270
278
  await cfg.audit?.record({
271
279
  type: 'organization.invitation_accepted',
272
280
  accountId,
281
+ orgId: invitation.organizationId,
273
282
  metadata: { orgId: invitation.organizationId, invitationId: invitation.id },
274
283
  });
275
284
  }
@@ -294,6 +303,7 @@ export default class AccountOrgsController {
294
303
  await cfg.audit?.record({
295
304
  type: 'organization.member_removed',
296
305
  actorId,
306
+ orgId: params.id,
297
307
  metadata: { orgId: params.id, targetAccountId: params.accountId },
298
308
  });
299
309
  }
@@ -318,6 +328,7 @@ export default class AccountOrgsController {
318
328
  await cfg.audit?.record({
319
329
  type: 'organization.invitation_revoked',
320
330
  actorId,
331
+ orgId: params.id,
321
332
  metadata: { orgId: params.id, invitationId: params.invId },
322
333
  });
323
334
  return response.redirect(accountPath('orgs'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "0.58.3",
3
+ "version": "0.59.0",
4
4
  "description": "AdonisJS OIDC/OAuth2 provider (Identity Provider) toolkit: ejectable auth server with sessions, rate-limiting, MFA/TOTP, audit log, federated logout and OpenTelemetry metrics.",
5
5
  "license": "MIT",
6
6
  "author": "dudousxd",
@@ -108,7 +108,7 @@
108
108
  "oidc-provider": "9.11.3",
109
109
  "otplib": "12.0.1",
110
110
  "qrcode": "1.5.4",
111
- "@adonis-agora/authkit-core": "0.7.1"
111
+ "@adonis-agora/authkit-core": "0.8.0"
112
112
  },
113
113
  "devDependencies": {
114
114
  "@adonis-agora/durable": "0.22.0",
@@ -150,7 +150,7 @@
150
150
  "react-error-boundary": "6.1.2",
151
151
  "nuqs": "2.9.5",
152
152
  "recharts": "3.10.1",
153
- "@adonis-agora/authkit-react": "0.19.0"
153
+ "@adonis-agora/authkit-react": "0.19.1"
154
154
  },
155
155
  "scripts": {
156
156
  "build": "node scripts/build_host_css.mjs && node scripts/build_webauthn.mjs && node scripts/build_ui.mjs && node -e \"const fs=require('node:fs');for(const d of ['build/stubs','build/host/views'])fs.rmSync(d,{recursive:true,force:true})\" && tsc && node -e \"require('node:fs').cpSync('src/host/assets','build/src/host/assets',{recursive:true})\" && node -e \"require('node:fs').cpSync('stubs','build/stubs',{recursive:true,filter:(s)=>!s.endsWith('.ts')})\" && node -e \"const fs=require('node:fs');if(fs.existsSync('assets'))fs.cpSync('assets','build/assets',{recursive:true})\" && node -e \"require('node:fs').cpSync('src/host/views','build/host/views',{recursive:true})\" && node -e \"const fs=require('node:fs');fs.mkdirSync('build/host/ui',{recursive:true});fs.readdirSync('src/host/ui').filter(f=>f.endsWith('.html')).forEach(f=>fs.copyFileSync('src/host/ui/'+f,'build/host/ui/'+f))\" && node -e \"require('node:fs').copyFileSync('commands/commands.json','build/commands/commands.json')\" && node -e \"const fs=require('node:fs');fs.mkdirSync('build/src/password',{recursive:true});fs.copyFileSync('src/password/common_passwords.txt','build/src/password/common_passwords.txt')\"",