@adonis-agora/authkit-server 0.82.2 → 0.83.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 (31) hide show
  1. package/build/src/accounts/account_store.d.ts +12 -0
  2. package/build/src/accounts/lucid_account_store.js +30 -0
  3. package/build/src/accounts/lucid_store/organizations.d.ts +6 -0
  4. package/build/src/accounts/lucid_store/organizations.js +65 -1
  5. package/build/src/define_config.d.ts +36 -4
  6. package/build/src/host/account_roles.d.ts +14 -3
  7. package/build/src/host/account_roles.js +15 -4
  8. package/build/src/host/admin_api/admin_orgs_service.js +5 -0
  9. package/build/src/host/admin_api/api_users_controller.d.ts +3 -0
  10. package/build/src/host/admin_api/api_users_controller.js +9 -5
  11. package/build/src/host/admin_api/dto.d.ts +24 -0
  12. package/build/src/host/admin_api/dto.js +14 -0
  13. package/build/src/host/admin_api/token_verify_service.js +7 -2
  14. package/build/src/host/admin_console/admin_shell_controller.js +3 -1
  15. package/build/src/host/admin_console/console_users_controller.d.ts +4 -0
  16. package/build/src/host/admin_console/console_users_controller.js +12 -6
  17. package/build/src/host/controllers/interaction_controller.d.ts +8 -0
  18. package/build/src/host/controllers/interaction_controller.js +46 -12
  19. package/build/src/host/controllers/pat_introspection_controller.d.ts +1 -1
  20. package/build/src/host/controllers/pat_introspection_controller.js +2 -1
  21. package/build/src/host/controllers/registration_controller.d.ts +10 -0
  22. package/build/src/host/controllers/registration_controller.js +94 -30
  23. package/build/src/host/controllers/social_controller.js +62 -1
  24. package/build/src/host/register_auth_host.js +4 -0
  25. package/build/src/host/signup_token.d.ts +9 -0
  26. package/build/src/host/signup_token.js +31 -0
  27. package/build/src/host/social_pending.d.ts +17 -0
  28. package/build/src/host/social_pending.js +11 -0
  29. package/build/src/host/ui-dist/assets/{index-C1eSfG8B.js → index-DhQzXw2i.js} +1 -1
  30. package/build/src/host/ui-dist/index.html +1 -1
  31. 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';
@@ -16,8 +17,19 @@ import { authkitOrigin } from '../origin.js';
16
17
  import { createOtpLockout, generateOtpUnlockToken, rawToDbOtpUnlockToken, resolveEffectiveOtpLockout, } from '../otp_lockout.js';
17
18
  import { resolveRuntimeSettingsOrNoop } from '../runtime_settings.js';
18
19
  import { resolveEffectiveAuthMethods, resolveEffectiveMaintenanceMode, resolveEffectiveRegistration, resolveEffectiveSessionPolicy, } from '../runtime_toggles.js';
20
+ import { SOCIAL_PENDING_KEY, SOCIAL_PENDING_TTL_MS, } from '../social_pending.js';
19
21
  import { buildTrustedDevicePayload, isTrustedDeviceValid, TRUSTED_DEVICE_COOKIE, } from '../trusted_device.js';
20
22
  import { normalizeUserLoginMethods, resolveEffectiveUserLoginMethods, } from '../user_login_methods.js';
23
+ /**
24
+ * Conta com papel administrativo (`cfg.admin.roles`, default `['ADMIN']`) — o
25
+ * bypass do modo manutenção. Os papéis vêm de `resolveAccountRoles`, a mesma
26
+ * fonte do claim (`resolveTokenRoles`, ex.: authz), não só do `globalRoles`.
27
+ */
28
+ async function isAdminAccount(cfg, account) {
29
+ const adminRoles = cfg.admin?.roles ?? ['ADMIN'];
30
+ const roles = await resolveAccountRoles(cfg, account);
31
+ return adminRoles.some((role) => roles.includes(role));
32
+ }
21
33
  /**
22
34
  * Chave i18n do erro de status de conta (disabled/expired) compartilhada pelos
23
35
  * fluxos passwordless (magic link, OTP, passkey).
@@ -105,8 +117,7 @@ export default class AuthInteractionController {
105
117
  if (!status.allowed)
106
118
  return fail(accountStatusErrorKey(status.reason));
107
119
  const maintenance = await resolveEffectiveMaintenanceMode(settings);
108
- if (maintenance.enabled &&
109
- !(cfg.admin?.roles ?? ['ADMIN']).some((role) => (account.globalRoles ?? []).includes(role))) {
120
+ if (maintenance.enabled && !(await isAdminAccount(cfg, account))) {
110
121
  return render(ctx, 'maintenance', {
111
122
  uid: details.uid,
112
123
  csrfToken: ctx.request.csrfToken,
@@ -468,9 +479,7 @@ export default class AuthInteractionController {
468
479
  // Contas admin (role em cfg.admin.roles) CONTINUAM podendo logar.
469
480
  // Contas comuns são bloqueadas com a tela de manutenção.
470
481
  if (maintenance.enabled) {
471
- const adminRoles = cfg.admin?.roles ?? ['ADMIN'];
472
- const accountRoles = acc.globalRoles ?? [];
473
- const isAdmin = adminRoles.some((r) => accountRoles.includes(r));
482
+ const isAdmin = await isAdminAccount(cfg, acc);
474
483
  if (!isAdmin) {
475
484
  return render(ctx, 'maintenance', {
476
485
  uid: details.uid,
@@ -563,9 +572,9 @@ export default class AuthInteractionController {
563
572
  ...(used.has('webauthn') ? { passkeyAvailable: false } : {}),
564
573
  });
565
574
  }
566
- async configuredMfaGate(ctx, cfg, accountId, details, primary, primaryFactorId, rememberRequested, passwordless = primary !== 'pwd') {
575
+ async configuredMfaGate(ctx, cfg, accountId, details, primary, primaryFactorId, rememberRequested, passwordless = primary !== 'pwd', forceRequired = false) {
567
576
  const requiredFactors = cfg.mfa?.requiredFactors ?? 2;
568
- const mfaRequired = this.acrRequiresMfa(cfg, details) || cfg.mfa?.requiredFactors !== undefined;
577
+ const mfaRequired = forceRequired || this.acrRequiresMfa(cfg, details) || cfg.mfa?.requiredFactors !== undefined;
569
578
  const mfa = (await cfg.accountStore.getMfaState?.(accountId)) ?? { enabled: false };
570
579
  const enrolledTotp = mfa.totp ?? mfa.enabled;
571
580
  const enrolledPasskey = await this.hasPasskeys(cfg, accountId);
@@ -736,8 +745,7 @@ export default class AuthInteractionController {
736
745
  });
737
746
  }
738
747
  const maintenance = await resolveEffectiveMaintenanceMode(settings);
739
- if (maintenance.enabled &&
740
- !(cfg.admin?.roles ?? ['ADMIN']).some((role) => (account.globalRoles ?? []).includes(role))) {
748
+ if (maintenance.enabled && !(await isAdminAccount(cfg, account))) {
741
749
  return cfg.render(ctx, 'maintenance', {
742
750
  uid: flow.uid,
743
751
  csrfToken: ctx.request.csrfToken,
@@ -950,12 +958,12 @@ export default class AuthInteractionController {
950
958
  * - caso geral → `challenge`, com o accountId em `MFA_PENDING_KEY` e o fator
951
959
  * primário (`primary`) em `MFA_PRIMARY_KEY`.
952
960
  */
953
- async secondFactorGate(ctx, cfg, accountId, details, primary, primaryFactorId = primary, rememberRequested, passwordless = primary !== 'pwd') {
961
+ async secondFactorGate(ctx, cfg, accountId, details, primary, primaryFactorId = primary, rememberRequested, passwordless = primary !== 'pwd', forceRequired = false) {
954
962
  ctx.session.forget(MFA_CUSTOM_PRIMARY_KEY);
955
963
  clearMfaFlow(ctx);
956
964
  if (cfg.mfa)
957
- return this.configuredMfaGate(ctx, cfg, accountId, details, primary, primaryFactorId, rememberRequested, passwordless);
958
- const mfaRequired = this.acrRequiresMfa(cfg, details);
965
+ return this.configuredMfaGate(ctx, cfg, accountId, details, primary, primaryFactorId, rememberRequested, passwordless, forceRequired);
966
+ const mfaRequired = forceRequired || this.acrRequiresMfa(cfg, details);
959
967
  const mfa = (await cfg.accountStore.getMfaState?.(accountId)) ?? { enabled: false };
960
968
  // Passkey disponível como alternativa ao TOTP se o store suporta E a conta
961
969
  // tem ao menos uma credencial registrada.
@@ -1212,6 +1220,32 @@ export default class AuthInteractionController {
1212
1220
  * Consome o magic link. Em sucesso finaliza o login (amr `['email']`). Token
1213
1221
  * inválido/expirado volta ao início do login.
1214
1222
  */
1223
+ /**
1224
+ * GET /auth/interaction/:uid/social/continue — segunda metade do login social
1225
+ * quando a conta deve o 2º fator (ver `AuthSocialController#callback`). Lê o
1226
+ * login pendente da sessão UMA vez, confere que é desta interaction e passa
1227
+ * pelo mesmo `secondFactorGate` dos outros fluxos, com `fed` como fator
1228
+ * primário no `amr`.
1229
+ */
1230
+ async socialContinue(ctx) {
1231
+ const service = await ctx.containerResolver.make('authkit.server');
1232
+ const cfg = service.config;
1233
+ const uid = ctx.request.param('uid');
1234
+ const pending = ctx.session.get(SOCIAL_PENDING_KEY);
1235
+ ctx.session.forget(SOCIAL_PENDING_KEY);
1236
+ const details = await service.interactions.details(ctx);
1237
+ if (!pending ||
1238
+ pending.uid !== uid ||
1239
+ details.uid !== uid ||
1240
+ details.prompt.name !== 'login' ||
1241
+ Date.now() - pending.at > SOCIAL_PENDING_TTL_MS) {
1242
+ return ctx.response.redirect(`/auth/interaction/${uid}`);
1243
+ }
1244
+ const gate = await this.secondFactorGate(ctx, cfg, pending.accountId, details, 'fed', 'fed', undefined, true, pending.required);
1245
+ if (gate.kind === 'challenge')
1246
+ return gate.response;
1247
+ return service.interactions.completeLogin(ctx, pending.accountId, { amr: ['fed'] });
1248
+ }
1215
1249
  async magicLinkConsume(ctx) {
1216
1250
  const service = await ctx.containerResolver.make('authkit.server');
1217
1251
  const cfg = service.config;
@@ -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,5 +1,6 @@
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';
@@ -10,6 +11,7 @@ import { authkitOrigin } from '../origin.js';
10
11
  import { RuntimeSettings, resolveRuntimeSettings } from '../runtime_settings.js';
11
12
  import { resolveEffectiveAuthMethods, resolveEffectiveMaintenanceMode, resolveEffectiveRegistration, } from '../runtime_toggles.js';
12
13
  import { dispatchSecurityNotice } from '../security_notice_service.js';
14
+ import { issueSignupToken, readSignupToken } from '../signup_token.js';
13
15
  import { forgotPasswordValidator, passwordlessSignupValidator, resetPasswordValidator, signupValidator, } from '../validators.js';
14
16
  /**
15
17
  * Best-effort: returns a RuntimeSettings backed by the container DB, or a no-op
@@ -170,6 +172,13 @@ export default class AuthRegistrationController {
170
172
  password: data.password,
171
173
  });
172
174
  if (!result.ok) {
175
+ // A conta acabou de nascer e o login dela falhou: sem desfazer, a pessoa
176
+ // ficaria com um e-mail "já cadastrado" numa conta em que nunca entrou.
177
+ if (created && supportsAccountDeletion(accountStore)) {
178
+ await accountStore.deleteAccount(created.id).catch((error) => {
179
+ ctx.logger.error({ err: error, accountId: created.id }, 'authkit: falha ao desfazer conta após signup sem login');
180
+ });
181
+ }
173
182
  return render(ctx, 'signup', {
174
183
  uid: ctx.request.param('uid'),
175
184
  csrfToken: ctx.request.csrfToken,
@@ -206,47 +215,46 @@ export default class AuthRegistrationController {
206
215
  }
207
216
  }
208
217
  /**
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).
218
+ * Cadastro passwordless: valida e-mail + nome e envia um link. Conta existente
219
+ * recebe um magic link de login; e-mail novo recebe o link de confirmação do
220
+ * cadastro (`signupConfirm`), e só então a conta é criada. Sempre responde
221
+ * "link enviado" (anti-enumeração), exista a conta ou não.
213
222
  */
214
223
  async #passwordlessSignup(ctx, deps) {
215
- const { cfg, brand, details } = deps;
224
+ const { cfg, brand } = deps;
216
225
  const render = cfg.render;
217
226
  const accountStore = cfg.accountStore;
218
227
  const uid = ctx.request.param('uid');
219
228
  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).
229
+ const origin = authkitOrigin(cfg);
222
230
  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
- });
231
+ if (existing) {
232
+ // Conta já existe: o "cadastro" vira um login por magic link.
233
+ // `data.email` já é a forma normalizada, a mesma do login.
234
+ const issued = await accountStore.issueMagicLinkToken(data.email);
235
+ if (issued) {
236
+ const magicUrl = `${origin}/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`;
237
+ const to = issued.account.email;
238
+ if (cfg.mail?.onMagicLink) {
239
+ await cfg.mail.onMagicLink({ email: to, magicUrl, token: issued.token });
240
+ }
241
+ else {
242
+ await sendMagicLinkEmail(ctx, { email: to, magicUrl });
243
+ }
244
+ }
236
245
  }
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;
246
+ else {
247
+ // Conta nova: NÃO cria aqui. O link de confirmação leva e-mail + nome num
248
+ // token cifrado; a conta só nasce quando o dono do e-mail o abre
249
+ // (`signupConfirm`). Um envio que falha não deixa conta órfã, e digitar o
250
+ // e-mail de outra pessoa não cria nada.
251
+ const token = await issueSignupToken({ email: data.email, fullName: data.fullName ?? null });
252
+ const magicUrl = `${origin}/auth/interaction/${uid}/signup/confirm?token=${encodeURIComponent(token)}`;
245
253
  if (cfg.mail?.onMagicLink) {
246
- await cfg.mail.onMagicLink({ email: to, magicUrl, token: issued.token });
254
+ await cfg.mail.onMagicLink({ email: data.email, magicUrl, token, kind: 'signup' });
247
255
  }
248
256
  else {
249
- await sendMagicLinkEmail(ctx, { email: to, magicUrl });
257
+ await sendMagicLinkEmail(ctx, { email: data.email, magicUrl });
250
258
  }
251
259
  }
252
260
  // Resposta uniforme: "enviamos um link" (não vaza existência da conta).
@@ -257,6 +265,62 @@ export default class AuthRegistrationController {
257
265
  magicLinkSent: true,
258
266
  });
259
267
  }
268
+ /**
269
+ * GET /auth/interaction/:uid/signup/confirm?token= — o dono do e-mail abriu o
270
+ * link do cadastro passwordless. Cria a conta (e-mail já verificado: quem
271
+ * clicou provou a caixa de entrada) e conclui pelo fluxo de magic link, que
272
+ * aplica os mesmos gates do login (status, manutenção, 2º fator).
273
+ *
274
+ * Conta que já existe → o link é recusado e a pessoa volta ao login: é o que
275
+ * faz o link valer uma vez só, sem estado no banco.
276
+ */
277
+ async signupConfirm(ctx) {
278
+ const service = await ctx.containerResolver.make('authkit.server');
279
+ const cfg = service.config;
280
+ const accountStore = cfg.accountStore;
281
+ const uid = ctx.request.param('uid');
282
+ const backToLogin = `/auth/interaction/${uid}`;
283
+ const token = ctx.request.qs().token ?? '';
284
+ if (!cfg.passwordless?.signup || !supportsMagicLink(accountStore) || !token) {
285
+ return ctx.response.redirect(backToLogin);
286
+ }
287
+ const runtimeSettings = await getRuntimeSettings(ctx);
288
+ const registrationEnabled = await resolveEffectiveRegistration(cfg.registration?.enabled ?? true, runtimeSettings);
289
+ if (!registrationEnabled)
290
+ return ctx.response.redirect(backToLogin);
291
+ const claim = await readSignupToken(token);
292
+ if (!claim)
293
+ return ctx.response.redirect(backToLogin);
294
+ if (await accountStore.findByEmail(claim.email))
295
+ return ctx.response.redirect(backToLogin);
296
+ let created;
297
+ try {
298
+ created = await accountStore.create({
299
+ email: claim.email,
300
+ fullName: claim.fullName,
301
+ password: randomBytes(24).toString('hex'),
302
+ emailVerified: true,
303
+ });
304
+ }
305
+ catch (error) {
306
+ // Dois cliques simultâneos: o segundo perde na unicidade do e-mail.
307
+ if (await accountStore.findByEmail(claim.email))
308
+ return ctx.response.redirect(backToLogin);
309
+ throw error;
310
+ }
311
+ const details = await service.interactions.details(ctx);
312
+ await cfg.audit?.record({
313
+ type: 'signup',
314
+ accountId: created.id,
315
+ email: claim.email,
316
+ ip: ctx.request.ip?.() ?? null,
317
+ clientId: details.params.client_id ?? null,
318
+ });
319
+ const issued = await accountStore.issueMagicLinkToken(claim.email);
320
+ if (!issued)
321
+ return ctx.response.redirect(backToLogin);
322
+ return ctx.response.redirect(`/auth/interaction/${uid}/magic?token=${encodeURIComponent(issued.token)}`);
323
+ }
260
324
  /** GET /auth/forgot-password — tela standalone. */
261
325
  async showForgot(ctx) {
262
326
  const service = await ctx.containerResolver.make('authkit.server');
@@ -1,10 +1,12 @@
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';
5
6
  import { assertLoginAllowed } from '../login_attempt.js';
6
7
  import { resolveRuntimeSettingsOrNoop } from '../runtime_settings.js';
7
8
  import { resolveEffectiveAuthMethods } from '../runtime_toggles.js';
9
+ import { SOCIAL_PENDING_KEY } from '../social_pending.js';
8
10
  import { resolveEffectiveUserLoginMethods, } from '../user_login_methods.js';
9
11
  const UID_SESSION_KEY = 'authkit_social_uid';
10
12
  /**
@@ -29,6 +31,31 @@ async function resolveGlobalAuthMethodsForUser(cfg, settings) {
29
31
  configOverrides: cfg.authMethods,
30
32
  });
31
33
  }
34
+ /**
35
+ * Política de 2º fator do login social para esta conta (ver
36
+ * `AuthSocialConfig.secondFactor`). Default `'enrolled'`. Os papéis passados à
37
+ * função vêm de `resolveAccountRoles` (o `resolveTokenRoles` do host, ex.: authz).
38
+ */
39
+ async function resolveSecondFactorPolicy(cfg, account) {
40
+ const policy = cfg.social?.secondFactor ?? 'enrolled';
41
+ if (typeof policy !== 'function')
42
+ return policy;
43
+ return await policy({
44
+ id: account.id,
45
+ email: account.email,
46
+ roles: await resolveAccountRoles(cfg, account),
47
+ });
48
+ }
49
+ /** true se a conta tem TOTP confirmado ou ao menos uma passkey. */
50
+ async function hasEnrolledSecondFactor(store, accountId) {
51
+ const mfa = (await store.getMfaState?.(accountId)) ?? { enabled: false };
52
+ if (mfa.totp ?? mfa.enabled)
53
+ return true;
54
+ if (!supportsPasskeys(store))
55
+ return false;
56
+ const list = await store.listPasskeys(accountId);
57
+ return Array.isArray(list) && list.length > 0;
58
+ }
32
59
  export default class AuthSocialController {
33
60
  /** GET /auth/:provider/redirect/:uid — guarda o uid e redireciona para o provider OAuth. */
34
61
  async redirect(ctx) {
@@ -70,6 +97,20 @@ export default class AuthSocialController {
70
97
  // 2. Senão, e-mail conhecido → acha por e-mail e LIGA a identidade (linking).
71
98
  // 3. Senão → cria conta nova e liga a identidade.
72
99
  let user = await store.findByProviderIdentity(provider, profile.id);
100
+ // E-mail que o provider declara NÃO verificado não identifica ninguém: ligar
101
+ // por ele entregaria a conta de quem é dono do endereço a quem só o digitou
102
+ // no provider. `unsupported`/ausente (provider que não informa) segue valendo.
103
+ const emailUnverified = profile.emailVerificationState === 'unverified';
104
+ if (!user && email && emailUnverified) {
105
+ await cfg.audit?.record({
106
+ type: 'login.failure',
107
+ email,
108
+ ip: ctx.request.ip?.() ?? null,
109
+ metadata: { stage: 'social', provider, reason: 'provider_email_unverified' },
110
+ });
111
+ ctx.session.forget(UID_SESSION_KEY);
112
+ return ctx.response.redirect(backToLogin);
113
+ }
73
114
  if (!user && email) {
74
115
  // Busca pela MESMA forma normalizada que o login usa. Uma conta gravada
75
116
  // com outra grafia não é encontrada aqui e a pessoa ganharia uma SEGUNDA
@@ -95,7 +136,8 @@ export default class AuthSocialController {
95
136
  email,
96
137
  password: randomUUID(),
97
138
  fullName: profile.name ?? null,
98
- emailVerified: true,
139
+ // Só o provider que confirma o e-mail o torna verificado aqui.
140
+ emailVerified: profile.emailVerificationState === 'verified',
99
141
  });
100
142
  await store.linkProviderIdentity({
101
143
  accountId: created.id,
@@ -154,6 +196,25 @@ export default class AuthSocialController {
154
196
  }
155
197
  }
156
198
  ctx.session.forget(UID_SESSION_KEY);
199
+ // 2º fator: o provider provou a identidade, mas quem cadastrou TOTP/passkey
200
+ // aqui espera que o fator valha também neste caminho. O desafio mora no
201
+ // interaction controller (mesmo gate de senha/magic link/OTP), então este
202
+ // passo só registra o login pendente e manda o browser para lá.
203
+ const policy = await resolveSecondFactorPolicy(cfg, user);
204
+ const needsGate = policy === 'required' ||
205
+ (policy === 'enrolled' && (!!cfg.mfa || (await hasEnrolledSecondFactor(store, user.id))));
206
+ if (needsGate) {
207
+ if (!uid)
208
+ return ctx.response.redirect(backToLogin);
209
+ const pending = {
210
+ accountId: user.id,
211
+ uid,
212
+ required: policy === 'required',
213
+ at: Date.now(),
214
+ };
215
+ ctx.session.put(SOCIAL_PENDING_KEY, pending);
216
+ return ctx.response.redirect(`/auth/interaction/${uid}/social/continue`);
217
+ }
157
218
  // Conclui a interaction OIDC para este usuário (escreve o 303 de volta ao authorize).
158
219
  // O cookie de interaction (path '/') sobrevive ao round-trip do provider OAuth.
159
220
  await service.interactions.completeLogin(ctx, user.id);
@@ -488,6 +488,8 @@ export function registerAuthHost(router, opts = {}) {
488
488
  router.get('/auth/otp-unlock/:token', [C.interaction, 'otpUnlock']);
489
489
  router.get('/auth/interaction/:uid/signup', [C.registration, 'showSignup']);
490
490
  withLogin(router.post('/auth/interaction/:uid/signup', [C.registration, 'signup']));
491
+ // Confirmação do cadastro passwordless: a conta nasce aqui, não no POST.
492
+ router.get('/auth/interaction/:uid/signup/confirm', [C.registration, 'signupConfirm']);
491
493
  // Recuperação de senha (standalone).
492
494
  router.get('/auth/forgot-password', [C.registration, 'showForgot']);
493
495
  withLogin(router.post('/auth/forgot-password', [C.registration, 'forgot']));
@@ -499,6 +501,8 @@ export function registerAuthHost(router, opts = {}) {
499
501
  if (social) {
500
502
  router.get('/auth/:provider/redirect/:uid', [C.social, 'redirect']);
501
503
  router.get('/auth/:provider/callback', [C.social, 'callback']);
504
+ // 2º fator da conta depois do provider (ver AuthSocialConfig.secondFactor).
505
+ router.get('/auth/interaction/:uid/social/continue', [C.interaction, 'socialContinue']);
502
506
  }
503
507
  // PAT introspection (server-to-server).
504
508
  withIntrospection(router.post('/authkit/pat/introspect', [C.patIntrospection, 'handle']));
@@ -0,0 +1,9 @@
1
+ /** Mesma janela do magic link de login. */
2
+ export declare const SIGNUP_TOKEN_TTL = "15 minutes";
3
+ export interface SignupClaim {
4
+ email: string;
5
+ fullName: string | null;
6
+ }
7
+ export declare function issueSignupToken(claim: SignupClaim): Promise<string>;
8
+ /** Claim do token, ou `null` se adulterado, de outro propósito ou vencido. */
9
+ export declare function readSignupToken(token: string): Promise<SignupClaim | null>;
@@ -0,0 +1,31 @@
1
+ import { loadEncryptionService } from '../keys/keystore_crypto.js';
2
+ /**
3
+ * Token do link de confirmação do cadastro passwordless.
4
+ *
5
+ * O cadastro não cria a conta no POST: guarda e-mail + nome num token cifrado
6
+ * (APP_KEY), com prazo e propósito próprios, e a conta só nasce quando o dono do
7
+ * e-mail abre o link. Assim um envio que falha, ou um e-mail digitado por quem
8
+ * não é o dono, não deixa conta para trás nem dispara `account.created`.
9
+ *
10
+ * Sem estado no banco: a unicidade vem da própria conta — se ela já existe
11
+ * quando o link é aberto, o link é recusado.
12
+ */
13
+ const PURPOSE = 'authkit_passwordless_signup';
14
+ /** Mesma janela do magic link de login. */
15
+ export const SIGNUP_TOKEN_TTL = '15 minutes';
16
+ async function encryption() {
17
+ return (await loadEncryptionService());
18
+ }
19
+ export async function issueSignupToken(claim) {
20
+ return (await encryption()).encrypt(claim, SIGNUP_TOKEN_TTL, PURPOSE);
21
+ }
22
+ /** Claim do token, ou `null` se adulterado, de outro propósito ou vencido. */
23
+ export async function readSignupToken(token) {
24
+ const claim = (await encryption()).decrypt(token, PURPOSE);
25
+ if (!claim || typeof claim.email !== 'string' || claim.email === '')
26
+ return null;
27
+ return {
28
+ email: claim.email,
29
+ fullName: typeof claim.fullName === 'string' ? claim.fullName : null,
30
+ };
31
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Passo pendente entre o callback social e o 2º fator, compartilhado pelo
3
+ * `AuthSocialController` (grava) e pelo `AuthInteractionController` (consome).
4
+ */
5
+ /**
6
+ * Login social que passou pelo provider mas ainda deve o 2º fator da conta.
7
+ * Lido (uma vez) por `AuthInteractionController#socialContinue`.
8
+ */
9
+ export declare const SOCIAL_PENDING_KEY = "authkit_social_pending";
10
+ /** Validade do passo pendente: o desafio do 2º fator vem logo em seguida. */
11
+ export declare const SOCIAL_PENDING_TTL_MS: number;
12
+ export interface SocialPendingLogin {
13
+ accountId: string;
14
+ uid: string;
15
+ required: boolean;
16
+ at: number;
17
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Passo pendente entre o callback social e o 2º fator, compartilhado pelo
3
+ * `AuthSocialController` (grava) e pelo `AuthInteractionController` (consome).
4
+ */
5
+ /**
6
+ * Login social que passou pelo provider mas ainda deve o 2º fator da conta.
7
+ * Lido (uma vez) por `AuthInteractionController#socialContinue`.
8
+ */
9
+ export const SOCIAL_PENDING_KEY = 'authkit_social_pending';
10
+ /** Validade do passo pendente: o desafio do 2º fator vem logo em seguida. */
11
+ export const SOCIAL_PENDING_TTL_MS = 5 * 60 * 1000;