@adonis-agora/authkit-server 0.46.0 → 0.47.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.
@@ -15,7 +15,7 @@
15
15
  </div>
16
16
  <h1 class="mb-2 text-lg font-semibold text-gray-900">{{ t('otp_unlock.ok_title') }}</h1>
17
17
  <p class="mb-6 text-sm text-gray-600">{{ t('otp_unlock.ok_body') }}</p>
18
- <a href="/account/login" class="inline-block rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700">{{ t('otp_unlock.login_link') }}</a>
18
+ <a href="{{ loginUrl ?? '/account/login' }}" class="inline-block rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700">{{ t('otp_unlock.login_link') }}</a>
19
19
  @else
20
20
  <div class="mb-4 flex justify-center">
21
21
  <span class="inline-flex h-12 w-12 items-center justify-center rounded-full bg-red-100 text-red-600">
@@ -32,7 +32,7 @@
32
32
  {{ t('otp_unlock.invalid_body') }}
33
33
  @end
34
34
  </p>
35
- <a href="/account/login" class="inline-block rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700">{{ t('otp_unlock.login_link') }}</a>
35
+ <a href="{{ loginUrl ?? '/account/login' }}" class="inline-block rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700">{{ t('otp_unlock.login_link') }}</a>
36
36
  @end
37
37
  </div>
38
38
  </div>
@@ -13,6 +13,7 @@ import { resolveKeystoreVault, KeystoreManager } from '../src/keys/keystore_mana
13
13
  import { KeystoreCodec } from '../src/keys/keystore_codec.js';
14
14
  import { loadEncryptionService } from '../src/keys/keystore_crypto.js';
15
15
  import { resolveAppKey } from '../src/host/app_key.js';
16
+ import { setBootedApp } from '../services/booted_app.js';
16
17
  export default class AuthkitServerProvider {
17
18
  app;
18
19
  constructor(app) {
@@ -85,6 +86,12 @@ export default class AuthkitServerProvider {
85
86
  }
86
87
  }
87
88
  register() {
89
+ // Entrega o app BOOTADO ao singleton de `services/main` (e ao
90
+ // `recordSubRevocation` do AdminSessionsService) para que nunca precisem
91
+ // `import`ar uma cópia de `@adonisjs/core`/`services/app` que pode não ser a
92
+ // que o `bin/server` bootou — dual-package hazard do core (ver
93
+ // `services/booted_app.ts`).
94
+ setBootedApp(this.app);
88
95
  this.app.container.singleton('authkit.server', async () => {
89
96
  const configProviderValue = this.app.config.get('authkit');
90
97
  const config = (await configProvider.resolve(this.app, configProviderValue));
@@ -0,0 +1,8 @@
1
+ import type { ApplicationService } from '@adonisjs/core/types';
2
+ /** Registra o app bootado. Chamado uma vez pelo {@link AuthkitServerProvider} no `register()`. */
3
+ export declare function setBootedApp(app: ApplicationService): void;
4
+ /**
5
+ * O app bootado capturado pelo provider. Lança se lido antes do provider registrar — um sinal claro
6
+ * de que `@adonis-agora/authkit-server/authkit_server_provider` está ausente dos providers do app.
7
+ */
8
+ export declare function getBootedApp(): ApplicationService;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * A {@link ApplicationService} BOOTADA, capturada por `AuthkitServerProvider.register()` — que o app
3
+ * instancia com a SUA própria cópia bootada do app.
4
+ *
5
+ * Por que capturar aqui em vez de `import app from '@adonisjs/core/services/app'`: num install pnpm
6
+ * (workspace / hoisted), este pacote pode resolver uma cópia FÍSICA de `@adonisjs/core` DIFERENTE da
7
+ * que o `bin/server` bootou. `services/app` expõe o app por um binding de nível de módulo definido no
8
+ * boot (`setApp`); numa cópia NÃO-bootada esse binding fica `undefined` — importar de lá devolve um
9
+ * app indefinido (`Cannot read properties of undefined (reading 'booted')`). A instância que o
10
+ * provider recebe é SEMPRE a bootada, então lê-la aqui é imune a splits de cópia / variantes de peer
11
+ * do core — o mesmo hazard de dual-package que já corrigimos no `'lucid.db'`.
12
+ */
13
+ let bootedApp;
14
+ /** Registra o app bootado. Chamado uma vez pelo {@link AuthkitServerProvider} no `register()`. */
15
+ export function setBootedApp(app) {
16
+ bootedApp = app;
17
+ }
18
+ /**
19
+ * O app bootado capturado pelo provider. Lança se lido antes do provider registrar — um sinal claro
20
+ * de que `@adonis-agora/authkit-server/authkit_server_provider` está ausente dos providers do app.
21
+ */
22
+ export function getBootedApp() {
23
+ if (!bootedApp) {
24
+ throw new Error('@adonis-agora/authkit-server: app acessado antes de AuthkitServerProvider registrar. Adicione "@adonis-agora/authkit-server/authkit_server_provider" aos providers do adonisrc.ts.');
25
+ }
26
+ return bootedApp;
27
+ }
@@ -10,6 +10,13 @@ import type { OidcService } from "../src/provider/oidc_service.js";
10
10
  * Roda `await app.booted()` no top-level, então SÓ funciona dentro de um app
11
11
  * booted — por isso `scripts/import-smoke.mjs` pula o diretório `services`.
12
12
  *
13
+ * O `app` vem do {@link getBootedApp} (capturado pelo provider no `register()`), NÃO de
14
+ * `import app from "@adonisjs/core/services/app"`: sob pnpm este pacote pode resolver uma cópia
15
+ * FÍSICA de `@adonisjs/core` diferente da que o `bin/server` bootou, cujo binding de `services/app`
16
+ * fica `undefined` — mesmo dual-package hazard do `'lucid.db'`, aqui para o singleton do core. Ver
17
+ * {@link ./booted_app.js}. A instância que o provider recebe é sempre a bootada. Back-compat total:
18
+ * o `default` continua sendo o {@link OidcService} resolvido.
19
+ *
13
20
  * Dentro da própria lib continuamos resolvendo via `ctx.containerResolver.make("authkit.server")`,
14
21
  * que é o idioma das libs first-party (ver `@adonisjs/auth`, `initialize_auth_middleware`).
15
22
  */
@@ -1,4 +1,4 @@
1
- import app from "@adonisjs/core/services/app";
1
+ import { getBootedApp } from "./booted_app.js";
2
2
  /**
3
3
  * Acessor singleton do {@link OidcService}, seguindo a convenção `services/main` do
4
4
  * AdonisJS (igual `@adonisjs/lucid/services/db`, `@adonisjs/drive/services/main` e
@@ -10,10 +10,18 @@ import app from "@adonisjs/core/services/app";
10
10
  * Roda `await app.booted()` no top-level, então SÓ funciona dentro de um app
11
11
  * booted — por isso `scripts/import-smoke.mjs` pula o diretório `services`.
12
12
  *
13
+ * O `app` vem do {@link getBootedApp} (capturado pelo provider no `register()`), NÃO de
14
+ * `import app from "@adonisjs/core/services/app"`: sob pnpm este pacote pode resolver uma cópia
15
+ * FÍSICA de `@adonisjs/core` diferente da que o `bin/server` bootou, cujo binding de `services/app`
16
+ * fica `undefined` — mesmo dual-package hazard do `'lucid.db'`, aqui para o singleton do core. Ver
17
+ * {@link ./booted_app.js}. A instância que o provider recebe é sempre a bootada. Back-compat total:
18
+ * o `default` continua sendo o {@link OidcService} resolvido.
19
+ *
13
20
  * Dentro da própria lib continuamos resolvendo via `ctx.containerResolver.make("authkit.server")`,
14
21
  * que é o idioma das libs first-party (ver `@adonisjs/auth`, `initialize_auth_middleware`).
15
22
  */
16
23
  let service;
24
+ const app = getBootedApp();
17
25
  await app.booted(async () => {
18
26
  service = await app.container.make("authkit.server");
19
27
  });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * URL de login do console de conta — singleton de processo.
3
+ *
4
+ * Definida em tempo de registro das rotas (`registerAuthHost`, via a opção
5
+ * `accountLoginUrl`) e lida em runtime por TODOS os pontos que redirecionam o
6
+ * visitante não-autenticado para "faça login": os guards (`accountGuard`/
7
+ * `adminGuard`), o middleware `AccountAuthMiddleware`, o helper público
8
+ * `consoleLoginUrl()`, os redirects de fallback dos controllers de conta e a
9
+ * view Edge `otp-unlock` (injetada como prop `loginUrl` pelo renderer).
10
+ *
11
+ * Existe porque a tela `account/login` é DESMONTÁVEL (`account: { login: false }`):
12
+ * um host OIDC passwordless não monta o login por senha da lib e aponta o
13
+ * redirect de não-autenticado para a própria rota de login dele (ex.: `/login`).
14
+ * Sem esta indireção, esses destinos ficariam presos no `/account/login` que
15
+ * deixou de existir.
16
+ *
17
+ * Default `/account/login` (back-compat total: hosts que não passam a opção
18
+ * seguem redirecionando para a tela montada de sempre).
19
+ *
20
+ * Por que módulo singleton e não binding do container? Mesma razão do
21
+ * `admin_prefix`: os guards são closures construídas em tempo de registro,
22
+ * ANTES da primeira request, quando o container ainda não existe. Um módulo ESM
23
+ * é inicializado uma vez por processo — ideal para configuração imutável de boot.
24
+ */
25
+ /**
26
+ * Define a URL de login do console de conta para este processo.
27
+ * Chamado UMA VEZ por `registerAuthHost` no boot da aplicação.
28
+ *
29
+ * Não normaliza o path: aceita qualquer caminho interno que o host queira
30
+ * (ex.: `'/login'`, `'/auth/entrar'`). Valor vazio/whitespace cai no default.
31
+ *
32
+ * @param url Caminho de destino do redirect de não-autenticado.
33
+ */
34
+ export declare function setAccountLoginUrl(url: string): void;
35
+ /**
36
+ * Retorna a URL de login do console de conta (default `'/account/login'`).
37
+ * Usada por todo redirect/link de "faça login" da lib.
38
+ */
39
+ export declare function getAccountLoginUrl(): string;
40
+ /** Restaura o default — uso em testes (isola o singleton entre casos). */
41
+ export declare function resetAccountLoginUrl(): void;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * URL de login do console de conta — singleton de processo.
3
+ *
4
+ * Definida em tempo de registro das rotas (`registerAuthHost`, via a opção
5
+ * `accountLoginUrl`) e lida em runtime por TODOS os pontos que redirecionam o
6
+ * visitante não-autenticado para "faça login": os guards (`accountGuard`/
7
+ * `adminGuard`), o middleware `AccountAuthMiddleware`, o helper público
8
+ * `consoleLoginUrl()`, os redirects de fallback dos controllers de conta e a
9
+ * view Edge `otp-unlock` (injetada como prop `loginUrl` pelo renderer).
10
+ *
11
+ * Existe porque a tela `account/login` é DESMONTÁVEL (`account: { login: false }`):
12
+ * um host OIDC passwordless não monta o login por senha da lib e aponta o
13
+ * redirect de não-autenticado para a própria rota de login dele (ex.: `/login`).
14
+ * Sem esta indireção, esses destinos ficariam presos no `/account/login` que
15
+ * deixou de existir.
16
+ *
17
+ * Default `/account/login` (back-compat total: hosts que não passam a opção
18
+ * seguem redirecionando para a tela montada de sempre).
19
+ *
20
+ * Por que módulo singleton e não binding do container? Mesma razão do
21
+ * `admin_prefix`: os guards são closures construídas em tempo de registro,
22
+ * ANTES da primeira request, quando o container ainda não existe. Um módulo ESM
23
+ * é inicializado uma vez por processo — ideal para configuração imutável de boot.
24
+ */
25
+ const DEFAULT_ACCOUNT_LOGIN_URL = '/account/login';
26
+ let _loginUrl = DEFAULT_ACCOUNT_LOGIN_URL;
27
+ /**
28
+ * Define a URL de login do console de conta para este processo.
29
+ * Chamado UMA VEZ por `registerAuthHost` no boot da aplicação.
30
+ *
31
+ * Não normaliza o path: aceita qualquer caminho interno que o host queira
32
+ * (ex.: `'/login'`, `'/auth/entrar'`). Valor vazio/whitespace cai no default.
33
+ *
34
+ * @param url Caminho de destino do redirect de não-autenticado.
35
+ */
36
+ export function setAccountLoginUrl(url) {
37
+ const trimmed = (url ?? '').trim();
38
+ _loginUrl = trimmed || DEFAULT_ACCOUNT_LOGIN_URL;
39
+ }
40
+ /**
41
+ * Retorna a URL de login do console de conta (default `'/account/login'`).
42
+ * Usada por todo redirect/link de "faça login" da lib.
43
+ */
44
+ export function getAccountLoginUrl() {
45
+ return _loginUrl;
46
+ }
47
+ /** Restaura o default — uso em testes (isola o singleton entre casos). */
48
+ export function resetAccountLoginUrl() {
49
+ _loginUrl = DEFAULT_ACCOUNT_LOGIN_URL;
50
+ }
@@ -55,7 +55,9 @@ export declare class AdminSessionsService {
55
55
  * a sessão na próxima request — INSTANTÂNEO, sem esperar o refresh token falhar.
56
56
  *
57
57
  * Best-effort: a destruição de grants/tokens acima já é a fonte da verdade; esta
58
- * linha só acelera o efeito nos clients. Falha (tabela ausente) é silenciosa.
58
+ * linha só acelera o efeito nos clients. Falha (tabela ausente / lucid indisponível)
59
+ * NÃO propaga — mas NUNCA é silenciosa: é logada em `error`, porque uma revogação de
60
+ * sessão que não persiste é um risco de segurança que não pode ficar invisível.
59
61
  *
60
62
  * @param accountId Conta cujas sessões devem ser derrubadas (gravado em `sub`).
61
63
  * @param revokedAt Timestamp da revogação. Default: agora. A checagem do client
@@ -1,3 +1,4 @@
1
+ import { getBootedApp } from '../../services/booted_app.js';
1
2
  /**
2
3
  * Serviço de inspeção/revogação das SESSÕES e GRANTS ativos de uma conta,
3
4
  * persistidos pelo oidc-provider via o MESMO `AdapterClass` (mesmo padrão do
@@ -32,7 +33,9 @@ export class AdminSessionsService {
32
33
  * a sessão na próxima request — INSTANTÂNEO, sem esperar o refresh token falhar.
33
34
  *
34
35
  * Best-effort: a destruição de grants/tokens acima já é a fonte da verdade; esta
35
- * linha só acelera o efeito nos clients. Falha (tabela ausente) é silenciosa.
36
+ * linha só acelera o efeito nos clients. Falha (tabela ausente / lucid indisponível)
37
+ * NÃO propaga — mas NUNCA é silenciosa: é logada em `error`, porque uma revogação de
38
+ * sessão que não persiste é um risco de segurança que não pode ficar invisível.
36
39
  *
37
40
  * @param accountId Conta cujas sessões devem ser derrubadas (gravado em `sub`).
38
41
  * @param revokedAt Timestamp da revogação. Default: agora. A checagem do client
@@ -43,7 +46,16 @@ export class AdminSessionsService {
43
46
  */
44
47
  async recordSubRevocation(accountId, revokedAt = new Date()) {
45
48
  try {
46
- const db = (await import('@adonisjs/lucid/services/db')).default;
49
+ // Resolve o Lucid pelo ALIAS STRING `'lucid.db'` (o mesmo idioma de
50
+ // `runtime_settings.ts` e do provider), NUNCA por `import('@adonisjs/lucid/services/db')`
51
+ // — esse resolve a CLASSE `Database` e a usa como TOKEN do container. Com duas cópias
52
+ // físicas do lucid na árvore do host (pins distintos ou o mesmo pin sob peer sets
53
+ // distintos, que o pnpm materializa em diretórios separados) os tokens-classe diferem, o
54
+ // `make()` falha (`Cannot construct "[class Database]"`) e a gravação era engolida em
55
+ // SILÊNCIO por este catch — a pior variante do bug. Um token string não pode ser
56
+ // duplicado. O app vem do provider (ver `services/booted_app.ts`), não de `services/app`.
57
+ const app = getBootedApp();
58
+ const db = await app.container.make('lucid.db');
47
59
  const conn = this.#schemaConnection
48
60
  ? db.connection(this.#schemaConnection)
49
61
  : db.connection();
@@ -53,8 +65,28 @@ export class AdminSessionsService {
53
65
  revoked_at: revokedAt,
54
66
  });
55
67
  }
68
+ catch (error) {
69
+ // A invalidação server-side (destruição de grants/tokens) já é a fonte da verdade, então
70
+ // NÃO propagamos. Mas NUNCA engolimos em silêncio: logamos em `error` para que a falha da
71
+ // propagação cookie-based (tabela ausente, lucid indisponível, provider não registrado)
72
+ // seja visível — uma revogação que não persiste é um risco de segurança invisível.
73
+ await this.#logRevocationFailure(accountId, error);
74
+ }
75
+ }
76
+ /**
77
+ * Loga (best-effort, sem nunca lançar) a falha de {@link recordSubRevocation}. Prefere o
78
+ * `logger` do container; se ele não puder ser resolvido (ex.: o próprio `getBootedApp()` foi o
79
+ * que falhou), cai em `console.error` — a falha PRECISA aparecer em algum lugar.
80
+ */
81
+ async #logRevocationFailure(accountId, error) {
82
+ const msg = 'authkit: falha ao gravar a revogação por sub (auth_session_revocations) — a propagação cookie-based da revogação de sessão não foi persistida';
83
+ try {
84
+ const logger = await getBootedApp().container.make('logger');
85
+ logger.error({ err: error, accountId }, msg);
86
+ }
56
87
  catch {
57
- /* tabela ausente / lucid indisponível — a invalidação server-side ainda vale */
88
+ // eslint-disable-next-line no-console
89
+ console.error(msg, { accountId, error });
58
90
  }
59
91
  }
60
92
  /** Indica se o adapter suporta enumeração (capacidade opcional). */
@@ -21,6 +21,10 @@ export declare function hasAccountSession(ctx: HttpContext): boolean;
21
21
  * URL do login do console, com `return_to` opcional de volta ao destino
22
22
  * original após autenticar.
23
23
  *
24
+ * Respeita a opção `accountLoginUrl` de `registerAuthHost`: quando o host
25
+ * desmontou a tela `account/login` e apontou para a própria rota de login
26
+ * (ex.: `/login`), este helper usa esse destino. Default `/account/login`.
27
+ *
24
28
  * @example
25
29
  * consoleLoginUrl('/telescope') // => '/account/login?return_to=%2Ftelescope'
26
30
  */
@@ -1,4 +1,5 @@
1
1
  import { ACCOUNT_SESSION_KEY } from './middleware/account_auth.js';
2
+ import { getAccountLoginUrl } from './account_login_url.js';
2
3
  /**
3
4
  * Helpers públicos para integrar a sessão do console do AuthKit com
4
5
  * qualquer coisa fora do pacote (ex.: proteger o dashboard do
@@ -26,11 +27,17 @@ export function hasAccountSession(ctx) {
26
27
  * URL do login do console, com `return_to` opcional de volta ao destino
27
28
  * original após autenticar.
28
29
  *
30
+ * Respeita a opção `accountLoginUrl` de `registerAuthHost`: quando o host
31
+ * desmontou a tela `account/login` e apontou para a própria rota de login
32
+ * (ex.: `/login`), este helper usa esse destino. Default `/account/login`.
33
+ *
29
34
  * @example
30
35
  * consoleLoginUrl('/telescope') // => '/account/login?return_to=%2Ftelescope'
31
36
  */
32
37
  export function consoleLoginUrl(returnTo) {
38
+ const loginUrl = getAccountLoginUrl();
33
39
  if (!returnTo)
34
- return '/account/login';
35
- return `/account/login?return_to=${encodeURIComponent(returnTo)}`;
40
+ return loginUrl;
41
+ const sep = loginUrl.includes('?') ? '&' : '?';
42
+ return `${loginUrl}${sep}return_to=${encodeURIComponent(returnTo)}`;
36
43
  }
@@ -1,6 +1,7 @@
1
1
  import '../augmentations.js';
2
2
  import { supportsOrganizations } from '../../accounts/account_store.js';
3
3
  import { ACCOUNT_SESSION_KEY } from '../middleware/account_auth.js';
4
+ import { getAccountLoginUrl } from '../account_login_url.js';
4
5
  import { resolveRuntimeSettings } from '../runtime_settings.js';
5
6
  import { isRoleInCatalog } from '../runtime_toggles.js';
6
7
  import { ACTIVE_ORG_COOKIE, ACTIVE_ORG_COOKIE_TTL, encodeActiveOrgCookie, } from '../active_org_cookie.js';
@@ -26,7 +27,7 @@ export default class AccountOrgsController {
26
27
  const accountId = session.get(ACCOUNT_SESSION_KEY);
27
28
  const account = await store.findById(accountId);
28
29
  if (!account)
29
- return response.redirect('/account/login');
30
+ return response.redirect(getAccountLoginUrl());
30
31
  const orgs = await store.listOrgsForAccount(accountId);
31
32
  const pendingInvitations = await store.listPendingInvitationsForEmail(account.email);
32
33
  // Enriquece convites com o nome da org
@@ -210,9 +211,11 @@ export default class AccountOrgsController {
210
211
  return response.notFound();
211
212
  const accountId = session.get(ACCOUNT_SESSION_KEY);
212
213
  if (!accountId) {
213
- // Não logado: redireciona para login com return URL
214
+ // Não logado: redireciona para login (configurável) com return URL
215
+ const loginUrl = getAccountLoginUrl();
214
216
  const returnTo = encodeURIComponent(`/account/orgs/invitations/${params.token}/accept`);
215
- return response.redirect(`/account/login?returnTo=${returnTo}`);
217
+ const sep = loginUrl.includes('?') ? '&' : '?';
218
+ return response.redirect(`${loginUrl}${sep}returnTo=${returnTo}`);
216
219
  }
217
220
  const tokenHash = createHash('sha256').update(params.token).digest('hex');
218
221
  const invitation = await store.findInvitationByTokenHash(tokenHash);
@@ -236,7 +239,7 @@ export default class AccountOrgsController {
236
239
  return response.notFound();
237
240
  const accountId = session.get(ACCOUNT_SESSION_KEY);
238
241
  if (!accountId)
239
- return response.redirect('/account/login');
242
+ return response.redirect(getAccountLoginUrl());
240
243
  const tokenHash = createHash('sha256').update(params.token).digest('hex');
241
244
  const invitation = await store.findInvitationByTokenHash(tokenHash);
242
245
  if (!invitation)
@@ -1,5 +1,6 @@
1
1
  import "../augmentations.js";
2
2
  import { ACCOUNT_SESSION_KEY } from "../middleware/account_auth.js";
3
+ import { getAccountLoginUrl } from "../account_login_url.js";
3
4
  import { supportsAccountSecurity, supportsAccountDeletion, supportsProfile, supportsPasswordHistory, } from "../../accounts/account_store.js";
4
5
  import { changePasswordValidator, changeEmailValidator, deleteAccountValidator, updateProfileValidator, } from "../validators.js";
5
6
  import { sendEmailChangeConfirmationEmail, sendEmailChangeNoticeEmail, sendEmailChangedCompletedEmail, } from "../default_mailer.js";
@@ -150,7 +151,7 @@ export default class AccountSecurityController {
150
151
  return sudoResultDel;
151
152
  const account = await store.findById(userId);
152
153
  if (!account) {
153
- return ctx.response.redirect("/account/login");
154
+ return ctx.response.redirect(getAccountLoginUrl());
154
155
  }
155
156
  const { currentPassword, confirmEmail } = await ctx.request.validateUsing(deleteAccountValidator);
156
157
  // Confirmação: senha atual correta OU e-mail digitado batendo com o da conta
@@ -196,7 +197,7 @@ export default class AccountSecurityController {
196
197
  ctx.session.forget(ACCOUNT_SESSION_KEY);
197
198
  await syncAdonisAuthLogout(ctx, cfg);
198
199
  ctx.session.flash("accountDeleted", translate(cfg.messages, "account.delete.deleted"));
199
- return ctx.response.redirect("/account/login");
200
+ return ctx.response.redirect(getAccountLoginUrl());
200
201
  }
201
202
  /**
202
203
  * POST /account/security/trusted-devices/revoke
@@ -5,6 +5,7 @@ import { attemptPasswordLogin } from '../login_attempt.js';
5
5
  import { notifyLoginSuccess } from '../login_notify.js';
6
6
  import { markSudo } from '../sudo_mode.js';
7
7
  import { accountHome } from '../account_home.js';
8
+ import { getAccountLoginUrl } from '../account_login_url.js';
8
9
  import { resolveRuntimeSettings } from '../runtime_settings.js';
9
10
  import { syncAdonisAuthLogin, syncAdonisAuthLogout } from '../adonis_auth_sync.js';
10
11
  /**
@@ -132,6 +133,7 @@ export default class AccountSessionController {
132
133
  await ctx.session.regenerate();
133
134
  // Opt-in: espelha o logout no guard de @adonisjs/auth (ver adonisAuth em define_config.ts).
134
135
  await syncAdonisAuthLogout(ctx, cfg);
135
- return ctx.response.redirect('/account/login');
136
+ // Destino configurável (`accountLoginUrl`): default `/account/login`.
137
+ return ctx.response.redirect(getAccountLoginUrl());
136
138
  }
137
139
  }
@@ -7,6 +7,11 @@ export default class AccountTokensController {
7
7
  const service = await ctx.containerResolver.make('authkit.server');
8
8
  const cfg = service.config;
9
9
  const render = cfg.render;
10
+ // PAT é capacidade opcional: sem `patStore` configurado, a tela não existe
11
+ // (404 limpo em vez de "Cannot read properties of undefined"). Tratado como
12
+ // orgs — a rota pode estar montada num host que não cabeou o store.
13
+ if (!cfg.patStore)
14
+ return ctx.response.notFound();
10
15
  const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
11
16
  const tokens = await cfg.patStore.listForAccount(userId);
12
17
  const createdToken = ctx.session.flashMessages.get('createdToken');
@@ -26,6 +31,9 @@ export default class AccountTokensController {
26
31
  async store(ctx) {
27
32
  const service = await ctx.containerResolver.make('authkit.server');
28
33
  const cfg = service.config;
34
+ // Sem `patStore`: 404 limpo (ver `index`).
35
+ if (!cfg.patStore)
36
+ return ctx.response.notFound();
29
37
  const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
30
38
  // Sudo mode gate.
31
39
  const sudoSettingsPat = await resolveRuntimeSettings(ctx);
@@ -46,6 +54,9 @@ export default class AccountTokensController {
46
54
  async destroy(ctx) {
47
55
  const service = await ctx.containerResolver.make('authkit.server');
48
56
  const cfg = service.config;
57
+ // Sem `patStore`: 404 limpo (ver `index`).
58
+ if (!cfg.patStore)
59
+ return ctx.response.notFound();
49
60
  const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
50
61
  // Sudo mode gate.
51
62
  const sudoSettingsPatRev = await resolveRuntimeSettings(ctx);
@@ -19,6 +19,16 @@ export default class PatIntrospectionController {
19
19
  if (!token || typeof token !== 'string') {
20
20
  return { active: false };
21
21
  }
22
+ // `patStore` é capacidade opcional; a rota `/authkit/pat/introspect` fica
23
+ // sempre montada (é infraestrutura M2M, não uma tela desmontável). Sem
24
+ // store cabeado, RFC 7662 §2.2 já cobre o caso: para o servidor de
25
+ // recursos, um token que não pode ser resolvido é indistinguível de um
26
+ // token inválido/desconhecido → `{ active: false }`, não 404. Um 404
27
+ // quebraria clientes de introspecção que só sabem parsear o corpo JSON do
28
+ // protocolo (mesma classe de bug do `account_tokens_controller`, mas a
29
+ // resposta correta aqui é a negativa do protocolo, não um HTTP status).
30
+ if (!cfg.patStore)
31
+ return { active: false };
22
32
  const meta = await cfg.patStore.findActiveByToken(token);
23
33
  if (!meta)
24
34
  return { active: false };
@@ -1,10 +1,12 @@
1
1
  import '../augmentations.js';
2
+ import { getAccountLoginUrl } from '../account_login_url.js';
2
3
  export const ACCOUNT_SESSION_KEY = 'account_user_id';
3
4
  export default class AccountAuthMiddleware {
4
5
  async handle(ctx, next) {
5
6
  const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
6
7
  if (!userId) {
7
- return ctx.response.redirect('/account/login');
8
+ // Destino configurável (`accountLoginUrl`): default `/account/login`.
9
+ return ctx.response.redirect(getAccountLoginUrl());
8
10
  }
9
11
  return next();
10
12
  }
@@ -7,9 +7,11 @@ export declare const ACCOUNT_LAST_SEEN_KEY = "authkit_last_seen";
7
7
  * Guard do console admin (B6). Como o `accountGuard`, é uma closure inline (forma
8
8
  * confiável do `.use()` num grupo). Exige:
9
9
  * 0. `config.admin.enabled` ligado (senão → 404; ver nota de flag-drift abaixo);
10
- * 1. sessão de conta ativa (senão → /account/login);
10
+ * 1. sessão de conta ativa (senão → `accountLoginUrl`, default /account/login);
11
11
  * 2. a conta logada com pelo menos UMA das `config.admin.roles` nas roles globais
12
- * (senão → /account/tokens, evitando vazar a existência do /admin).
12
+ * (senão → `accountHome(cfg)`, default /account/security NÃO revela a
13
+ * existência do /admin, e cai numa tela que o host controla via `accountHome`;
14
+ * se a tela default estiver desmontada, aponte `config.accountHome` para uma montada).
13
15
  * As roles permitidas são resolvidas em runtime do `authkit.server` (config lazy).
14
16
  */
15
17
  export declare const adminGuard: (ctx: any, next: () => Promise<void>) => Promise<any>;
@@ -108,6 +110,68 @@ export interface AuthHostOptions {
108
110
  * Ausente → `[password(), passkey()]`.
109
111
  */
110
112
  sudoMethods?: SudoMethod[];
113
+ /**
114
+ * Montagem por tela do console de conta (`/account/*`). Espelha o padrão
115
+ * `admin`/`adminApi`: a decisão de MONTAR cada grupo de rotas é tomada em
116
+ * tempo de registro, antes de o config (lazy) resolver.
117
+ *
118
+ * - Ausente → tudo montado (back-compat total).
119
+ * - `false` → NENHUMA tela do console de conta é montada (as rotas sudo
120
+ * `/account/confirm` e a JSON API `/account/api/*` continuam — são
121
+ * infraestrutura, não telas navegáveis).
122
+ * - objeto → montagem seletiva; cada flag ausente default `true`.
123
+ * - `login` → `/account/login`, `/account/logout` (a porta por senha do console).
124
+ * - `tokens` → `/account/tokens*` (Personal Access Tokens).
125
+ * - `orgs` → `/account/orgs*` (multi-tenancy, incl. o accept de convite público).
126
+ * - `security` → `/account/security*` + `/account/email/confirm` (perfil, senha, troca de e-mail, export/LGPD, deleção).
127
+ * - `mfa` → `/account/mfa*` (TOTP + passkeys).
128
+ * - `apps` → `/account/apps*` (grants de consentimento OIDC).
129
+ *
130
+ * NOTA (flag-drift): como `admin`/`adminApi`, estas flags controlam apenas se
131
+ * as ROTAS existem; o comportamento em runtime continua vindo do config
132
+ * resolvido. Mantenha em sincronia — os guards são a rede de segurança.
133
+ *
134
+ * ⚠️ Ao desmontar `login`, os redirects internos de "faça login"
135
+ * (`accountGuard`, `adminGuard`, `AccountAuthMiddleware`, `consoleLoginUrl()`,
136
+ * a view `otp-unlock`) apontariam para uma rota inexistente — passe
137
+ * `accountLoginUrl` com a rota de login do host (ex.: `'/login'`).
138
+ *
139
+ * @example
140
+ * // Console passwordless: só segurança + MFA, login delegado ao OIDC do host.
141
+ * registerAuthHost(router, {
142
+ * account: { login: false, tokens: false, orgs: false },
143
+ * accountLoginUrl: '/login',
144
+ * })
145
+ */
146
+ account?: false | AccountScreensOptions;
147
+ /**
148
+ * Destino do redirect de "não-autenticado → faça login" do console de conta.
149
+ * Default `/account/login`. Aponte para a rota de login do host quando a tela
150
+ * `account/login` da lib estiver desmontada (`account: { login: false }`).
151
+ *
152
+ * Usado por TODOS os pontos de redirect/link de login: `accountGuard`,
153
+ * `adminGuard`, `AccountAuthMiddleware`, `consoleLoginUrl()`, os fallbacks dos
154
+ * controllers de conta e a view `otp-unlock`. Ver `account_login_url.ts`.
155
+ */
156
+ accountLoginUrl?: string;
157
+ }
158
+ /**
159
+ * Flags de montagem por tela do console de conta. Cada campo ausente é `true`
160
+ * (montado). Ver {@link AuthHostOptions.account}.
161
+ */
162
+ export interface AccountScreensOptions {
163
+ /** Tela de login por senha do console (`/account/login`, `/account/logout`). */
164
+ login?: boolean;
165
+ /** Personal Access Tokens (`/account/tokens*`). */
166
+ tokens?: boolean;
167
+ /** Organizations / multi-tenancy (`/account/orgs*`). */
168
+ orgs?: boolean;
169
+ /** Perfil, senha, troca de e-mail, export/LGPD e deleção (`/account/security*`). */
170
+ security?: boolean;
171
+ /** MFA — TOTP + passkeys (`/account/mfa*`). */
172
+ mfa?: boolean;
173
+ /** Apps com acesso / grants de consentimento OIDC (`/account/apps*`). */
174
+ apps?: boolean;
111
175
  }
112
176
  /**
113
177
  * Monta todas as rotas do host-kit do Authorization Server numa chamada.
@@ -8,6 +8,7 @@ import { setAdminPrefix, normalizeAdminPrefix, setAdminApiPrefix, normalizeAdmin
8
8
  import { resolveRuntimeSettings } from './runtime_settings.js';
9
9
  import { resolveEffectiveSessionPolicy } from './runtime_toggles.js';
10
10
  import { getAuthHostConfig } from './auth_host_config.js';
11
+ import { setAccountLoginUrl, getAccountLoginUrl } from './account_login_url.js';
11
12
  import { completeSudo, fail, guardSudoRoutes, sudoContextFrom, setMountedSudoMethods, } from './sudo/runtime.js';
12
13
  import { password as sudoPassword } from './sudo/methods/password.js';
13
14
  import { passkey as sudoPasskey } from './sudo/methods/passkey.js';
@@ -68,16 +69,26 @@ async function checkAndRefreshIdle(ctx) {
68
69
  * @internal
69
70
  */
70
71
  function buildLoginRedirect(ctx, extra) {
72
+ // Destino configurável (`accountLoginUrl`): default `/account/login`, mas um
73
+ // host que desmontou a tela de login (`account: { login: false }`) aponta para
74
+ // a própria rota de login dele (ex.: `/login`). Ver `account_login_url.ts`.
75
+ const loginUrl = getAccountLoginUrl();
71
76
  const url = ctx.request?.url?.() ?? '';
72
77
  const qs = ctx.request?.parsedUrl?.search ?? '';
73
78
  const dest = qs ? `${url}${qs}` : url;
74
79
  // Só inclui return_to quando há um caminho real (não vazio, não é o próprio login).
75
- if (dest && dest !== '/' && !dest.startsWith('/account/login')) {
80
+ if (dest && dest !== '/' && !dest.startsWith(loginUrl)) {
76
81
  const encoded = encodeURIComponent(dest);
77
- const base = extra ? `/account/login?${extra}&return_to=${encoded}` : `/account/login?return_to=${encoded}`;
82
+ const sep = loginUrl.includes('?') ? '&' : '?';
83
+ const base = extra
84
+ ? `${loginUrl}${sep}${extra}&return_to=${encoded}`
85
+ : `${loginUrl}${sep}return_to=${encoded}`;
78
86
  return base;
79
87
  }
80
- return extra ? `/account/login?${extra}` : '/account/login';
88
+ if (!extra)
89
+ return loginUrl;
90
+ const sep = loginUrl.includes('?') ? '&' : '?';
91
+ return `${loginUrl}${sep}${extra}`;
81
92
  }
82
93
  /**
83
94
  * Guard inline do console de conta. Usamos uma closure (forma confiável do
@@ -100,9 +111,11 @@ const accountGuard = async (ctx, next) => {
100
111
  * Guard do console admin (B6). Como o `accountGuard`, é uma closure inline (forma
101
112
  * confiável do `.use()` num grupo). Exige:
102
113
  * 0. `config.admin.enabled` ligado (senão → 404; ver nota de flag-drift abaixo);
103
- * 1. sessão de conta ativa (senão → /account/login);
114
+ * 1. sessão de conta ativa (senão → `accountLoginUrl`, default /account/login);
104
115
  * 2. a conta logada com pelo menos UMA das `config.admin.roles` nas roles globais
105
- * (senão → /account/tokens, evitando vazar a existência do /admin).
116
+ * (senão → `accountHome(cfg)`, default /account/security NÃO revela a
117
+ * existência do /admin, e cai numa tela que o host controla via `accountHome`;
118
+ * se a tela default estiver desmontada, aponte `config.accountHome` para uma montada).
106
119
  * As roles permitidas são resolvidas em runtime do `authkit.server` (config lazy).
107
120
  */
108
121
  export const adminGuard = async (ctx, next) => {
@@ -188,6 +201,29 @@ export function registerAuthHost(router, opts = {}) {
188
201
  const social = opts.social ?? hostCfg?.social;
189
202
  const adminOpt = opts.admin ?? (hostCfg?.adminEnabled ? true : undefined);
190
203
  const adminApiOpt = opts.adminApi ?? (hostCfg?.adminApiEnabled ? true : undefined);
204
+ // Destino do redirect de "faça login" — persiste no singleton de processo para
205
+ // que os guards (closures de tempo de registro), o middleware, os controllers e
206
+ // as views leiam o mesmo valor. Só quando a opção foi passada (senão fica o
207
+ // default `/account/login` do singleton — back-compat).
208
+ if (opts.accountLoginUrl !== undefined) {
209
+ setAccountLoginUrl(opts.accountLoginUrl);
210
+ }
211
+ // Montagem por tela do console de conta. `undefined` → tudo montado;
212
+ // `false` → nada; objeto → cada flag ausente default `true`.
213
+ const accountOpt = opts.account;
214
+ const mountScreen = (key) => {
215
+ if (accountOpt === false)
216
+ return false;
217
+ if (accountOpt && typeof accountOpt === 'object')
218
+ return accountOpt[key] !== false;
219
+ return true;
220
+ };
221
+ const mountLogin = mountScreen('login');
222
+ const mountTokens = mountScreen('tokens');
223
+ const mountOrgs = mountScreen('orgs');
224
+ const mountSecurity = mountScreen('security');
225
+ const mountMfa = mountScreen('mfa');
226
+ const mountApps = mountScreen('apps');
191
227
  // Throttles opt-in (anti-brute-force). `undefined` quando rate-limit desligado.
192
228
  const resolvedRateLimit = opts.rateLimit !== undefined
193
229
  ? resolveRateLimit(opts.rateLimit)
@@ -258,50 +294,70 @@ export function registerAuthHost(router, opts = {}) {
258
294
  // PAT introspection (server-to-server).
259
295
  withIntrospection(router.post('/authkit/pat/introspect', [C.patIntrospection, 'handle']));
260
296
  // Organizations — invitation accept (sem guard: controller lida com não-autenticado).
261
- router.get('/account/orgs/invitations/:token/accept', [C.accountOrgs, 'showAcceptInvitation']);
262
- router.post('/account/orgs/invitations/:token/accept', [C.accountOrgs, 'acceptInvitation']);
297
+ // Parte da tela `orgs`: desmontada junto (sem multi-tenancy, não há convite a aceitar).
298
+ if (mountOrgs) {
299
+ router.get('/account/orgs/invitations/:token/accept', [C.accountOrgs, 'showAcceptInvitation']);
300
+ router.post('/account/orgs/invitations/:token/accept', [C.accountOrgs, 'acceptInvitation']);
301
+ }
263
302
  // Console de conta (login de sessão do IdP + gerência de PAT).
264
- router.get('/account/login', [C.accountSession, 'show']);
265
- // L6: throttle por IP no login/logout do console de conta (anti-brute-force),
266
- // alinhado com as demais rotas de credencial (interaction, forgot, reset).
267
- withLogin(router.post('/account/login', [C.accountSession, 'login']));
268
- withLogin(router.post('/account/logout', [C.accountSession, 'logout']));
303
+ // Tela `login` desmontável: hosts passwordless delegam ao OIDC próprio e
304
+ // apontam `accountLoginUrl` para a rota de login deles.
305
+ if (mountLogin) {
306
+ router.get('/account/login', [C.accountSession, 'show']);
307
+ // L6: throttle por IP no login/logout do console de conta (anti-brute-force),
308
+ // alinhado com as demais rotas de credencial (interaction, forgot, reset).
309
+ withLogin(router.post('/account/login', [C.accountSession, 'login']));
310
+ withLogin(router.post('/account/logout', [C.accountSession, 'logout']));
311
+ }
269
312
  // Confirmação de troca de e-mail (standalone, GET-only — consome o token do link;
270
- // pode ser aberta em outro dispositivo, então NÃO exige sessão).
271
- router.get('/account/email/confirm', [C.accountSecurity, 'confirmEmail']);
313
+ // pode ser aberta em outro dispositivo, então NÃO exige sessão). Parte da tela
314
+ // `security` (o terminal do fluxo de troca de e-mail).
315
+ if (mountSecurity) {
316
+ router.get('/account/email/confirm', [C.accountSecurity, 'confirmEmail']);
317
+ }
272
318
  // Rotas de tokens protegidas por AccountAuthMiddleware (redireciona para /account/login se não autenticado).
273
319
  router
274
320
  .group(() => {
275
- router.get('/account/tokens', [C.accountTokens, 'index']);
276
- router.post('/account/tokens', [C.accountTokens, 'store']);
277
- router.post('/account/tokens/:id/revoke', [C.accountTokens, 'destroy']);
278
- // Segurança da conta: trocar senha + solicitar troca de e-mail + perfil.
279
- router.get('/account/security', [C.accountSecurity, 'index']);
280
- router.post('/account/security/password', [C.accountSecurity, 'changePassword']);
281
- router.post('/account/security/email', [C.accountSecurity, 'changeEmail']);
282
- router.post('/account/security/email/cancel', [C.accountSecurity, 'cancelEmailChange']);
283
- router.post('/account/security/profile', [C.accountSecurity, 'updateProfile']);
284
- // LGPD/GDPR: export de dados (portabilidade) + deleção self-service (danger zone).
285
- // O export carrega o throttle de login (anti-abuso) quando o rate-limit existe.
286
- withLogin(router.get('/account/security/export', [C.accountSecurity, 'exportData']));
287
- router.post('/account/security/delete', [C.accountSecurity, 'deleteAccount']);
288
- // Trusted devices: limpa o cookie de confiança DESTE navegador.
289
- router.post('/account/security/trusted-devices/revoke', [
290
- C.accountSecurity,
291
- 'revokeTrustedDevices',
292
- ]);
293
- // Apps com acesso (consentimento): lista os grants da conta + revogação por client.
294
- router.get('/account/apps', [C.accountApps, 'index']);
295
- router.post('/account/apps/:clientId/revoke', [C.accountApps, 'revoke']);
296
- // MFA / TOTP (enrollment, confirmação, disable).
297
- router.get('/account/mfa', [C.accountMfa, 'index']);
298
- router.post('/account/mfa/enroll', [C.accountMfa, 'enroll']);
299
- router.post('/account/mfa/confirm', [C.accountMfa, 'confirm']);
300
- router.post('/account/mfa/disable', [C.accountMfa, 'disable']);
301
- // MFA / WebAuthn (passkeys): registro (begin/finish) + remoção.
302
- router.post('/account/mfa/passkeys/options', [C.accountMfa, 'passkeyRegisterOptions']);
303
- router.post('/account/mfa/passkeys/verify', [C.accountMfa, 'passkeyRegisterVerify']);
304
- router.post('/account/mfa/passkeys/:id/remove', [C.accountMfa, 'passkeyRemove']);
321
+ // Personal Access Tokens (tela `tokens`).
322
+ if (mountTokens) {
323
+ router.get('/account/tokens', [C.accountTokens, 'index']);
324
+ router.post('/account/tokens', [C.accountTokens, 'store']);
325
+ router.post('/account/tokens/:id/revoke', [C.accountTokens, 'destroy']);
326
+ }
327
+ // Segurança da conta: trocar senha + solicitar troca de e-mail + perfil (tela `security`).
328
+ if (mountSecurity) {
329
+ router.get('/account/security', [C.accountSecurity, 'index']);
330
+ router.post('/account/security/password', [C.accountSecurity, 'changePassword']);
331
+ router.post('/account/security/email', [C.accountSecurity, 'changeEmail']);
332
+ router.post('/account/security/email/cancel', [C.accountSecurity, 'cancelEmailChange']);
333
+ router.post('/account/security/profile', [C.accountSecurity, 'updateProfile']);
334
+ // LGPD/GDPR: export de dados (portabilidade) + deleção self-service (danger zone).
335
+ // O export carrega o throttle de login (anti-abuso) quando o rate-limit existe.
336
+ withLogin(router.get('/account/security/export', [C.accountSecurity, 'exportData']));
337
+ router.post('/account/security/delete', [C.accountSecurity, 'deleteAccount']);
338
+ // Trusted devices: limpa o cookie de confiança DESTE navegador.
339
+ router.post('/account/security/trusted-devices/revoke', [
340
+ C.accountSecurity,
341
+ 'revokeTrustedDevices',
342
+ ]);
343
+ }
344
+ // Apps com acesso (consentimento): lista os grants da conta + revogação por client (tela `apps`).
345
+ if (mountApps) {
346
+ router.get('/account/apps', [C.accountApps, 'index']);
347
+ router.post('/account/apps/:clientId/revoke', [C.accountApps, 'revoke']);
348
+ }
349
+ // MFA — TOTP + passkeys (tela `mfa`).
350
+ if (mountMfa) {
351
+ // MFA / TOTP (enrollment, confirmação, disable).
352
+ router.get('/account/mfa', [C.accountMfa, 'index']);
353
+ router.post('/account/mfa/enroll', [C.accountMfa, 'enroll']);
354
+ router.post('/account/mfa/confirm', [C.accountMfa, 'confirm']);
355
+ router.post('/account/mfa/disable', [C.accountMfa, 'disable']);
356
+ // MFA / WebAuthn (passkeys): registro (begin/finish) + remoção.
357
+ router.post('/account/mfa/passkeys/options', [C.accountMfa, 'passkeyRegisterOptions']);
358
+ router.post('/account/mfa/passkeys/verify', [C.accountMfa, 'passkeyRegisterVerify']);
359
+ router.post('/account/mfa/passkeys/:id/remove', [C.accountMfa, 'passkeyRemove']);
360
+ }
305
361
  // Sudo mode (confirm identity): o GET lista os métodos; cada método
306
362
  // registra suas próprias rotas de verificação (SPI `SudoMethod`).
307
363
  router.get('/account/confirm', [C.accountConfirm, 'show']);
@@ -342,19 +398,22 @@ export function registerAuthHost(router, opts = {}) {
342
398
  // configura `config.sudo.methods`: a tela oferece exatamente isto, e os
343
399
  // handlers aceitam exatamente isto.
344
400
  setMountedSudoMethods(sudoMethodsToMount);
345
- // Organizations (multi-tenancy) — sempre montadas; controller retorna 404/403 sem tabelas.
346
- router.get('/account/orgs', [C.accountOrgs, 'index']);
347
- router.post('/account/orgs', [C.accountOrgs, 'store']);
348
- router.post('/account/orgs/deactivate', [C.accountOrgs, 'deactivate']);
349
- router.post('/account/orgs/:id/activate', [C.accountOrgs, 'activate']);
350
- router.post('/account/orgs/:id/leave', [C.accountOrgs, 'leave']);
351
- router.post('/account/orgs/:id/invite', [C.accountOrgs, 'invite']);
352
- router.post('/account/orgs/:id/members/:accountId/remove', [C.accountOrgs, 'removeMember']);
353
- router.post('/account/orgs/:id/invitations/:invId/revoke', [C.accountOrgs, 'revokeInvitation']);
354
- // JSON endpoints for React hooks (authkit-react).
355
- router.get('/account/orgs/json', [C.accountOrgs, 'listJson']);
356
- router.get('/account/orgs/invitations/json', [C.accountOrgs, 'listInvitationsJson']);
357
- router.get('/account/orgs/:id/json', [C.accountOrgs, 'showJson']);
401
+ // Organizations (multi-tenancy) — tela `orgs`. Montadas por default;
402
+ // controller retorna 404/403 sem tabelas (capability-probed).
403
+ if (mountOrgs) {
404
+ router.get('/account/orgs', [C.accountOrgs, 'index']);
405
+ router.post('/account/orgs', [C.accountOrgs, 'store']);
406
+ router.post('/account/orgs/deactivate', [C.accountOrgs, 'deactivate']);
407
+ router.post('/account/orgs/:id/activate', [C.accountOrgs, 'activate']);
408
+ router.post('/account/orgs/:id/leave', [C.accountOrgs, 'leave']);
409
+ router.post('/account/orgs/:id/invite', [C.accountOrgs, 'invite']);
410
+ router.post('/account/orgs/:id/members/:accountId/remove', [C.accountOrgs, 'removeMember']);
411
+ router.post('/account/orgs/:id/invitations/:invId/revoke', [C.accountOrgs, 'revokeInvitation']);
412
+ // JSON endpoints for React hooks (authkit-react).
413
+ router.get('/account/orgs/json', [C.accountOrgs, 'listJson']);
414
+ router.get('/account/orgs/invitations/json', [C.accountOrgs, 'listInvitationsJson']);
415
+ router.get('/account/orgs/:id/json', [C.accountOrgs, 'showJson']);
416
+ }
358
417
  // ─── Account self-service JSON API (authkit-react TanStack hooks) ─────
359
418
  // ⚠️ ORDER MATTERS: fixed-segment routes before parameterised ones.
360
419
  // Registered INSIDE the accountGuard group → same session-auth protection.
@@ -1,4 +1,5 @@
1
1
  import { DEFAULT_MESSAGES, translate } from '../i18n.js';
2
+ import { getAccountLoginUrl } from '../account_login_url.js';
2
3
  /**
3
4
  * Resolve o catálogo de mensagens ativo a partir do `authkit.server` (config
4
5
  * resolvida com o locale do host). Defensivo: se o container/serviço não estiver
@@ -28,7 +29,11 @@ async function resolveMessagesFromCtx(ctx) {
28
29
  export async function renderEdgeView(ctx, view, props) {
29
30
  const messages = await resolveMessagesFromCtx(ctx);
30
31
  const t = (key, params) => translate(messages, key, params);
31
- return ctx.view.render(`authkit::${view}`, { ...props, t, messages });
32
+ // `loginUrl` como prop global: as views que linkam "faça login" (ex.: `otp-unlock`)
33
+ // usam o destino configurável (`accountLoginUrl`) em vez do `/account/login` fixo,
34
+ // que pode estar desmontado. Props explícitas ainda têm precedência (spread depois).
35
+ const loginUrl = getAccountLoginUrl();
36
+ return ctx.view.render(`authkit::${view}`, { loginUrl, ...props, t, messages });
32
37
  }
33
38
  /** Renderer do seam para hosts Edge. As views são donas-da-lib (disco `authkit::`). */
34
39
  export function edgeRenderer() {
@@ -66,5 +66,60 @@ export type AuthkitScreen = 'login' | 'signup' | 'consent' | 'forgot' | 'reset'
66
66
  * | `returnTo` | `string \| null` | Caminho interno de destino pós-login (já validado pelo servidor — só caminhos internos). Quando presente, o formulário deve incluir `<input type="hidden" name="return_to" value={returnTo} />`. O servidor revalida o valor no POST; hosts com tela custom precisam propagar esse hidden input. |
67
67
  * | `error` | `string \| undefined` | Mensagem de erro de autenticação localizada (credenciais inválidas, conta bloqueada, etc.). |
68
68
  * | `messages` | `AuthMessages` | Catálogo de mensagens i18n. |
69
+ *
70
+ * ### Props da tela `account/security`
71
+ *
72
+ * | Prop | Tipo | Descrição |
73
+ * |-------------------------|---------------------|-----------|
74
+ * | `csrfToken` | `string` | Token CSRF para os formulários da tela. |
75
+ * | `supported` | `boolean` | `false` quando o store não suporta o self-service de segurança (troca de senha/e-mail) — a tela deve degradar. |
76
+ * | `profileSupported` | `boolean` | `true` quando o store suporta editar nome/avatar (`updateProfile`). |
77
+ * | `avatarUploadSupported` | `boolean` | `true` quando algum backend (drive OU media) pode armazenar o upload de avatar. |
78
+ * | `email` | `string` | E-mail atual da conta (`''` se ausente). |
79
+ * | `name` | `string` | Nome atual da conta (`''` se ausente). |
80
+ * | `avatarUrl` | `string` | URL do avatar atual (`''` se ausente). |
81
+ * | `passwordChanged` | `string \| null` | Flash de sucesso da troca de senha (mensagem localizada) ou `null`. |
82
+ * | `emailChangeRequested` | `string \| null` | Flash: link de confirmação de troca de e-mail enviado (ou cancelamento) ou `null`. |
83
+ * | `emailChanged` | `string \| null` | Flash: troca de e-mail concluída ou `null`. |
84
+ * | `profileUpdated` | `string \| null` | Flash: perfil atualizado ou `null`. |
85
+ * | `error` | `string \| null` | Flash de erro de segurança (senha inválida, e-mail em uso, política violada) ou `null`. |
86
+ * | `trustedDevicesEnabled` | `boolean` | `true` quando o recurso de dispositivos confiáveis está ligado. |
87
+ * | `trustedDevicesRevoked` | `string \| null` | Flash: confiança deste navegador revogada ou `null`. |
88
+ * | `sessionsSupported` | `boolean` | `true` quando o adapter OIDC enumera as sessões ativas da conta. |
89
+ * | `sessions` | `Array<{ loginTs: string; browser: string; os: string; ip: string; location: string }>` | Sessões ativas da própria conta (vazio quando não suportado). `loginTs` é ISO ou `''`. |
90
+ * | `exportSupported` | `boolean` | Sempre `true` — export de dados (portabilidade/LGPD) disponível para a conta logada. |
91
+ * | `deletionSupported` | `boolean` | `true` quando o store suporta hard delete (danger zone). |
92
+ * | `deleteError` | `string \| null` | Flash de erro da confirmação de deleção ou `null`. |
93
+ * | `messages` | `AuthMessages` | Catálogo de mensagens i18n. |
94
+ *
95
+ * ### Props da tela `account/mfa`
96
+ *
97
+ * | Prop | Tipo | Descrição |
98
+ * |---------------------|---------------------|-----------|
99
+ * | `csrfToken` | `string` | Token CSRF para os formulários de enroll/confirm/disable e passkeys. |
100
+ * | `enabled` | `boolean` | `true` quando o TOTP já está confirmado (habilitado) para a conta. |
101
+ * | `recoveryCodes` | `string[] \| null` | Códigos de recuperação recém-gerados (exibidos UMA vez após enroll/confirm) ou `null`. |
102
+ * | `passkeysSupported` | `boolean` | `true` quando o store persiste credenciais WebAuthn (passkeys). |
103
+ * | `passkeys` | `Array<{ id: string; label?: string; createdAt: string }>` | Passkeys cadastradas (vazio quando não suportado). `id` é base64url; `createdAt` é ISO. |
104
+ * | `messages` | `AuthMessages` | Catálogo de mensagens i18n. |
105
+ *
106
+ * ### Props da tela `account/confirm` (sudo — confirmar identidade)
107
+ *
108
+ * | Prop | Tipo | Descrição |
109
+ * |---------------|---------------------|-----------|
110
+ * | `csrfToken` | `string` | Token CSRF para o POST de cada método. |
111
+ * | `returnTo` | `string \| null` | Caminho interno de destino após confirmar (validado pelo servidor) ou `null`. |
112
+ * | `error` | `string \| null` | Flash de erro da última tentativa de confirmação ou `null`. |
113
+ * | `notice` | `string \| null` | Flash informativo (ex.: "link de confirmação enviado") ou `null`. |
114
+ * | `methods` | `Array<{ id: string; labelKey: string; kind: 'form' \| 'action' \| 'redirect' \| 'webauthn'; endpoint: string; fields?: Array<{ name: string; type: 'password' \| 'text'; labelKey: string }> }>` | Métodos de sudo disponíveis para a conta. A tela renderiza por `kind`; `endpoint` é o POST de verificação (`webauthn` pede options em `${endpoint}/options`). |
115
+ * | `preferredId` | `string \| null` | `id` do último método usado (destaque na UI) ou `null`. |
116
+ * | `messages` | `AuthMessages` | Catálogo de mensagens i18n. |
117
+ *
118
+ * ### Props da tela `account/email-confirmed` (terminal do link de troca de e-mail)
119
+ *
120
+ * | Prop | Tipo | Descrição |
121
+ * |------------|----------------|-----------|
122
+ * | `ok` | `boolean` | `true` quando o token era válido e o novo e-mail foi aplicado; `false` para token inválido/expirado ou store sem suporte. A tela mostra sucesso ou falha conforme o valor. |
123
+ * | `messages` | `AuthMessages` | Catálogo de mensagens i18n. |
69
124
  */
70
125
  export declare function inertiaRenderer(opts: InertiaRendererOptions): (ctx: HttpContext, view: string, props: Record<string, unknown>) => Promise<any>;
@@ -37,6 +37,61 @@ async function resolveMessagesFromCtx(ctx) {
37
37
  * | `returnTo` | `string \| null` | Caminho interno de destino pós-login (já validado pelo servidor — só caminhos internos). Quando presente, o formulário deve incluir `<input type="hidden" name="return_to" value={returnTo} />`. O servidor revalida o valor no POST; hosts com tela custom precisam propagar esse hidden input. |
38
38
  * | `error` | `string \| undefined` | Mensagem de erro de autenticação localizada (credenciais inválidas, conta bloqueada, etc.). |
39
39
  * | `messages` | `AuthMessages` | Catálogo de mensagens i18n. |
40
+ *
41
+ * ### Props da tela `account/security`
42
+ *
43
+ * | Prop | Tipo | Descrição |
44
+ * |-------------------------|---------------------|-----------|
45
+ * | `csrfToken` | `string` | Token CSRF para os formulários da tela. |
46
+ * | `supported` | `boolean` | `false` quando o store não suporta o self-service de segurança (troca de senha/e-mail) — a tela deve degradar. |
47
+ * | `profileSupported` | `boolean` | `true` quando o store suporta editar nome/avatar (`updateProfile`). |
48
+ * | `avatarUploadSupported` | `boolean` | `true` quando algum backend (drive OU media) pode armazenar o upload de avatar. |
49
+ * | `email` | `string` | E-mail atual da conta (`''` se ausente). |
50
+ * | `name` | `string` | Nome atual da conta (`''` se ausente). |
51
+ * | `avatarUrl` | `string` | URL do avatar atual (`''` se ausente). |
52
+ * | `passwordChanged` | `string \| null` | Flash de sucesso da troca de senha (mensagem localizada) ou `null`. |
53
+ * | `emailChangeRequested` | `string \| null` | Flash: link de confirmação de troca de e-mail enviado (ou cancelamento) ou `null`. |
54
+ * | `emailChanged` | `string \| null` | Flash: troca de e-mail concluída ou `null`. |
55
+ * | `profileUpdated` | `string \| null` | Flash: perfil atualizado ou `null`. |
56
+ * | `error` | `string \| null` | Flash de erro de segurança (senha inválida, e-mail em uso, política violada) ou `null`. |
57
+ * | `trustedDevicesEnabled` | `boolean` | `true` quando o recurso de dispositivos confiáveis está ligado. |
58
+ * | `trustedDevicesRevoked` | `string \| null` | Flash: confiança deste navegador revogada ou `null`. |
59
+ * | `sessionsSupported` | `boolean` | `true` quando o adapter OIDC enumera as sessões ativas da conta. |
60
+ * | `sessions` | `Array<{ loginTs: string; browser: string; os: string; ip: string; location: string }>` | Sessões ativas da própria conta (vazio quando não suportado). `loginTs` é ISO ou `''`. |
61
+ * | `exportSupported` | `boolean` | Sempre `true` — export de dados (portabilidade/LGPD) disponível para a conta logada. |
62
+ * | `deletionSupported` | `boolean` | `true` quando o store suporta hard delete (danger zone). |
63
+ * | `deleteError` | `string \| null` | Flash de erro da confirmação de deleção ou `null`. |
64
+ * | `messages` | `AuthMessages` | Catálogo de mensagens i18n. |
65
+ *
66
+ * ### Props da tela `account/mfa`
67
+ *
68
+ * | Prop | Tipo | Descrição |
69
+ * |---------------------|---------------------|-----------|
70
+ * | `csrfToken` | `string` | Token CSRF para os formulários de enroll/confirm/disable e passkeys. |
71
+ * | `enabled` | `boolean` | `true` quando o TOTP já está confirmado (habilitado) para a conta. |
72
+ * | `recoveryCodes` | `string[] \| null` | Códigos de recuperação recém-gerados (exibidos UMA vez após enroll/confirm) ou `null`. |
73
+ * | `passkeysSupported` | `boolean` | `true` quando o store persiste credenciais WebAuthn (passkeys). |
74
+ * | `passkeys` | `Array<{ id: string; label?: string; createdAt: string }>` | Passkeys cadastradas (vazio quando não suportado). `id` é base64url; `createdAt` é ISO. |
75
+ * | `messages` | `AuthMessages` | Catálogo de mensagens i18n. |
76
+ *
77
+ * ### Props da tela `account/confirm` (sudo — confirmar identidade)
78
+ *
79
+ * | Prop | Tipo | Descrição |
80
+ * |---------------|---------------------|-----------|
81
+ * | `csrfToken` | `string` | Token CSRF para o POST de cada método. |
82
+ * | `returnTo` | `string \| null` | Caminho interno de destino após confirmar (validado pelo servidor) ou `null`. |
83
+ * | `error` | `string \| null` | Flash de erro da última tentativa de confirmação ou `null`. |
84
+ * | `notice` | `string \| null` | Flash informativo (ex.: "link de confirmação enviado") ou `null`. |
85
+ * | `methods` | `Array<{ id: string; labelKey: string; kind: 'form' \| 'action' \| 'redirect' \| 'webauthn'; endpoint: string; fields?: Array<{ name: string; type: 'password' \| 'text'; labelKey: string }> }>` | Métodos de sudo disponíveis para a conta. A tela renderiza por `kind`; `endpoint` é o POST de verificação (`webauthn` pede options em `${endpoint}/options`). |
86
+ * | `preferredId` | `string \| null` | `id` do último método usado (destaque na UI) ou `null`. |
87
+ * | `messages` | `AuthMessages` | Catálogo de mensagens i18n. |
88
+ *
89
+ * ### Props da tela `account/email-confirmed` (terminal do link de troca de e-mail)
90
+ *
91
+ * | Prop | Tipo | Descrição |
92
+ * |------------|----------------|-----------|
93
+ * | `ok` | `boolean` | `true` quando o token era válido e o novo e-mail foi aplicado; `false` para token inválido/expirado ou store sem suporte. A tela mostra sucesso ou falha conforme o valor. |
94
+ * | `messages` | `AuthMessages` | Catálogo de mensagens i18n. |
40
95
  */
41
96
  export function inertiaRenderer(opts) {
42
97
  const allowed = opts.views ? new Set(opts.views) : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "0.46.0",
3
+ "version": "0.47.0",
4
4
  "description": "AdonisJS OIDC/OAuth2 provider (Identity Provider) toolkit: ejectable auth server with sessions, rate-limiting, MFA/TOTP, audit log, federated logout and OpenTelemetry metrics.",
5
5
  "license": "MIT",
6
6
  "author": "dudousxd",