@adonis-agora/authkit-server 0.82.3 → 0.84.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/host/views/login.edge +25 -5
  2. package/build/host/views/partials/styles.edge +1 -1
  3. package/build/src/define_config.d.ts +44 -4
  4. package/build/src/define_config.js +1 -0
  5. package/build/src/host/account_roles.d.ts +14 -3
  6. package/build/src/host/account_roles.js +15 -4
  7. package/build/src/host/admin_api/api_users_controller.d.ts +3 -0
  8. package/build/src/host/admin_api/api_users_controller.js +9 -5
  9. package/build/src/host/admin_api/dto.d.ts +24 -0
  10. package/build/src/host/admin_api/dto.js +14 -0
  11. package/build/src/host/admin_api/token_verify_service.js +7 -2
  12. package/build/src/host/admin_console/admin_shell_controller.js +3 -1
  13. package/build/src/host/admin_console/console_users_controller.d.ts +4 -0
  14. package/build/src/host/admin_console/console_users_controller.js +12 -6
  15. package/build/src/host/controllers/interaction_controller.d.ts +8 -0
  16. package/build/src/host/controllers/interaction_controller.js +59 -12
  17. package/build/src/host/controllers/pat_introspection_controller.d.ts +1 -1
  18. package/build/src/host/controllers/pat_introspection_controller.js +2 -1
  19. package/build/src/host/controllers/registration_controller.d.ts +10 -0
  20. package/build/src/host/controllers/registration_controller.js +97 -30
  21. package/build/src/host/controllers/social_controller.js +64 -1
  22. package/build/src/host/i18n.d.ts +2 -0
  23. package/build/src/host/i18n.js +3 -0
  24. package/build/src/host/last_login_method.d.ts +67 -0
  25. package/build/src/host/last_login_method.js +119 -0
  26. package/build/src/host/register_auth_host.js +4 -0
  27. package/build/src/host/signup_token.d.ts +9 -0
  28. package/build/src/host/signup_token.js +31 -0
  29. package/build/src/host/social_pending.d.ts +17 -0
  30. package/build/src/host/social_pending.js +11 -0
  31. package/build/src/host/ui-dist/assets/{index-C1eSfG8B.js → index-DhQzXw2i.js} +1 -1
  32. package/build/src/host/ui-dist/index.html +1 -1
  33. package/build/src/provider/interaction_actions.js +7 -0
  34. package/package.json +2 -2
@@ -1,5 +1,6 @@
1
1
  import '../augmentations.js';
2
2
  import { supportsLoginMethodsPreference, supportsMagicLink, supportsOtpLogin, supportsPasskeys, } from '../../accounts/account_store.js';
3
+ import { resolveAccountRoles } from '../account_roles.js';
3
4
  import { AdminSessionsService } from '../admin_sessions_service.js';
4
5
  import { guardBotProtection, resolveEffectiveBotProtection } from '../bot_protection.js';
5
6
  import { brandFor, isFirstParty } from '../branding.js';
@@ -9,6 +10,7 @@ import { beginCustomMfa, clearMfaFlow, customMfaViewProps, getMfaFlow, initializ
9
10
  import { sendMagicLinkEmail, sendOtpUnlockEmail } from '../default_mailer.js';
10
11
  import { normalizeEmailIdentifier } from '../email_identifier.js';
11
12
  import { translate } from '../i18n.js';
13
+ import { readLastLoginMethod, rememberLastLoginMethod } from '../last_login_method.js';
12
14
  import { assertLoginAllowed, attemptPasswordLogin, isEmailUnverifiedBlock, } from '../login_attempt.js';
13
15
  import { magicChannelProp, normalizeLoginChannel } from '../login_channel.js';
14
16
  import { notifyLoginSuccess } from '../login_notify.js';
@@ -16,8 +18,19 @@ import { authkitOrigin } from '../origin.js';
16
18
  import { createOtpLockout, generateOtpUnlockToken, rawToDbOtpUnlockToken, resolveEffectiveOtpLockout, } from '../otp_lockout.js';
17
19
  import { resolveRuntimeSettingsOrNoop } from '../runtime_settings.js';
18
20
  import { resolveEffectiveAuthMethods, resolveEffectiveMaintenanceMode, resolveEffectiveRegistration, resolveEffectiveSessionPolicy, } from '../runtime_toggles.js';
21
+ import { SOCIAL_PENDING_KEY, SOCIAL_PENDING_TTL_MS, } from '../social_pending.js';
19
22
  import { buildTrustedDevicePayload, isTrustedDeviceValid, TRUSTED_DEVICE_COOKIE, } from '../trusted_device.js';
20
23
  import { normalizeUserLoginMethods, resolveEffectiveUserLoginMethods, } from '../user_login_methods.js';
24
+ /**
25
+ * Conta com papel administrativo (`cfg.admin.roles`, default `['ADMIN']`) — o
26
+ * bypass do modo manutenção. Os papéis vêm de `resolveAccountRoles`, a mesma
27
+ * fonte do claim (`resolveTokenRoles`, ex.: authz), não só do `globalRoles`.
28
+ */
29
+ async function isAdminAccount(cfg, account) {
30
+ const adminRoles = cfg.admin?.roles ?? ['ADMIN'];
31
+ const roles = await resolveAccountRoles(cfg, account);
32
+ return adminRoles.some((role) => roles.includes(role));
33
+ }
21
34
  /**
22
35
  * Chave i18n do erro de status de conta (disabled/expired) compartilhada pelos
23
36
  * fluxos passwordless (magic link, OTP, passkey).
@@ -105,8 +118,7 @@ export default class AuthInteractionController {
105
118
  if (!status.allowed)
106
119
  return fail(accountStatusErrorKey(status.reason));
107
120
  const maintenance = await resolveEffectiveMaintenanceMode(settings);
108
- if (maintenance.enabled &&
109
- !(cfg.admin?.roles ?? ['ADMIN']).some((role) => (account.globalRoles ?? []).includes(role))) {
121
+ if (maintenance.enabled && !(await isAdminAccount(cfg, account))) {
110
122
  return render(ctx, 'maintenance', {
111
123
  uid: details.uid,
112
124
  csrfToken: ctx.request.csrfToken,
@@ -114,6 +126,7 @@ export default class AuthInteractionController {
114
126
  message: maintenance.message ?? translate(cfg.messages, 'maintenance.default_message'),
115
127
  });
116
128
  }
129
+ rememberLastLoginMethod(ctx, cfg, `custom:${input.method}`);
117
130
  const gate = await this.secondFactorGate(ctx, cfg, account.id, details, input.method, input.factorId, input.remember, input.passwordless === true);
118
131
  if (gate.kind === 'challenge') {
119
132
  ctx.session.put(MFA_CUSTOM_PRIMARY_KEY, {
@@ -164,6 +177,8 @@ export default class AuthInteractionController {
164
177
  authMethods,
165
178
  magicLinkAvailable: authMethods.magicLink && magicLinkCapableConfig,
166
179
  otpEnabled,
180
+ // Selo "último usado": método com que ESTE dispositivo entrou da última vez.
181
+ lastUsedLoginMethod: readLastLoginMethod(ctx, cfg),
167
182
  };
168
183
  }
169
184
  /**
@@ -468,9 +483,7 @@ export default class AuthInteractionController {
468
483
  // Contas admin (role em cfg.admin.roles) CONTINUAM podendo logar.
469
484
  // Contas comuns são bloqueadas com a tela de manutenção.
470
485
  if (maintenance.enabled) {
471
- const adminRoles = cfg.admin?.roles ?? ['ADMIN'];
472
- const accountRoles = acc.globalRoles ?? [];
473
- const isAdmin = adminRoles.some((r) => accountRoles.includes(r));
486
+ const isAdmin = await isAdminAccount(cfg, acc);
474
487
  if (!isAdmin) {
475
488
  return render(ctx, 'maintenance', {
476
489
  uid: details.uid,
@@ -486,6 +499,7 @@ export default class AuthInteractionController {
486
499
  // Gate do 2º fator — a MESMA regra dos três caminhos de login (ver
487
500
  // `secondFactorGate`). Quando desafia, não finaliza a interaction: guarda o
488
501
  // accountId pendente e devolve a tela.
502
+ rememberLastLoginMethod(ctx, cfg, 'password');
489
503
  const gate = await this.secondFactorGate(ctx, cfg, acc.id, details, 'pwd');
490
504
  if (gate.kind === 'challenge')
491
505
  return gate.response;
@@ -563,9 +577,9 @@ export default class AuthInteractionController {
563
577
  ...(used.has('webauthn') ? { passkeyAvailable: false } : {}),
564
578
  });
565
579
  }
566
- async configuredMfaGate(ctx, cfg, accountId, details, primary, primaryFactorId, rememberRequested, passwordless = primary !== 'pwd') {
580
+ async configuredMfaGate(ctx, cfg, accountId, details, primary, primaryFactorId, rememberRequested, passwordless = primary !== 'pwd', forceRequired = false) {
567
581
  const requiredFactors = cfg.mfa?.requiredFactors ?? 2;
568
- const mfaRequired = this.acrRequiresMfa(cfg, details) || cfg.mfa?.requiredFactors !== undefined;
582
+ const mfaRequired = forceRequired || this.acrRequiresMfa(cfg, details) || cfg.mfa?.requiredFactors !== undefined;
569
583
  const mfa = (await cfg.accountStore.getMfaState?.(accountId)) ?? { enabled: false };
570
584
  const enrolledTotp = mfa.totp ?? mfa.enabled;
571
585
  const enrolledPasskey = await this.hasPasskeys(cfg, accountId);
@@ -736,8 +750,7 @@ export default class AuthInteractionController {
736
750
  });
737
751
  }
738
752
  const maintenance = await resolveEffectiveMaintenanceMode(settings);
739
- if (maintenance.enabled &&
740
- !(cfg.admin?.roles ?? ['ADMIN']).some((role) => (account.globalRoles ?? []).includes(role))) {
753
+ if (maintenance.enabled && !(await isAdminAccount(cfg, account))) {
741
754
  return cfg.render(ctx, 'maintenance', {
742
755
  uid: flow.uid,
743
756
  csrfToken: ctx.request.csrfToken,
@@ -950,12 +963,12 @@ export default class AuthInteractionController {
950
963
  * - caso geral → `challenge`, com o accountId em `MFA_PENDING_KEY` e o fator
951
964
  * primário (`primary`) em `MFA_PRIMARY_KEY`.
952
965
  */
953
- async secondFactorGate(ctx, cfg, accountId, details, primary, primaryFactorId = primary, rememberRequested, passwordless = primary !== 'pwd') {
966
+ async secondFactorGate(ctx, cfg, accountId, details, primary, primaryFactorId = primary, rememberRequested, passwordless = primary !== 'pwd', forceRequired = false) {
954
967
  ctx.session.forget(MFA_CUSTOM_PRIMARY_KEY);
955
968
  clearMfaFlow(ctx);
956
969
  if (cfg.mfa)
957
- return this.configuredMfaGate(ctx, cfg, accountId, details, primary, primaryFactorId, rememberRequested, passwordless);
958
- const mfaRequired = this.acrRequiresMfa(cfg, details);
970
+ return this.configuredMfaGate(ctx, cfg, accountId, details, primary, primaryFactorId, rememberRequested, passwordless, forceRequired);
971
+ const mfaRequired = forceRequired || this.acrRequiresMfa(cfg, details);
959
972
  const mfa = (await cfg.accountStore.getMfaState?.(accountId)) ?? { enabled: false };
960
973
  // Passkey disponível como alternativa ao TOTP se o store suporta E a conta
961
974
  // tem ao menos uma credencial registrada.
@@ -1212,6 +1225,32 @@ export default class AuthInteractionController {
1212
1225
  * Consome o magic link. Em sucesso finaliza o login (amr `['email']`). Token
1213
1226
  * inválido/expirado volta ao início do login.
1214
1227
  */
1228
+ /**
1229
+ * GET /auth/interaction/:uid/social/continue — segunda metade do login social
1230
+ * quando a conta deve o 2º fator (ver `AuthSocialController#callback`). Lê o
1231
+ * login pendente da sessão UMA vez, confere que é desta interaction e passa
1232
+ * pelo mesmo `secondFactorGate` dos outros fluxos, com `fed` como fator
1233
+ * primário no `amr`.
1234
+ */
1235
+ async socialContinue(ctx) {
1236
+ const service = await ctx.containerResolver.make('authkit.server');
1237
+ const cfg = service.config;
1238
+ const uid = ctx.request.param('uid');
1239
+ const pending = ctx.session.get(SOCIAL_PENDING_KEY);
1240
+ ctx.session.forget(SOCIAL_PENDING_KEY);
1241
+ const details = await service.interactions.details(ctx);
1242
+ if (!pending ||
1243
+ pending.uid !== uid ||
1244
+ details.uid !== uid ||
1245
+ details.prompt.name !== 'login' ||
1246
+ Date.now() - pending.at > SOCIAL_PENDING_TTL_MS) {
1247
+ return ctx.response.redirect(`/auth/interaction/${uid}`);
1248
+ }
1249
+ const gate = await this.secondFactorGate(ctx, cfg, pending.accountId, details, 'fed', 'fed', undefined, true, pending.required);
1250
+ if (gate.kind === 'challenge')
1251
+ return gate.response;
1252
+ return service.interactions.completeLogin(ctx, pending.accountId, { amr: ['fed'] });
1253
+ }
1215
1254
  async magicLinkConsume(ctx) {
1216
1255
  const service = await ctx.containerResolver.make('authkit.server');
1217
1256
  const cfg = service.config;
@@ -1315,6 +1354,7 @@ export default class AuthInteractionController {
1315
1354
  });
1316
1355
  }
1317
1356
  // Conta com segundo fator não termina o login só com o e-mail.
1357
+ rememberLastLoginMethod(ctx, cfg, 'magic_link');
1318
1358
  const magicGate = await this.secondFactorGate(ctx, cfg, acc.id, await service.interactions.details(ctx), 'email');
1319
1359
  if (magicGate.kind === 'challenge')
1320
1360
  return magicGate.response;
@@ -1437,6 +1477,7 @@ export default class AuthInteractionController {
1437
1477
  clientId,
1438
1478
  });
1439
1479
  // Conta com segundo fator não termina o login só com o código do e-mail.
1480
+ rememberLastLoginMethod(ctx, cfg, 'otp');
1440
1481
  const otpGate = await this.secondFactorGate(ctx, cfg, result.account.id, await service.interactions.details(ctx), 'email');
1441
1482
  if (otpGate.kind === 'challenge')
1442
1483
  return otpGate.response;
@@ -1704,6 +1745,11 @@ export default class AuthInteractionController {
1704
1745
  return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
1705
1746
  return this.finishVerifiedMfa(ctx, cfg, details, 'webauthn', 'webauthn');
1706
1747
  }
1748
+ // Passkey como fator PRIMÁRIO (passkey-first/autofill). Como 2º fator ela não
1749
+ // é o método com que a pessoa se identificou — o selo fica com o primário.
1750
+ if (ctx.session.get(MFA_PENDING_KEY) !== accountId) {
1751
+ rememberLastLoginMethod(ctx, cfg, 'passkey');
1752
+ }
1707
1753
  if (cfg.mfa) {
1708
1754
  const gate = await this.secondFactorGate(ctx, cfg, accountId, details, 'webauthn');
1709
1755
  if (gate.kind === 'challenge')
@@ -1811,6 +1857,7 @@ export default class AuthInteractionController {
1811
1857
  // Senha trocada: limpa o step e finaliza o login.
1812
1858
  ctx.session.forget(PASSWORD_EXPIRED_KEY);
1813
1859
  // Verifica se precisa de MFA mesmo após a troca — mesma regra do resto.
1860
+ rememberLastLoginMethod(ctx, cfg, 'password');
1814
1861
  const expiredGate = await this.secondFactorGate(ctx, cfg, accountId, details, 'pwd');
1815
1862
  if (expiredGate.kind === 'challenge')
1816
1863
  return expiredGate.response;
@@ -14,7 +14,7 @@ export default class PatIntrospectionController {
14
14
  sub: any;
15
15
  email: any;
16
16
  name: any;
17
- roles: any;
17
+ roles: string[];
18
18
  scopes: any;
19
19
  audience: any;
20
20
  exp: any;
@@ -1,4 +1,5 @@
1
1
  import { timingSafeEqual } from 'node:crypto';
2
+ import { resolveAccountRoles } from '../account_roles.js';
2
3
  function bearerMatches(header, expected) {
3
4
  if (!header || !header.startsWith('Bearer '))
4
5
  return false;
@@ -47,7 +48,7 @@ export default class PatIntrospectionController {
47
48
  sub: account.id,
48
49
  email: account.email,
49
50
  name: account.name ?? null,
50
- roles: account.globalRoles ?? [],
51
+ roles: await resolveAccountRoles(cfg, account),
51
52
  scopes: meta.scopes,
52
53
  audience: meta.audience,
53
54
  exp: meta.exp,
@@ -6,6 +6,16 @@ export default class AuthRegistrationController {
6
6
  showSignup(ctx: HttpContext): Promise<any>;
7
7
  /** POST /auth/interaction/:uid/signup — cria o usuário e finaliza o login. */
8
8
  signup(ctx: HttpContext): Promise<any>;
9
+ /**
10
+ * GET /auth/interaction/:uid/signup/confirm?token= — o dono do e-mail abriu o
11
+ * link do cadastro passwordless. Cria a conta (e-mail já verificado: quem
12
+ * clicou provou a caixa de entrada) e conclui pelo fluxo de magic link, que
13
+ * aplica os mesmos gates do login (status, manutenção, 2º fator).
14
+ *
15
+ * Conta que já existe → o link é recusado e a pessoa volta ao login: é o que
16
+ * faz o link valer uma vez só, sem estado no banco.
17
+ */
18
+ signupConfirm(ctx: HttpContext): Promise<void>;
9
19
  /** GET /auth/forgot-password — tela standalone. */
10
20
  showForgot(ctx: HttpContext): Promise<any>;
11
21
  /** POST /auth/forgot-password — gera token e (dev) loga o link. Sempre responde sucesso (não vaza emails). */
@@ -1,15 +1,18 @@
1
1
  import '../augmentations.js';
2
2
  import { randomBytes } from 'node:crypto';
3
+ import { supportsAccountDeletion, supportsMagicLink } from '../../accounts/account_store.js';
3
4
  import { PasswordPolicyError } from '../../password/password_manager.js';
4
5
  import { AdminSessionsService } from '../admin_sessions_service.js';
5
6
  import { guardBotProtection, resolveEffectiveBotProtection } from '../bot_protection.js';
6
7
  import { brandFor } from '../branding.js';
7
8
  import { sendEmailVerificationEmail, sendMagicLinkEmail, sendPasswordResetEmail, } from '../default_mailer.js';
8
9
  import { translate } from '../i18n.js';
10
+ import { rememberLastLoginMethod } from '../last_login_method.js';
9
11
  import { authkitOrigin } from '../origin.js';
10
12
  import { RuntimeSettings, resolveRuntimeSettings } from '../runtime_settings.js';
11
13
  import { resolveEffectiveAuthMethods, resolveEffectiveMaintenanceMode, resolveEffectiveRegistration, } from '../runtime_toggles.js';
12
14
  import { dispatchSecurityNotice } from '../security_notice_service.js';
15
+ import { issueSignupToken, readSignupToken } from '../signup_token.js';
13
16
  import { forgotPasswordValidator, passwordlessSignupValidator, resetPasswordValidator, signupValidator, } from '../validators.js';
14
17
  /**
15
18
  * Best-effort: returns a RuntimeSettings backed by the container DB, or a no-op
@@ -163,6 +166,8 @@ export default class AuthRegistrationController {
163
166
  ip: ctx.request.ip?.() ?? null,
164
167
  clientId: details.params.client_id ?? null,
165
168
  });
169
+ // Conta nova entra por senha: o selo "último usado" já nasce no método certo.
170
+ rememberLastLoginMethod(ctx, cfg, 'password');
166
171
  // Finaliza a interaction como login (interactionFinished escreve o redirect 303 que
167
172
  // retoma o authorize; o form nativo da tela segue esse redirect no sucesso).
168
173
  const result = await service.interactions.login(ctx, {
@@ -170,6 +175,13 @@ export default class AuthRegistrationController {
170
175
  password: data.password,
171
176
  });
172
177
  if (!result.ok) {
178
+ // A conta acabou de nascer e o login dela falhou: sem desfazer, a pessoa
179
+ // ficaria com um e-mail "já cadastrado" numa conta em que nunca entrou.
180
+ if (created && supportsAccountDeletion(accountStore)) {
181
+ await accountStore.deleteAccount(created.id).catch((error) => {
182
+ ctx.logger.error({ err: error, accountId: created.id }, 'authkit: falha ao desfazer conta após signup sem login');
183
+ });
184
+ }
173
185
  return render(ctx, 'signup', {
174
186
  uid: ctx.request.param('uid'),
175
187
  csrfToken: ctx.request.csrfToken,
@@ -206,47 +218,46 @@ export default class AuthRegistrationController {
206
218
  }
207
219
  }
208
220
  /**
209
- * Cadastro passwordless: valida e-mail + nome, cria a conta (senha random
210
- * inutilizável) se ainda não existe, emite um magic link e o envia. Sempre
211
- * responde "link enviado" (anti-enumeração), exista a conta ou não. Consumir o
212
- * link finaliza o login pelo fluxo de magic link já existente (GET /magic).
221
+ * Cadastro passwordless: valida e-mail + nome e envia um link. Conta existente
222
+ * recebe um magic link de login; e-mail novo recebe o link de confirmação do
223
+ * cadastro (`signupConfirm`), e só então a conta é criada. Sempre responde
224
+ * "link enviado" (anti-enumeração), exista a conta ou não.
213
225
  */
214
226
  async #passwordlessSignup(ctx, deps) {
215
- const { cfg, brand, details } = deps;
227
+ const { cfg, brand } = deps;
216
228
  const render = cfg.render;
217
229
  const accountStore = cfg.accountStore;
218
230
  const uid = ctx.request.param('uid');
219
231
  const data = await ctx.request.validateUsing(passwordlessSignupValidator);
220
- // Cria a conta se ainda não existe. Senha random inutilizável: o login é 100%
221
- // passwordless (mesmo precedente das contas criadas por identidade social).
232
+ const origin = authkitOrigin(cfg);
222
233
  const existing = await accountStore.findByEmail(data.email);
223
- if (!existing) {
224
- const created = await accountStore.create({
225
- email: data.email,
226
- fullName: data.fullName,
227
- password: randomBytes(24).toString('hex'),
228
- });
229
- await cfg.audit?.record({
230
- type: 'signup',
231
- accountId: created.id,
232
- email: data.email,
233
- ip: ctx.request.ip?.() ?? null,
234
- clientId: details.params.client_id ?? null,
235
- });
234
+ if (existing) {
235
+ // Conta já existe: o "cadastro" vira um login por magic link.
236
+ // `data.email` já é a forma normalizada, a mesma do login.
237
+ const issued = await accountStore.issueMagicLinkToken(data.email);
238
+ if (issued) {
239
+ const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
240
+ const to = issued.account.email;
241
+ if (cfg.mail?.onMagicLink) {
242
+ await cfg.mail.onMagicLink({ email: to, magicUrl, token: issued.token });
243
+ }
244
+ else {
245
+ await sendMagicLinkEmail(ctx, { email: to, magicUrl });
246
+ }
247
+ }
236
248
  }
237
- // Emite + envia o magic link (mesma construção do login por magic link).
238
- // `data.email` já é a forma normalizada — a mesma sob a qual a conta foi
239
- // encontrada acima ou acabou de ser criada.
240
- const issued = await accountStore.issueMagicLinkToken(data.email);
241
- if (issued) {
242
- const origin = authkitOrigin(cfg);
243
- const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
244
- const to = issued.account.email;
249
+ else {
250
+ // Conta nova: NÃO cria aqui. O link de confirmação leva e-mail + nome num
251
+ // token cifrado; a conta só nasce quando o dono do e-mail o abre
252
+ // (`signupConfirm`). Um envio que falha não deixa conta órfã, e digitar o
253
+ // e-mail de outra pessoa não cria nada.
254
+ const token = await issueSignupToken({ email: data.email, fullName: data.fullName ?? null });
255
+ const magicUrl = `${origin}/auth/interaction/${uid}/signup/confirm?token=${encodeURIComponent(token)}`;
245
256
  if (cfg.mail?.onMagicLink) {
246
- await cfg.mail.onMagicLink({ email: to, magicUrl, token: issued.token });
257
+ await cfg.mail.onMagicLink({ email: data.email, magicUrl, token, kind: 'signup' });
247
258
  }
248
259
  else {
249
- await sendMagicLinkEmail(ctx, { email: to, magicUrl });
260
+ await sendMagicLinkEmail(ctx, { email: data.email, magicUrl });
250
261
  }
251
262
  }
252
263
  // Resposta uniforme: "enviamos um link" (não vaza existência da conta).
@@ -257,6 +268,62 @@ export default class AuthRegistrationController {
257
268
  magicLinkSent: true,
258
269
  });
259
270
  }
271
+ /**
272
+ * GET /auth/interaction/:uid/signup/confirm?token= — o dono do e-mail abriu o
273
+ * link do cadastro passwordless. Cria a conta (e-mail já verificado: quem
274
+ * clicou provou a caixa de entrada) e conclui pelo fluxo de magic link, que
275
+ * aplica os mesmos gates do login (status, manutenção, 2º fator).
276
+ *
277
+ * Conta que já existe → o link é recusado e a pessoa volta ao login: é o que
278
+ * faz o link valer uma vez só, sem estado no banco.
279
+ */
280
+ async signupConfirm(ctx) {
281
+ const service = await ctx.containerResolver.make('authkit.server');
282
+ const cfg = service.config;
283
+ const accountStore = cfg.accountStore;
284
+ const uid = ctx.request.param('uid');
285
+ const backToLogin = `/auth/interaction/${uid}`;
286
+ const token = ctx.request.qs().token ?? '';
287
+ if (!cfg.passwordless?.signup || !supportsMagicLink(accountStore) || !token) {
288
+ return ctx.response.redirect(backToLogin);
289
+ }
290
+ const runtimeSettings = await getRuntimeSettings(ctx);
291
+ const registrationEnabled = await resolveEffectiveRegistration(cfg.registration?.enabled ?? true, runtimeSettings);
292
+ if (!registrationEnabled)
293
+ return ctx.response.redirect(backToLogin);
294
+ const claim = await readSignupToken(token);
295
+ if (!claim)
296
+ return ctx.response.redirect(backToLogin);
297
+ if (await accountStore.findByEmail(claim.email))
298
+ return ctx.response.redirect(backToLogin);
299
+ let created;
300
+ try {
301
+ created = await accountStore.create({
302
+ email: claim.email,
303
+ fullName: claim.fullName,
304
+ password: randomBytes(24).toString('hex'),
305
+ emailVerified: true,
306
+ });
307
+ }
308
+ catch (error) {
309
+ // Dois cliques simultâneos: o segundo perde na unicidade do e-mail.
310
+ if (await accountStore.findByEmail(claim.email))
311
+ return ctx.response.redirect(backToLogin);
312
+ throw error;
313
+ }
314
+ const details = await service.interactions.details(ctx);
315
+ await cfg.audit?.record({
316
+ type: 'signup',
317
+ accountId: created.id,
318
+ email: claim.email,
319
+ ip: ctx.request.ip?.() ?? null,
320
+ clientId: details.params.client_id ?? null,
321
+ });
322
+ const issued = await accountStore.issueMagicLinkToken(claim.email);
323
+ if (!issued)
324
+ return ctx.response.redirect(backToLogin);
325
+ return ctx.response.redirect(`/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`);
326
+ }
260
327
  /** GET /auth/forgot-password — tela standalone. */
261
328
  async showForgot(ctx) {
262
329
  const service = await ctx.containerResolver.make('authkit.server');
@@ -1,10 +1,13 @@
1
1
  import '../augmentations.js';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { supportsLoginMethodsPreference, supportsMagicLink, supportsPasskeys, supportsProviderIdentity, } from '../../accounts/account_store.js';
4
+ import { resolveAccountRoles } from '../account_roles.js';
4
5
  import { normalizeEmailIdentifier } from '../email_identifier.js';
6
+ import { rememberLastLoginMethod } from '../last_login_method.js';
5
7
  import { assertLoginAllowed } from '../login_attempt.js';
6
8
  import { resolveRuntimeSettingsOrNoop } from '../runtime_settings.js';
7
9
  import { resolveEffectiveAuthMethods } from '../runtime_toggles.js';
10
+ import { SOCIAL_PENDING_KEY } from '../social_pending.js';
8
11
  import { resolveEffectiveUserLoginMethods, } from '../user_login_methods.js';
9
12
  const UID_SESSION_KEY = 'authkit_social_uid';
10
13
  /**
@@ -29,6 +32,31 @@ async function resolveGlobalAuthMethodsForUser(cfg, settings) {
29
32
  configOverrides: cfg.authMethods,
30
33
  });
31
34
  }
35
+ /**
36
+ * Política de 2º fator do login social para esta conta (ver
37
+ * `AuthSocialConfig.secondFactor`). Default `'enrolled'`. Os papéis passados à
38
+ * função vêm de `resolveAccountRoles` (o `resolveTokenRoles` do host, ex.: authz).
39
+ */
40
+ async function resolveSecondFactorPolicy(cfg, account) {
41
+ const policy = cfg.social?.secondFactor ?? 'enrolled';
42
+ if (typeof policy !== 'function')
43
+ return policy;
44
+ return await policy({
45
+ id: account.id,
46
+ email: account.email,
47
+ roles: await resolveAccountRoles(cfg, account),
48
+ });
49
+ }
50
+ /** true se a conta tem TOTP confirmado ou ao menos uma passkey. */
51
+ async function hasEnrolledSecondFactor(store, accountId) {
52
+ const mfa = (await store.getMfaState?.(accountId)) ?? { enabled: false };
53
+ if (mfa.totp ?? mfa.enabled)
54
+ return true;
55
+ if (!supportsPasskeys(store))
56
+ return false;
57
+ const list = await store.listPasskeys(accountId);
58
+ return Array.isArray(list) && list.length > 0;
59
+ }
32
60
  export default class AuthSocialController {
33
61
  /** GET /auth/:provider/redirect/:uid — guarda o uid e redireciona para o provider OAuth. */
34
62
  async redirect(ctx) {
@@ -70,6 +98,20 @@ export default class AuthSocialController {
70
98
  // 2. Senão, e-mail conhecido → acha por e-mail e LIGA a identidade (linking).
71
99
  // 3. Senão → cria conta nova e liga a identidade.
72
100
  let user = await store.findByProviderIdentity(provider, profile.id);
101
+ // E-mail que o provider declara NÃO verificado não identifica ninguém: ligar
102
+ // por ele entregaria a conta de quem é dono do endereço a quem só o digitou
103
+ // no provider. `unsupported`/ausente (provider que não informa) segue valendo.
104
+ const emailUnverified = profile.emailVerificationState === 'unverified';
105
+ if (!user && email && emailUnverified) {
106
+ await cfg.audit?.record({
107
+ type: 'login.failure',
108
+ email,
109
+ ip: ctx.request.ip?.() ?? null,
110
+ metadata: { stage: 'social', provider, reason: 'provider_email_unverified' },
111
+ });
112
+ ctx.session.forget(UID_SESSION_KEY);
113
+ return ctx.response.redirect(backToLogin);
114
+ }
73
115
  if (!user && email) {
74
116
  // Busca pela MESMA forma normalizada que o login usa. Uma conta gravada
75
117
  // com outra grafia não é encontrada aqui e a pessoa ganharia uma SEGUNDA
@@ -95,7 +137,8 @@ export default class AuthSocialController {
95
137
  email,
96
138
  password: randomUUID(),
97
139
  fullName: profile.name ?? null,
98
- emailVerified: true,
140
+ // Só o provider que confirma o e-mail o torna verificado aqui.
141
+ emailVerified: profile.emailVerificationState === 'verified',
99
142
  });
100
143
  await store.linkProviderIdentity({
101
144
  accountId: created.id,
@@ -154,6 +197,26 @@ export default class AuthSocialController {
154
197
  }
155
198
  }
156
199
  ctx.session.forget(UID_SESSION_KEY);
200
+ rememberLastLoginMethod(ctx, cfg, `social:${provider}`);
201
+ // 2º fator: o provider provou a identidade, mas quem cadastrou TOTP/passkey
202
+ // aqui espera que o fator valha também neste caminho. O desafio mora no
203
+ // interaction controller (mesmo gate de senha/magic link/OTP), então este
204
+ // passo só registra o login pendente e manda o browser para lá.
205
+ const policy = await resolveSecondFactorPolicy(cfg, user);
206
+ const needsGate = policy === 'required' ||
207
+ (policy === 'enrolled' && (!!cfg.mfa || (await hasEnrolledSecondFactor(store, user.id))));
208
+ if (needsGate) {
209
+ if (!uid)
210
+ return ctx.response.redirect(backToLogin);
211
+ const pending = {
212
+ accountId: user.id,
213
+ uid,
214
+ required: policy === 'required',
215
+ at: Date.now(),
216
+ };
217
+ ctx.session.put(SOCIAL_PENDING_KEY, pending);
218
+ return ctx.response.redirect(`/auth/interaction/${uid}/social/continue`);
219
+ }
157
220
  // Conclui a interaction OIDC para este usuário (escreve o 303 de volta ao authorize).
158
221
  // O cookie de interaction (path '/') sobrevive ao round-trip do provider OAuth.
159
222
  await service.interactions.completeLogin(ctx, user.id);
@@ -50,6 +50,7 @@ export declare const DEFAULT_MESSAGES: {
50
50
  'login.magic_link_sent': string;
51
51
  'signup.magic_link_sent': string;
52
52
  'login.passkey_button': string;
53
+ 'login.last_used': string;
53
54
  'login.otp_label': string;
54
55
  'login.otp_placeholder': string;
55
56
  'login.otp_submit': string;
@@ -809,6 +810,7 @@ export declare const PT_BR_MESSAGES: {
809
810
  'login.magic_link_sent': string;
810
811
  'signup.magic_link_sent': string;
811
812
  'login.passkey_button': string;
813
+ 'login.last_used': string;
812
814
  'login.otp_label': string;
813
815
  'login.otp_placeholder': string;
814
816
  'login.otp_submit': string;
@@ -41,6 +41,8 @@ export const DEFAULT_MESSAGES = {
41
41
  'login.magic_link_sent': 'If the account exists, we sent you a login link.',
42
42
  'signup.magic_link_sent': 'Check your email — we sent you a link to finish creating your account.',
43
43
  'login.passkey_button': 'Sign in with a passkey',
44
+ // Selo do método com que este dispositivo entrou da última vez.
45
+ 'login.last_used': 'Last used',
44
46
  // Login por OTP (código digitável).
45
47
  'login.otp_label': 'Enter the login code from the email',
46
48
  'login.otp_placeholder': '000000',
@@ -891,6 +893,7 @@ export const PT_BR_MESSAGES = {
891
893
  'login.magic_link_sent': 'Se a conta existir, enviamos um link de login.',
892
894
  'signup.magic_link_sent': 'Enviamos um link para o seu e-mail. Abra-o para concluir o cadastro.',
893
895
  'login.passkey_button': 'Entrar com passkey',
896
+ 'login.last_used': 'Último usado',
894
897
  // Login por OTP (código digitável).
895
898
  'login.otp_label': 'Digite o código de login do e-mail',
896
899
  'login.otp_placeholder': '000000',
@@ -0,0 +1,67 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ /**
3
+ * "Último método usado" no login (o selo "Last used" do Cloudflare, Vercel, Clerk…).
4
+ *
5
+ * O método fica num cookie DESTE DISPOSITIVO, não na conta. É de propósito:
6
+ * - o passo 1 (identifier) ainda não sabe quem é o usuário, e é exatamente ali
7
+ * que o selo mais ajuda ("da última vez você entrou com Google");
8
+ * - guardar por conta e mostrar no passo 2 diria a quem digita um e-mail qualquer
9
+ * como aquela conta entra — vazamento de enumeração. O cookie só conta ao
10
+ * próprio browser o que ele mesmo fez.
11
+ *
12
+ * O cookie não carrega identidade nenhuma (nem e-mail, nem accountId), só o método.
13
+ * É puramente de SUPERFÍCIE: a tela destaca um botão, mas nada no fluxo muda — os
14
+ * métodos oferecidos continuam vindo de `authMethods`, e um valor adulterado no
15
+ * cookie só destaca outro botão (ou nenhum, se não passar na validação).
16
+ *
17
+ * Gravado quando o fator PRIMÁRIO é aceito. Numa conta com 2º fator, isso acontece
18
+ * antes do desafio de MFA: o selo responde "como você se identificou", que é o
19
+ * botão que a pessoa procura na próxima vez.
20
+ */
21
+ /** Nome do cookie do último método de login. */
22
+ export declare const LAST_LOGIN_METHOD_COOKIE = "authkit_last_login";
23
+ /**
24
+ * Método de login primário. `social:<provider>` e `custom:<id>` carregam o id do
25
+ * provedor/método para a tela destacar o botão certo entre vários.
26
+ */
27
+ export type LastLoginMethod = 'password' | 'magic_link' | 'otp' | 'passkey' | `social:${string}` | `custom:${string}`;
28
+ /**
29
+ * Valida um valor lido do cookie. Só os métodos conhecidos passam; qualquer outra
30
+ * coisa (ausente, lixo, valor adulterado) vira `undefined` — sem selo.
31
+ */
32
+ export declare function normalizeLastLoginMethod(raw: unknown): LastLoginMethod | undefined;
33
+ /**
34
+ * Lê o último método deste dispositivo para a tela de login. Fail-safe: feature
35
+ * desligada, ctx sem cookies ou qualquer erro → `undefined` (sem selo).
36
+ */
37
+ export declare function readLastLoginMethod(ctx: HttpContext, cfg: {
38
+ login?: {
39
+ lastUsedMethod?: boolean;
40
+ };
41
+ }): LastLoginMethod | undefined;
42
+ /**
43
+ * Registra o método primário que acabou de ser aceito. Grava via resposta do
44
+ * Adonis; quando o login termina no `interactionFinished` (que encerra a resposta
45
+ * crua), o `completeLogin` repassa o cookie pendente — ver
46
+ * {@link relayPendingCookies}. Best-effort: nunca derruba o login.
47
+ */
48
+ export declare function rememberLastLoginMethod(ctx: HttpContext, cfg: {
49
+ login?: {
50
+ lastUsedMethod?: boolean;
51
+ };
52
+ }, method: LastLoginMethod): void;
53
+ /**
54
+ * Repassa os `Set-Cookie` que o handler gravou na resposta do Adonis para a
55
+ * resposta crua do Node.
56
+ *
57
+ * O Adonis só escreve os headers no `finish()`, depois do handler. Mas o
58
+ * `interactionFinished` do oidc-provider escreve o 303 e dá `res.end()` direto na
59
+ * resposta crua — quando o Adonis chega no `finish()`, a resposta já saiu e todo
60
+ * cookie gravado via `ctx.response.cookie()` no mesmo request é descartado em
61
+ * silêncio. Chamado pelo `completeLogin` logo antes do `interactionFinished`.
62
+ *
63
+ * Os cookies saem do Adonis ao serem repassados, então não há como irem em dobro.
64
+ * O `Set-Cookie` que o próprio oidc-provider grava depois é ACRESCENTADO aos
65
+ * existentes (a lib `cookies` do Koa preserva o header), então nada se perde.
66
+ */
67
+ export declare function relayPendingCookies(ctx: HttpContext): void;