@adonis-agora/authkit-server 0.53.0 → 0.55.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 (63) hide show
  1. package/build/index.d.ts +6 -2
  2. package/build/index.js +7 -1
  3. package/build/providers/authkit_server_provider.js +18 -0
  4. package/build/src/accounts/lucid_account_store.d.ts +35 -0
  5. package/build/src/accounts/lucid_account_store.js +9 -0
  6. package/build/src/accounts/lucid_store/core.js +88 -19
  7. package/build/src/accounts/lucid_store/shared.d.ts +14 -0
  8. package/build/src/accounts/lucid_store/token_hash.d.ts +79 -0
  9. package/build/src/accounts/lucid_store/token_hash.js +145 -0
  10. package/build/src/define_config.d.ts +93 -8
  11. package/build/src/define_config.js +28 -2
  12. package/build/src/host/account_api/account_api_controller.js +5 -4
  13. package/build/src/host/admin_api/admin_users_service.js +2 -1
  14. package/build/src/host/admin_api/api_orgs_controller.js +2 -1
  15. package/build/src/host/admin_console/console_impersonation_controller.d.ts +15 -1
  16. package/build/src/host/admin_console/console_impersonation_controller.js +26 -2
  17. package/build/src/host/admin_console/console_orgs_controller.js +3 -1
  18. package/build/src/host/admin_validators.d.ts +3 -3
  19. package/build/src/host/auth_host_config.d.ts +30 -0
  20. package/build/src/host/auth_host_config.js +15 -0
  21. package/build/src/host/config_locks.d.ts +29 -0
  22. package/build/src/host/config_locks.js +46 -0
  23. package/build/src/host/console_session.d.ts +38 -2
  24. package/build/src/host/console_session.js +46 -2
  25. package/build/src/host/controllers/account_mfa_controller.js +5 -5
  26. package/build/src/host/controllers/account_orgs_controller.js +2 -1
  27. package/build/src/host/controllers/account_security_controller.js +6 -5
  28. package/build/src/host/controllers/interaction_controller.js +122 -26
  29. package/build/src/host/controllers/registration_controller.js +5 -4
  30. package/build/src/host/controllers/social_controller.js +37 -0
  31. package/build/src/host/default_mailer.d.ts +36 -5
  32. package/build/src/host/default_mailer.js +63 -10
  33. package/build/src/host/i18n.d.ts +10 -0
  34. package/build/src/host/i18n.js +16 -0
  35. package/build/src/host/login_attempt.d.ts +88 -2
  36. package/build/src/host/login_attempt.js +101 -48
  37. package/build/src/host/login_notify.js +2 -2
  38. package/build/src/host/oidc_rp_guard.d.ts +112 -0
  39. package/build/src/host/oidc_rp_guard.js +200 -0
  40. package/build/src/host/origin.d.ts +21 -0
  41. package/build/src/host/origin.js +22 -0
  42. package/build/src/host/register_auth_host.d.ts +104 -3
  43. package/build/src/host/register_auth_host.js +222 -26
  44. package/build/src/host/runtime_settings.d.ts +16 -0
  45. package/build/src/host/runtime_settings.js +24 -0
  46. package/build/src/host/runtime_toggles.d.ts +10 -0
  47. package/build/src/host/runtime_toggles.js +4 -0
  48. package/build/src/host/security_notice_service.d.ts +4 -2
  49. package/build/src/host/security_notice_service.js +4 -2
  50. package/build/src/host/sudo/index.d.ts +8 -0
  51. package/build/src/host/sudo/index.js +8 -0
  52. package/build/src/host/sudo/methods/magic_link.d.ts +17 -3
  53. package/build/src/host/sudo/methods/magic_link.js +37 -13
  54. package/build/src/host/sudo/runtime.d.ts +44 -4
  55. package/build/src/host/sudo/runtime.js +90 -6
  56. package/build/src/host/sudo/satisfiability.d.ts +62 -0
  57. package/build/src/host/sudo/satisfiability.js +89 -0
  58. package/build/src/password/common_passwords.js +27 -7
  59. package/build/src/provider/oidc_service.js +25 -13
  60. package/build/src/provider/token_exchange.d.ts +12 -1
  61. package/build/src/provider/token_exchange.js +12 -0
  62. package/package.json +6 -3
  63. /package/build/{password → src/password}/common_passwords.txt +0 -0
@@ -9,6 +9,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
9
  import { accountPath } from './account_paths.js';
10
10
  import { renderTransactionalEmail } from './email_templates.js';
11
11
  import { resolveMessages, translate } from './i18n.js';
12
+ import { authkitOrigin } from './origin.js';
12
13
  let mailServicePromise;
13
14
  /**
14
15
  * Importa o service de mail do HOST de forma preguiçosa e fail-safe.
@@ -174,11 +175,11 @@ export async function sendEmailChangeConfirmationEmail(ctx, data) {
174
175
  * Envia o e-mail de alerta de NOVO acesso à conta (login de um IP novo).
175
176
  * Best-effort: no fallback (sem mail) loga o evento; nunca lança.
176
177
  */
177
- export async function sendNewLoginEmail(ctx, data) {
178
+ export async function sendNewLoginEmail(ctx, cfg, data) {
178
179
  try {
179
180
  const brand = resolveBrand(ctx);
180
181
  const { messages: t, locale } = resolveMailMessages(ctx);
181
- const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
182
+ const origin = authkitOrigin(cfg);
182
183
  const intro = [
183
184
  translate(t, 'mail.new_login.intro'),
184
185
  translate(t, 'mail.new_login.when', { date: data.when }),
@@ -208,11 +209,11 @@ export async function sendNewLoginEmail(ctx, data) {
208
209
  * Envia o e-mail de alerta de NOVO DISPOSITIVO (login sem cookie de dispositivo
209
210
  * confiável). Best-effort: no fallback (sem mail) loga o evento; nunca lança.
210
211
  */
211
- export async function sendNewDeviceLoginEmail(ctx, data) {
212
+ export async function sendNewDeviceLoginEmail(ctx, cfg, data) {
212
213
  try {
213
214
  const brand = resolveBrand(ctx);
214
215
  const { messages: t, locale } = resolveMailMessages(ctx);
215
- const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
216
+ const origin = authkitOrigin(cfg);
216
217
  const lines = [
217
218
  translate(t, 'mail.new_login.intro'),
218
219
  translate(t, 'mail.new_login.when', { date: data.when }),
@@ -293,16 +294,68 @@ export async function sendMagicLinkEmail(ctx, data) {
293
294
  ctx.logger.error({ err: error, email: data.email }, 'authkit: falha ao enviar magic link de login');
294
295
  }
295
296
  }
297
+ /**
298
+ * Envia o link de CONFIRMAÇÃO DE IDENTIDADE (sudo) pelo mailer default do host.
299
+ *
300
+ * POR QUE EXISTE. `sudoMethods.magicLink()` dependia EXCLUSIVAMENTE do hook
301
+ * `mail.onSudoLink`, e sem ele o método se declarava indisponível. Num host
302
+ * passwordless isso fechava o deadlock: sem senha, sem passkey e sem hook, a
303
+ * tela `/account/confirm` não tinha um único método satisfazível — e o usuário
304
+ * ficava trancado fora de exportar/excluir os próprios dados, do MFA, dos PATs
305
+ * e da troca de e-mail, inclusive do cadastro de passkey que destravaria tudo.
306
+ *
307
+ * A postura aqui é a MESMA de todos os outros e-mails da lib (reset de senha,
308
+ * verificação, magic link de login, avisos de segurança): o hook do host, quando
309
+ * existe, tem prioridade; sem hook, o próprio host-kit envia pelo mailer default
310
+ * (`@adonisjs/mail`) com branding e i18n; sem mailer, loga o link (dev).
311
+ *
312
+ * O TOKEN continua sendo o de sudo — emitido e verificado em
313
+ * `sudo/methods/magic_link.ts`, hasheado na sessão que pediu, de uso único,
314
+ * 5 min, vinculado ao `accountId` emissor. Isto aqui é só ENTREGA; nada é
315
+ * compartilhado com o token de login.
316
+ *
317
+ * @returns `true` quando o e-mail foi entregue ao mailer (ou logado no fallback
318
+ * de dev) e `false` quando o envio falhou de verdade. O chamador PRECISA disso:
319
+ * ao contrário dos outros e-mails da lib, um link de sudo que não saiu tem de
320
+ * apagar o pendente da sessão e recusar — flashar "link enviado" deixaria o
321
+ * usuário esperando um e-mail que nunca vem, na tela que já é o gargalo dele.
322
+ */
323
+ export async function sendSudoLinkEmail(ctx, data) {
324
+ try {
325
+ const brand = resolveBrand(ctx);
326
+ const { messages: t, locale } = resolveMailMessages(ctx);
327
+ const content = renderTransactionalEmail({
328
+ brand,
329
+ locale,
330
+ linkFallback: translate(t, 'mail.common.link_fallback'),
331
+ subject: translate(t, 'mail.sudo_link.subject'),
332
+ heading: translate(t, 'mail.sudo_link.heading'),
333
+ intro: translate(t, 'mail.sudo_link.intro'),
334
+ ctaLabel: translate(t, 'mail.sudo_link.cta'),
335
+ ctaUrl: data.sudoUrl,
336
+ footnote: translate(t, 'mail.sudo_link.fallback'),
337
+ });
338
+ const sent = await sendEmail(ctx, data.email, content);
339
+ if (!sent) {
340
+ ctx.logger?.info({ sudoUrl: data.sudoUrl, email: data.email }, 'authkit: link de confirmação de identidade (dev — @adonisjs/mail ausente)');
341
+ }
342
+ return true;
343
+ }
344
+ catch (error) {
345
+ ctx.logger?.error({ err: error, email: data.email }, 'authkit: falha ao enviar link de confirmação de identidade');
346
+ return false;
347
+ }
348
+ }
296
349
  /**
297
350
  * Envia o e-mail de aviso de segurança ao e-mail ATUAL quando uma troca de
298
351
  * e-mail é solicitada ("Alguém pediu troca para X — se não foi você...").
299
352
  * Best-effort: no fallback (sem mail) loga o evento; nunca lança.
300
353
  */
301
- export async function sendEmailChangeNoticeEmail(ctx, data) {
354
+ export async function sendEmailChangeNoticeEmail(ctx, cfg, data) {
302
355
  try {
303
356
  const brand = resolveBrand(ctx);
304
357
  const { messages: t, locale } = resolveMailMessages(ctx);
305
- const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
358
+ const origin = authkitOrigin(cfg);
306
359
  const content = renderTransactionalEmail({
307
360
  brand,
308
361
  locale,
@@ -328,11 +381,11 @@ export async function sendEmailChangeNoticeEmail(ctx, data) {
328
381
  * troca foi concluída ("Seu e-mail foi alterado de A para B — se não foi você...").
329
382
  * Best-effort: no fallback (sem mail) loga o evento; nunca lança.
330
383
  */
331
- export async function sendEmailChangedCompletedEmail(ctx, data) {
384
+ export async function sendEmailChangedCompletedEmail(ctx, cfg, data) {
332
385
  try {
333
386
  const brand = resolveBrand(ctx);
334
387
  const { messages: t, locale } = resolveMailMessages(ctx);
335
- const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
388
+ const origin = authkitOrigin(cfg);
336
389
  const content = renderTransactionalEmail({
337
390
  brand,
338
391
  locale,
@@ -361,11 +414,11 @@ export async function sendEmailChangedCompletedEmail(ctx, data) {
361
414
  * MFA habilitado/desabilitado, passkey adicionada/removida, e-mail alterado).
362
415
  * Best-effort: no fallback (sem mail) loga o evento; nunca lança.
363
416
  */
364
- export async function sendSecurityNoticeEmail(ctx, data) {
417
+ export async function sendSecurityNoticeEmail(ctx, cfg, data) {
365
418
  try {
366
419
  const brand = resolveBrand(ctx);
367
420
  const { messages: t, locale } = resolveMailMessages(ctx);
368
- const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
421
+ const origin = authkitOrigin(cfg);
369
422
  const kindKey = `mail.security_notice.kind_${data.kind}`;
370
423
  const kindLabel = translate(t, kindKey);
371
424
  const intro = [
@@ -530,6 +530,11 @@ export declare const DEFAULT_MESSAGES: {
530
530
  'mail.magic_link.code_subject': string;
531
531
  'mail.magic_link.code_intro': string;
532
532
  'mail.magic_link.code_only_label': string;
533
+ 'mail.sudo_link.subject': string;
534
+ 'mail.sudo_link.heading': string;
535
+ 'mail.sudo_link.intro': string;
536
+ 'mail.sudo_link.cta': string;
537
+ 'mail.sudo_link.fallback': string;
533
538
  'mail.new_login.subject': string;
534
539
  'mail.new_login.heading': string;
535
540
  'mail.new_login.intro': string;
@@ -1243,6 +1248,11 @@ export declare const PT_BR_MESSAGES: {
1243
1248
  'mail.magic_link.code_subject': string;
1244
1249
  'mail.magic_link.code_intro': string;
1245
1250
  'mail.magic_link.code_only_label': string;
1251
+ 'mail.sudo_link.subject': string;
1252
+ 'mail.sudo_link.heading': string;
1253
+ 'mail.sudo_link.intro': string;
1254
+ 'mail.sudo_link.cta': string;
1255
+ 'mail.sudo_link.fallback': string;
1246
1256
  'mail.new_login.subject': string;
1247
1257
  'mail.new_login.heading': string;
1248
1258
  'mail.new_login.intro': string;
@@ -573,6 +573,16 @@ export const DEFAULT_MESSAGES = {
573
573
  'mail.magic_link.code_subject': 'Your login code',
574
574
  'mail.magic_link.code_intro': 'Use the code below to sign in. It expires shortly and can be used once.',
575
575
  'mail.magic_link.code_only_label': 'Enter this code to sign in:',
576
+ // Link de CONFIRMAÇÃO DE IDENTIDADE (sudo). Copy DISTINTA da do magic link de
577
+ // login de propósito: este link não autentica ninguém — quem o abre já está
578
+ // logado e só reprova que é quem diz ser. Prometer "entre na sua conta" num
579
+ // e-mail que não faz login é o tipo de erro que ensina o usuário a clicar em
580
+ // qualquer link de "acesso" que chegue.
581
+ 'mail.sudo_link.subject': 'Confirm it is you',
582
+ 'mail.sudo_link.heading': 'Confirm your identity',
583
+ 'mail.sudo_link.intro': 'You asked to confirm your identity before a sensitive account action. Click the button below to continue. The link expires in 5 minutes, can be used once and only works in the browser that requested it.',
584
+ 'mail.sudo_link.cta': 'Confirm identity',
585
+ 'mail.sudo_link.fallback': 'If you did not request this, you can ignore this email — nothing was changed.',
576
586
  'mail.new_login.subject': 'New login to your account',
577
587
  'mail.new_login.heading': 'New login detected',
578
588
  'mail.new_login.intro': 'We detected a new login to your account.',
@@ -1365,6 +1375,12 @@ export const PT_BR_MESSAGES = {
1365
1375
  'mail.magic_link.code_subject': 'Seu código de login',
1366
1376
  'mail.magic_link.code_intro': 'Use o código abaixo para entrar. Ele expira em instantes e serve para um único acesso.',
1367
1377
  'mail.magic_link.code_only_label': 'Digite este código para entrar:',
1378
+ // Link de CONFIRMAÇÃO DE IDENTIDADE (sudo) — ver o comentário no catálogo `en`.
1379
+ 'mail.sudo_link.subject': 'Confirme que é você',
1380
+ 'mail.sudo_link.heading': 'Confirme sua identidade',
1381
+ 'mail.sudo_link.intro': 'Você pediu para confirmar sua identidade antes de uma ação sensível da conta. Clique no botão abaixo para continuar. O link expira em 5 minutos, serve uma única vez e só funciona no navegador que o pediu.',
1382
+ 'mail.sudo_link.cta': 'Confirmar identidade',
1383
+ 'mail.sudo_link.fallback': 'Se você não solicitou isso, pode ignorar este e-mail — nada mudou.',
1368
1384
  'mail.new_login.subject': 'Novo login na sua conta',
1369
1385
  'mail.new_login.heading': 'Novo login detectado',
1370
1386
  'mail.new_login.intro': 'Detectamos um novo login na sua conta.',
@@ -1,4 +1,4 @@
1
- import type { AuthAccount } from '../accounts/account_store.js';
1
+ import type { AccountStore, AuthAccount } from '../accounts/account_store.js';
2
2
  import type { AuditSink } from '../audit/audit_sink.js';
3
3
  import type { ResolvedServerConfig } from '../define_config.js';
4
4
  import type { SettingsCapability } from './runtime_settings.js';
@@ -23,7 +23,7 @@ export declare function isEmailUnverifiedBlock(cfg: ResolvedServerConfig, accoun
23
23
  *
24
24
  * @returns true se a senha expirou e deve ser trocada antes de completar o login.
25
25
  */
26
- export declare function isPasswordExpired(cfg: ResolvedServerConfig, accountId: string, settings: SettingsCapability): Promise<boolean>;
26
+ export declare function isPasswordExpired(cfg: Pick<ResolvedServerConfig, 'accountStore'>, accountId: string, settings: SettingsCapability): Promise<boolean>;
27
27
  /**
28
28
  * Verifica se a conta está expirada por inatividade (account_expiration setting).
29
29
  *
@@ -43,6 +43,92 @@ export declare function isAccountExpired(audit: AuditSink | null | undefined, ac
43
43
  export interface LoginAttemptLogger {
44
44
  warn(obj: unknown, msg?: string): void;
45
45
  }
46
+ /**
47
+ * Motivo pelo qual um login foi recusado no gate de status de conta,
48
+ * compartilhado por TODOS os fluxos de login (senha, magic link, OTP,
49
+ * passkey, social, token-exchange/impersonation) — não só o de senha.
50
+ */
51
+ export type AccountStatusReason = 'disabled' | 'password_expired' | 'account_expired';
52
+ /** Resultado do gate de status de conta. Discriminado por `allowed`. */
53
+ export type LoginAllowedResult = {
54
+ allowed: true;
55
+ } | {
56
+ allowed: false;
57
+ reason: AccountStatusReason;
58
+ };
59
+ /**
60
+ * Subconjunto de {@link ResolvedServerConfig} que o gate de status precisa: o
61
+ * accountStore (para as capacidades opcionais) e o audit sink (para os
62
+ * `login.failure`/`password.expired_change_forced`/`account.expired_login_blocked`
63
+ * emitidos). Estrutural — qualquer `ResolvedServerConfig` serve aqui sem cast;
64
+ * chamadores fora do host (ex.: `token_exchange.ts`, que não tem o
65
+ * `ResolvedServerConfig` inteiro em mãos) podem montar só este pedaço.
66
+ */
67
+ export interface LoginAllowedConfig {
68
+ accountStore: AccountStore;
69
+ audit?: AuditSink;
70
+ }
71
+ /** Entrada comum aos dois gates de status (`assertAccountEnabled`/`assertAccountNotExpired`). */
72
+ export interface LoginAllowedInput {
73
+ email: string;
74
+ ip: string | null;
75
+ /** `clientId` só entra no evento de auditoria quando o fluxo o fornece (mesma regra do resto do arquivo). */
76
+ clientId?: string | null;
77
+ /** Ausente → password-expiry/account-expiry ficam no-op (precisam da runtime setting). */
78
+ settings?: SettingsCapability;
79
+ /**
80
+ * `true` → o chamador declara ser um fluxo SEM senha, e a checagem de senha
81
+ * vencida (`password_expiration`) é pulada. A de conta expirada por
82
+ * inatividade (`account_expiration`) continua valendo normalmente.
83
+ *
84
+ * Existe porque a expiração de senha pressupõe duas coisas: que a conta TEM
85
+ * senha, e que o fluxo tem um passo de troca obrigatória para onde mandar o
86
+ * usuário. `isPasswordExpired` trata `password_changed_at` NULL como vencida
87
+ * (seguro no fluxo de senha, que força a troca na 1ª vez) — num fluxo que não
88
+ * tem nenhuma das duas, isso recusa contas que nunca tiveram senha e não lhes
89
+ * oferece saída alguma.
90
+ *
91
+ * Default (ausente/`false`) = a checagem se aplica.
92
+ */
93
+ passwordless?: boolean;
94
+ logger?: LoginAttemptLogger;
95
+ }
96
+ /**
97
+ * Gate 1/2: conta desabilitada. Extraído de {@link attemptPasswordLogin}
98
+ * (bloco `isDisabled` original) para ser reutilizável por TODO fluxo que
99
+ * finaliza um login (`completeLogin`), não só o de senha.
100
+ *
101
+ * Capability-probed via `supportsAccountStatus` — store sem a capacidade
102
+ * degrada para "allowed" (comportamento preservado exatamente).
103
+ */
104
+ export declare function assertAccountEnabled(cfg: LoginAllowedConfig, accountId: string, input: LoginAllowedInput): Promise<LoginAllowedResult>;
105
+ /**
106
+ * Gate 2/2: senha vencida (password_expiration) e conta expirada por
107
+ * inatividade (account_expiration). Extraído dos blocos originais de
108
+ * {@link attemptPasswordLogin} que seguiam o gate de e-mail não verificado.
109
+ *
110
+ * Capability/setting-probed: sem `input.settings`, ambas as checagens são
111
+ * no-op (mesma guarda `if (input.settings)` do código original).
112
+ *
113
+ * `input.passwordless === true` pula APENAS a checagem de senha vencida — ver
114
+ * {@link LoginAllowedInput.passwordless}. A expiração por inatividade continua.
115
+ */
116
+ export declare function assertAccountNotExpired(cfg: LoginAllowedConfig, accountId: string, input: LoginAllowedInput): Promise<LoginAllowedResult>;
117
+ /**
118
+ * Gate combinado (disabled → password_expired → account_expired), na ordem
119
+ * canônica. É o que os fluxos passwordless (magic link, OTP, passkey, social)
120
+ * e o token-exchange (impersonation) chamam UMA VEZ, imediatamente antes de
121
+ * `completeLogin` — eles não têm o gate de e-mail-não-verificado intercalado
122
+ * entre o disabled-check e os expiry-checks como `attemptPasswordLogin` tem,
123
+ * então uma chamada única e sequencial é observacionalmente idêntica a chamar
124
+ * os dois gates separados nessa ordem.
125
+ *
126
+ * `attemptPasswordLogin` NÃO usa este combinado — chama `assertAccountEnabled`
127
+ * e `assertAccountNotExpired` separadamente, com o gate de e-mail não
128
+ * verificado (`isEmailUnverifiedBlock`) intercalado entre os dois, para
129
+ * preservar a ordem de checagens original byte-a-byte.
130
+ */
131
+ export declare function assertLoginAllowed(cfg: LoginAllowedConfig, accountId: string, input: LoginAllowedInput): Promise<LoginAllowedResult>;
46
132
  /** Entrada de uma tentativa de login por senha (keyed por email). */
47
133
  export interface PasswordLoginInput {
48
134
  email: string;
@@ -124,6 +124,83 @@ export async function isAccountExpired(audit, accountId, settings, logger) {
124
124
  return false;
125
125
  }
126
126
  }
127
+ /**
128
+ * Gate 1/2: conta desabilitada. Extraído de {@link attemptPasswordLogin}
129
+ * (bloco `isDisabled` original) para ser reutilizável por TODO fluxo que
130
+ * finaliza um login (`completeLogin`), não só o de senha.
131
+ *
132
+ * Capability-probed via `supportsAccountStatus` — store sem a capacidade
133
+ * degrada para "allowed" (comportamento preservado exatamente).
134
+ */
135
+ export async function assertAccountEnabled(cfg, accountId, input) {
136
+ const { email, ip, clientId } = input;
137
+ if (supportsAccountStatus(cfg.accountStore) && (await cfg.accountStore.isDisabled(accountId))) {
138
+ await cfg.audit?.record(clientId !== undefined
139
+ ? {
140
+ type: 'login.failure',
141
+ email,
142
+ ip,
143
+ clientId,
144
+ metadata: { reason: 'disabled' },
145
+ }
146
+ : { type: 'login.failure', email, ip, metadata: { reason: 'disabled' } });
147
+ return { allowed: false, reason: 'disabled' };
148
+ }
149
+ return { allowed: true };
150
+ }
151
+ /**
152
+ * Gate 2/2: senha vencida (password_expiration) e conta expirada por
153
+ * inatividade (account_expiration). Extraído dos blocos originais de
154
+ * {@link attemptPasswordLogin} que seguiam o gate de e-mail não verificado.
155
+ *
156
+ * Capability/setting-probed: sem `input.settings`, ambas as checagens são
157
+ * no-op (mesma guarda `if (input.settings)` do código original).
158
+ *
159
+ * `input.passwordless === true` pula APENAS a checagem de senha vencida — ver
160
+ * {@link LoginAllowedInput.passwordless}. A expiração por inatividade continua.
161
+ */
162
+ export async function assertAccountNotExpired(cfg, accountId, input) {
163
+ const { email, ip, clientId, settings, logger } = input;
164
+ if (settings && input.passwordless !== true) {
165
+ const expired = await isPasswordExpired(cfg, accountId, settings);
166
+ if (expired) {
167
+ await cfg.audit?.record(clientId !== undefined
168
+ ? { type: 'password.expired_change_forced', accountId, email, ip, clientId }
169
+ : { type: 'password.expired_change_forced', accountId, email, ip });
170
+ return { allowed: false, reason: 'password_expired' };
171
+ }
172
+ }
173
+ if (settings) {
174
+ const expired = await isAccountExpired(cfg.audit, accountId, settings, logger);
175
+ if (expired) {
176
+ await cfg.audit?.record(clientId !== undefined
177
+ ? { type: 'account.expired_login_blocked', accountId, email, ip, clientId }
178
+ : { type: 'account.expired_login_blocked', accountId, email, ip });
179
+ return { allowed: false, reason: 'account_expired' };
180
+ }
181
+ }
182
+ return { allowed: true };
183
+ }
184
+ /**
185
+ * Gate combinado (disabled → password_expired → account_expired), na ordem
186
+ * canônica. É o que os fluxos passwordless (magic link, OTP, passkey, social)
187
+ * e o token-exchange (impersonation) chamam UMA VEZ, imediatamente antes de
188
+ * `completeLogin` — eles não têm o gate de e-mail-não-verificado intercalado
189
+ * entre o disabled-check e os expiry-checks como `attemptPasswordLogin` tem,
190
+ * então uma chamada única e sequencial é observacionalmente idêntica a chamar
191
+ * os dois gates separados nessa ordem.
192
+ *
193
+ * `attemptPasswordLogin` NÃO usa este combinado — chama `assertAccountEnabled`
194
+ * e `assertAccountNotExpired` separadamente, com o gate de e-mail não
195
+ * verificado (`isEmailUnverifiedBlock`) intercalado entre os dois, para
196
+ * preservar a ordem de checagens original byte-a-byte.
197
+ */
198
+ export async function assertLoginAllowed(cfg, accountId, input) {
199
+ const enabled = await assertAccountEnabled(cfg, accountId, input);
200
+ if (!enabled.allowed)
201
+ return enabled;
202
+ return assertAccountNotExpired(cfg, accountId, input);
203
+ }
127
204
  /**
128
205
  * Sequência canônica de login por senha + bloqueio progressivo, compartilhada
129
206
  * pelos dois fluxos que pedem senha (interaction OIDC e console de conta):
@@ -165,17 +242,14 @@ export async function attemptPasswordLogin(cfg, input) {
165
242
  // Conta desabilitada: rejeita o login (mesmo com senha correta). A capacidade é
166
243
  // opcional — só checada quando o store a implementa. Emite `login.failure` (com
167
244
  // motivo `disabled` no metadata) e NÃO registra falha no lockout (não é tentativa
168
- // de adivinhar senha).
169
- if (supportsAccountStatus(cfg.accountStore) && (await cfg.accountStore.isDisabled(account.id))) {
170
- await cfg.audit?.record(input.clientId !== undefined
171
- ? {
172
- type: 'login.failure',
173
- email,
174
- ip,
175
- clientId: input.clientId,
176
- metadata: { reason: 'disabled' },
177
- }
178
- : { type: 'login.failure', email, ip, metadata: { reason: 'disabled' } });
245
+ // de adivinhar senha). Extraído em `assertAccountEnabled` — reutilizado por TODO
246
+ // fluxo de login (magic link, OTP, passkey, social, token-exchange), não este.
247
+ const enabledCheck = await assertAccountEnabled(cfg, account.id, {
248
+ email,
249
+ ip,
250
+ clientId: input.clientId,
251
+ });
252
+ if (!enabledCheck.allowed) {
179
253
  return { ok: false, locked: false, disabled: true };
180
254
  }
181
255
  // E-mail não verificado (LGPD/compliance): rejeita o login se a política exige
@@ -194,45 +268,24 @@ export async function attemptPasswordLogin(cfg, input) {
194
268
  : { type: 'login.failure', email, ip, metadata: { reason: 'unverified' } });
195
269
  return { ok: false, locked: false, unverified: true };
196
270
  }
197
- // Senha expirada (password expiration): se a senha está vencida, sinaliza ao
198
- // controller para forçar a troca ANTES de completar o login.
199
- // Capability-probed: sem `getPasswordChangedAt` ou sem a setting no-op.
200
- if (input.settings) {
201
- const expired = await isPasswordExpired(cfg, account.id, input.settings);
202
- if (expired) {
203
- // Não é uma falha de credenciais — limpa o contador.
204
- await lockout.clearFailures(email);
205
- await cfg.audit?.record(input.clientId !== undefined
206
- ? {
207
- type: 'password.expired_change_forced',
208
- accountId: account.id,
209
- email,
210
- ip,
211
- clientId: input.clientId,
212
- }
213
- : { type: 'password.expired_change_forced', accountId: account.id, email, ip });
271
+ // Senha expirada (password expiration) e conta expirada por inatividade
272
+ // (account_expiration): extraído em `assertAccountNotExpired`, na mesma ordem
273
+ // e com os mesmos eventos de auditoria do código original. Capability/setting
274
+ // -probed internamente (sem `input.settings` → no-op, como antes).
275
+ const expiryCheck = await assertAccountNotExpired(cfg, account.id, {
276
+ email,
277
+ ip,
278
+ clientId: input.clientId,
279
+ settings: input.settings,
280
+ logger: input.logger,
281
+ });
282
+ if (!expiryCheck.allowed) {
283
+ // Nenhuma das duas é falha de credenciais — limpa o contador.
284
+ await lockout.clearFailures(email);
285
+ if (expiryCheck.reason === 'password_expired') {
214
286
  return { ok: false, locked: false, passwordExpired: true, account: account };
215
287
  }
216
- }
217
- // Conta expirada por inatividade (account_expiration setting): bloqueia o login
218
- // se a conta não teve login.success há mais de `inactiveDays` dias (via audit).
219
- // Capability-probed: sem audit.list ou sem setting → no-op.
220
- // Reativação: fluxo de reset de senha (link exibido pelo controller na mensagem).
221
- if (input.settings) {
222
- const expired = await isAccountExpired(cfg.audit, account.id, input.settings, input.logger);
223
- if (expired) {
224
- await lockout.clearFailures(email);
225
- await cfg.audit?.record(input.clientId !== undefined
226
- ? {
227
- type: 'account.expired_login_blocked',
228
- accountId: account.id,
229
- email,
230
- ip,
231
- clientId: input.clientId,
232
- }
233
- : { type: 'account.expired_login_blocked', accountId: account.id, email, ip });
234
- return { ok: false, locked: false, accountExpired: true };
235
- }
288
+ return { ok: false, locked: false, accountExpired: true };
236
289
  }
237
290
  // Senha correta: limpa o contador de falhas (o lockout protege a etapa de senha).
238
291
  await lockout.clearFailures(email);
@@ -98,7 +98,7 @@ async function notifyNewDevice(ctx, cfg, data) {
98
98
  });
99
99
  return;
100
100
  }
101
- await sendNewDeviceLoginEmail(ctx, {
101
+ await sendNewDeviceLoginEmail(ctx, cfg, {
102
102
  email: data.email,
103
103
  ip: data.ip,
104
104
  userAgent: data.userAgent,
@@ -132,7 +132,7 @@ async function maybeNotifyNewLogin(ctx, cfg, data) {
132
132
  if (sameIpCount > 1)
133
133
  return;
134
134
  const when = new Date().toISOString();
135
- await sendNewLoginEmail(ctx, { email, ip, when });
135
+ await sendNewLoginEmail(ctx, cfg, { email, ip, when });
136
136
  await cfg.audit?.record({
137
137
  type: 'login.new_ip_notified',
138
138
  accountId,
@@ -0,0 +1,112 @@
1
+ import type { symbols } from '@adonisjs/auth';
2
+ import type { AuthClientResponse, GuardConfigProvider, GuardContract } from '@adonisjs/auth/types';
3
+ import type { SessionUserProviderContract } from '@adonisjs/auth/types/session';
4
+ import type { HttpContext } from '@adonisjs/core/http';
5
+ import type { ConfigProvider } from '@adonisjs/core/types';
6
+ import type { EmitterLike } from '@adonisjs/core/types/events';
7
+ type RealUser<UserProvider> = UserProvider extends SessionUserProviderContract<infer U> ? U : never;
8
+ /**
9
+ * Construtor do `E_UNAUTHORIZED_ACCESS` do `@adonisjs/auth` — a MESMA classe que
10
+ * os guards nativos lançam (`static status = 401`, `redirectTo`, renderers
11
+ * html/json/jsonapi). Tipado à mão porque o pacote não pode ser importado
12
+ * estaticamente daqui (peer opcional).
13
+ */
14
+ export type UnauthorizedAccessConstructor = new (message: string, options: {
15
+ guardDriverName: string;
16
+ redirectTo?: string;
17
+ }) => Error;
18
+ /**
19
+ * Captura o `E_UNAUTHORIZED_ACCESS` real do `@adonisjs/auth` sem import
20
+ * estático. Chamado no boot pelo {@link oidcRpGuard} (falha cedo e com uma
21
+ * mensagem útil se o peer não estiver instalado) e, como rede de segurança, na
22
+ * primeira `authenticate()` de um guard construído à mão.
23
+ */
24
+ export declare function loadUnauthorizedAccess(): Promise<UnauthorizedAccessConstructor>;
25
+ export type OidcRpGuardOptions<UserProvider extends SessionUserProviderContract<unknown>> = {
26
+ provider: UserProvider | ConfigProvider<UserProvider>;
27
+ sessionKey?: string;
28
+ };
29
+ export type OidcRpGuardEvents<User> = {
30
+ 'oidc_rp:login_succeeded': {
31
+ ctx: HttpContext;
32
+ guardName: string;
33
+ user: User;
34
+ };
35
+ 'oidc_rp:authentication_succeeded': {
36
+ ctx: HttpContext;
37
+ guardName: string;
38
+ user: User;
39
+ };
40
+ 'oidc_rp:authentication_failed': {
41
+ ctx: HttpContext;
42
+ guardName: string;
43
+ };
44
+ 'oidc_rp:logged_out': {
45
+ ctx: HttpContext;
46
+ guardName: string;
47
+ user: User | null;
48
+ };
49
+ };
50
+ /**
51
+ * Guard de `@adonisjs/auth` pra Relying Parties OIDC — o app não autentica
52
+ * ninguém (sem senha, sem remember-me); a identidade vem da sessão gravada
53
+ * pelo callback OIDC (`account_user_id`). O guard só LÊ essa chave e resolve
54
+ * o user via provider.
55
+ *
56
+ * ```ts
57
+ * // config/auth.ts
58
+ * import { oidcRpGuard } from '@adonis-agora/authkit-server'
59
+ * import { sessionUserProvider } from '@adonisjs/auth/session'
60
+ *
61
+ * const authConfig = defineConfig({
62
+ * default: 'web',
63
+ * guards: {
64
+ * web: oidcRpGuard({
65
+ * provider: sessionUserProvider({ model: () => import('#models/user') }),
66
+ * }),
67
+ * },
68
+ * })
69
+ * ```
70
+ *
71
+ * O callback OIDC do RP chama `ctx.auth.use('web').login(user)` pra gravar a
72
+ * sessão; a partir daí `ctx.auth.user`, `auth.check()`, `middleware.auth()` —
73
+ * tudo funciona nativamente.
74
+ */
75
+ export declare class OidcRpGuard<UserProvider extends SessionUserProviderContract<unknown>> implements GuardContract<RealUser<UserProvider>> {
76
+ #private;
77
+ [symbols.GUARD_KNOWN_EVENTS]: OidcRpGuardEvents<RealUser<UserProvider>>;
78
+ driverName: "oidc_rp";
79
+ authenticationAttempted: boolean;
80
+ isAuthenticated: boolean;
81
+ isLoggedOut: boolean;
82
+ user?: RealUser<UserProvider>;
83
+ constructor(name: string, ctx: HttpContext, sessionKey: string, emitter: EmitterLike<OidcRpGuardEvents<RealUser<UserProvider>>>, userProvider: UserProvider, unauthorized?: UnauthorizedAccessConstructor);
84
+ getUserOrFail(): RealUser<UserProvider>;
85
+ /**
86
+ * Grava a identidade na sessão. Chamado pelo callback OIDC após validar o
87
+ * grant — o guard NÃO verifica credenciais, só persiste o id do user.
88
+ */
89
+ login(user: RealUser<UserProvider>): Promise<void>;
90
+ /**
91
+ * Limpa a identidade da sessão. O RP-initiated logout (redirect pro
92
+ * `end_session` do issuer) é responsabilidade do controller — o guard só
93
+ * cuida da sessão local.
94
+ */
95
+ logout(): Promise<void>;
96
+ authenticate(): Promise<RealUser<UserProvider>>;
97
+ /**
98
+ * `authenticate()` sem lançar — mas SÓ para falha de autenticação. Igual ao
99
+ * `SessionGuard` nativo: engole apenas `E_UNAUTHORIZED_ACCESS` e relança o
100
+ * resto. Um `catch` cego aqui transformava uma queda do banco dentro de
101
+ * `provider.findById` em "não logado" para todo mundo, sem nada nos logs.
102
+ */
103
+ check(): Promise<boolean>;
104
+ authenticateAsClient(user: RealUser<UserProvider>): Promise<AuthClientResponse>;
105
+ }
106
+ /**
107
+ * Factory de config pro `oidcRpGuard` — mesmo padrão do `sessionGuard()` de
108
+ * `@adonisjs/auth/session`. Retorna um `GuardConfigProvider` que o
109
+ * `defineConfig` de `config/auth.ts` resolve no boot.
110
+ */
111
+ export declare function oidcRpGuard<UserProvider extends SessionUserProviderContract<unknown>>(config: OidcRpGuardOptions<UserProvider>): GuardConfigProvider<(ctx: HttpContext) => OidcRpGuard<UserProvider>>;
112
+ export {};