@dudousxd/adonis-authkit-server 0.1.1 → 0.2.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 (34) hide show
  1. package/build/index.d.ts +3 -2
  2. package/build/index.js +2 -1
  3. package/build/src/accounts/account_store.d.ts +74 -17
  4. package/build/src/accounts/account_store.js +12 -1
  5. package/build/src/accounts/lucid_account_store.d.ts +12 -27
  6. package/build/src/accounts/lucid_account_store.js +38 -365
  7. package/build/src/accounts/lucid_store/core.d.ts +8 -0
  8. package/build/src/accounts/lucid_store/core.js +108 -0
  9. package/build/src/accounts/lucid_store/mfa.d.ts +8 -0
  10. package/build/src/accounts/lucid_store/mfa.js +77 -0
  11. package/build/src/accounts/lucid_store/provider_identity.d.ts +8 -0
  12. package/build/src/accounts/lucid_store/provider_identity.js +41 -0
  13. package/build/src/accounts/lucid_store/shared.d.ts +48 -0
  14. package/build/src/accounts/lucid_store/shared.js +15 -0
  15. package/build/src/accounts/lucid_store/webauthn.d.ts +8 -0
  16. package/build/src/accounts/lucid_store/webauthn.js +135 -0
  17. package/build/src/define_config.d.ts +6 -0
  18. package/build/src/define_config.js +20 -5
  19. package/build/src/host/controllers/account_mfa_controller.js +2 -1
  20. package/build/src/host/controllers/account_session_controller.js +10 -18
  21. package/build/src/host/controllers/interaction_controller.js +13 -32
  22. package/build/src/host/controllers/social_controller.js +7 -0
  23. package/build/src/host/login_attempt.d.ts +39 -0
  24. package/build/src/host/login_attempt.js +37 -0
  25. package/build/src/host/register_auth_host.d.ts +13 -0
  26. package/build/src/host/register_auth_host.js +9 -2
  27. package/build/src/mixins/json_column.d.ts +38 -0
  28. package/build/src/mixins/json_column.js +31 -0
  29. package/build/src/mixins/with_audit_log.js +2 -4
  30. package/build/src/mixins/with_auth_user.js +2 -4
  31. package/build/src/mixins/with_mfa.js +2 -6
  32. package/build/src/mixins/with_personal_access_token.js +2 -4
  33. package/build/src/mixins/with_webauthn_credential.js +6 -8
  34. package/package.json +1 -1
@@ -0,0 +1,48 @@
1
+ import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from '@simplewebauthn/server';
2
+ import type { AuthAccount } from '../account_store.js';
3
+ /**
4
+ * Encripta/decripta um valor (ex.: o segredo TOTP) em repouso. Mantém a lib
5
+ * desacoplada do serviço de encryption do app — qualquer implementação que
6
+ * faça round-trip serve (em prod, normalmente o `@adonisjs/core/services/encryption`).
7
+ * `decrypt` retorna `null` se o valor foi adulterado/é inválido.
8
+ */
9
+ export interface AccountSecretEncrypter {
10
+ encrypt(value: string): string;
11
+ decrypt(value: string): string | null;
12
+ }
13
+ /**
14
+ * Funções das cerimônias WebAuthn. Espelham a assinatura do `@simplewebauthn/server`
15
+ * (subconjunto usado). Injetáveis via {@link LucidAccountStoreOptions.webauthnCeremonies}
16
+ * para testes.
17
+ */
18
+ export interface WebauthnCeremonies {
19
+ generateRegistrationOptions: typeof generateRegistrationOptions;
20
+ verifyRegistrationResponse: typeof verifyRegistrationResponse;
21
+ generateAuthenticationOptions: typeof generateAuthenticationOptions;
22
+ verifyAuthenticationResponse: typeof verifyAuthenticationResponse;
23
+ }
24
+ /** RP (Relying Party) do WebAuthn usado nas cerimônias. */
25
+ export interface ResolvedRp {
26
+ rpName: string;
27
+ rpId: string;
28
+ origin: string | string[];
29
+ }
30
+ /**
31
+ * Contexto compartilhado pelos builders de capacidade. Carrega o model principal,
32
+ * os helpers de segredo e (quando configurados) os models/parametros das capacidades.
33
+ */
34
+ export interface LucidStoreContext {
35
+ Model: any;
36
+ mfaIssuer: string;
37
+ recoveryCodeCount: number;
38
+ /** Encripta o segredo antes de persistir (no-op sem encrypter). */
39
+ sealSecret(secret: string): string;
40
+ /** Decripta o segredo armazenado; null em falha/adulteração (no-op sem encrypter). */
41
+ openSecret(stored: string | null | undefined): string | null;
42
+ toAccount(row: any): AuthAccount;
43
+ }
44
+ export declare const sha256: (value: string) => string;
45
+ /** Recovery code legível: 10 chars hex em duas metades (ex.: a1b2c-3d4e5). */
46
+ export declare function generateRecoveryCode(): string;
47
+ /** Comparação de hashes hex resistente a timing. */
48
+ export declare function hashesEqual(a: string, b: string): boolean;
@@ -0,0 +1,15 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ export const sha256 = (value) => createHash('sha256').update(value).digest('hex');
3
+ /** Recovery code legível: 10 chars hex em duas metades (ex.: a1b2c-3d4e5). */
4
+ export function generateRecoveryCode() {
5
+ const raw = randomBytes(5).toString('hex');
6
+ return `${raw.slice(0, 5)}-${raw.slice(5, 10)}`;
7
+ }
8
+ /** Comparação de hashes hex resistente a timing. */
9
+ export function hashesEqual(a, b) {
10
+ const ba = Buffer.from(a);
11
+ const bb = Buffer.from(b);
12
+ if (ba.length !== bb.length)
13
+ return false;
14
+ return timingSafeEqual(ba, bb);
15
+ }
@@ -0,0 +1,8 @@
1
+ import type { WebauthnCapability } from '../account_store.js';
2
+ import type { LucidStoreContext, ResolvedRp, WebauthnCeremonies } from './shared.js';
3
+ /**
4
+ * Capacidade de passkeys / WebAuthn (2º fator alternativo ao TOTP). Só é montada
5
+ * quando o `webauthnCredentialModel` é fornecido — ausente, a capacidade inteira
6
+ * fica ABSENTE do store (a UI esconde a seção de passkeys).
7
+ */
8
+ export declare function buildWebauthn(ctx: LucidStoreContext, Credential: any, webauthn: ResolvedRp, ceremonies: WebauthnCeremonies): WebauthnCapability;
@@ -0,0 +1,135 @@
1
+ import { DateTime } from 'luxon';
2
+ /**
3
+ * Capacidade de passkeys / WebAuthn (2º fator alternativo ao TOTP). Só é montada
4
+ * quando o `webauthnCredentialModel` é fornecido — ausente, a capacidade inteira
5
+ * fica ABSENTE do store (a UI esconde a seção de passkeys).
6
+ */
7
+ export function buildWebauthn(ctx, Credential, webauthn, ceremonies) {
8
+ const { Model } = ctx;
9
+ return {
10
+ async generatePasskeyRegistrationOptions(accountId) {
11
+ const row = await Model.find(accountId);
12
+ if (!row)
13
+ return null;
14
+ const existing = await Credential.query().where('accountId', accountId);
15
+ const options = await ceremonies.generateRegistrationOptions({
16
+ rpName: webauthn.rpName,
17
+ rpID: webauthn.rpId,
18
+ userName: row.email,
19
+ userDisplayName: row.fullName ?? row.email,
20
+ // Não pede attestation (privacidade); confia na verificação local.
21
+ attestationType: 'none',
22
+ // Evita registrar a mesma credencial duas vezes.
23
+ excludeCredentials: existing.map((c) => ({
24
+ id: c.id,
25
+ transports: (c.transports ?? undefined),
26
+ })),
27
+ authenticatorSelection: { residentKey: 'preferred', userVerification: 'preferred' },
28
+ });
29
+ return {
30
+ options: options,
31
+ challenge: options.challenge,
32
+ };
33
+ },
34
+ async verifyPasskeyRegistration(accountId, response, expectedChallenge) {
35
+ const row = await Model.find(accountId);
36
+ if (!row)
37
+ return false;
38
+ let verification;
39
+ try {
40
+ verification = await ceremonies.verifyRegistrationResponse({
41
+ response: response,
42
+ expectedChallenge,
43
+ expectedOrigin: webauthn.origin,
44
+ expectedRPID: webauthn.rpId,
45
+ });
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ if (!verification.verified || !verification.registrationInfo)
51
+ return false;
52
+ const { credential } = verification.registrationInfo;
53
+ // publicKey vem como Uint8Array → armazenamos como base64url (texto).
54
+ const publicKey = Buffer.from(credential.publicKey).toString('base64url');
55
+ await Credential.create({
56
+ id: credential.id,
57
+ accountId,
58
+ publicKey,
59
+ counter: credential.counter,
60
+ transports: credential.transports ?? null,
61
+ label: null,
62
+ });
63
+ // Registrar uma passkey também habilita o MFA (2º fator presente).
64
+ if (!row.mfaEnabledAt) {
65
+ row.mfaEnabledAt = DateTime.now();
66
+ await row.save();
67
+ }
68
+ return true;
69
+ },
70
+ async generatePasskeyAuthenticationOptions(accountId) {
71
+ const creds = await Credential.query().where('accountId', accountId);
72
+ if (creds.length === 0)
73
+ return null;
74
+ const options = await ceremonies.generateAuthenticationOptions({
75
+ rpID: webauthn.rpId,
76
+ allowCredentials: creds.map((c) => ({
77
+ id: c.id,
78
+ transports: (c.transports ?? undefined),
79
+ })),
80
+ userVerification: 'preferred',
81
+ });
82
+ return {
83
+ options: options,
84
+ challenge: options.challenge,
85
+ };
86
+ },
87
+ async verifyPasskeyAuthentication(accountId, response, expectedChallenge) {
88
+ const resp = response;
89
+ // O credential id vem na resposta (base64url) → acha a credencial da conta.
90
+ const cred = await Credential.query()
91
+ .where('accountId', accountId)
92
+ .where('id', resp?.id ?? '')
93
+ .first();
94
+ if (!cred)
95
+ return false;
96
+ let verification;
97
+ try {
98
+ verification = await ceremonies.verifyAuthenticationResponse({
99
+ response: resp,
100
+ expectedChallenge,
101
+ expectedOrigin: webauthn.origin,
102
+ expectedRPID: webauthn.rpId,
103
+ credential: {
104
+ id: cred.id,
105
+ publicKey: new Uint8Array(Buffer.from(cred.publicKey, 'base64url')),
106
+ counter: cred.counter,
107
+ transports: (cred.transports ?? undefined),
108
+ },
109
+ });
110
+ }
111
+ catch {
112
+ return false;
113
+ }
114
+ if (!verification.verified)
115
+ return false;
116
+ // Atualiza o signature counter (anti-replay).
117
+ cred.counter = verification.authenticationInfo.newCounter;
118
+ await cred.save();
119
+ return true;
120
+ },
121
+ async listPasskeys(accountId) {
122
+ const creds = await Credential.query()
123
+ .where('accountId', accountId)
124
+ .orderBy('createdAt', 'asc');
125
+ return creds.map((c) => ({
126
+ id: c.id,
127
+ label: c.label ?? undefined,
128
+ createdAt: c.createdAt?.toISO?.() ?? String(c.createdAt ?? ''),
129
+ }));
130
+ },
131
+ async removePasskey(accountId, credentialId) {
132
+ await Credential.query().where('accountId', accountId).where('id', credentialId).delete();
133
+ },
134
+ };
135
+ }
@@ -117,6 +117,12 @@ export interface ResolvedDynamicRegistrationConfig {
117
117
  initialAccessToken?: string;
118
118
  management: boolean;
119
119
  }
120
+ /**
121
+ * Resolve a config de registro dinâmico e VALIDA invariantes em tempo de resolução.
122
+ * O Registration Management (RFC 7592) só faz sentido com o registro habilitado
123
+ * (RFC 7591) — `management: true` com `enabled: false` é um erro de configuração.
124
+ */
125
+ export declare function resolveDynamicRegistration(input?: DynamicRegistrationConfigInput): ResolvedDynamicRegistrationConfig;
120
126
  /**
121
127
  * Console admin opt-in do IdP (B6). Quando habilitado, monta o grupo `/admin/*`
122
128
  * (dashboard, usuários/papéis, clients, audit) atrás de um guard que exige sessão
@@ -26,6 +26,25 @@ export function resolveLockout(input) {
26
26
  store: input?.store,
27
27
  };
28
28
  }
29
+ /**
30
+ * Resolve a config de registro dinâmico e VALIDA invariantes em tempo de resolução.
31
+ * O Registration Management (RFC 7592) só faz sentido com o registro habilitado
32
+ * (RFC 7591) — `management: true` com `enabled: false` é um erro de configuração.
33
+ */
34
+ export function resolveDynamicRegistration(input) {
35
+ const enabled = input?.enabled ?? false;
36
+ const management = input?.management ?? false;
37
+ if (management && !enabled) {
38
+ throw new Error('authkit: dynamicRegistration.management (RFC 7592) requer ' +
39
+ 'dynamicRegistration.enabled: true (RFC 7591). Habilite o registro dinâmico ' +
40
+ 'ou desligue o management.');
41
+ }
42
+ return {
43
+ enabled,
44
+ initialAccessToken: input?.initialAccessToken,
45
+ management,
46
+ };
47
+ }
29
48
  export function resolveAdmin(input) {
30
49
  return {
31
50
  enabled: input?.enabled ?? false,
@@ -102,11 +121,7 @@ export function defineConfig(config) {
102
121
  audit: config.audit,
103
122
  mfaIssuer: config.mfaIssuer ?? 'AuthKit',
104
123
  webauthn: resolveWebauthn(config.issuer, config.mfaIssuer ?? 'AuthKit', config.webauthn),
105
- dynamicRegistration: {
106
- enabled: config.dynamicRegistration?.enabled ?? false,
107
- initialAccessToken: config.dynamicRegistration?.initialAccessToken,
108
- management: config.dynamicRegistration?.management ?? false,
109
- },
124
+ dynamicRegistration: resolveDynamicRegistration(config.dynamicRegistration),
110
125
  admin: resolveAdmin(config.admin),
111
126
  messages: resolveMessages(config.i18n),
112
127
  locale: config.i18n?.locale ?? 'pt-BR',
@@ -2,6 +2,7 @@ import '../augmentations.js';
2
2
  import QRCode from 'qrcode';
3
3
  import { ACCOUNT_SESSION_KEY } from '../middleware/account_auth.js';
4
4
  import { translate } from '../i18n.js';
5
+ import { supportsPasskeys } from '../../accounts/account_store.js';
5
6
  /** Desafio WebAuthn pendente (registro) guardado na sessão entre begin/finish. */
6
7
  const PASSKEY_REG_CHALLENGE_KEY = 'authkit_passkey_reg_challenge';
7
8
  /**
@@ -18,7 +19,7 @@ export default class AccountMfaController {
18
19
  const state = (await cfg.accountStore.getMfaState?.(userId)) ?? { enabled: false };
19
20
  const recoveryCodes = ctx.session.flashMessages.get('recoveryCodes');
20
21
  // Passkeys disponíveis quando o store as suporta (model de credenciais wired).
21
- const passkeysSupported = typeof cfg.accountStore.listPasskeys === 'function';
22
+ const passkeysSupported = supportsPasskeys(cfg.accountStore);
22
23
  const passkeys = passkeysSupported ? await cfg.accountStore.listPasskeys(userId) : [];
23
24
  return render(ctx, 'account/mfa', {
24
25
  csrfToken: ctx.request.csrfToken,
@@ -1,7 +1,7 @@
1
1
  import '../augmentations.js';
2
2
  import { ACCOUNT_SESSION_KEY } from '../middleware/account_auth.js';
3
3
  import { translate } from '../i18n.js';
4
- import { createAccountLockout } from '../account_lockout.js';
4
+ import { attemptPasswordLogin } from '../login_attempt.js';
5
5
  export default class AccountSessionController {
6
6
  async show(ctx) {
7
7
  const service = await ctx.containerResolver.make('authkit.server');
@@ -18,27 +18,19 @@ export default class AccountSessionController {
18
18
  const render = cfg.render;
19
19
  const { email, password } = ctx.request.only(['email', 'password']);
20
20
  const ip = ctx.request.ip?.() ?? null;
21
- const lockout = createAccountLockout(cfg.lockout);
22
- // Bloqueio progressivo: se a conta está travada, nem verifica a senha.
23
- const lock = await lockout.isLocked(email);
24
- if (lock.locked) {
21
+ // Verificação + lockout + auditoria de falha centralizados (sem clientId no console).
22
+ const result = await attemptPasswordLogin(cfg, { email, password, ip });
23
+ if (!result.ok) {
25
24
  return render(ctx, 'account/login', {
26
25
  csrfToken: ctx.request.csrfToken,
27
- error: translate(cfg.messages, 'errors.account_locked', {
28
- seconds: lock.retryAfterSec ?? 0,
29
- }),
26
+ error: result.locked
27
+ ? translate(cfg.messages, 'errors.account_locked', {
28
+ seconds: result.retryAfterSec ?? 0,
29
+ })
30
+ : translate(cfg.messages, 'errors.invalid_credentials'),
30
31
  });
31
32
  }
32
- const acc = await cfg.accountStore.verifyCredentials(email, password);
33
- if (!acc) {
34
- await cfg.audit?.record({ type: 'login.failure', email, ip });
35
- await lockout.recordFailure(email, { sink: cfg.audit, ip });
36
- return render(ctx, 'account/login', {
37
- csrfToken: ctx.request.csrfToken,
38
- error: translate(cfg.messages, 'errors.invalid_credentials'),
39
- });
40
- }
41
- await lockout.clearFailures(email);
33
+ const acc = result.account;
42
34
  ctx.session.put(ACCOUNT_SESSION_KEY, acc.id);
43
35
  await cfg.audit?.record({ type: 'login.success', accountId: acc.id, email, ip });
44
36
  return ctx.response.redirect('/account/tokens');
@@ -1,7 +1,8 @@
1
1
  import '../augmentations.js';
2
2
  import { brandFor, isFirstParty } from '../branding.js';
3
3
  import { translate } from '../i18n.js';
4
- import { createAccountLockout } from '../account_lockout.js';
4
+ import { attemptPasswordLogin } from '../login_attempt.js';
5
+ import { supportsPasskeys } from '../../accounts/account_store.js';
5
6
  const SESSION_KEY = 'authkit_login_email';
6
7
  /** accountId aguardando o 2º fator depois da senha verificada. */
7
8
  const MFA_PENDING_KEY = 'authkit_mfa_pending';
@@ -80,35 +81,12 @@ export default class AuthInteractionController {
80
81
  const { password } = ctx.request.only(['password']);
81
82
  const ip = ctx.request.ip?.() ?? null;
82
83
  const clientId = details.params.client_id ?? null;
83
- const lockout = createAccountLockout(cfg.lockout);
84
- // Bloqueio progressivo (keyed por email da sessão): se travada, não verifica a senha.
85
- const lock = await lockout.isLocked(email);
86
- if (lock.locked) {
87
- const found = await cfg.accountStore.findByEmail(email);
88
- const account = found
89
- ? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
90
- : null;
91
- return render(ctx, 'login', {
92
- uid: ctx.request.param('uid'),
93
- csrfToken: ctx.request.csrfToken,
94
- step: 'password',
95
- email,
96
- account,
97
- error: translate(cfg.messages, 'errors.account_locked', {
98
- seconds: lock.retryAfterSec ?? 0,
99
- }),
100
- brand,
101
- });
102
- }
103
84
  // Verificamos as credenciais ANTES de finalizar a interaction, porque com MFA
104
85
  // ligado precisamos exigir o 2º fator e NÃO podemos chamar interactionFinished
105
- // ainda. (`service.interactions.login` verifica E finaliza num passo por
106
- // isso fazemos a verificação aqui via accountStore e finalizamos depois.)
107
- const acc = await cfg.accountStore.verifyCredentials(email, password);
108
- if (!acc) {
109
- await cfg.audit?.record({ type: 'login.failure', email, ip, clientId });
110
- await lockout.recordFailure(email, { sink: cfg.audit, ip });
111
- // Re-render password step with error (keep email in session)
86
+ // ainda. A sequência verificação + lockout + auditoria de falha é centralizada
87
+ // em attemptPasswordLogin; a renderização (lookup p/ personalização) fica aqui.
88
+ const result = await attemptPasswordLogin(cfg, { email, password, ip, clientId });
89
+ if (!result.ok) {
112
90
  const found = await cfg.accountStore.findByEmail(email);
113
91
  const account = found
114
92
  ? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
@@ -119,12 +97,15 @@ export default class AuthInteractionController {
119
97
  step: 'password',
120
98
  email,
121
99
  account,
122
- error: translate(cfg.messages, 'errors.invalid_credentials'),
100
+ error: result.locked
101
+ ? translate(cfg.messages, 'errors.account_locked', {
102
+ seconds: result.retryAfterSec ?? 0,
103
+ })
104
+ : translate(cfg.messages, 'errors.invalid_credentials'),
123
105
  brand,
124
106
  });
125
107
  }
126
- // Senha correta: limpa o contador de falhas (o lockout protege a etapa de senha).
127
- await lockout.clearFailures(email);
108
+ const acc = result.account;
128
109
  // MFA gate: se a conta tem TOTP ativo, NÃO finaliza a interaction agora —
129
110
  // guarda o accountId pendente na sessão e renderiza o desafio do 2º fator.
130
111
  const mfa = (await cfg.accountStore.getMfaState?.(acc.id)) ?? { enabled: false };
@@ -204,7 +185,7 @@ export default class AuthInteractionController {
204
185
  }
205
186
  /** true se o store suporta passkeys E a conta tem ao menos uma registrada. */
206
187
  async hasPasskeys(cfg, accountId) {
207
- if (typeof cfg.accountStore.listPasskeys !== 'function')
188
+ if (!supportsPasskeys(cfg.accountStore))
208
189
  return false;
209
190
  const list = await cfg.accountStore.listPasskeys(accountId);
210
191
  return Array.isArray(list) && list.length > 0;
@@ -1,5 +1,6 @@
1
1
  import '../augmentations.js';
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { supportsProviderIdentity } from '../../accounts/account_store.js';
3
4
  const UID_SESSION_KEY = 'authkit_social_uid';
4
5
  /**
5
6
  * `AllyService.use()` é tipado contra a interface `SocialProviders`, que só é
@@ -37,6 +38,12 @@ export default class AuthSocialController {
37
38
  const cfg = service.config;
38
39
  const store = cfg.accountStore;
39
40
  const email = profile.email ?? undefined;
41
+ // Account linking exige a capacidade de provider-identity (model wired no store).
42
+ // Ausente → não há como ligar a identidade; volta ao login em vez de quebrar.
43
+ if (!supportsProviderIdentity(store)) {
44
+ ctx.session.forget(UID_SESSION_KEY);
45
+ return ctx.response.redirect(backToLogin);
46
+ }
40
47
  // Precedência do account linking:
41
48
  // 1. Identidade de provider já ligada → loga essa conta.
42
49
  // 2. Senão, e-mail conhecido → acha por e-mail e LIGA a identidade (linking).
@@ -0,0 +1,39 @@
1
+ import type { AuthAccount } from '../accounts/account_store.js';
2
+ import type { ResolvedServerConfig } from '../define_config.js';
3
+ /** Entrada de uma tentativa de login por senha (keyed por email). */
4
+ export interface PasswordLoginInput {
5
+ email: string;
6
+ password: string;
7
+ /** IP da request (para auditoria + lockout). null quando indisponível. */
8
+ ip: string | null;
9
+ /**
10
+ * client_id da interaction OIDC, quando existir. Só é incluído no evento de
11
+ * auditoria nos fluxos que o têm (interaction); ausente no console de conta.
12
+ */
13
+ clientId?: string | null;
14
+ }
15
+ /** Resultado de {@link attemptPasswordLogin}. */
16
+ export type PasswordLoginResult = {
17
+ ok: true;
18
+ account: AuthAccount;
19
+ } | {
20
+ ok: false;
21
+ locked: boolean;
22
+ retryAfterSec?: number;
23
+ };
24
+ /**
25
+ * Sequência canônica de login por senha + bloqueio progressivo, compartilhada
26
+ * pelos dois fluxos que pedem senha (interaction OIDC e console de conta):
27
+ *
28
+ * 1. `lockout.isLocked(email)` — se travada, NÃO verifica a senha; devolve
29
+ * `{ ok: false, locked: true, retryAfterSec }` (o controller renderiza o erro).
30
+ * 2. `verifyCredentials(email, password)` — em falha: emite `login.failure`
31
+ * (com `clientId` apenas quando fornecido), registra a falha no lockout
32
+ * (`{ sink: cfg.audit, ip }`) e devolve `{ ok: false, locked: false }`.
33
+ * 3. Em sucesso: limpa o contador de falhas e devolve `{ ok: true, account }`.
34
+ *
35
+ * O evento `login.success` e a finalização da sessão/interaction ficam a cargo de
36
+ * cada controller (os fluxos diferem: MFA gate na interaction, sessão no console),
37
+ * assim como toda a renderização.
38
+ */
39
+ export declare function attemptPasswordLogin(cfg: ResolvedServerConfig, input: PasswordLoginInput): Promise<PasswordLoginResult>;
@@ -0,0 +1,37 @@
1
+ import { createAccountLockout } from './account_lockout.js';
2
+ /**
3
+ * Sequência canônica de login por senha + bloqueio progressivo, compartilhada
4
+ * pelos dois fluxos que pedem senha (interaction OIDC e console de conta):
5
+ *
6
+ * 1. `lockout.isLocked(email)` — se travada, NÃO verifica a senha; devolve
7
+ * `{ ok: false, locked: true, retryAfterSec }` (o controller renderiza o erro).
8
+ * 2. `verifyCredentials(email, password)` — em falha: emite `login.failure`
9
+ * (com `clientId` apenas quando fornecido), registra a falha no lockout
10
+ * (`{ sink: cfg.audit, ip }`) e devolve `{ ok: false, locked: false }`.
11
+ * 3. Em sucesso: limpa o contador de falhas e devolve `{ ok: true, account }`.
12
+ *
13
+ * O evento `login.success` e a finalização da sessão/interaction ficam a cargo de
14
+ * cada controller (os fluxos diferem: MFA gate na interaction, sessão no console),
15
+ * assim como toda a renderização.
16
+ */
17
+ export async function attemptPasswordLogin(cfg, input) {
18
+ const { email, password, ip } = input;
19
+ const lockout = createAccountLockout(cfg.lockout);
20
+ // Bloqueio progressivo (keyed por email): se travada, não verifica a senha.
21
+ const lock = await lockout.isLocked(email);
22
+ if (lock.locked) {
23
+ return { ok: false, locked: true, retryAfterSec: lock.retryAfterSec };
24
+ }
25
+ const account = await cfg.accountStore.verifyCredentials(email, password);
26
+ if (!account) {
27
+ // `clientId` só entra no evento quando o fluxo o fornece (interaction).
28
+ await cfg.audit?.record(input.clientId !== undefined
29
+ ? { type: 'login.failure', email, ip, clientId: input.clientId }
30
+ : { type: 'login.failure', email, ip });
31
+ await lockout.recordFailure(email, { sink: cfg.audit, ip });
32
+ return { ok: false, locked: false };
33
+ }
34
+ // Senha correta: limpa o contador de falhas (o lockout protege a etapa de senha).
35
+ await lockout.clearFailures(email);
36
+ return { ok: true, account };
37
+ }
@@ -3,12 +3,25 @@ import type { AuthSocialConfig, RateLimitConfigInput } from '../define_config.js
3
3
  /**
4
4
  * Guard do console admin (B6). Como o `accountGuard`, é uma closure inline (forma
5
5
  * confiável do `.use()` num grupo). Exige:
6
+ * 0. `config.admin.enabled` ligado (senão → 404; ver nota de flag-drift abaixo);
6
7
  * 1. sessão de conta ativa (senão → /account/login);
7
8
  * 2. a conta logada com pelo menos UMA das `config.admin.roles` nas roles globais
8
9
  * (senão → /account/tokens, evitando vazar a existência do /admin).
9
10
  * As roles permitidas são resolvidas em runtime do `authkit.server` (config lazy).
10
11
  */
11
12
  export declare const adminGuard: (ctx: any, next: () => Promise<void>) => Promise<any>;
13
+ /**
14
+ * Opções de montagem das rotas do host-kit.
15
+ *
16
+ * NOTA (flag-drift): vários campos aqui (`social`, `rateLimit`, `admin`) ESPELHAM
17
+ * o config (`config/authkit.ts`) porque a decisão de MONTAR as rotas acontece em
18
+ * tempo de registro, antes de o config (lazy) resolver. Eles controlam apenas se
19
+ * as rotas existem; a fonte de verdade do COMPORTAMENTO continua sendo o config
20
+ * resolvido. Se um flag aqui divergir do config (ex.: `admin: true` aqui com
21
+ * `admin.enabled: false` no config), os guards das rotas são a rede de segurança
22
+ * (o `adminGuard` 404a quando `config.admin.enabled` é false). Mantenha-os em
23
+ * sincronia; os guards garantem que a divergência não vire um bypass.
24
+ */
12
25
  export interface AuthHostOptions {
13
26
  /** Onde o provider OIDC é montado (default '/oidc'). Deve casar com o final do issuer. */
14
27
  mountPath: string;
@@ -16,18 +16,25 @@ const accountGuard = async (ctx, next) => {
16
16
  /**
17
17
  * Guard do console admin (B6). Como o `accountGuard`, é uma closure inline (forma
18
18
  * confiável do `.use()` num grupo). Exige:
19
+ * 0. `config.admin.enabled` ligado (senão → 404; ver nota de flag-drift abaixo);
19
20
  * 1. sessão de conta ativa (senão → /account/login);
20
21
  * 2. a conta logada com pelo menos UMA das `config.admin.roles` nas roles globais
21
22
  * (senão → /account/tokens, evitando vazar a existência do /admin).
22
23
  * As roles permitidas são resolvidas em runtime do `authkit.server` (config lazy).
23
24
  */
24
25
  export const adminGuard = async (ctx, next) => {
26
+ const service = await ctx.containerResolver.make('authkit.server');
27
+ const cfg = service.config;
28
+ // Defesa contra flag-drift: as rotas são montadas com `admin: true` em tempo de
29
+ // registro, ANTES de o config resolver. Se o config tiver `admin.enabled: false`,
30
+ // as rotas existem mas o console deve estar desligado — 404 (não vaza a existência).
31
+ if (!cfg.admin.enabled) {
32
+ return ctx.response.notFound();
33
+ }
25
34
  const accountId = ctx.session?.get(ACCOUNT_SESSION_KEY);
26
35
  if (!accountId) {
27
36
  return ctx.response.redirect('/account/login');
28
37
  }
29
- const service = await ctx.containerResolver.make('authkit.server');
30
- const cfg = service.config;
31
38
  const allowed = cfg.admin.roles;
32
39
  const account = await cfg.accountStore.findById(accountId);
33
40
  const roles = account?.globalRoles ?? [];
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Serializer canônico de coluna JSON para os mixins. Centraliza o par
3
+ * `prepare`/`consume` (`JSON.stringify`/`JSON.parse`) que cada mixin escrevia à
4
+ * mão com tratamentos de null/default ligeiramente diferentes.
5
+ *
6
+ * As opções parametrizam exatamente as variações observadas entre os mixins, de
7
+ * modo que o comportamento por coluna fica IDÊNTICO ao código original:
8
+ *
9
+ * - `fallback`: valor devolvido por `consume` quando a coluna é null/undefined
10
+ * (ex.: `[]` para `globalRoles`/`scopes`; `null` para `recoveryCodes`/`metadata`).
11
+ * - `emptyOnWrite`: o que `prepare` grava quando o valor é "vazio" (null/undefined,
12
+ * ou — quando `treatEmptyArrayAsEmpty` — um array de length 0). `'null'` grava
13
+ * `null` na coluna; `'serialize'` ainda serializa (ex.: `globalRoles` grava
14
+ * `"[]"`). Default: `'null'`.
15
+ * - `treatEmptyArrayAsEmpty`: quando true, um array vazio também conta como "vazio"
16
+ * no write (caso do `transports`, que grava null em `[]`). Default: false.
17
+ * - `passthroughParsed`: quando true, `consume` aceita um valor JÁ desserializado
18
+ * (array/objeto) e o devolve sem reparsear — alguns dialetos/drivers entregam o
19
+ * JSON já decodificado. Default: false.
20
+ */
21
+ export interface JsonColumnOptions<T> {
22
+ /** Valor devolvido por `consume` quando a coluna está null/undefined. */
23
+ fallback: T;
24
+ /** O que `prepare` grava para valores vazios. Default: 'null'. */
25
+ emptyOnWrite?: 'null' | 'serialize';
26
+ /** Trata array vazio como "vazio" no write (grava null). Default: false. */
27
+ treatEmptyArrayAsEmpty?: boolean;
28
+ /** Aceita valor já desserializado em `consume` (passthrough). Default: false. */
29
+ passthroughParsed?: boolean;
30
+ }
31
+ /**
32
+ * Devolve o par `{ prepare, consume }` para usar num `@column({...})`.
33
+ * `T` é o tipo lógico da coluna (ex.: `string[]` ou `Record<string, unknown>`).
34
+ */
35
+ export declare function jsonColumn<T>(opts: JsonColumnOptions<T>): {
36
+ prepare: (value: T | null | undefined) => string | null;
37
+ consume: (value: unknown) => T;
38
+ };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Devolve o par `{ prepare, consume }` para usar num `@column({...})`.
3
+ * `T` é o tipo lógico da coluna (ex.: `string[]` ou `Record<string, unknown>`).
4
+ */
5
+ export function jsonColumn(opts) {
6
+ const emptyOnWrite = opts.emptyOnWrite ?? 'null';
7
+ const treatEmptyArrayAsEmpty = opts.treatEmptyArrayAsEmpty ?? false;
8
+ const passthroughParsed = opts.passthroughParsed ?? false;
9
+ const isEmpty = (value) => {
10
+ if (value === null || value === undefined)
11
+ return true;
12
+ if (treatEmptyArrayAsEmpty && Array.isArray(value) && value.length === 0)
13
+ return true;
14
+ return false;
15
+ };
16
+ return {
17
+ prepare: (value) => {
18
+ if (isEmpty(value)) {
19
+ return emptyOnWrite === 'serialize' ? JSON.stringify(opts.fallback) : null;
20
+ }
21
+ return JSON.stringify(value);
22
+ },
23
+ consume: (value) => {
24
+ if (value === null || value === undefined)
25
+ return opts.fallback;
26
+ if (passthroughParsed && Array.isArray(value))
27
+ return value;
28
+ return JSON.parse(value);
29
+ },
30
+ };
31
+ }
@@ -5,6 +5,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
5
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
7
  import { column } from '@adonisjs/lucid/orm';
8
+ import { jsonColumn } from './json_column.js';
8
9
  export function withAuditLog() {
9
10
  return (superclass) => {
10
11
  class AuditLogMixin extends superclass {
@@ -28,10 +29,7 @@ export function withAuditLog() {
28
29
  column()
29
30
  ], AuditLogMixin.prototype, "ip", void 0);
30
31
  __decorate([
31
- column({
32
- prepare: (value) => value ? JSON.stringify(value) : null,
33
- consume: (value) => (value ? JSON.parse(value) : null),
34
- })
32
+ column(jsonColumn({ fallback: null }))
35
33
  ], AuditLogMixin.prototype, "metadata", void 0);
36
34
  __decorate([
37
35
  column.dateTime({ autoCreate: true })