@adonis-agora/authkit-server 0.68.4 → 0.70.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 (54) hide show
  1. package/build/commands/import_users.js +6 -1
  2. package/build/index.d.ts +5 -1
  3. package/build/index.js +7 -1
  4. package/build/src/accounts/account_store.d.ts +40 -0
  5. package/build/src/accounts/account_store.js +20 -0
  6. package/build/src/accounts/lucid_store/mfa.js +22 -0
  7. package/build/src/audit/audit_sink.d.ts +1 -1
  8. package/build/src/audit/audit_sink.js +4 -0
  9. package/build/src/commands/import_users.d.ts +7 -0
  10. package/build/src/commands/import_users.js +15 -3
  11. package/build/src/define_config.d.ts +81 -0
  12. package/build/src/define_config.js +18 -3
  13. package/build/src/host/account_api/account_api_controller.d.ts +2 -0
  14. package/build/src/host/account_api/account_api_controller.js +22 -3
  15. package/build/src/host/account_api/account_mfa_api_controller.d.ts +101 -0
  16. package/build/src/host/account_api/account_mfa_api_controller.js +286 -0
  17. package/build/src/host/account_api/account_orgs_api_controller.d.ts +126 -0
  18. package/build/src/host/account_api/account_orgs_api_controller.js +468 -0
  19. package/build/src/host/account_lockout.js +7 -2
  20. package/build/src/host/admin_api/admin_orgs_service.js +3 -2
  21. package/build/src/host/admin_api/admin_users_service.js +13 -3
  22. package/build/src/host/admin_api/dto.d.ts +1 -1
  23. package/build/src/host/admin_validators.d.ts +2 -2
  24. package/build/src/host/admin_validators.js +3 -2
  25. package/build/src/host/controllers/account_mfa_controller.js +1 -2
  26. package/build/src/host/controllers/account_orgs_controller.js +19 -11
  27. package/build/src/host/controllers/account_security_controller.js +3 -1
  28. package/build/src/host/controllers/account_session_controller.js +17 -6
  29. package/build/src/host/controllers/interaction_controller.d.ts +10 -0
  30. package/build/src/host/controllers/interaction_controller.js +93 -34
  31. package/build/src/host/controllers/registration_controller.js +35 -9
  32. package/build/src/host/controllers/social_controller.js +11 -2
  33. package/build/src/host/email_identifier.d.ts +92 -0
  34. package/build/src/host/email_identifier.js +234 -0
  35. package/build/src/host/idp_session_bridge.d.ts +55 -0
  36. package/build/src/host/idp_session_bridge.js +108 -0
  37. package/build/src/host/middleware/account_auth.js +3 -2
  38. package/build/src/host/org_policy.d.ts +26 -0
  39. package/build/src/host/org_policy.js +35 -0
  40. package/build/src/host/passkey_registration_challenge.d.ts +12 -0
  41. package/build/src/host/passkey_registration_challenge.js +12 -0
  42. package/build/src/host/register_auth_host.js +56 -1
  43. package/build/src/host/runtime_toggles.d.ts +2 -2
  44. package/build/src/host/runtime_toggles.js +3 -1
  45. package/build/src/host/sudo_mode.d.ts +17 -0
  46. package/build/src/host/sudo_mode.js +31 -12
  47. package/build/src/host/ui-dist/assets/{index-D9CYQnZR.js → index-Dct63ai-.js} +2 -2
  48. package/build/src/host/ui-dist/index.html +1 -1
  49. package/build/src/host/validators.d.ts +5 -5
  50. package/build/src/host/validators.js +17 -5
  51. package/build/src/provider/build_provider.js +46 -14
  52. package/build/src/provider/registration_policy.d.ts +99 -0
  53. package/build/src/provider/registration_policy.js +229 -0
  54. package/package.json +2 -2
@@ -1,6 +1,7 @@
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 { normalizeEmailIdentifier, resolveEmailIdentifier } from '../email_identifier.js';
4
5
  import { assertLoginAllowed } from '../login_attempt.js';
5
6
  import { resolveRuntimeSettingsOrNoop } from '../runtime_settings.js';
6
7
  import { resolveEffectiveAuthMethods } from '../runtime_toggles.js';
@@ -54,7 +55,10 @@ export default class AuthSocialController {
54
55
  const service = await ctx.containerResolver.make('authkit.server');
55
56
  const cfg = service.config;
56
57
  const store = cfg.accountStore;
57
- const email = profile.email ?? undefined;
58
+ // Normaliza o e-mail do provider com a MESMA regra do cadastro/login: sem
59
+ // isto, um provider que devolve o endereço com maiúsculas criava uma conta
60
+ // que o login (normalizado) não encontrava mais.
61
+ const email = profile.email ? normalizeEmailIdentifier(profile.email) : undefined;
58
62
  // Account linking exige a capacidade de provider-identity (model wired no store).
59
63
  // Ausente → não há como ligar a identidade; volta ao login em vez de quebrar.
60
64
  if (!supportsProviderIdentity(store)) {
@@ -67,7 +71,12 @@ export default class AuthSocialController {
67
71
  // 3. Senão → cria conta nova e liga a identidade.
68
72
  let user = await store.findByProviderIdentity(provider, profile.id);
69
73
  if (!user && email) {
70
- const byEmail = await store.findByEmail(email);
74
+ // Ponte legada: sem ela, quem tem a conta gravada com o endereço mutilado
75
+ // pelo cadastro antigo ganharia uma SEGUNDA conta ao "Continuar com o
76
+ // Google" em vez de ligar a identidade à conta que já tem.
77
+ const byEmail = (await resolveEmailIdentifier(store, profile.email, {
78
+ legacyFallback: cfg.login?.legacyEmailFallback ?? true,
79
+ })).account;
71
80
  if (byEmail) {
72
81
  await store.linkProviderIdentity({
73
82
  accountId: byEmail.id,
@@ -0,0 +1,92 @@
1
+ import type { AuthAccount } from '../accounts/account_store.js';
2
+ /**
3
+ * Normalização ÚNICA e CONSERVADORA do e-mail usado como identidade da conta.
4
+ *
5
+ * Faz `trim()` + `toLowerCase()` e MAIS NADA. O endereço que a pessoa digitou é a
6
+ * identidade dela: a lib NÃO decide que `a.b@gmail.com` e `ab@gmail.com` são a
7
+ * mesma pessoa, nem descarta o sub-endereço (`+tag`) que ela escolheu usar.
8
+ *
9
+ * POR QUE ISTO EXISTE: até a v0.68 o cadastro validava o e-mail com
10
+ * `.normalizeEmail()` do VineJS (os defaults do validator.js), que para o gmail
11
+ * REMOVE os pontos e o `+tag` do local part. A conta nascia com um endereço
12
+ * DIFERENTE do digitado (`davi.carvalho96@gmail.com` → `davicarvalho96@gmail.com`)
13
+ * enquanto o passo de identificador do login não normalizava NADA e buscava por
14
+ * igualdade exata — resultado: quem tinha ponto ou `+tag` no gmail ficava
15
+ * trancado do lado de fora, e, por o login ser à prova de enumeração, sem
16
+ * nenhuma mensagem de erro. Uma normalização só, usada nos DOIS lados, fecha a
17
+ * assimetria.
18
+ *
19
+ * Use em TODO ponto que grava ou busca a identidade: cadastro (com e sem senha),
20
+ * "esqueci a senha", troca de e-mail, criação por admin, convite de organização,
21
+ * import de usuários, cadastro social e o passo de identificador do login.
22
+ */
23
+ export declare function normalizeEmailIdentifier(raw: unknown): string;
24
+ /**
25
+ * Réplica da normalização LEGADA — `normalizeEmail()` do validator.js com os
26
+ * defaults, que é o que o `.normalizeEmail()` do VineJS aplicava no cadastro até
27
+ * a v0.68. Existe SÓ para reencontrar as contas que nasceram com o endereço
28
+ * mutilado; nada novo deve ser gravado com ela.
29
+ *
30
+ * É uma PONTE TEMPORÁRIA: quando as contas antigas tiverem sido migradas para o
31
+ * endereço real (ou o suficiente delas), esta função, a opção
32
+ * `login.legacyEmailFallback` e {@link resolveEmailIdentifier} podem sair.
33
+ *
34
+ * Retorna `null` para entradas que o validator.js também recusaria (local part
35
+ * vazio depois do colapso) ou que não são um endereço com `@`.
36
+ */
37
+ export declare function legacyNormalizeEmailIdentifier(raw: unknown): string | null;
38
+ /** Só o que {@link resolveEmailIdentifier} precisa do account store. */
39
+ export interface EmailIdentifierLookup {
40
+ findByEmail(email: string): Promise<AuthAccount | null>;
41
+ }
42
+ export interface ResolvedEmailIdentifier {
43
+ /** O que a pessoa digitou, normalizado. É o que a tela SEMPRE mostra. */
44
+ email: string;
45
+ /**
46
+ * E-mail sob o qual a conta está GRAVADA — o que deve ir para o store em
47
+ * qualquer busca/emissão de token. Igual a {@link email} quando a conta foi
48
+ * achada direto (ou quando não foi achada nenhuma).
49
+ */
50
+ lookupEmail: string;
51
+ /** A conta, quando alguma foi encontrada. */
52
+ account: AuthAccount | null;
53
+ /** `true` quando a conta só foi alcançada pela ponte legada. */
54
+ viaLegacyFallback: boolean;
55
+ }
56
+ /**
57
+ * Resolve o e-mail digitado na conta correspondente.
58
+ *
59
+ * Passe o valor **CRU** (como veio do formulário, do provider ou do arquivo): a
60
+ * normalização acontece aqui dentro, e a forma crua é uma das candidatas da
61
+ * ponte. Passar um valor já normalizado apaga essa candidata e reduz o alcance
62
+ * da ponte às contas gravadas com o endereço mutilado.
63
+ *
64
+ * 1. Busca pela forma normalizada NOVA (trim + lowercase) — o caminho de sempre,
65
+ * uma única query por igualdade (indexada).
66
+ * 2. Não achando, e com a ponte ligada (`login.legacyEmailFallback`, default
67
+ * `true`), tenta as formas de compatibilidade:
68
+ * - o endereço EXATAMENTE como digitado (só com `trim`), para as contas que
69
+ * foram gravadas com maiúsculas antes desta normalização existir (import,
70
+ * convite, criação por admin, provider social);
71
+ * - a {@link legacyNormalizeEmailIdentifier normalização legada}, para as
72
+ * contas que nasceram com o endereço mutilado.
73
+ * O resultado só é aceito quando as formas de compatibilidade apontam para
74
+ * EXATAMENTE UMA conta. Duas contas distintas (ex.: `Davi.C@Gmail.com` criada
75
+ * pelo social E `davic@gmail.com` criada pelo cadastro legado) são um empate:
76
+ * a lib não adivinha qual é a pessoa e trata como "não achei".
77
+ *
78
+ * LIMITE CONHECIDO: a ponte só alcança grafias que dá para derivar do que foi
79
+ * digitado. Uma conta gravada `Davi@Acme.com` é alcançada por quem digita
80
+ * `Davi@Acme.com` (a forma crua), mas NÃO por quem digita `davi@acme.com` — aí as
81
+ * três formas coincidem e só uma busca case-insensitive no store resolveria, o
82
+ * que exigiria varrer a tabela a cada login com e-mail desconhecido (justamente o
83
+ * caminho de ataque). Essas contas pedem migração do endereço gravado, não ponte.
84
+ *
85
+ * À PROVA DE ENUMERAÇÃO: nunca lança, nunca sinaliza nada para fora — quem chama
86
+ * segue com `account: null` exatamente como seguia antes. As buscas extras só
87
+ * acontecem quando as formas de compatibilidade DIFEREM da normalizada (e-mail
88
+ * digitado em minúsculas e sem ponto/tag → uma query só, como hoje).
89
+ */
90
+ export declare function resolveEmailIdentifier(store: EmailIdentifierLookup, raw: unknown, options?: {
91
+ legacyFallback?: boolean;
92
+ }): Promise<ResolvedEmailIdentifier>;
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Normalização ÚNICA e CONSERVADORA do e-mail usado como identidade da conta.
3
+ *
4
+ * Faz `trim()` + `toLowerCase()` e MAIS NADA. O endereço que a pessoa digitou é a
5
+ * identidade dela: a lib NÃO decide que `a.b@gmail.com` e `ab@gmail.com` são a
6
+ * mesma pessoa, nem descarta o sub-endereço (`+tag`) que ela escolheu usar.
7
+ *
8
+ * POR QUE ISTO EXISTE: até a v0.68 o cadastro validava o e-mail com
9
+ * `.normalizeEmail()` do VineJS (os defaults do validator.js), que para o gmail
10
+ * REMOVE os pontos e o `+tag` do local part. A conta nascia com um endereço
11
+ * DIFERENTE do digitado (`davi.carvalho96@gmail.com` → `davicarvalho96@gmail.com`)
12
+ * enquanto o passo de identificador do login não normalizava NADA e buscava por
13
+ * igualdade exata — resultado: quem tinha ponto ou `+tag` no gmail ficava
14
+ * trancado do lado de fora, e, por o login ser à prova de enumeração, sem
15
+ * nenhuma mensagem de erro. Uma normalização só, usada nos DOIS lados, fecha a
16
+ * assimetria.
17
+ *
18
+ * Use em TODO ponto que grava ou busca a identidade: cadastro (com e sem senha),
19
+ * "esqueci a senha", troca de e-mail, criação por admin, convite de organização,
20
+ * import de usuários, cadastro social e o passo de identificador do login.
21
+ */
22
+ export function normalizeEmailIdentifier(raw) {
23
+ return typeof raw === 'string' ? raw.trim().toLowerCase() : '';
24
+ }
25
+ // ─── Ponte de compatibilidade com a normalização LEGADA ─────────────────────
26
+ /** Domínios do gmail (pontos e sub-endereço eram removidos do local part). */
27
+ const GMAIL_DOMAINS = ['gmail.com', 'googlemail.com'];
28
+ /** Domínios do iCloud (sub-endereço `+tag` removido). */
29
+ const ICLOUD_DOMAINS = ['icloud.com', 'me.com'];
30
+ /** Domínios do Outlook.com/Hotmail/Live (sub-endereço `+tag` removido). */
31
+ const OUTLOOK_DOMAINS = [
32
+ 'hotmail.at',
33
+ 'hotmail.be',
34
+ 'hotmail.ca',
35
+ 'hotmail.cl',
36
+ 'hotmail.co.il',
37
+ 'hotmail.co.nz',
38
+ 'hotmail.co.th',
39
+ 'hotmail.co.uk',
40
+ 'hotmail.com',
41
+ 'hotmail.com.ar',
42
+ 'hotmail.com.au',
43
+ 'hotmail.com.br',
44
+ 'hotmail.com.gr',
45
+ 'hotmail.com.mx',
46
+ 'hotmail.com.pe',
47
+ 'hotmail.com.tr',
48
+ 'hotmail.com.vn',
49
+ 'hotmail.cz',
50
+ 'hotmail.de',
51
+ 'hotmail.dk',
52
+ 'hotmail.es',
53
+ 'hotmail.fr',
54
+ 'hotmail.hu',
55
+ 'hotmail.id',
56
+ 'hotmail.ie',
57
+ 'hotmail.in',
58
+ 'hotmail.it',
59
+ 'hotmail.jp',
60
+ 'hotmail.kr',
61
+ 'hotmail.lv',
62
+ 'hotmail.my',
63
+ 'hotmail.ph',
64
+ 'hotmail.pt',
65
+ 'hotmail.sa',
66
+ 'hotmail.sg',
67
+ 'hotmail.sk',
68
+ 'live.be',
69
+ 'live.co.uk',
70
+ 'live.com',
71
+ 'live.com.ar',
72
+ 'live.com.mx',
73
+ 'live.de',
74
+ 'live.es',
75
+ 'live.eu',
76
+ 'live.fr',
77
+ 'live.it',
78
+ 'live.nl',
79
+ 'msn.com',
80
+ 'outlook.at',
81
+ 'outlook.be',
82
+ 'outlook.cl',
83
+ 'outlook.co.il',
84
+ 'outlook.co.nz',
85
+ 'outlook.co.th',
86
+ 'outlook.com',
87
+ 'outlook.com.ar',
88
+ 'outlook.com.au',
89
+ 'outlook.com.br',
90
+ 'outlook.com.gr',
91
+ 'outlook.com.pe',
92
+ 'outlook.com.tr',
93
+ 'outlook.com.vn',
94
+ 'outlook.cz',
95
+ 'outlook.de',
96
+ 'outlook.dk',
97
+ 'outlook.es',
98
+ 'outlook.fr',
99
+ 'outlook.hu',
100
+ 'outlook.id',
101
+ 'outlook.ie',
102
+ 'outlook.in',
103
+ 'outlook.it',
104
+ 'outlook.jp',
105
+ 'outlook.kr',
106
+ 'outlook.lv',
107
+ 'outlook.my',
108
+ 'outlook.ph',
109
+ 'outlook.pt',
110
+ 'outlook.sa',
111
+ 'outlook.sg',
112
+ 'outlook.sk',
113
+ 'passport.com',
114
+ ];
115
+ /** Domínios do Yahoo (sub-endereço `-tag` removido). */
116
+ const YAHOO_DOMAINS = [
117
+ 'rocketmail.com',
118
+ 'yahoo.ca',
119
+ 'yahoo.co.uk',
120
+ 'yahoo.com',
121
+ 'yahoo.de',
122
+ 'yahoo.fr',
123
+ 'yahoo.in',
124
+ 'yahoo.it',
125
+ 'ymail.com',
126
+ ];
127
+ /** Domínios do Yandex (todos colapsavam em `yandex.ru`). */
128
+ const YANDEX_DOMAINS = ['yandex.ru', 'yandex.ua', 'yandex.kz', 'yandex.com', 'yandex.by', 'ya.ru'];
129
+ /** Remove pontos SOLTOS do local part (pontos consecutivos ficam — regra do validator.js). */
130
+ function stripSingleDots(local) {
131
+ return local.replace(/\.+/g, (match) => (match.length > 1 ? match : ''));
132
+ }
133
+ /**
134
+ * Réplica da normalização LEGADA — `normalizeEmail()` do validator.js com os
135
+ * defaults, que é o que o `.normalizeEmail()` do VineJS aplicava no cadastro até
136
+ * a v0.68. Existe SÓ para reencontrar as contas que nasceram com o endereço
137
+ * mutilado; nada novo deve ser gravado com ela.
138
+ *
139
+ * É uma PONTE TEMPORÁRIA: quando as contas antigas tiverem sido migradas para o
140
+ * endereço real (ou o suficiente delas), esta função, a opção
141
+ * `login.legacyEmailFallback` e {@link resolveEmailIdentifier} podem sair.
142
+ *
143
+ * Retorna `null` para entradas que o validator.js também recusaria (local part
144
+ * vazio depois do colapso) ou que não são um endereço com `@`.
145
+ */
146
+ export function legacyNormalizeEmailIdentifier(raw) {
147
+ const email = normalizeEmailIdentifier(raw);
148
+ const at = email.lastIndexOf('@');
149
+ if (at <= 0 || at === email.length - 1)
150
+ return null;
151
+ let local = email.slice(0, at);
152
+ let domain = email.slice(at + 1);
153
+ if (GMAIL_DOMAINS.includes(domain)) {
154
+ local = local.split('+')[0];
155
+ local = stripSingleDots(local);
156
+ domain = 'gmail.com';
157
+ }
158
+ else if (ICLOUD_DOMAINS.includes(domain) || OUTLOOK_DOMAINS.includes(domain)) {
159
+ local = local.split('+')[0];
160
+ }
161
+ else if (YAHOO_DOMAINS.includes(domain)) {
162
+ const parts = local.split('-');
163
+ local = parts.length > 1 ? parts.slice(0, -1).join('-') : parts[0];
164
+ }
165
+ else if (YANDEX_DOMAINS.includes(domain)) {
166
+ domain = 'yandex.ru';
167
+ }
168
+ if (!local.length)
169
+ return null;
170
+ return `${local}@${domain}`;
171
+ }
172
+ /**
173
+ * Resolve o e-mail digitado na conta correspondente.
174
+ *
175
+ * Passe o valor **CRU** (como veio do formulário, do provider ou do arquivo): a
176
+ * normalização acontece aqui dentro, e a forma crua é uma das candidatas da
177
+ * ponte. Passar um valor já normalizado apaga essa candidata e reduz o alcance
178
+ * da ponte às contas gravadas com o endereço mutilado.
179
+ *
180
+ * 1. Busca pela forma normalizada NOVA (trim + lowercase) — o caminho de sempre,
181
+ * uma única query por igualdade (indexada).
182
+ * 2. Não achando, e com a ponte ligada (`login.legacyEmailFallback`, default
183
+ * `true`), tenta as formas de compatibilidade:
184
+ * - o endereço EXATAMENTE como digitado (só com `trim`), para as contas que
185
+ * foram gravadas com maiúsculas antes desta normalização existir (import,
186
+ * convite, criação por admin, provider social);
187
+ * - a {@link legacyNormalizeEmailIdentifier normalização legada}, para as
188
+ * contas que nasceram com o endereço mutilado.
189
+ * O resultado só é aceito quando as formas de compatibilidade apontam para
190
+ * EXATAMENTE UMA conta. Duas contas distintas (ex.: `Davi.C@Gmail.com` criada
191
+ * pelo social E `davic@gmail.com` criada pelo cadastro legado) são um empate:
192
+ * a lib não adivinha qual é a pessoa e trata como "não achei".
193
+ *
194
+ * LIMITE CONHECIDO: a ponte só alcança grafias que dá para derivar do que foi
195
+ * digitado. Uma conta gravada `Davi@Acme.com` é alcançada por quem digita
196
+ * `Davi@Acme.com` (a forma crua), mas NÃO por quem digita `davi@acme.com` — aí as
197
+ * três formas coincidem e só uma busca case-insensitive no store resolveria, o
198
+ * que exigiria varrer a tabela a cada login com e-mail desconhecido (justamente o
199
+ * caminho de ataque). Essas contas pedem migração do endereço gravado, não ponte.
200
+ *
201
+ * À PROVA DE ENUMERAÇÃO: nunca lança, nunca sinaliza nada para fora — quem chama
202
+ * segue com `account: null` exatamente como seguia antes. As buscas extras só
203
+ * acontecem quando as formas de compatibilidade DIFEREM da normalizada (e-mail
204
+ * digitado em minúsculas e sem ponto/tag → uma query só, como hoje).
205
+ */
206
+ export async function resolveEmailIdentifier(store, raw, options = {}) {
207
+ const email = normalizeEmailIdentifier(raw);
208
+ const miss = {
209
+ email,
210
+ lookupEmail: email,
211
+ account: null,
212
+ viaLegacyFallback: false,
213
+ };
214
+ if (!email)
215
+ return miss;
216
+ const direct = await store.findByEmail(email);
217
+ if (direct)
218
+ return { email, lookupEmail: email, account: direct, viaLegacyFallback: false };
219
+ if (options.legacyFallback === false)
220
+ return miss;
221
+ const typed = typeof raw === 'string' ? raw.trim() : '';
222
+ const legacy = legacyNormalizeEmailIdentifier(email);
223
+ const candidates = [typed, legacy].filter((candidate) => !!candidate && candidate !== email);
224
+ const byId = new Map();
225
+ for (const candidate of new Set(candidates)) {
226
+ const found = await store.findByEmail(candidate);
227
+ if (found)
228
+ byId.set(found.id, found);
229
+ }
230
+ if (byId.size !== 1)
231
+ return miss;
232
+ const account = [...byId.values()][0];
233
+ return { email, lookupEmail: account.email, account, viaLegacyFallback: true };
234
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Ponte SSO: sessão do IdP → sessão do console de conta (`/account/*`, `/admin/*`).
3
+ *
4
+ * POR QUE EXISTE. São duas sessões diferentes no mesmo host:
5
+ *
6
+ * - a sessão do IdP (oidc-provider): cookie `_session`, criada quando o
7
+ * usuário completa a interaction de login do `/oidc/auth` (senha, magic
8
+ * link, OTP, passkey, social). É ela que dá o SSO entre os clients OIDC;
9
+ * - a sessão do console: a chave `ACCOUNT_SESSION_KEY` na sessão do Adonis,
10
+ * criada SÓ pelo login do próprio console (`POST /account/login`).
11
+ *
12
+ * O login do IdP nunca escrevia a segunda, então um usuário que acabou de
13
+ * entrar num app OIDC (inclusive o próprio host, quando ele é IdP e RP ao mesmo
14
+ * tempo) e abria `/account/*` levava um SEGUNDO pedido de login.
15
+ *
16
+ * Com `accountSession.acceptIdpSession: true`, os guards do console aceitam a
17
+ * sessão ATIVA do IdP: sem sessão de console, mas com uma sessão do IdP válida
18
+ * (cookie assinado + registro no adapter, não expirado) de uma conta existente
19
+ * e habilitada, o console é aberto para essa conta. A ponte fica AMARRADA à
20
+ * sessão do IdP que a originou (o `uid` dela vai na sessão do Adonis): quando a
21
+ * sessão do IdP acaba (logout OIDC, expiração, troca de conta), o console
22
+ * derivado dela acaba junto no próximo request.
23
+ *
24
+ * Default `false`: quem não liga continua exatamente como antes.
25
+ */
26
+ import type { HttpContext } from '@adonisjs/core/http';
27
+ /** Chave da sessão Adonis com o `uid` da sessão do IdP que originou o console. */
28
+ export declare const ACCOUNT_IDP_SESSION_KEY = "authkit_idp_session_uid";
29
+ /** O que interessa de uma sessão do oidc-provider (instância do model `Session`). */
30
+ interface IdpSession {
31
+ uid: string;
32
+ accountId?: string;
33
+ destroy(): Promise<void>;
34
+ }
35
+ /**
36
+ * Lê a sessão do IdP a partir do cookie do request (mesma leitura que o
37
+ * provider faz: cookie `_session` assinado com as keys do provider, e o registro
38
+ * no adapter, que já descarta expirados). `null` sem sessão logada.
39
+ */
40
+ export declare function readIdpSession(ctx: HttpContext, service: any): Promise<IdpSession | null>;
41
+ /**
42
+ * Garante a sessão do console, aceitando a sessão do IdP quando configurado.
43
+ * Devolve `true` quando o request tem (ou passou a ter) sessão de console.
44
+ *
45
+ * Sem `accountSession.acceptIdpSession`, é só "existe `ACCOUNT_SESSION_KEY`?" —
46
+ * o comportamento de sempre, sem tocar no provider.
47
+ */
48
+ export declare function ensureConsoleSession(ctx: HttpContext): Promise<boolean>;
49
+ /**
50
+ * Logout do console de uma sessão que veio da ponte: encerra também a sessão
51
+ * do IdP que a originou — senão o próximo request reabriria o console pela
52
+ * própria ponte e o "Sair" não teria efeito. No-op fora da ponte.
53
+ */
54
+ export declare function endBridgedIdpSession(ctx: HttpContext): Promise<void>;
55
+ export {};
@@ -0,0 +1,108 @@
1
+ import { ACCOUNT_SESSION_KEY } from './account_session_key.js';
2
+ import { syncAdonisAuthLogin } from './adonis_auth_sync.js';
3
+ import { impersonationState } from './impersonation_session.js';
4
+ import { assertAccountEnabled } from './login_attempt.js';
5
+ /** Chave da sessão Adonis com o `uid` da sessão do IdP que originou o console. */
6
+ export const ACCOUNT_IDP_SESSION_KEY = 'authkit_idp_session_uid';
7
+ /**
8
+ * Lê a sessão do IdP a partir do cookie do request (mesma leitura que o
9
+ * provider faz: cookie `_session` assinado com as keys do provider, e o registro
10
+ * no adapter, que já descarta expirados). `null` sem sessão logada.
11
+ */
12
+ export async function readIdpSession(ctx, service) {
13
+ const provider = service?.provider;
14
+ if (!provider?.Session || typeof provider.createContext !== 'function')
15
+ return null;
16
+ try {
17
+ const kctx = provider.createContext(ctx.request.request, ctx.response.response);
18
+ const id = kctx.cookies.get(provider.cookieName('session'));
19
+ if (!id)
20
+ return null;
21
+ const session = (await provider.Session.find(id));
22
+ return session?.accountId ? session : null;
23
+ }
24
+ catch {
25
+ // Fail-safe: sem ponte (o guard segue para o login normal).
26
+ return null;
27
+ }
28
+ }
29
+ function acceptsIdpSession(service) {
30
+ return service?.config?.accountSession?.acceptIdpSession === true;
31
+ }
32
+ /** Id do humano por trás da sessão do console (impersonation → o admin real). */
33
+ function realConsoleAccount(ctx, current) {
34
+ try {
35
+ return impersonationState(ctx).impersonatorId ?? current;
36
+ }
37
+ catch {
38
+ return current;
39
+ }
40
+ }
41
+ /**
42
+ * Garante a sessão do console, aceitando a sessão do IdP quando configurado.
43
+ * Devolve `true` quando o request tem (ou passou a ter) sessão de console.
44
+ *
45
+ * Sem `accountSession.acceptIdpSession`, é só "existe `ACCOUNT_SESSION_KEY`?" —
46
+ * o comportamento de sempre, sem tocar no provider.
47
+ */
48
+ export async function ensureConsoleSession(ctx) {
49
+ const current = ctx.session?.get(ACCOUNT_SESSION_KEY);
50
+ const service = await ctx.containerResolver?.make('authkit.server').catch(() => null);
51
+ if (!acceptsIdpSession(service))
52
+ return Boolean(current);
53
+ const bridgedUid = ctx.session?.get(ACCOUNT_IDP_SESSION_KEY);
54
+ // Login próprio do console (não veio da ponte): intocado.
55
+ if (current && !bridgedUid)
56
+ return true;
57
+ const idp = await readIdpSession(ctx, service);
58
+ if (current && bridgedUid) {
59
+ if (idp && idp.uid === bridgedUid && idp.accountId === realConsoleAccount(ctx, current)) {
60
+ return true;
61
+ }
62
+ // A sessão do IdP que originou o console acabou (ou virou outra conta):
63
+ // encerra o console derivado dela. Se houver outra sessão do IdP viva, a
64
+ // ponte abaixo reabre para a conta dela.
65
+ ctx.session.forget(ACCOUNT_SESSION_KEY);
66
+ ctx.session.forget(ACCOUNT_IDP_SESSION_KEY);
67
+ }
68
+ if (!idp?.accountId)
69
+ return false;
70
+ const cfg = service.config;
71
+ const account = await cfg.accountStore.findById(idp.accountId);
72
+ if (!account)
73
+ return false;
74
+ const gate = await assertAccountEnabled(cfg, account.id, {
75
+ email: account.email ?? '',
76
+ ip: ctx.request.ip?.() ?? null,
77
+ });
78
+ if (!gate.allowed)
79
+ return false;
80
+ // Elevação anônimo → autenticado: troca o id da sessão (anti-fixation), como
81
+ // o login do console faz.
82
+ await ctx.session.regenerate();
83
+ ctx.session.put(ACCOUNT_SESSION_KEY, account.id);
84
+ ctx.session.put(ACCOUNT_IDP_SESSION_KEY, idp.uid);
85
+ await syncAdonisAuthLogin(ctx, cfg, account);
86
+ return true;
87
+ }
88
+ /**
89
+ * Logout do console de uma sessão que veio da ponte: encerra também a sessão
90
+ * do IdP que a originou — senão o próximo request reabriria o console pela
91
+ * própria ponte e o "Sair" não teria efeito. No-op fora da ponte.
92
+ */
93
+ export async function endBridgedIdpSession(ctx) {
94
+ const bridgedUid = ctx.session?.get(ACCOUNT_IDP_SESSION_KEY);
95
+ if (!bridgedUid)
96
+ return;
97
+ ctx.session.forget(ACCOUNT_IDP_SESSION_KEY);
98
+ const service = await ctx.containerResolver?.make('authkit.server').catch(() => null);
99
+ const idp = await readIdpSession(ctx, service);
100
+ if (idp && idp.uid === bridgedUid) {
101
+ try {
102
+ await idp.destroy();
103
+ }
104
+ catch {
105
+ // best-effort
106
+ }
107
+ }
108
+ }
@@ -1,11 +1,12 @@
1
1
  import '../augmentations.js';
2
2
  import { getAccountLoginUrl } from '../account_login_url.js';
3
3
  import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
4
+ import { ensureConsoleSession } from '../idp_session_bridge.js';
4
5
  export { ACCOUNT_SESSION_KEY };
5
6
  export default class AccountAuthMiddleware {
6
7
  async handle(ctx, next) {
7
- const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
8
- if (!userId) {
8
+ // Sessão do console — ou, com `accountSession.acceptIdpSession`, a do IdP (SSO).
9
+ if (!(await ensureConsoleSession(ctx))) {
9
10
  // Destino configurável (`accountLoginUrl`): default `/account/login`.
10
11
  return ctx.response.redirect(getAccountLoginUrl());
11
12
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Política EFETIVA de organizações — ponto de verdade ÚNICO das duas
3
+ * superfícies member-facing.
4
+ *
5
+ * O console HTML (`AccountOrgsController`) e o espelho JSON
6
+ * (`AccountOrgsApiController`) precisam responder à MESMA pergunta — "este
7
+ * usuário pode criar org? que papéis existem? quanto tempo o convite vale?" —
8
+ * e a resposta não está no config estático: ela é resolvida na ordem setting da
9
+ * org → setting global → `config.organizations` → default da lib.
10
+ *
11
+ * Mora num módulo próprio, e não dentro de um dos controllers, porque duas
12
+ * cópias desta resolução são exatamente como o caminho JSON acaba mais frouxo
13
+ * (ou mais apertado) que o formulário que ele espelha — o bug que este pacote
14
+ * de mudanças existe para não ter.
15
+ */
16
+ import './augmentations.js';
17
+ import type { HttpContext } from '@adonisjs/core/http';
18
+ import { type OrganizationsPolicyConfigDefaults, type ResolvedOrganizationsPolicySetting } from './runtime_toggles.js';
19
+ /** Defaults estáticos da política de org (config do host) — o fallback da setting. */
20
+ export declare function orgPolicyDefaults(cfg: any): OrganizationsPolicyConfigDefaults;
21
+ /**
22
+ * Política efetiva para o `orgId` (ou global, quando ausente). `settings` nulo —
23
+ * DB fora do ar, app sem lucid — cai no config estático, fail-safe herdado de
24
+ * {@link resolveEffectiveOrganizationsPolicy}.
25
+ */
26
+ export declare function effectiveOrgPolicy(ctx: HttpContext, cfg: any, orgId?: string | null): Promise<ResolvedOrganizationsPolicySetting>;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Política EFETIVA de organizações — ponto de verdade ÚNICO das duas
3
+ * superfícies member-facing.
4
+ *
5
+ * O console HTML (`AccountOrgsController`) e o espelho JSON
6
+ * (`AccountOrgsApiController`) precisam responder à MESMA pergunta — "este
7
+ * usuário pode criar org? que papéis existem? quanto tempo o convite vale?" —
8
+ * e a resposta não está no config estático: ela é resolvida na ordem setting da
9
+ * org → setting global → `config.organizations` → default da lib.
10
+ *
11
+ * Mora num módulo próprio, e não dentro de um dos controllers, porque duas
12
+ * cópias desta resolução são exatamente como o caminho JSON acaba mais frouxo
13
+ * (ou mais apertado) que o formulário que ele espelha — o bug que este pacote
14
+ * de mudanças existe para não ter.
15
+ */
16
+ import './augmentations.js';
17
+ import { resolveRuntimeSettings } from './runtime_settings.js';
18
+ import { resolveEffectiveOrganizationsPolicy, } from './runtime_toggles.js';
19
+ /** Defaults estáticos da política de org (config do host) — o fallback da setting. */
20
+ export function orgPolicyDefaults(cfg) {
21
+ return {
22
+ roles: cfg.organizations.roles,
23
+ allowSelfCreate: cfg.organizations.allowSelfCreate,
24
+ invitationTtlHours: cfg.organizations.invitationTtlHours,
25
+ };
26
+ }
27
+ /**
28
+ * Política efetiva para o `orgId` (ou global, quando ausente). `settings` nulo —
29
+ * DB fora do ar, app sem lucid — cai no config estático, fail-safe herdado de
30
+ * {@link resolveEffectiveOrganizationsPolicy}.
31
+ */
32
+ export async function effectiveOrgPolicy(ctx, cfg, orgId) {
33
+ const settings = await resolveRuntimeSettings(ctx);
34
+ return resolveEffectiveOrganizationsPolicy(settings, orgPolicyDefaults(cfg), orgId);
35
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Chave de sessão do desafio WebAuthn PENDENTE de REGISTRO de passkey.
3
+ *
4
+ * Mora num módulo próprio porque a cerimônia agora tem DOIS pares de rotas — o
5
+ * clássico (`/account/mfa/passkeys/{options,verify}`, que responde redirect numa
6
+ * navegação) e o JSON (`/account/api/mfa/passkeys/{options,verify}`, que nunca
7
+ * navega). Os dois precisam ler e escrever o MESMO slot: um `begin` feito por um
8
+ * caminho tem de poder ser finalizado pelo outro, e — mais importante — um
9
+ * desafio só pode existir UMA vez por sessão. Duas constantes com o mesmo valor
10
+ * em arquivos diferentes seriam a mesma coisa até alguém mudar uma delas.
11
+ */
12
+ export declare const PASSKEY_REG_CHALLENGE_KEY = "authkit_passkey_reg_challenge";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Chave de sessão do desafio WebAuthn PENDENTE de REGISTRO de passkey.
3
+ *
4
+ * Mora num módulo próprio porque a cerimônia agora tem DOIS pares de rotas — o
5
+ * clássico (`/account/mfa/passkeys/{options,verify}`, que responde redirect numa
6
+ * navegação) e o JSON (`/account/api/mfa/passkeys/{options,verify}`, que nunca
7
+ * navega). Os dois precisam ler e escrever o MESMO slot: um `begin` feito por um
8
+ * caminho tem de poder ser finalizado pelo outro, e — mais importante — um
9
+ * desafio só pode existir UMA vez por sessão. Duas constantes com o mesmo valor
10
+ * em arquivos diferentes seriam a mesma coisa até alguém mudar uma delas.
11
+ */
12
+ export const PASSKEY_REG_CHALLENGE_KEY = 'authkit_passkey_reg_challenge';