@dudousxd/adonis-authkit-server 0.1.1 → 0.3.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.
Files changed (50) hide show
  1. package/build/host/views/admin/client_form.edge +83 -0
  2. package/build/host/views/admin/clients.edge +68 -3
  3. package/build/index.d.ts +3 -2
  4. package/build/index.js +2 -1
  5. package/build/src/accounts/account_store.d.ts +74 -17
  6. package/build/src/accounts/account_store.js +12 -1
  7. package/build/src/accounts/lucid_account_store.d.ts +12 -27
  8. package/build/src/accounts/lucid_account_store.js +38 -365
  9. package/build/src/accounts/lucid_store/core.d.ts +8 -0
  10. package/build/src/accounts/lucid_store/core.js +108 -0
  11. package/build/src/accounts/lucid_store/mfa.d.ts +8 -0
  12. package/build/src/accounts/lucid_store/mfa.js +77 -0
  13. package/build/src/accounts/lucid_store/provider_identity.d.ts +8 -0
  14. package/build/src/accounts/lucid_store/provider_identity.js +41 -0
  15. package/build/src/accounts/lucid_store/shared.d.ts +48 -0
  16. package/build/src/accounts/lucid_store/shared.js +15 -0
  17. package/build/src/accounts/lucid_store/webauthn.d.ts +8 -0
  18. package/build/src/accounts/lucid_store/webauthn.js +135 -0
  19. package/build/src/adapters/adapter_contract.d.ts +12 -0
  20. package/build/src/adapters/database_adapter.d.ts +8 -1
  21. package/build/src/adapters/database_adapter.js +17 -0
  22. package/build/src/adapters/redis_adapter.d.ts +8 -1
  23. package/build/src/adapters/redis_adapter.js +26 -0
  24. package/build/src/audit/audit_sink.d.ts +1 -1
  25. package/build/src/define_config.d.ts +6 -0
  26. package/build/src/define_config.js +20 -5
  27. package/build/src/host/admin_clients_service.d.ts +65 -0
  28. package/build/src/host/admin_clients_service.js +136 -0
  29. package/build/src/host/controllers/account_mfa_controller.js +2 -1
  30. package/build/src/host/controllers/account_session_controller.js +10 -18
  31. package/build/src/host/controllers/admin/admin_clients_controller.d.ts +17 -3
  32. package/build/src/host/controllers/admin/admin_clients_controller.js +158 -4
  33. package/build/src/host/controllers/interaction_controller.js +13 -32
  34. package/build/src/host/controllers/social_controller.js +7 -0
  35. package/build/src/host/i18n.d.ts +27 -0
  36. package/build/src/host/i18n.js +28 -1
  37. package/build/src/host/login_attempt.d.ts +39 -0
  38. package/build/src/host/login_attempt.js +37 -0
  39. package/build/src/host/register_auth_host.d.ts +13 -0
  40. package/build/src/host/register_auth_host.js +17 -2
  41. package/build/src/mixins/json_column.d.ts +38 -0
  42. package/build/src/mixins/json_column.js +31 -0
  43. package/build/src/mixins/with_audit_log.js +2 -4
  44. package/build/src/mixins/with_auth_user.js +2 -4
  45. package/build/src/mixins/with_mfa.js +2 -6
  46. package/build/src/mixins/with_personal_access_token.js +2 -4
  47. package/build/src/mixins/with_webauthn_credential.js +6 -8
  48. package/build/src/provider/oidc_service.d.ts +15 -0
  49. package/build/src/provider/oidc_service.js +27 -0
  50. package/package.json +1 -1
@@ -1,26 +1,18 @@
1
- import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
- import { DateTime } from 'luxon';
3
- import { authenticator } from 'otplib';
4
1
  import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
5
- const sha256 = (value) => createHash('sha256').update(value).digest('hex');
6
- /** Recovery code legível: 10 chars hex em duas metades (ex.: a1b2c-3d4e5). */
7
- function generateRecoveryCode() {
8
- const raw = randomBytes(5).toString('hex');
9
- return `${raw.slice(0, 5)}-${raw.slice(5, 10)}`;
10
- }
11
- /** Comparação de hashes hex resistente a timing. */
12
- function hashesEqual(a, b) {
13
- const ba = Buffer.from(a);
14
- const bb = Buffer.from(b);
15
- if (ba.length !== bb.length)
16
- return false;
17
- return timingSafeEqual(ba, bb);
18
- }
2
+ import { buildCore } from './lucid_store/core.js';
3
+ import { buildMfa } from './lucid_store/mfa.js';
4
+ import { buildProviderIdentity } from './lucid_store/provider_identity.js';
5
+ import { buildWebauthn } from './lucid_store/webauthn.js';
19
6
  /**
20
7
  * Implementação default do {@link AccountStore} sobre um model Lucid composto
21
8
  * de `withAuthUser()` + `withCredentials()` (+ opcionalmente `withMfa()`). O
22
9
  * model carrega `connection`/`table` (app-específico) e, por convenção, uma
23
10
  * coluna `fullName` (mapeada de `name`).
11
+ *
12
+ * Composição por CAPACIDADE: o núcleo + MFA são sempre montados; passkeys
13
+ * (WebAuthn) e account linking por provider só são montados quando o model
14
+ * correspondente é fornecido — caso contrário a capacidade fica ABSENTE (os
15
+ * métodos não existem no objeto retornado, em vez de presentes-mas-lançando).
24
16
  */
25
17
  export function lucidAccountStore(Model, options = {}) {
26
18
  const mfaIssuer = options.mfaIssuer ?? 'AuthKit';
@@ -42,355 +34,36 @@ export function lucidAccountStore(Model, options = {}) {
42
34
  verifyAuthenticationResponse,
43
35
  ...options.webauthnCeremonies,
44
36
  };
45
- // Garante que o store foi configurado com o model das identidades antes de
46
- // tocar nos métodos de account linking (contrato: lança com mensagem clara).
47
- const requireProviderIdentityModel = () => {
48
- if (!ProviderIdentityModel) {
49
- throw new Error('lucidAccountStore: account linking por provider identity requer a opção ' +
50
- '`providerIdentityModel` (um model Lucid composto de `withProviderIdentity()`).');
51
- }
52
- return ProviderIdentityModel;
53
- };
54
- const requireWebauthnModel = () => {
55
- if (!WebauthnCredentialModel) {
56
- throw new Error('lucidAccountStore: passkeys (WebAuthn) requerem a opção ' +
57
- '`webauthnCredentialModel` (um model Lucid composto de `withWebauthnCredential()`).');
58
- }
59
- return WebauthnCredentialModel;
60
- };
61
- // Encripta o segredo antes de persistir (no-op sem encrypter).
62
- const sealSecret = (secret) => (encrypter ? encrypter.encrypt(secret) : secret);
63
- // Decripta o segredo armazenado; retorna null em falha/adulteração (no-op sem encrypter).
64
- const openSecret = (stored) => {
65
- if (!stored)
66
- return null;
67
- if (!encrypter)
68
- return stored;
69
- return encrypter.decrypt(stored);
37
+ const ctx = {
38
+ Model,
39
+ mfaIssuer,
40
+ recoveryCodeCount,
41
+ // Encripta o segredo antes de persistir (no-op sem encrypter).
42
+ sealSecret: (secret) => (encrypter ? encrypter.encrypt(secret) : secret),
43
+ // Decripta o segredo armazenado; retorna null em falha/adulteração (no-op sem encrypter).
44
+ openSecret: (stored) => {
45
+ if (!stored)
46
+ return null;
47
+ if (!encrypter)
48
+ return stored;
49
+ return encrypter.decrypt(stored);
50
+ },
51
+ toAccount: (row) => ({
52
+ id: row.id,
53
+ email: row.email,
54
+ globalRoles: row.globalRoles ?? [],
55
+ name: row.fullName ?? undefined,
56
+ }),
70
57
  };
71
- const toAccount = (row) => ({
72
- id: row.id,
73
- email: row.email,
74
- globalRoles: row.globalRoles ?? [],
75
- name: row.fullName ?? undefined,
76
- });
58
+ // Núcleo + MFA são sempre presentes. Passkeys e provider-identity só entram
59
+ // quando o model correspondente foi fornecido (capacidade genuinamente ausente
60
+ // quando não configurada).
77
61
  return {
78
- async findById(id) {
79
- const row = await Model.find(id);
80
- return row ? toAccount(row) : null;
81
- },
82
- async findByEmail(email) {
83
- const row = await Model.query().where('email', email).first();
84
- return row ? toAccount(row) : null;
85
- },
86
- async verifyCredentials(email, password) {
87
- const row = await Model.query().where('email', email).first();
88
- if (!row || !(await row.verifyPassword(password)))
89
- return null;
90
- return toAccount(row);
91
- },
92
- async create(input) {
93
- const row = await Model.create({
94
- email: input.email,
95
- password: input.password,
96
- fullName: input.fullName ?? null,
97
- globalRoles: input.globalRoles ?? [],
98
- emailVerifiedAt: input.emailVerified ? DateTime.now() : null,
99
- });
100
- return toAccount(row);
101
- },
102
- async findByProviderIdentity(provider, providerUserId) {
103
- const Identity = requireProviderIdentityModel();
104
- const identity = await Identity.query()
105
- .where('provider', provider)
106
- .where('providerUserId', providerUserId)
107
- .first();
108
- if (!identity)
109
- return null;
110
- const row = await Model.find(identity.accountId);
111
- return row ? toAccount(row) : null;
112
- },
113
- async linkProviderIdentity(data) {
114
- const Identity = requireProviderIdentityModel();
115
- // Upsert idempotente na chave única (provider, providerUserId): atualiza
116
- // account/email se já existir, cria caso contrário.
117
- const existing = await Identity.query()
118
- .where('provider', data.provider)
119
- .where('providerUserId', data.providerUserId)
120
- .first();
121
- if (existing) {
122
- existing.accountId = data.accountId;
123
- if (data.email !== undefined)
124
- existing.email = data.email;
125
- await existing.save();
126
- return;
127
- }
128
- await Identity.create({
129
- provider: data.provider,
130
- providerUserId: data.providerUserId,
131
- accountId: data.accountId,
132
- email: data.email ?? null,
133
- });
134
- },
135
- async issuePasswordResetToken(email) {
136
- const row = await Model.query().where('email', email).first();
137
- if (!row)
138
- return null;
139
- const token = randomBytes(32).toString('hex');
140
- row.passwordResetToken = token;
141
- row.passwordResetExpiresAt = DateTime.now().plus({ hours: 1 });
142
- await row.save();
143
- return { token, account: toAccount(row) };
144
- },
145
- async consumePasswordResetToken(token, newPassword) {
146
- const row = await Model.query().where('passwordResetToken', token).first();
147
- if (!row)
148
- return false;
149
- if (!row.passwordResetExpiresAt || row.passwordResetExpiresAt < DateTime.now())
150
- return false;
151
- row.password = newPassword;
152
- row.passwordResetToken = null;
153
- row.passwordResetExpiresAt = null;
154
- await row.save();
155
- return true;
156
- },
157
- async issueEmailVerificationToken(email) {
158
- const row = await Model.query().where('email', email).first();
159
- if (!row)
160
- return null;
161
- const token = randomBytes(32).toString('hex');
162
- row.emailVerificationToken = token;
163
- await row.save();
164
- return { token, account: toAccount(row) };
165
- },
166
- async consumeEmailVerificationToken(token) {
167
- if (!token)
168
- return false;
169
- const row = await Model.query().where('emailVerificationToken', token).first();
170
- if (!row)
171
- return false;
172
- row.emailVerifiedAt = DateTime.now();
173
- row.emailVerificationToken = null;
174
- await row.save();
175
- return true;
176
- },
177
- // ----- Administração (console admin) -----
178
- async listAccounts(params) {
179
- const page = Math.max(1, params.page ?? 1);
180
- const limit = Math.max(1, params.limit ?? 20);
181
- const search = params.search?.trim();
182
- const base = () => {
183
- const q = Model.query();
184
- // Filtro por e-mail (substring, case-insensitive). `whereILike` cai no LIKE
185
- // no sqlite (case-insensitive por default p/ ASCII), e em ILIKE no Postgres.
186
- if (search)
187
- q.whereILike('email', `%${search}%`);
188
- return q;
189
- };
190
- const countResult = await base().count('* as total');
191
- // O shape do count varia por dialeto; lê de $extras.total (Lucid).
192
- const total = Number(countResult[0]?.$extras?.total ?? 0);
193
- const rows = await base()
194
- .orderBy('email', 'asc')
195
- .offset((page - 1) * limit)
196
- .limit(limit);
197
- return { data: rows.map(toAccount), total };
198
- },
199
- async setGlobalRoles(accountId, roles) {
200
- const row = await Model.find(accountId);
201
- if (!row)
202
- return;
203
- // A coluna `globalRoles` é serializada como JSON pelo mixin withAuthUser.
204
- row.globalRoles = roles;
205
- await row.save();
206
- },
207
- // ----- MFA / TOTP -----
208
- async getMfaState(accountId) {
209
- const row = await Model.find(accountId);
210
- return { enabled: !!row?.mfaEnabledAt };
211
- },
212
- async startTotpEnrollment(accountId) {
213
- const row = await Model.find(accountId);
214
- if (!row)
215
- return null;
216
- const secret = authenticator.generateSecret();
217
- // Segredo PENDENTE: armazenado (encriptado em repouso) mas mfaEnabledAt continua null.
218
- row.totpSecret = sealSecret(secret);
219
- row.mfaEnabledAt = null;
220
- row.recoveryCodes = null;
221
- await row.save();
222
- const otpauthUri = authenticator.keyuri(row.email, mfaIssuer, secret);
223
- return { secret, otpauthUri };
224
- },
225
- async confirmTotpEnrollment(accountId, code) {
226
- const row = await Model.find(accountId);
227
- if (!row || !row.totpSecret)
228
- return { ok: false };
229
- const secret = openSecret(row.totpSecret);
230
- if (!secret)
231
- return { ok: false };
232
- // Só confirma a partir de um segredo pendente (não re-confirma um já ativo).
233
- const valid = authenticator.verify({ token: String(code ?? ''), secret });
234
- if (!valid)
235
- return { ok: false };
236
- const codes = Array.from({ length: recoveryCodeCount }, () => generateRecoveryCode());
237
- row.mfaEnabledAt = DateTime.now();
238
- row.recoveryCodes = codes.map(sha256);
239
- await row.save();
240
- return { ok: true, recoveryCodes: codes };
241
- },
242
- async verifyTotp(accountId, code) {
243
- const row = await Model.find(accountId);
244
- if (!row || !row.mfaEnabledAt || !row.totpSecret)
245
- return false;
246
- const secret = openSecret(row.totpSecret);
247
- if (!secret)
248
- return false;
249
- return authenticator.verify({ token: String(code ?? ''), secret });
250
- },
251
- async consumeRecoveryCode(accountId, code) {
252
- const row = await Model.find(accountId);
253
- if (!row || !row.mfaEnabledAt || !Array.isArray(row.recoveryCodes))
254
- return false;
255
- const target = sha256(String(code ?? '').trim());
256
- const remaining = row.recoveryCodes.filter((h) => !hashesEqual(h, target));
257
- if (remaining.length === row.recoveryCodes.length)
258
- return false; // nada casou
259
- row.recoveryCodes = remaining;
260
- await row.save();
261
- return true;
262
- },
263
- async disableMfa(accountId) {
264
- const row = await Model.find(accountId);
265
- if (!row)
266
- return;
267
- row.totpSecret = null;
268
- row.mfaEnabledAt = null;
269
- row.recoveryCodes = null;
270
- await row.save();
271
- },
272
- // ----- MFA / WebAuthn (passkeys) -----
273
- async generatePasskeyRegistrationOptions(accountId) {
274
- const Credential = requireWebauthnModel();
275
- const row = await Model.find(accountId);
276
- if (!row)
277
- return null;
278
- const existing = await Credential.query().where('accountId', accountId);
279
- const options = await ceremonies.generateRegistrationOptions({
280
- rpName: webauthn.rpName,
281
- rpID: webauthn.rpId,
282
- userName: row.email,
283
- userDisplayName: row.fullName ?? row.email,
284
- // Não pede attestation (privacidade); confia na verificação local.
285
- attestationType: 'none',
286
- // Evita registrar a mesma credencial duas vezes.
287
- excludeCredentials: existing.map((c) => ({
288
- id: c.id,
289
- transports: (c.transports ?? undefined),
290
- })),
291
- authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
292
- });
293
- return { options: options, challenge: options.challenge };
294
- },
295
- async verifyPasskeyRegistration(accountId, response, expectedChallenge) {
296
- const Credential = requireWebauthnModel();
297
- const row = await Model.find(accountId);
298
- if (!row)
299
- return false;
300
- let verification;
301
- try {
302
- verification = await ceremonies.verifyRegistrationResponse({
303
- response: response,
304
- expectedChallenge,
305
- expectedOrigin: webauthn.origin,
306
- expectedRPID: webauthn.rpId,
307
- });
308
- }
309
- catch {
310
- return false;
311
- }
312
- if (!verification.verified || !verification.registrationInfo)
313
- return false;
314
- const { credential } = verification.registrationInfo;
315
- // publicKey vem como Uint8Array → armazenamos como base64url (texto).
316
- const publicKey = Buffer.from(credential.publicKey).toString('base64url');
317
- await Credential.create({
318
- id: credential.id,
319
- accountId,
320
- publicKey,
321
- counter: credential.counter,
322
- transports: credential.transports ?? null,
323
- label: null,
324
- });
325
- // Registrar uma passkey também habilita o MFA (2º fator presente).
326
- if (!row.mfaEnabledAt) {
327
- row.mfaEnabledAt = DateTime.now();
328
- await row.save();
329
- }
330
- return true;
331
- },
332
- async generatePasskeyAuthenticationOptions(accountId) {
333
- const Credential = requireWebauthnModel();
334
- const creds = await Credential.query().where('accountId', accountId);
335
- if (creds.length === 0)
336
- return null;
337
- const options = await ceremonies.generateAuthenticationOptions({
338
- rpID: webauthn.rpId,
339
- allowCredentials: creds.map((c) => ({
340
- id: c.id,
341
- transports: (c.transports ?? undefined),
342
- })),
343
- userVerification: 'preferred',
344
- });
345
- return { options: options, challenge: options.challenge };
346
- },
347
- async verifyPasskeyAuthentication(accountId, response, expectedChallenge) {
348
- const Credential = requireWebauthnModel();
349
- const resp = response;
350
- // O credential id vem na resposta (base64url) → acha a credencial da conta.
351
- const cred = await Credential.query()
352
- .where('accountId', accountId)
353
- .where('id', resp?.id ?? '')
354
- .first();
355
- if (!cred)
356
- return false;
357
- let verification;
358
- try {
359
- verification = await ceremonies.verifyAuthenticationResponse({
360
- response: resp,
361
- expectedChallenge,
362
- expectedOrigin: webauthn.origin,
363
- expectedRPID: webauthn.rpId,
364
- credential: {
365
- id: cred.id,
366
- publicKey: new Uint8Array(Buffer.from(cred.publicKey, 'base64url')),
367
- counter: cred.counter,
368
- transports: (cred.transports ?? undefined),
369
- },
370
- });
371
- }
372
- catch {
373
- return false;
374
- }
375
- if (!verification.verified)
376
- return false;
377
- // Atualiza o signature counter (anti-replay).
378
- cred.counter = verification.authenticationInfo.newCounter;
379
- await cred.save();
380
- return true;
381
- },
382
- async listPasskeys(accountId) {
383
- const Credential = requireWebauthnModel();
384
- const creds = await Credential.query().where('accountId', accountId).orderBy('createdAt', 'asc');
385
- return creds.map((c) => ({
386
- id: c.id,
387
- label: c.label ?? undefined,
388
- createdAt: c.createdAt?.toISO?.() ?? String(c.createdAt ?? ''),
389
- }));
390
- },
391
- async removePasskey(accountId, credentialId) {
392
- const Credential = requireWebauthnModel();
393
- await Credential.query().where('accountId', accountId).where('id', credentialId).delete();
394
- },
62
+ ...buildCore(ctx),
63
+ ...buildMfa(ctx),
64
+ ...(ProviderIdentityModel ? buildProviderIdentity(ctx, ProviderIdentityModel) : {}),
65
+ ...(WebauthnCredentialModel
66
+ ? buildWebauthn(ctx, WebauthnCredentialModel, webauthn, ceremonies)
67
+ : {}),
395
68
  };
396
69
  }
@@ -0,0 +1,8 @@
1
+ import type { CoreAccountStore } from '../account_store.js';
2
+ import type { LucidStoreContext } from './shared.js';
3
+ /**
4
+ * Núcleo SEMPRE presente do {@link CoreAccountStore} sobre um model Lucid:
5
+ * identidade, cadastro, reset de senha, verificação de e-mail e administração
6
+ * (listagem paginada + roles globais).
7
+ */
8
+ export declare function buildCore(ctx: LucidStoreContext): CoreAccountStore;
@@ -0,0 +1,108 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { DateTime } from 'luxon';
3
+ /**
4
+ * Núcleo SEMPRE presente do {@link CoreAccountStore} sobre um model Lucid:
5
+ * identidade, cadastro, reset de senha, verificação de e-mail e administração
6
+ * (listagem paginada + roles globais).
7
+ */
8
+ export function buildCore(ctx) {
9
+ const { Model, toAccount } = ctx;
10
+ return {
11
+ async findById(id) {
12
+ const row = await Model.find(id);
13
+ return row ? toAccount(row) : null;
14
+ },
15
+ async findByEmail(email) {
16
+ const row = await Model.query().where('email', email).first();
17
+ return row ? toAccount(row) : null;
18
+ },
19
+ async verifyCredentials(email, password) {
20
+ const row = await Model.query().where('email', email).first();
21
+ if (!row || !(await row.verifyPassword(password)))
22
+ return null;
23
+ return toAccount(row);
24
+ },
25
+ async create(input) {
26
+ const row = await Model.create({
27
+ email: input.email,
28
+ password: input.password,
29
+ fullName: input.fullName ?? null,
30
+ globalRoles: input.globalRoles ?? [],
31
+ emailVerifiedAt: input.emailVerified ? DateTime.now() : null,
32
+ });
33
+ return toAccount(row);
34
+ },
35
+ async issuePasswordResetToken(email) {
36
+ const row = await Model.query().where('email', email).first();
37
+ if (!row)
38
+ return null;
39
+ const token = randomBytes(32).toString('hex');
40
+ row.passwordResetToken = token;
41
+ row.passwordResetExpiresAt = DateTime.now().plus({ hours: 1 });
42
+ await row.save();
43
+ return { token, account: toAccount(row) };
44
+ },
45
+ async consumePasswordResetToken(token, newPassword) {
46
+ const row = await Model.query().where('passwordResetToken', token).first();
47
+ if (!row)
48
+ return false;
49
+ if (!row.passwordResetExpiresAt || row.passwordResetExpiresAt < DateTime.now())
50
+ return false;
51
+ row.password = newPassword;
52
+ row.passwordResetToken = null;
53
+ row.passwordResetExpiresAt = null;
54
+ await row.save();
55
+ return true;
56
+ },
57
+ async issueEmailVerificationToken(email) {
58
+ const row = await Model.query().where('email', email).first();
59
+ if (!row)
60
+ return null;
61
+ const token = randomBytes(32).toString('hex');
62
+ row.emailVerificationToken = token;
63
+ await row.save();
64
+ return { token, account: toAccount(row) };
65
+ },
66
+ async consumeEmailVerificationToken(token) {
67
+ if (!token)
68
+ return false;
69
+ const row = await Model.query().where('emailVerificationToken', token).first();
70
+ if (!row)
71
+ return false;
72
+ row.emailVerifiedAt = DateTime.now();
73
+ row.emailVerificationToken = null;
74
+ await row.save();
75
+ return true;
76
+ },
77
+ // ----- Administração (console admin) -----
78
+ async listAccounts(params) {
79
+ const page = Math.max(1, params.page ?? 1);
80
+ const limit = Math.max(1, params.limit ?? 20);
81
+ const search = params.search?.trim();
82
+ const base = () => {
83
+ const q = Model.query();
84
+ // Filtro por e-mail (substring, case-insensitive). `whereILike` cai no LIKE
85
+ // no sqlite (case-insensitive por default p/ ASCII), e em ILIKE no Postgres.
86
+ if (search)
87
+ q.whereILike('email', `%${search}%`);
88
+ return q;
89
+ };
90
+ const countResult = await base().count('* as total');
91
+ // O shape do count varia por dialeto; lê de $extras.total (Lucid).
92
+ const total = Number(countResult[0]?.$extras?.total ?? 0);
93
+ const rows = await base()
94
+ .orderBy('email', 'asc')
95
+ .offset((page - 1) * limit)
96
+ .limit(limit);
97
+ return { data: rows.map(toAccount), total };
98
+ },
99
+ async setGlobalRoles(accountId, roles) {
100
+ const row = await Model.find(accountId);
101
+ if (!row)
102
+ return;
103
+ // A coluna `globalRoles` é serializada como JSON pelo mixin withAuthUser.
104
+ row.globalRoles = roles;
105
+ await row.save();
106
+ },
107
+ };
108
+ }
@@ -0,0 +1,8 @@
1
+ import type { MfaCapability } from '../account_store.js';
2
+ import { type LucidStoreContext } from './shared.js';
3
+ /**
4
+ * Capacidade de MFA / TOTP sobre o model principal (composto de `withMfa()`).
5
+ * Sempre presente no {@link lucidAccountStore} — o model carrega as colunas
6
+ * `totp_secret`/`mfa_enabled_at`/`recovery_codes`.
7
+ */
8
+ export declare function buildMfa(ctx: LucidStoreContext): MfaCapability;
@@ -0,0 +1,77 @@
1
+ import { DateTime } from 'luxon';
2
+ import { authenticator } from 'otplib';
3
+ import { generateRecoveryCode, hashesEqual, sha256 } from './shared.js';
4
+ /**
5
+ * Capacidade de MFA / TOTP sobre o model principal (composto de `withMfa()`).
6
+ * Sempre presente no {@link lucidAccountStore} — o model carrega as colunas
7
+ * `totp_secret`/`mfa_enabled_at`/`recovery_codes`.
8
+ */
9
+ export function buildMfa(ctx) {
10
+ const { Model, mfaIssuer, recoveryCodeCount, sealSecret, openSecret } = ctx;
11
+ return {
12
+ async getMfaState(accountId) {
13
+ const row = await Model.find(accountId);
14
+ return { enabled: !!row?.mfaEnabledAt };
15
+ },
16
+ async startTotpEnrollment(accountId) {
17
+ const row = await Model.find(accountId);
18
+ if (!row)
19
+ return null;
20
+ const secret = authenticator.generateSecret();
21
+ // Segredo PENDENTE: armazenado (encriptado em repouso) mas mfaEnabledAt continua null.
22
+ row.totpSecret = sealSecret(secret);
23
+ row.mfaEnabledAt = null;
24
+ row.recoveryCodes = null;
25
+ await row.save();
26
+ const otpauthUri = authenticator.keyuri(row.email, mfaIssuer, secret);
27
+ return { secret, otpauthUri };
28
+ },
29
+ async confirmTotpEnrollment(accountId, code) {
30
+ const row = await Model.find(accountId);
31
+ if (!row || !row.totpSecret)
32
+ return { ok: false };
33
+ const secret = openSecret(row.totpSecret);
34
+ if (!secret)
35
+ return { ok: false };
36
+ // Só confirma a partir de um segredo pendente (não re-confirma um já ativo).
37
+ const valid = authenticator.verify({ token: String(code ?? ''), secret });
38
+ if (!valid)
39
+ return { ok: false };
40
+ const codes = Array.from({ length: recoveryCodeCount }, () => generateRecoveryCode());
41
+ row.mfaEnabledAt = DateTime.now();
42
+ row.recoveryCodes = codes.map(sha256);
43
+ await row.save();
44
+ return { ok: true, recoveryCodes: codes };
45
+ },
46
+ async verifyTotp(accountId, code) {
47
+ const row = await Model.find(accountId);
48
+ if (!row || !row.mfaEnabledAt || !row.totpSecret)
49
+ return false;
50
+ const secret = openSecret(row.totpSecret);
51
+ if (!secret)
52
+ return false;
53
+ return authenticator.verify({ token: String(code ?? ''), secret });
54
+ },
55
+ async consumeRecoveryCode(accountId, code) {
56
+ const row = await Model.find(accountId);
57
+ if (!row || !row.mfaEnabledAt || !Array.isArray(row.recoveryCodes))
58
+ return false;
59
+ const target = sha256(String(code ?? '').trim());
60
+ const remaining = row.recoveryCodes.filter((h) => !hashesEqual(h, target));
61
+ if (remaining.length === row.recoveryCodes.length)
62
+ return false; // nada casou
63
+ row.recoveryCodes = remaining;
64
+ await row.save();
65
+ return true;
66
+ },
67
+ async disableMfa(accountId) {
68
+ const row = await Model.find(accountId);
69
+ if (!row)
70
+ return;
71
+ row.totpSecret = null;
72
+ row.mfaEnabledAt = null;
73
+ row.recoveryCodes = null;
74
+ await row.save();
75
+ },
76
+ };
77
+ }
@@ -0,0 +1,8 @@
1
+ import type { ProviderIdentityCapability } from '../account_store.js';
2
+ import type { LucidStoreContext } from './shared.js';
3
+ /**
4
+ * Capacidade de account linking por identidade de provider (Google, GitHub, …).
5
+ * Só é montada quando o `providerIdentityModel` é fornecido — quando ausente, a
6
+ * capacidade inteira fica ABSENTE do store (sem método presente-mas-lançando).
7
+ */
8
+ export declare function buildProviderIdentity(ctx: LucidStoreContext, ProviderIdentityModel: any): ProviderIdentityCapability;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Capacidade de account linking por identidade de provider (Google, GitHub, …).
3
+ * Só é montada quando o `providerIdentityModel` é fornecido — quando ausente, a
4
+ * capacidade inteira fica ABSENTE do store (sem método presente-mas-lançando).
5
+ */
6
+ export function buildProviderIdentity(ctx, ProviderIdentityModel) {
7
+ const { Model, toAccount } = ctx;
8
+ return {
9
+ async findByProviderIdentity(provider, providerUserId) {
10
+ const identity = await ProviderIdentityModel.query()
11
+ .where('provider', provider)
12
+ .where('providerUserId', providerUserId)
13
+ .first();
14
+ if (!identity)
15
+ return null;
16
+ const row = await Model.find(identity.accountId);
17
+ return row ? toAccount(row) : null;
18
+ },
19
+ async linkProviderIdentity(data) {
20
+ // Upsert idempotente na chave única (provider, providerUserId): atualiza
21
+ // account/email se já existir, cria caso contrário.
22
+ const existing = await ProviderIdentityModel.query()
23
+ .where('provider', data.provider)
24
+ .where('providerUserId', data.providerUserId)
25
+ .first();
26
+ if (existing) {
27
+ existing.accountId = data.accountId;
28
+ if (data.email !== undefined)
29
+ existing.email = data.email;
30
+ await existing.save();
31
+ return;
32
+ }
33
+ await ProviderIdentityModel.create({
34
+ provider: data.provider,
35
+ providerUserId: data.providerUserId,
36
+ accountId: data.accountId,
37
+ email: data.email ?? null,
38
+ });
39
+ },
40
+ };
41
+ }