@adonis-agora/authkit-server 0.54.0 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/build/index.d.ts +4 -2
  2. package/build/index.js +2 -1
  3. package/build/providers/authkit_server_provider.js +18 -0
  4. package/build/src/accounts/lucid_account_store.d.ts +35 -0
  5. package/build/src/accounts/lucid_account_store.js +9 -0
  6. package/build/src/accounts/lucid_store/core.js +88 -19
  7. package/build/src/accounts/lucid_store/shared.d.ts +14 -0
  8. package/build/src/accounts/lucid_store/token_hash.d.ts +79 -0
  9. package/build/src/accounts/lucid_store/token_hash.js +145 -0
  10. package/build/src/define_config.d.ts +93 -8
  11. package/build/src/define_config.js +28 -2
  12. package/build/src/host/account_api/account_api_controller.js +5 -4
  13. package/build/src/host/admin_api/admin_users_service.js +2 -1
  14. package/build/src/host/admin_api/api_orgs_controller.js +2 -1
  15. package/build/src/host/admin_console/console_impersonation_controller.d.ts +15 -1
  16. package/build/src/host/admin_console/console_impersonation_controller.js +26 -2
  17. package/build/src/host/admin_console/console_orgs_controller.js +3 -1
  18. package/build/src/host/admin_validators.d.ts +3 -3
  19. package/build/src/host/auth_host_config.d.ts +30 -0
  20. package/build/src/host/auth_host_config.js +15 -0
  21. package/build/src/host/config_locks.d.ts +29 -0
  22. package/build/src/host/config_locks.js +46 -0
  23. package/build/src/host/console_session.d.ts +38 -2
  24. package/build/src/host/console_session.js +46 -2
  25. package/build/src/host/controllers/account_mfa_controller.js +5 -5
  26. package/build/src/host/controllers/account_orgs_controller.js +2 -1
  27. package/build/src/host/controllers/account_security_controller.js +6 -5
  28. package/build/src/host/controllers/interaction_controller.js +122 -26
  29. package/build/src/host/controllers/registration_controller.js +5 -4
  30. package/build/src/host/controllers/social_controller.js +37 -0
  31. package/build/src/host/default_mailer.d.ts +36 -5
  32. package/build/src/host/default_mailer.js +63 -10
  33. package/build/src/host/i18n.d.ts +10 -0
  34. package/build/src/host/i18n.js +16 -0
  35. package/build/src/host/login_attempt.d.ts +88 -2
  36. package/build/src/host/login_attempt.js +101 -48
  37. package/build/src/host/login_notify.js +2 -2
  38. package/build/src/host/oidc_rp_guard.d.ts +25 -2
  39. package/build/src/host/oidc_rp_guard.js +55 -9
  40. package/build/src/host/origin.d.ts +21 -0
  41. package/build/src/host/origin.js +22 -0
  42. package/build/src/host/register_auth_host.d.ts +104 -3
  43. package/build/src/host/register_auth_host.js +222 -26
  44. package/build/src/host/runtime_settings.d.ts +16 -0
  45. package/build/src/host/runtime_settings.js +24 -0
  46. package/build/src/host/runtime_toggles.d.ts +10 -0
  47. package/build/src/host/runtime_toggles.js +4 -0
  48. package/build/src/host/security_notice_service.d.ts +4 -2
  49. package/build/src/host/security_notice_service.js +4 -2
  50. package/build/src/host/sudo/index.d.ts +8 -0
  51. package/build/src/host/sudo/index.js +8 -0
  52. package/build/src/host/sudo/methods/magic_link.d.ts +17 -3
  53. package/build/src/host/sudo/methods/magic_link.js +37 -13
  54. package/build/src/host/sudo/runtime.d.ts +44 -4
  55. package/build/src/host/sudo/runtime.js +90 -6
  56. package/build/src/host/sudo/satisfiability.d.ts +62 -0
  57. package/build/src/host/sudo/satisfiability.js +89 -0
  58. package/build/src/password/common_passwords.js +27 -7
  59. package/build/src/provider/oidc_service.js +25 -13
  60. package/build/src/provider/token_exchange.d.ts +12 -1
  61. package/build/src/provider/token_exchange.js +12 -0
  62. package/package.json +6 -3
  63. /package/build/{password → src/password}/common_passwords.txt +0 -0
@@ -1,6 +1,27 @@
1
- import { symbols } from '@adonisjs/auth';
2
1
  import { RuntimeException } from '@adonisjs/core/exceptions';
3
2
  import { ACCOUNT_SESSION_KEY } from './middleware/account_auth.js';
3
+ /** Memo do construtor entre instâncias — o `import()` só paga o custo uma vez. */
4
+ let cachedUnauthorizedAccess;
5
+ /**
6
+ * Captura o `E_UNAUTHORIZED_ACCESS` real do `@adonisjs/auth` sem import
7
+ * estático. Chamado no boot pelo {@link oidcRpGuard} (falha cedo e com uma
8
+ * mensagem útil se o peer não estiver instalado) e, como rede de segurança, na
9
+ * primeira `authenticate()` de um guard construído à mão.
10
+ */
11
+ export async function loadUnauthorizedAccess() {
12
+ if (cachedUnauthorizedAccess)
13
+ return cachedUnauthorizedAccess;
14
+ try {
15
+ const auth = (await import('@adonisjs/auth'));
16
+ cachedUnauthorizedAccess = auth.errors.E_UNAUTHORIZED_ACCESS;
17
+ return cachedUnauthorizedAccess;
18
+ }
19
+ catch (error) {
20
+ throw new RuntimeException('oidcRpGuard() precisa de "@adonisjs/auth" instalado (é um peer opcional do ' +
21
+ '@adonis-agora/authkit-server, só necessário se você plugar este guard em config/auth.ts). ' +
22
+ 'Rode `npm i @adonisjs/auth` (ou pnpm/yarn).', { cause: error });
23
+ }
24
+ }
4
25
  /**
5
26
  * Guard de `@adonisjs/auth` pra Relying Parties OIDC — o app não autentica
6
27
  * ninguém (sem senha, sem remember-me); a identidade vem da sessão gravada
@@ -32,22 +53,36 @@ export class OidcRpGuard {
32
53
  #sessionKey;
33
54
  #emitter;
34
55
  #userProvider;
35
- [symbols.GUARD_KNOWN_EVENTS] = {};
56
+ #unauthorized;
36
57
  driverName = 'oidc_rp';
37
58
  authenticationAttempted = false;
38
59
  isAuthenticated = false;
39
60
  isLoggedOut = false;
40
61
  user;
41
- constructor(name, ctx, sessionKey, emitter, userProvider) {
62
+ constructor(name, ctx, sessionKey, emitter, userProvider, unauthorized) {
42
63
  this.#name = name;
43
64
  this.#ctx = ctx;
44
65
  this.#sessionKey = sessionKey;
45
66
  this.#emitter = emitter;
46
67
  this.#userProvider = userProvider;
68
+ this.#unauthorized = unauthorized ?? cachedUnauthorizedAccess;
69
+ }
70
+ /**
71
+ * O `E_UNAUTHORIZED_ACCESS` do framework — `status` 401 e os renderers
72
+ * html/json, então o handler de exceção do host trata igual ao dos guards
73
+ * nativos. Se o construtor ainda não foi resolvido (guard instanciado à mão,
74
+ * antes de qualquer `authenticate()`), cai num `RuntimeException` em vez de
75
+ * mentir sobre o tipo.
76
+ */
77
+ #unauthorizedError(message) {
78
+ const Unauthorized = this.#unauthorized ?? cachedUnauthorizedAccess;
79
+ if (!Unauthorized)
80
+ return new RuntimeException(message);
81
+ return new Unauthorized(message, { guardDriverName: this.driverName });
47
82
  }
48
83
  getUserOrFail() {
49
84
  if (!this.user) {
50
- throw new RuntimeException('Cannot access user. Authentication has not been attempted or failed.');
85
+ throw this.#unauthorizedError('Cannot access user. Authentication has not been attempted or failed.');
51
86
  }
52
87
  return this.user;
53
88
  }
@@ -89,13 +124,14 @@ export class OidcRpGuard {
89
124
  return this.getUserOrFail();
90
125
  }
91
126
  this.authenticationAttempted = true;
127
+ this.#unauthorized ??= await loadUnauthorizedAccess();
92
128
  const userId = this.#ctx.session.get(this.#sessionKey);
93
129
  if (!userId) {
94
130
  this.#emitter.emit('oidc_rp:authentication_failed', {
95
131
  ctx: this.#ctx,
96
132
  guardName: this.#name,
97
133
  });
98
- throw new RuntimeException('Unauthorized', { cause: 'E_UNAUTHORIZED_ACCESS' });
134
+ throw this.#unauthorizedError('Unauthorized');
99
135
  }
100
136
  const guardUser = await this.#userProvider.findById(userId);
101
137
  if (!guardUser) {
@@ -104,7 +140,7 @@ export class OidcRpGuard {
104
140
  ctx: this.#ctx,
105
141
  guardName: this.#name,
106
142
  });
107
- throw new RuntimeException('Unauthorized', { cause: 'E_UNAUTHORIZED_ACCESS' });
143
+ throw this.#unauthorizedError('Unauthorized');
108
144
  }
109
145
  this.user = guardUser.getOriginal();
110
146
  this.isAuthenticated = true;
@@ -115,13 +151,22 @@ export class OidcRpGuard {
115
151
  });
116
152
  return this.user;
117
153
  }
154
+ /**
155
+ * `authenticate()` sem lançar — mas SÓ para falha de autenticação. Igual ao
156
+ * `SessionGuard` nativo: engole apenas `E_UNAUTHORIZED_ACCESS` e relança o
157
+ * resto. Um `catch` cego aqui transformava uma queda do banco dentro de
158
+ * `provider.findById` em "não logado" para todo mundo, sem nada nos logs.
159
+ */
118
160
  async check() {
119
161
  try {
120
162
  await this.authenticate();
121
163
  return true;
122
164
  }
123
- catch {
124
- return false;
165
+ catch (error) {
166
+ const Unauthorized = this.#unauthorized ?? cachedUnauthorizedAccess;
167
+ if (Unauthorized && error instanceof Unauthorized)
168
+ return false;
169
+ throw error;
125
170
  }
126
171
  }
127
172
  async authenticateAsClient(user) {
@@ -139,6 +184,7 @@ export function oidcRpGuard(config) {
139
184
  async resolver(name, app) {
140
185
  const emitter = await app.container.make('emitter');
141
186
  const sessionKey = config.sessionKey ?? ACCOUNT_SESSION_KEY;
187
+ const unauthorized = await loadUnauthorizedAccess();
142
188
  let userProvider;
143
189
  if (typeof config.provider.resolver === 'function') {
144
190
  userProvider = await config.provider.resolver(app);
@@ -147,7 +193,7 @@ export function oidcRpGuard(config) {
147
193
  userProvider = config.provider;
148
194
  }
149
195
  return (ctx) => {
150
- return new OidcRpGuard(name, ctx, sessionKey, emitter, userProvider);
196
+ return new OidcRpGuard(name, ctx, sessionKey, emitter, userProvider, unauthorized);
151
197
  };
152
198
  },
153
199
  };
@@ -0,0 +1,21 @@
1
+ import type { ResolvedServerConfig } from '../define_config.js';
2
+ /**
3
+ * The canonical public origin for links we EMAIL (password reset, magic link,
4
+ * OTP unlock, org invitations, email verification/change, security notices).
5
+ *
6
+ * Never derive these from `request.host()` / `request.protocol()` (which reads
7
+ * `X-Forwarded-Proto` under a trusting proxy config): both are client-supplied.
8
+ * An attacker who can reach the server directly (common when the app sits
9
+ * behind a load balancer that does not pin `Host`) can submit a password-reset
10
+ * or magic-link request for a victim's address with a `Host` of their choosing,
11
+ * and the victim's genuine email ends up pointing at attacker-controlled
12
+ * infrastructure. This is classic password-reset poisoning.
13
+ *
14
+ * Defaults to the origin (`scheme://host[:port]`, no trailing slash, mount
15
+ * path stripped) of the resolved `issuer` — the one canonical origin this
16
+ * library already has. Hosts that legitimately serve the same issuer under
17
+ * multiple public hostnames (and therefore want emailed links to follow the
18
+ * request instead of normalizing to a single hostname) can opt out via the
19
+ * `mail.origin` config escape hatch.
20
+ */
21
+ export declare function authkitOrigin(cfg: ResolvedServerConfig): string;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The canonical public origin for links we EMAIL (password reset, magic link,
3
+ * OTP unlock, org invitations, email verification/change, security notices).
4
+ *
5
+ * Never derive these from `request.host()` / `request.protocol()` (which reads
6
+ * `X-Forwarded-Proto` under a trusting proxy config): both are client-supplied.
7
+ * An attacker who can reach the server directly (common when the app sits
8
+ * behind a load balancer that does not pin `Host`) can submit a password-reset
9
+ * or magic-link request for a victim's address with a `Host` of their choosing,
10
+ * and the victim's genuine email ends up pointing at attacker-controlled
11
+ * infrastructure. This is classic password-reset poisoning.
12
+ *
13
+ * Defaults to the origin (`scheme://host[:port]`, no trailing slash, mount
14
+ * path stripped) of the resolved `issuer` — the one canonical origin this
15
+ * library already has. Hosts that legitimately serve the same issuer under
16
+ * multiple public hostnames (and therefore want emailed links to follow the
17
+ * request instead of normalizing to a single hostname) can opt out via the
18
+ * `mail.origin` config escape hatch.
19
+ */
20
+ export function authkitOrigin(cfg) {
21
+ return cfg.mail?.origin ?? new URL(cfg.issuer).origin;
22
+ }
@@ -1,6 +1,7 @@
1
1
  import type { Router } from '@adonisjs/core/http';
2
2
  import type { AuthSocialConfig, RateLimitConfigInput } from '../define_config.js';
3
- import { type AccountPathsOptions } from './account_paths.js';
3
+ import { type AccountPathKey, type AccountPathsOptions } from './account_paths.js';
4
+ import type { PolicyRouteOption } from './config_locks.js';
4
5
  import type { SudoMethod } from './sudo/types.js';
5
6
  /** Chave da sessão Adonis que registra o timestamp da última atividade (idle timeout). */
6
7
  export declare const ACCOUNT_LAST_SEEN_KEY = "authkit_last_seen";
@@ -108,7 +109,14 @@ export interface AuthHostOptions {
108
109
  * fechada, mas é a promessa do SPI pela metade; `magicLink()` em particular
109
110
  * não teria como ser alcançado em runtime.
110
111
  *
111
- * Ausente → `[password(), passkey()]`.
112
+ * Ausente → `[password(), passkey(), magicLink()]`, e o config resolvido
113
+ * decide quais deles a tela OFERECE: host com senha recebe
114
+ * `[password, passkey]` (o histórico, byte a byte) e host que declarou
115
+ * `authMethods: { password: false }` recebe `[passkey, magicLink]` — sem isso
116
+ * ele não teria um único método satisfazível. Ver `derivedSudoMethods` em
117
+ * `sudo/runtime.ts`.
118
+ *
119
+ * Passar a opção desliga essa derivação: a lista é do host, ao pé da letra.
112
120
  */
113
121
  sudoMethods?: SudoMethod[];
114
122
  /**
@@ -203,8 +211,101 @@ export interface AccountScreensOptions {
203
211
  /** Apps com acesso / grants de consentimento OIDC (`/account/apps*`). */
204
212
  apps?: boolean;
205
213
  }
214
+ /**
215
+ * Mapa RESOLVIDO das rotas montadas, devolvido por `registerAuthHost`.
216
+ *
217
+ * Existe porque os overrides de path (`accountRoutes`) chegavam ao servidor
218
+ * (via `accountPath()`) mas NÃO ao frontend: o layout React e os formulários do
219
+ * host tinham de repetir os mesmos paths à mão, e um override era meio-recurso —
220
+ * certo no servidor, errado na UI. Entregue este mapa ao frontend (uma shared
221
+ * prop do Inertia, um `<script type="application/json">`, um endpoint) em vez de
222
+ * hardcodar `href`s.
223
+ *
224
+ * @example
225
+ * const authkitRoutes = registerAuthHost(router)
226
+ * router.get('/', ({ inertia }) => inertia.render('home', { authkitRoutes }))
227
+ */
228
+ export interface AuthHostRouteMap {
229
+ /** Onde o provider OIDC foi montado (o wildcard é `${mountPath}/*`). */
230
+ mountPath: string;
231
+ /** Console de conta: prefixo, base da JSON API e o path de CADA tela. */
232
+ account: {
233
+ /** Prefixo base resolvido (default `/account`). */
234
+ prefix: string;
235
+ /** Base da JSON API do console de conta (default `/account/api`). */
236
+ api: string;
237
+ /** Path completo de cada tela navegável (`security` → `/account/security`). */
238
+ paths: Record<AccountPathKey, string>;
239
+ /** Destino do redirect de "faça login" em vigor. */
240
+ loginUrl: string;
241
+ /** Quais telas foram efetivamente montadas. */
242
+ screens: Record<keyof AccountScreensOptions, boolean>;
243
+ };
244
+ /** Console admin: prefixo resolvido, ou `null` quando não foi montado. */
245
+ admin: {
246
+ prefix: string;
247
+ } | null;
248
+ /** Admin REST API: prefixo resolvido, ou `null` quando não foi montada. */
249
+ adminApi: {
250
+ prefix: string;
251
+ } | null;
252
+ /** Nomes das rotas nomeadas (as demais herdam o auto-naming do AdonisJS). */
253
+ names: Record<string, string>;
254
+ /** Ids dos métodos de sudo cujas rotas foram montadas, na ordem de montagem. */
255
+ sudoMethods: string[];
256
+ /**
257
+ * Opções de POLÍTICA passadas como argumento que foram IGNORADAS porque o
258
+ * `defineConfig` as declarou (config vence). Vazio no caso normal. Cada uma
259
+ * também sai como `console.warn` no boot — ver a regra de precedência abaixo.
260
+ */
261
+ overriddenByConfig: PolicyRouteOption[];
262
+ }
206
263
  /**
207
264
  * Monta todas as rotas do host-kit do Authorization Server numa chamada.
208
265
  * Substitui registerOidcRoutes + o hand-wiring do start/routes.ts do host.
266
+ *
267
+ * ── A REGRA DE PRECEDÊNCIA (config × argumento) ─────────────────────────────
268
+ *
269
+ * 1. **Argumento omitido HERDA do config.** `registerAuthHost(router)` é
270
+ * totalmente config-driven; `registerAuthHost(router, { mountPath: '/sso' })`
271
+ * troca o mountPath e herda TODO o resto. Omitir uma chave significa "usa o
272
+ * config", nunca "usa nada" — é o que dispensa repetir `sudo.methods` no
273
+ * `start/routes.ts`.
274
+ *
275
+ * 2. **Chaves ESTRUTURAIS: o argumento vence.** `mountPath`, os prefixos
276
+ * (`admin.prefix`, `adminApi.prefix`, `accountRoutes.prefix`), os segmentos
277
+ * de tela, `account` (quais telas montar) e `accountLoginUrl`. São decisões
278
+ * do ponto de chamada por natureza, e a forma de função é estritamente mais
279
+ * expressiva (dá para montar duas vezes sob dois prefixos — nenhum config
280
+ * expressa isso).
281
+ *
282
+ * 3. **Chaves de POLÍTICA: o config vence, e trava.** `social`, `rateLimit`,
283
+ * `sudoMethods` e o liga/desliga de `admin`/`adminApi` decidem o que é
284
+ * PERMITIDO. Quando o `defineConfig` as declara, o argumento NÃO as altera —
285
+ * senão o `config/authkit.ts` deixa de ser auditável e seria preciso ler o
286
+ * `start/routes.ts` de cada app para saber o que está valendo. Mesma regra
287
+ * (e mesma derivação) de `defineConfig({ authMethods })`, que já fixa os
288
+ * métodos de login contra o runtime. Ver `deriveLockedRouteOptions`.
289
+ * A divergência NÃO é silenciosa: sai um `console.warn` nomeando a chave e
290
+ * a chave aparece em `AuthHostRouteMap.overriddenByConfig`.
291
+ *
292
+ * Devolve o {@link AuthHostRouteMap} resolvido — entregue-o ao frontend em vez
293
+ * de hardcodar `href`s.
294
+ */
295
+ export declare function registerAuthHost(router: Router, opts?: AuthHostOptions): AuthHostRouteMap;
296
+ /**
297
+ * Auto-montagem a partir de `config.routes` (R1). Chamada UMA vez pelo
298
+ * `boot()` do provider.
299
+ *
300
+ * O CAMINHO AUTOMÁTICO É O CAMINHO MANUAL: chama literalmente
301
+ * `registerAuthHost`, sem nenhuma segunda implementação. Toda a resolução
302
+ * (herança do config, precedência política × estrutural, mapa de retorno) é a
303
+ * mesma — duas implementações de um comportamento sempre divergem.
304
+ *
305
+ * Não recebe opções: os defaults ESTRUTURAIS vêm de `config.routes` pelo stash
306
+ * (`AuthHostRuntimeConfig.routes`), pelo mesmo caminho que uma chamada manual
307
+ * os leria.
308
+ *
309
+ * @internal
209
310
  */
210
- export declare function registerAuthHost(router: Router, opts?: AuthHostOptions): void;
311
+ export declare function autoMountAuthHost(router: Router): AuthHostRouteMap;
@@ -1,28 +1,43 @@
1
1
  import { resolveRateLimit } from '../define_config.js';
2
2
  import { accountHome } from './account_home.js';
3
3
  import { getAccountLoginUrl, setAccountLoginUrl } from './account_login_url.js';
4
- import { accountPath, joinAccountPath, setAccountPaths, } from './account_paths.js';
4
+ import { accountPath, accountPathsMap, accountPrefix, joinAccountPath, setAccountPaths, } from './account_paths.js';
5
5
  import { resolveAccountRoles } from './account_roles.js';
6
6
  import { adminApiGuard } from './admin_api/admin_api_guard.js';
7
7
  import { normalizeAdminApiPrefix, normalizeAdminPrefix, setAdminApiPrefix, setAdminPrefix, } from './admin_prefix.js';
8
- import { getAuthHostConfig } from './auth_host_config.js';
8
+ import { getAuthHostConfig, markAuthHostAutoMounted, wasAuthHostAutoMounted, } from './auth_host_config.js';
9
9
  import { ACCOUNT_SESSION_KEY } from './middleware/account_auth.js';
10
10
  import { createAuthThrottles } from './rate_limit.js';
11
11
  import { resolveRuntimeSettings } from './runtime_settings.js';
12
12
  import { resolveEffectiveSessionPolicy } from './runtime_toggles.js';
13
+ import { magicLink as sudoMagicLink } from './sudo/methods/magic_link.js';
13
14
  import { passkey as sudoPasskey } from './sudo/methods/passkey.js';
14
15
  import { password as sudoPassword } from './sudo/methods/password.js';
15
16
  import { completeSudo, fail, guardSudoRoutes, setMountedSudoMethods, sudoContextFrom, } from './sudo/runtime.js';
16
17
  /**
17
- * Métodos de sudo montados quando o host não passa `sudoMethods` —
18
- * comportamento histórico (senha + passkey).
18
+ * Métodos de sudo cujas ROTAS são montadas quando o host não passa
19
+ * `sudoMethods`.
19
20
  *
20
- * PONTO ÚNICO. A tela não tem mais uma cópia desta lista: sem
21
- * `config.sudo.methods`, `configuredSudoMethods` cai no que FOI MONTADO, ou
22
- * seja, no resultado do `??` abaixo. Duas listas de default é como os dois
23
- * lados divergiam.
21
+ * `magicLink()` entra aqui, e a lista deixou de ser o histórico
22
+ * `[password, passkey]`, porque os dois históricos exigem uma credencial
23
+ * PREVIAMENTE CADASTRADA. Num host passwordless não existe nenhuma: o usuário
24
+ * não tem senha, não tem passkey — e cadastrar passkey exige sudo. Deadlock
25
+ * fechado, e era o DEFAULT. O `oidcStepUp` não pode entrar (exige uma URL do
26
+ * host), então o magic link de sudo é o único método sem credencial prévia que
27
+ * a lib consegue montar sozinha.
28
+ *
29
+ * MONTAR NÃO É OFERECER. Esta lista decide apenas quais ENDPOINTS existem; o
30
+ * que a tela oferece e o que os handlers aceitam é derivado do config resolvido,
31
+ * em `sudo/runtime.ts` (`derivedSudoMethods`) — um host COM senha continua
32
+ * recebendo exatamente `[password, passkey]`, e o endpoint do magic link fica
33
+ * montado mas inerte. Não é possível decidir a oferta aqui: a montagem acontece
34
+ * antes de o config (lazy) resolver, e `authMethods` vive no config.
35
+ *
36
+ * PONTO ÚNICO. A tela não tem uma cópia desta lista: sem `config.sudo.methods`,
37
+ * `configuredSudoMethods` cai no que FOI MONTADO (derivado). Duas listas de
38
+ * default é como os dois lados divergiam.
24
39
  */
25
- const SUDO_METHOD_DEFAULTS = [sudoPassword(), sudoPasskey()];
40
+ const SUDO_METHOD_DEFAULTS = [sudoPassword(), sudoPasskey(), sudoMagicLink()];
26
41
  /** Chave da sessão Adonis que registra o timestamp da última atividade (idle timeout). */
27
42
  export const ACCOUNT_LAST_SEEN_KEY = 'authkit_last_seen';
28
43
  /**
@@ -190,37 +205,123 @@ const C = {
190
205
  /**
191
206
  * Monta todas as rotas do host-kit do Authorization Server numa chamada.
192
207
  * Substitui registerOidcRoutes + o hand-wiring do start/routes.ts do host.
208
+ *
209
+ * ── A REGRA DE PRECEDÊNCIA (config × argumento) ─────────────────────────────
210
+ *
211
+ * 1. **Argumento omitido HERDA do config.** `registerAuthHost(router)` é
212
+ * totalmente config-driven; `registerAuthHost(router, { mountPath: '/sso' })`
213
+ * troca o mountPath e herda TODO o resto. Omitir uma chave significa "usa o
214
+ * config", nunca "usa nada" — é o que dispensa repetir `sudo.methods` no
215
+ * `start/routes.ts`.
216
+ *
217
+ * 2. **Chaves ESTRUTURAIS: o argumento vence.** `mountPath`, os prefixos
218
+ * (`admin.prefix`, `adminApi.prefix`, `accountRoutes.prefix`), os segmentos
219
+ * de tela, `account` (quais telas montar) e `accountLoginUrl`. São decisões
220
+ * do ponto de chamada por natureza, e a forma de função é estritamente mais
221
+ * expressiva (dá para montar duas vezes sob dois prefixos — nenhum config
222
+ * expressa isso).
223
+ *
224
+ * 3. **Chaves de POLÍTICA: o config vence, e trava.** `social`, `rateLimit`,
225
+ * `sudoMethods` e o liga/desliga de `admin`/`adminApi` decidem o que é
226
+ * PERMITIDO. Quando o `defineConfig` as declara, o argumento NÃO as altera —
227
+ * senão o `config/authkit.ts` deixa de ser auditável e seria preciso ler o
228
+ * `start/routes.ts` de cada app para saber o que está valendo. Mesma regra
229
+ * (e mesma derivação) de `defineConfig({ authMethods })`, que já fixa os
230
+ * métodos de login contra o runtime. Ver `deriveLockedRouteOptions`.
231
+ * A divergência NÃO é silenciosa: sai um `console.warn` nomeando a chave e
232
+ * a chave aparece em `AuthHostRouteMap.overriddenByConfig`.
233
+ *
234
+ * Devolve o {@link AuthHostRouteMap} resolvido — entregue-o ao frontend em vez
235
+ * de hardcodar `href`s.
193
236
  */
194
237
  export function registerAuthHost(router, opts = {}) {
195
238
  // Config resolvido (stashado no boot do provider) — fonte única; `opts` só faz
196
- // OVERRIDE. Elimina o drift: o consumidor pode chamar `registerAuthHost(router)`
197
- // e mountPath/social/rateLimit/admin/adminApi vêm do config/authkit.ts.
239
+ // OVERRIDE das chaves ESTRUTURAIS. Elimina o drift: o consumidor pode chamar
240
+ // `registerAuthHost(router)` e tudo vem do config/authkit.ts.
198
241
  // Fallback p/ defaults quando o stash não está disponível (ex.: testes sem boot).
199
242
  const hostCfg = getAuthHostConfig();
200
- const mount = opts.mountPath ?? hostCfg?.mountPath ?? '/oidc';
201
- // social: opt explícito > config. admin/adminApi: opt explícito > (config.enabled monta).
202
- const social = opts.social ?? hostCfg?.social;
203
- const adminOpt = opts.admin ?? (hostCfg?.adminEnabled ? true : undefined);
204
- const adminApiOpt = opts.adminApi ?? (hostCfg?.adminApiEnabled ? true : undefined);
243
+ // `config.routes` montou tudo no boot do provider. Registrar de novo
244
+ // duplicaria CADA rota e duas rotas com o mesmo nome derrubam o boot do
245
+ // AdonisJS com um `RuntimeException` que não diz de onde veio. Falha aqui,
246
+ // alto e com a saída explícita.
247
+ if (wasAuthHostAutoMounted()) {
248
+ throw new Error('authkit: registerAuthHost() foi chamado depois de `routes` no config/authkit.ts já ter montado as rotas automaticamente — o duplo registro derruba o boot ("route name already exists"). Escolha UM dos dois: remova a chamada do start/routes.ts, ou ponha `routes: false` no defineConfig e configure tudo pela chamada.');
249
+ }
250
+ // Defaults ESTRUTURAIS declarados em `config.routes` — o argumento ainda vence.
251
+ const routesCfg = hostCfg?.routes;
252
+ // Chaves de POLÍTICA travadas pelo defineConfig (ver o item 3 acima).
253
+ const locked = new Set(hostCfg?.lockedRouteOptions ?? []);
254
+ const overriddenByConfig = [];
255
+ /**
256
+ * Uma chave de política travada IGNORA o argumento e reporta. Não silencia:
257
+ * um argumento sem efeito que ninguém percebe é como o drift começa.
258
+ */
259
+ const isLocked = (key, passed) => {
260
+ if (!locked.has(key))
261
+ return false;
262
+ if (passed) {
263
+ overriddenByConfig.push(key);
264
+ console.warn(`authkit: registerAuthHost(router, { ${key} }) foi IGNORADO — "${key}" é uma opção de política e está definida no defineConfig(), que vence (o config precisa ser auditável sem ler o start/routes.ts). Remova-a da chamada, ou remova-a do config para controlá-la aqui.`);
265
+ }
266
+ return true;
267
+ };
268
+ const mount = opts.mountPath ?? routesCfg?.mountPath ?? hostCfg?.mountPath ?? '/oidc';
269
+ // social — POLÍTICA: decide se existe um caminho de autenticação a mais.
270
+ const social = isLocked('social', opts.social !== undefined)
271
+ ? hostCfg?.social
272
+ : (opts.social ?? routesCfg?.social ?? hostCfg?.social);
273
+ // admin/adminApi — o LIGA/DESLIGA é política (config vence); o PREFIXO é
274
+ // estrutural (o argumento vence). Por isso os dois eixos são resolvidos
275
+ // separadamente em vez de a opção inteira ser um bloco só.
276
+ const enableFrom = (v) => v === undefined ? undefined : v !== false;
277
+ const prefixFrom = (...vs) => {
278
+ for (const v of vs)
279
+ if (typeof v === 'object' && v?.prefix)
280
+ return v.prefix;
281
+ return undefined;
282
+ };
283
+ // `{ prefix: '/painel' }` mexe SÓ no eixo estrutural: só há divergência a
284
+ // reportar quando a intenção de liga/desliga do argumento CONTRADIZ o config.
285
+ const contradicts = (arg, configEnabled) => {
286
+ const intent = enableFrom(arg);
287
+ return intent !== undefined && intent !== (configEnabled ?? false);
288
+ };
289
+ const adminEnabled = isLocked('admin', contradicts(opts.admin, hostCfg?.adminEnabled))
290
+ ? (hostCfg?.adminEnabled ?? false)
291
+ : (enableFrom(opts.admin) ?? enableFrom(routesCfg?.admin) ?? hostCfg?.adminEnabled ?? false);
292
+ const adminPrefixOpt = prefixFrom(opts.admin, routesCfg?.admin);
293
+ const adminOpt = adminEnabled ? { prefix: adminPrefixOpt } : undefined;
294
+ const adminApiEnabled = isLocked('adminApi', contradicts(opts.adminApi, hostCfg?.adminApiEnabled))
295
+ ? (hostCfg?.adminApiEnabled ?? false)
296
+ : (enableFrom(opts.adminApi) ??
297
+ enableFrom(routesCfg?.adminApi) ??
298
+ hostCfg?.adminApiEnabled ??
299
+ false);
300
+ const adminApiPrefixOpt = prefixFrom(opts.adminApi, routesCfg?.adminApi);
301
+ const adminApiOpt = adminApiEnabled
302
+ ? { prefix: adminApiPrefixOpt }
303
+ : undefined;
205
304
  // Prefixo/segmentos configuráveis do console de conta — persiste no singleton
206
305
  // de processo ANTES de qualquer construção de rota/closure, para que o registro
207
306
  // de rotas, os guards, os controllers, os e-mails e as views leiam o mesmo
208
307
  // valor. Top-level de propósito (vale mesmo com `account: false`): as rotas de
209
308
  // sudo e a JSON API respeitam o prefixo. Ausente → default `/account/*`
210
309
  // (back-compat). Ver `account_paths.ts`.
211
- if (opts.accountRoutes !== undefined) {
212
- setAccountPaths(opts.accountRoutes);
310
+ const accountRoutesOpt = opts.accountRoutes ?? routesCfg?.accountRoutes;
311
+ if (accountRoutesOpt !== undefined) {
312
+ setAccountPaths(accountRoutesOpt);
213
313
  }
214
314
  // Destino do redirect de "faça login" — persiste no singleton de processo para
215
315
  // que os guards (closures de tempo de registro), o middleware, os controllers e
216
316
  // as views leiam o mesmo valor. Só quando a opção foi passada (senão deriva de
217
317
  // `accountPath('login')` — back-compat).
218
- if (opts.accountLoginUrl !== undefined) {
219
- setAccountLoginUrl(opts.accountLoginUrl);
318
+ const accountLoginUrlOpt = opts.accountLoginUrl ?? routesCfg?.accountLoginUrl;
319
+ if (accountLoginUrlOpt !== undefined) {
320
+ setAccountLoginUrl(accountLoginUrlOpt);
220
321
  }
221
322
  // Montagem por tela do console de conta. `undefined` → tudo montado;
222
323
  // `false` → nada; objeto → cada flag ausente default `true`.
223
- const accountOpt = opts.account;
324
+ const accountOpt = opts.account ?? routesCfg?.account;
224
325
  const mountScreen = (key) => {
225
326
  if (accountOpt === false)
226
327
  return false;
@@ -234,10 +335,21 @@ export function registerAuthHost(router, opts = {}) {
234
335
  const mountSecurity = mountScreen('security');
235
336
  const mountMfa = mountScreen('mfa');
236
337
  const mountApps = mountScreen('apps');
338
+ // Ids dos métodos de sudo efetivamente montados — preenchido dentro do grupo
339
+ // do console de conta (a callback do `.group()` roda de forma síncrona) e
340
+ // devolvido no `AuthHostRouteMap`.
341
+ let mountedSudoIds = [];
237
342
  // Throttles opt-in (anti-brute-force). `undefined` quando rate-limit desligado.
238
- const resolvedRateLimit = opts.rateLimit !== undefined
239
- ? resolveRateLimit(opts.rateLimit)
240
- : (hostCfg?.rateLimit ?? resolveRateLimit(undefined));
343
+ // POLÍTICA: `rateLimit: { enabled: false }` num routes.ts desligaria a proteção
344
+ // anti-brute-force que o config ligou — e o config.rateLimit JÁ trava a setting
345
+ // de runtime homônima (`deriveLockedSettingKeys`). Travar aqui só fecha o
346
+ // terceiro caminho para o mesmo valor.
347
+ const rateLimitOpt = opts.rateLimit ?? routesCfg?.rateLimit;
348
+ const resolvedRateLimit = isLocked('rateLimit', opts.rateLimit !== undefined)
349
+ ? (hostCfg?.rateLimit ?? resolveRateLimit(undefined))
350
+ : rateLimitOpt !== undefined
351
+ ? resolveRateLimit(rateLimitOpt)
352
+ : (hostCfg?.rateLimit ?? resolveRateLimit(undefined));
241
353
  const throttles = createAuthThrottles(resolvedRateLimit);
242
354
  // Helpers: aplicam o middleware de throttle quando presente; senão no-op.
243
355
  const withLogin = (route) => {
@@ -413,7 +525,16 @@ export function registerAuthHost(router, opts = {}) {
413
525
  completeSudo,
414
526
  fail,
415
527
  };
416
- const sudoMethodsToMount = opts?.sudoMethods ?? SUDO_METHOD_DEFAULTS;
528
+ // POLÍTICA. `config.sudo.methods` decide o que a tela OFERECE e o que
529
+ // os handlers ACEITAM (`isSudoMethodEnabled`); agora decide também o que é
530
+ // MONTADO. Era a única das três decisões que ficava fora do config, e era
531
+ // exatamente por isso que o host tinha de manter a lista em DOIS lugares —
532
+ // divergiram, a tela oferecia um método sem endpoint (404) e escondia um
533
+ // que funcionava.
534
+ const sudoMethodsDeclared = isLocked('sudoMethods', opts.sudoMethods !== undefined)
535
+ ? hostCfg?.sudoMethods
536
+ : (opts.sudoMethods ?? routesCfg?.sudoMethods ?? hostCfg?.sudoMethods);
537
+ const sudoMethodsToMount = sudoMethodsDeclared ?? SUDO_METHOD_DEFAULTS;
417
538
  for (const method of sudoMethodsToMount) {
418
539
  // `guardSudoRoutes` embrulha os handlers que o método registrar, para
419
540
  // que `config.sudo.methods` os desabilite de fato mesmo que o método
@@ -434,7 +555,16 @@ export function registerAuthHost(router, opts = {}) {
434
555
  // A lista montada é a fonte de verdade dos DOIS lados quando o host não
435
556
  // configura `config.sudo.methods`: a tela oferece exatamente isto, e os
436
557
  // handlers aceitam exatamente isto.
437
- setMountedSudoMethods(sudoMethodsToMount);
558
+ //
559
+ // O segundo argumento diz se a lista é a da LIB ou a do HOST, e a
560
+ // distinção é o que mantém o conserto do deadlock inofensivo: só a lista
561
+ // de defaults é derivada do config (`derivedSudoMethods`). Uma lista que o
562
+ // host escreveu — no argumento ou no `config.sudo.methods` — vale ao pé da
563
+ // letra, em qualquer direção, porque foi ele quem a escreveu.
564
+ setMountedSudoMethods(sudoMethodsToMount, {
565
+ fromDefaults: sudoMethodsDeclared === undefined,
566
+ });
567
+ mountedSudoIds = sudoMethodsToMount.map((m) => m.id);
438
568
  // Organizations (multi-tenancy) — tela `orgs`. Montadas por default;
439
569
  // controller retorna 404/403 sem tabelas (capability-probed).
440
570
  if (mountOrgs) {
@@ -489,11 +619,16 @@ export function registerAuthHost(router, opts = {}) {
489
619
  router.get(`${apiBase}/orgs/:id`, [C.accountApi, 'showOrg']);
490
620
  })
491
621
  .use([accountGuard]);
622
+ // Prefixos resolvidos dos consoles — `null` quando o grupo não foi montado.
623
+ // Compõem o `AuthHostRouteMap` devolvido no fim.
624
+ let resolvedAdminPrefix = null;
625
+ let resolvedAdminApiPrefix = null;
492
626
  // Console admin (do config.admin.enabled ou opts). Protegido pelo adminGuard (sessão + role global).
493
627
  if (adminOpt) {
494
628
  // Resolve o prefixo: `true` → '/admin' (default); objeto → usa prefix fornecido.
495
629
  const rawPrefix = typeof adminOpt === 'object' && adminOpt.prefix ? adminOpt.prefix : '/admin';
496
630
  const ap = normalizeAdminPrefix(rawPrefix);
631
+ resolvedAdminPrefix = ap;
497
632
  // Persiste no singleton de processo para que controllers e views usem o mesmo prefixo.
498
633
  setAdminPrefix(ap);
499
634
  router
@@ -586,6 +721,7 @@ export function registerAuthHost(router, opts = {}) {
586
721
  ? adminApiOpt.prefix
587
722
  : '/api/authkit/v1';
588
723
  const aap = normalizeAdminApiPrefix(rawApiPrefix);
724
+ resolvedAdminApiPrefix = aap;
589
725
  // Persiste no singleton de processo para que o SDK remoto e outros consumidores
590
726
  // usem o mesmo prefixo sem precisar receber a opção.
591
727
  setAdminApiPrefix(aap);
@@ -648,4 +784,64 @@ export function registerAuthHost(router, opts = {}) {
648
784
  // O `withApiThrottle` (introspection, por token) continua como camada adicional.
649
785
  .use(throttles ? [throttles.adminIp, adminApiGuard] : [adminApiGuard]);
650
786
  }
787
+ // ─── Mapa resolvido (R4) ───────────────────────────────────────────────────
788
+ // Lido DEPOIS de todo o registro: `accountPath()`/`accountPrefix()` já
789
+ // refletem o `accountRoutes` aplicado no topo, e os prefixos de console já
790
+ // foram normalizados. É este objeto que o host entrega ao frontend em vez de
791
+ // hardcodar `href`s — o que faltava para um override de path ser um recurso
792
+ // inteiro, e não só metade (certo no servidor, errado na UI).
793
+ return {
794
+ mountPath: mount,
795
+ account: {
796
+ prefix: accountPrefix(),
797
+ api: apiBase,
798
+ paths: accountPathsMap(),
799
+ loginUrl: getAccountLoginUrl(),
800
+ screens: {
801
+ login: mountLogin,
802
+ tokens: mountTokens,
803
+ orgs: mountOrgs,
804
+ security: mountSecurity,
805
+ mfa: mountMfa,
806
+ apps: mountApps,
807
+ },
808
+ },
809
+ admin: resolvedAdminPrefix ? { prefix: resolvedAdminPrefix } : null,
810
+ adminApi: resolvedAdminApiPrefix ? { prefix: resolvedAdminApiPrefix } : null,
811
+ names: {
812
+ webauthnAsset: 'authkit.assets.webauthn',
813
+ oidcWildcard: 'authkit.oidc.wildcard',
814
+ oidcRoot: 'authkit.oidc.root',
815
+ ...(resolvedAdminPrefix
816
+ ? {
817
+ consoleAssets: 'authkit_console_assets',
818
+ consoleRoot: 'authkit_console_root',
819
+ consoleShell: 'authkit_console_shell',
820
+ }
821
+ : {}),
822
+ },
823
+ sudoMethods: mountedSudoIds,
824
+ overriddenByConfig,
825
+ };
826
+ }
827
+ /**
828
+ * Auto-montagem a partir de `config.routes` (R1). Chamada UMA vez pelo
829
+ * `boot()` do provider.
830
+ *
831
+ * O CAMINHO AUTOMÁTICO É O CAMINHO MANUAL: chama literalmente
832
+ * `registerAuthHost`, sem nenhuma segunda implementação. Toda a resolução
833
+ * (herança do config, precedência política × estrutural, mapa de retorno) é a
834
+ * mesma — duas implementações de um comportamento sempre divergem.
835
+ *
836
+ * Não recebe opções: os defaults ESTRUTURAIS vêm de `config.routes` pelo stash
837
+ * (`AuthHostRuntimeConfig.routes`), pelo mesmo caminho que uma chamada manual
838
+ * os leria.
839
+ *
840
+ * @internal
841
+ */
842
+ export function autoMountAuthHost(router) {
843
+ const map = registerAuthHost(router);
844
+ // Depois, nunca antes: `registerAuthHost` LANÇA quando já houve auto-mount.
845
+ markAuthHostAutoMounted();
846
+ return map;
651
847
  }