@adonis-agora/authkit-server 0.45.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.
Files changed (64) hide show
  1. package/build/commands/ui_preset.js +15 -1
  2. package/build/host/views/account/confirm.edge +118 -52
  3. package/build/host/views/account/mfa.edge +1 -1
  4. package/build/host/views/login.edge +2 -4
  5. package/build/host/views/mfa-challenge.edge +1 -1
  6. package/build/host/views/otp-unlock.edge +2 -2
  7. package/build/host/views/partials/styles.edge +1 -1
  8. package/build/index.d.ts +5 -1
  9. package/build/index.js +15 -1
  10. package/build/providers/authkit_server_provider.js +7 -0
  11. package/build/services/booted_app.d.ts +8 -0
  12. package/build/services/booted_app.js +27 -0
  13. package/build/services/main.d.ts +7 -0
  14. package/build/services/main.js +9 -1
  15. package/build/src/define_config.d.ts +51 -0
  16. package/build/src/define_config.js +8 -0
  17. package/build/src/host/account_login_url.d.ts +41 -0
  18. package/build/src/host/account_login_url.js +50 -0
  19. package/build/src/host/admin_sessions_service.d.ts +3 -1
  20. package/build/src/host/admin_sessions_service.js +35 -3
  21. package/build/src/host/assets/webauthn.js +2 -0
  22. package/build/src/host/console_session.d.ts +4 -0
  23. package/build/src/host/console_session.js +9 -2
  24. package/build/src/host/controllers/account_confirm_controller.d.ts +11 -14
  25. package/build/src/host/controllers/account_confirm_controller.js +42 -130
  26. package/build/src/host/controllers/account_orgs_controller.js +7 -4
  27. package/build/src/host/controllers/account_security_controller.js +3 -2
  28. package/build/src/host/controllers/account_session_controller.js +23 -5
  29. package/build/src/host/controllers/account_tokens_controller.js +11 -0
  30. package/build/src/host/controllers/pat_introspection_controller.js +10 -0
  31. package/build/src/host/controllers/webauthn_asset_controller.d.ts +22 -0
  32. package/build/src/host/controllers/webauthn_asset_controller.js +66 -0
  33. package/build/src/host/i18n.d.ts +14 -0
  34. package/build/src/host/i18n.js +16 -0
  35. package/build/src/host/impersonation_session.js +15 -0
  36. package/build/src/host/middleware/account_auth.js +3 -1
  37. package/build/src/host/rate_limit.d.ts +10 -0
  38. package/build/src/host/rate_limit.js +6 -0
  39. package/build/src/host/register_auth_host.d.ts +85 -2
  40. package/build/src/host/register_auth_host.js +188 -61
  41. package/build/src/host/renderers/edge_renderer.js +6 -1
  42. package/build/src/host/renderers/inertia_renderer.d.ts +55 -0
  43. package/build/src/host/renderers/inertia_renderer.js +55 -0
  44. package/build/src/host/sudo/index.d.ts +47 -0
  45. package/build/src/host/sudo/index.js +41 -0
  46. package/build/src/host/sudo/methods/magic_link.d.ts +43 -0
  47. package/build/src/host/sudo/methods/magic_link.js +174 -0
  48. package/build/src/host/sudo/methods/oidc_step_up.d.ts +68 -0
  49. package/build/src/host/sudo/methods/oidc_step_up.js +78 -0
  50. package/build/src/host/sudo/methods/passkey.d.ts +28 -0
  51. package/build/src/host/sudo/methods/passkey.js +139 -0
  52. package/build/src/host/sudo/methods/password.d.ts +19 -0
  53. package/build/src/host/sudo/methods/password.js +93 -0
  54. package/build/src/host/sudo/runtime.d.ts +141 -0
  55. package/build/src/host/sudo/runtime.js +327 -0
  56. package/build/src/host/sudo/types.d.ts +93 -0
  57. package/build/src/host/sudo/types.js +1 -0
  58. package/build/src/host/sudo_mode.d.ts +116 -6
  59. package/build/src/host/sudo_mode.js +133 -7
  60. package/package.json +6 -2
  61. package/build/stubs/ui/edge/views/consent.edge +0 -13
  62. package/build/stubs/ui/edge/views/login.edge +0 -19
  63. package/stubs/ui/edge/views/consent.edge +0 -13
  64. package/stubs/ui/edge/views/login.edge +0 -19
@@ -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). */
@@ -0,0 +1,2 @@
1
+ function l(e){let t=new Uint8Array(e),r="";for(let n of t)r+=String.fromCharCode(n);return btoa(r).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}function h(e){let t=e.replace(/-/g,"+").replace(/_/g,"/"),r=(4-t.length%4)%4,i=t.padEnd(t.length+r,"="),n=atob(i),c=new ArrayBuffer(n.length),f=new Uint8Array(c);for(let a=0;a<n.length;a++)f[a]=n.charCodeAt(a);return c}function p(){return C.stubThis(globalThis?.PublicKeyCredential!==void 0&&typeof globalThis.PublicKeyCredential=="function")}var C={stubThis:e=>e};function R(e){let{id:t}=e;return{...e,id:h(t),transports:e.transports}}function w(e){return e==="localhost"||/^((xn--[a-z0-9-]+|[a-z0-9]+(-[a-z0-9]+)*)\.)+([a-z]{2,}|xn--[a-z0-9-]+)$/i.test(e)}var o=class extends Error{constructor({message:t,code:r,cause:i,name:n}){super(t,{cause:i}),Object.defineProperty(this,"code",{enumerable:!0,configurable:!0,writable:!0,value:void 0}),this.name=n??i.name,this.code=r}};function I({error:e,options:t}){let{publicKey:r}=t;if(!r)throw Error("options was missing required publicKey property");if(e.name==="AbortError"){if(t.signal instanceof AbortSignal)return new o({message:"Registration ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else if(e.name==="ConstraintError"){if(r.authenticatorSelection?.requireResidentKey===!0)return new o({message:"Discoverable credentials were required but no available authenticator supported it",code:"ERROR_AUTHENTICATOR_MISSING_DISCOVERABLE_CREDENTIAL_SUPPORT",cause:e});if(t.mediation==="conditional"&&r.authenticatorSelection?.userVerification==="required")return new o({message:"User verification was required during automatic registration but it could not be performed",code:"ERROR_AUTO_REGISTER_USER_VERIFICATION_FAILURE",cause:e});if(r.authenticatorSelection?.userVerification==="required")return new o({message:"User verification was required but no available authenticator supported it",code:"ERROR_AUTHENTICATOR_MISSING_USER_VERIFICATION_SUPPORT",cause:e})}else{if(e.name==="InvalidStateError")return new o({message:"The authenticator was previously registered",code:"ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED",cause:e});if(e.name==="NotAllowedError")return new o({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if(e.name==="NotSupportedError")return r.pubKeyCredParams.filter(n=>n.type==="public-key").length===0?new o({message:'No entry in pubKeyCredParams was of type "public-key"',code:"ERROR_MALFORMED_PUBKEYCREDPARAMS",cause:e}):new o({message:"No available authenticator supported any of the specified pubKeyCredParams algorithms",code:"ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG",cause:e});if(e.name==="SecurityError"){let i=globalThis.location.hostname;if(w(i)){if(r.rp.id!==i)return new o({message:`The RP ID "${r.rp.id}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else return new o({message:`${globalThis.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e})}else if(e.name==="TypeError"){if(r.user.id.byteLength<1||r.user.id.byteLength>64)return new o({message:"User ID was not between 1 and 64 characters",code:"ERROR_INVALID_USER_ID_LENGTH",cause:e})}else if(e.name==="UnknownError")return new o({message:"The authenticator was unable to process the specified options, or could not create a new credential",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}var S=class{constructor(){Object.defineProperty(this,"controller",{enumerable:!0,configurable:!0,writable:!0,value:void 0})}createNewAbortSignal(){if(this.controller){let r=new Error("Cancelling existing WebAuthn API call for new one");r.name="AbortError",this.controller.abort(r)}let t=new AbortController;return this.controller=t,t.signal}cancelCeremony(){if(this.controller){let t=new Error("Manually cancelling existing WebAuthn API call");t.name="AbortError",this.controller.abort(t),this.controller=void 0}}},A=new S;var x=["cross-platform","platform"];function E(e){if(e&&!(x.indexOf(e)<0))return e}async function N(e){!e.optionsJSON&&e.challenge&&(console.warn("startRegistration() was not called correctly. It will try to continue with the provided options, but this call should be refactored to use the expected call structure instead. See https://simplewebauthn.dev/docs/packages/browser#typeerror-cannot-read-properties-of-undefined-reading-challenge for more information."),e={optionsJSON:e});let{optionsJSON:t,useAutoRegister:r=!1}=e;if(!p())throw new Error("WebAuthn is not supported in this browser");let i={...t,challenge:h(t.challenge),user:{...t.user,id:h(t.user.id)},excludeCredentials:t.excludeCredentials?.map(R)},n={};r&&(n.mediation="conditional"),n.publicKey=i,n.signal=A.createNewAbortSignal();let c;try{c=await navigator.credentials.create(n)}catch(u){throw I({error:u,options:n})}if(!c)throw new Error("Registration was not completed");let{id:f,rawId:a,response:s,type:y}=c,d;typeof s.getTransports=="function"&&(d=s.getTransports());let g;if(typeof s.getPublicKeyAlgorithm=="function")try{g=s.getPublicKeyAlgorithm()}catch(u){O("getPublicKeyAlgorithm()",u)}let b;if(typeof s.getPublicKey=="function")try{let u=s.getPublicKey();u!==null&&(b=l(u))}catch(u){O("getPublicKey()",u)}let m;if(typeof s.getAuthenticatorData=="function")try{m=l(s.getAuthenticatorData())}catch(u){O("getAuthenticatorData()",u)}return{id:f,rawId:l(a),response:{attestationObject:l(s.attestationObject),clientDataJSON:l(s.clientDataJSON),transports:d,publicKeyAlgorithm:g,publicKey:b,authenticatorData:m},type:y,clientExtensionResults:c.getClientExtensionResults(),authenticatorAttachment:E(c.authenticatorAttachment)}}function O(e,t){console.warn(`The browser extension that intercepted this WebAuthn API call incorrectly implemented ${e}. You should report this error to them.
2
+ `,t)}function _(){if(!p())return T.stubThis(new Promise(t=>t(!1)));let e=globalThis.PublicKeyCredential;return e?.isConditionalMediationAvailable===void 0?T.stubThis(new Promise(t=>t(!1))):T.stubThis(e.isConditionalMediationAvailable())}var T={stubThis:e=>e};function P({error:e,options:t}){let{publicKey:r}=t;if(!r)throw Error("options was missing required publicKey property");if(e.name==="AbortError"){if(t.signal instanceof AbortSignal)return new o({message:"Authentication ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else{if(e.name==="NotAllowedError")return new o({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if(e.name==="SecurityError"){let i=globalThis.location.hostname;if(w(i)){if(r.rpId!==i)return new o({message:`The RP ID "${r.rpId}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else return new o({message:`${globalThis.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e})}else if(e.name==="UnknownError")return new o({message:"The authenticator was unable to process the specified options, or could not create a new assertion signature",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}async function D(e){!e.optionsJSON&&e.challenge&&(console.warn("startAuthentication() was not called correctly. It will try to continue with the provided options, but this call should be refactored to use the expected call structure instead. See https://simplewebauthn.dev/docs/packages/browser#typeerror-cannot-read-properties-of-undefined-reading-challenge for more information."),e={optionsJSON:e});let{optionsJSON:t,useBrowserAutofill:r=!1,verifyBrowserAutofillInput:i=!0}=e;if(!p())throw new Error("WebAuthn is not supported in this browser");let n;t.allowCredentials?.length!==0&&(n=t.allowCredentials?.map(R));let c={...t,challenge:h(t.challenge),allowCredentials:n},f={};if(r){if(!await _())throw Error("Browser does not support WebAuthn autofill");if(document.querySelectorAll("input[autocomplete$='webauthn']").length<1&&i)throw Error('No <input> with "webauthn" as the only or last value in its `autocomplete` attribute was detected');f.mediation="conditional",c.allowCredentials=[]}f.publicKey=c,f.signal=A.createNewAbortSignal();let a;try{a=await navigator.credentials.get(f)}catch(m){throw P({error:m,options:f})}if(!a)throw new Error("Authentication was not completed");let{id:s,rawId:y,response:d,type:g}=a,b;return d.userHandle&&(b=l(d.userHandle)),{id:s,rawId:l(y),response:{authenticatorData:l(d.authenticatorData),clientDataJSON:l(d.clientDataJSON),signature:l(d.signature),userHandle:b},type:g,clientExtensionResults:a.getClientExtensionResults(),authenticatorAttachment:E(a.authenticatorAttachment)}}function v(){return p()?PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable():new Promise(e=>e(!1))}export{A as WebAuthnAbortService,o as WebAuthnError,p as browserSupportsWebAuthn,_ as browserSupportsWebAuthnAutofill,v as platformAuthenticatorIsAvailable,D as startAuthentication,N as startRegistration};
@@ -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,24 +1,21 @@
1
1
  /**
2
2
  * Sudo mode — tela de confirmação de identidade (/account/confirm).
3
3
  *
4
- * GET /account/confirm → exibe o formulário de senha (e opção de passkey se disponível).
5
- * POST /account/confirm → verifica a senha e, se correta, marca o sudo na sessão.
6
- * POST /account/confirm/passkey/options gera as opções de autenticação.
7
- * POST /account/confirm/passkey → verifica a resposta da passkey e marca o sudo.
4
+ * O GET lista os métodos DISPONÍVEIS para a conta (SPI `SudoMethod`); a
5
+ * verificação de cada um vive no próprio método, nas rotas que ele registra.
6
+ * Este controller não verifica credencial nem chama `markSudo`.
8
7
  *
9
8
  * A tela está atrás do `accountGuard` (requer sessão de conta ativa).
10
- * Após confirmação, redireciona para `return_to` (validado) ou para `/account/tokens`.
11
9
  */
12
10
  import '../augmentations.js';
13
11
  import type { HttpContext } from '@adonisjs/core/http';
12
+ import { sudoContextFrom } from '../sudo/runtime.js';
13
+ /**
14
+ * Reexport de compatibilidade. O construtor canônico do `SudoContext` vive em
15
+ * `sudo/runtime.ts` (é runtime do SPI, não detalhe da tela); este caminho
16
+ * antigo segue valendo para quem já o importava.
17
+ */
18
+ export { sudoContextFrom };
14
19
  export default class AccountConfirmController {
15
- show(ctx: HttpContext): Promise<any>;
16
- confirm(ctx: HttpContext): Promise<void>;
17
- passkeyOptions(ctx: HttpContext): Promise<any>;
18
- passkeyConfirm(ctx: HttpContext): Promise<void>;
19
- /**
20
- * Verifica se a conta é "passwordless" (sem hash de senha definido).
21
- * Fail-safe: retorna `false` quando não é possível determinar.
22
- */
23
- private isPasswordless;
20
+ show(ctx: HttpContext): Promise<unknown>;
24
21
  }
@@ -1,144 +1,56 @@
1
1
  /**
2
2
  * Sudo mode — tela de confirmação de identidade (/account/confirm).
3
3
  *
4
- * GET /account/confirm → exibe o formulário de senha (e opção de passkey se disponível).
5
- * POST /account/confirm → verifica a senha e, se correta, marca o sudo na sessão.
6
- * POST /account/confirm/passkey/options gera as opções de autenticação.
7
- * POST /account/confirm/passkey → verifica a resposta da passkey e marca o sudo.
4
+ * O GET lista os métodos DISPONÍVEIS para a conta (SPI `SudoMethod`); a
5
+ * verificação de cada um vive no próprio método, nas rotas que ele registra.
6
+ * Este controller não verifica credencial nem chama `markSudo`.
8
7
  *
9
8
  * A tela está atrás do `accountGuard` (requer sessão de conta ativa).
10
- * Após confirmação, redireciona para `return_to` (validado) ou para `/account/tokens`.
11
9
  */
12
10
  import '../augmentations.js';
13
- import { ACCOUNT_SESSION_KEY } from '../middleware/account_auth.js';
14
- import { translate } from '../i18n.js';
15
- import { supportsPasskeys } from '../../accounts/account_store.js';
16
- import { markSudo } from '../sudo_mode.js';
17
- import { validateReturnTo } from './account_session_controller.js';
18
- import { accountHome } from '../account_home.js';
19
- /** Chave de sessão para o challenge de passkey no confirm. */
20
- const CONFIRM_PASSKEY_CHALLENGE_KEY = 'authkit_confirm_passkey_challenge';
11
+ import { resolveAvailableMethods, configuredSudoMethods, isSudoMethodMounted, sudoContextFrom, LAST_METHOD_SESSION_KEY, } from '../sudo/runtime.js';
12
+ /**
13
+ * Reexport de compatibilidade. O construtor canônico do `SudoContext` vive em
14
+ * `sudo/runtime.ts` runtime do SPI, não detalhe da tela); este caminho
15
+ * antigo segue valendo para quem já o importava.
16
+ */
17
+ export { sudoContextFrom };
21
18
  export default class AccountConfirmController {
22
19
  async show(ctx) {
23
- const service = await ctx.containerResolver.make('authkit.server');
24
- const cfg = service.config;
25
- const render = cfg.render;
26
- const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
27
- const passkeyAvailable = supportsPasskeys(cfg.accountStore)
28
- ? (await cfg.accountStore.listPasskeys(userId)).length > 0
29
- : false;
30
- // Conta passwordless: sem hash de senha (campo `password` vazio/null no model).
31
- // Verificamos indiretamente: verifyCredentials com senha fictícia vai falhar,
32
- // mas precisamos saber se a conta TEM senha. Abordagem: a conta é "passwordless"
33
- // se o store suporta passkeys, a conta tem pelo menos uma passkey E não tem
34
- // hash de senha mas sem expor o hash, usamos uma flag do store se disponível.
35
- // Fallback conservador: assumimos que a conta tem senha se não soubermos.
36
- const passwordless = await this.isPasswordless(cfg, userId);
37
- const rawReturnTo = ctx.request.qs?.()?.return_to ?? ctx.request.input?.('return_to');
38
- const returnTo = validateReturnTo(rawReturnTo);
39
- return render(ctx, 'account/confirm', {
20
+ const c = await sudoContextFrom(ctx);
21
+ const available = await resolveAvailableMethods(c, configuredSudoMethods(c.cfg));
22
+ const methods = await Promise.all(available.map(async (m) => ({ id: m.id, ...(await m.describe(c)) })));
23
+ if (!methods.length) {
24
+ // Nenhum método disponível é erro de CONFIGURAÇÃO do host, não usuário
25
+ // preso: a tela informa e o log aponta o problema.
26
+ ;
27
+ ctx.logger?.error({ accountId: c.accountId }, 'authkit: nenhum método de sudo disponível para a conta — verifique config.sudo.methods');
28
+ }
29
+ // FLAG-DRIFT: `config.sudo.methods` decide o que a tela OFERECE, mas as
30
+ // rotas são montadas em tempo de registro, por `registerAuthHost`. Se as
31
+ // duas listas divergirem, a tela mostra um método cujo endpoint não existe
32
+ // falha silenciosa e confusa. Avisa alto, uma vez por render.
33
+ //
34
+ // o caso de config EXPLÍCITA chega aqui divergindo: sem config,
35
+ // `configuredSudoMethods` devolve a própria lista montada.
36
+ for (const m of methods) {
37
+ if (!isSudoMethodMounted(m.id)) {
38
+ ;
39
+ ctx.logger?.warn({ method: m.id }, `authkit: método de sudo "${m.id}" está em config.sudo.methods mas não teve ` +
40
+ 'rotas montadas por registerAuthHost — a tela vai oferecer uma opção cujo ' +
41
+ 'endpoint não existe');
42
+ }
43
+ }
44
+ return c.cfg.render(ctx, 'account/confirm', {
40
45
  csrfToken: ctx.request.csrfToken,
41
- returnTo,
46
+ returnTo: c.returnTo,
42
47
  error: ctx.session.flashMessages.get('confirmError') ?? null,
43
- passwordless,
44
- passkeyAvailable,
45
- });
46
- }
47
- async confirm(ctx) {
48
- const service = await ctx.containerResolver.make('authkit.server');
49
- const cfg = service.config;
50
- const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
51
- const account = await cfg.accountStore.findById(userId);
52
- const rawReturnTo = ctx.request.input?.('return_to');
53
- const returnTo = validateReturnTo(rawReturnTo);
54
- const { password } = ctx.request.only(['password']);
55
- if (!password || !account) {
56
- ctx.session.flash('confirmError', translate(cfg.messages, 'account.confirm.error'));
57
- return ctx.response.redirect(`/account/confirm${returnTo ? `?return_to=${encodeURIComponent(returnTo)}` : ''}`);
58
- }
59
- const verified = await cfg.accountStore.verifyCredentials(account.email, password);
60
- if (!verified) {
61
- ctx.session.flash('confirmError', translate(cfg.messages, 'account.confirm.error'));
62
- return ctx.response.redirect(`/account/confirm${returnTo ? `?return_to=${encodeURIComponent(returnTo)}` : ''}`);
63
- }
64
- // Confirmado: marca o sudo na sessão.
65
- markSudo(ctx);
66
- await cfg.audit?.record({
67
- type: 'sudo.confirmed',
68
- accountId: userId,
69
- ip: ctx.request.ip?.() ?? null,
70
- metadata: { method: 'password' },
71
- });
72
- return ctx.response.redirect(returnTo ?? accountHome(cfg));
73
- }
74
- async passkeyOptions(ctx) {
75
- const service = await ctx.containerResolver.make('authkit.server');
76
- const cfg = service.config;
77
- const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
78
- const generated = await cfg.accountStore.generatePasskeyAuthenticationOptions?.(userId);
79
- if (!generated) {
80
- return ctx.response.notFound({
81
- message: translate(cfg.messages, 'errors.no_passkey_registered'),
82
- });
83
- }
84
- ctx.session.put(CONFIRM_PASSKEY_CHALLENGE_KEY, generated.challenge);
85
- return generated.options;
86
- }
87
- async passkeyConfirm(ctx) {
88
- const service = await ctx.containerResolver.make('authkit.server');
89
- const cfg = service.config;
90
- const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
91
- const challenge = ctx.session.get(CONFIRM_PASSKEY_CHALLENGE_KEY);
92
- const rawReturnTo = ctx.request.input?.('return_to');
93
- const returnTo = validateReturnTo(rawReturnTo);
94
- if (!challenge) {
95
- ctx.session.flash('confirmError', translate(cfg.messages, 'account.confirm.passkey_error'));
96
- return ctx.response.redirect(`/account/confirm${returnTo ? `?return_to=${encodeURIComponent(returnTo)}` : ''}`);
97
- }
98
- const raw = ctx.request.input('response');
99
- let parsed = null;
100
- try {
101
- parsed = raw ? JSON.parse(raw) : null;
102
- }
103
- catch {
104
- parsed = null;
105
- }
106
- const ok = parsed
107
- ? ((await cfg.accountStore.verifyPasskeyAuthentication?.(userId, parsed, challenge)) ?? false)
108
- : false;
109
- ctx.session.forget(CONFIRM_PASSKEY_CHALLENGE_KEY);
110
- if (!ok) {
111
- ctx.session.flash('confirmError', translate(cfg.messages, 'account.confirm.passkey_error'));
112
- return ctx.response.redirect(`/account/confirm${returnTo ? `?return_to=${encodeURIComponent(returnTo)}` : ''}`);
113
- }
114
- // Passkey confirmada: marca o sudo.
115
- markSudo(ctx);
116
- await cfg.audit?.record({
117
- type: 'sudo.confirmed',
118
- accountId: userId,
119
- ip: ctx.request.ip?.() ?? null,
120
- metadata: { method: 'passkey' },
48
+ // `confirmNotice` é flashado por `magic_link.ts` ao enviar o link (já
49
+ // traduzido, mesmo padrão de `confirmError`). Sem repassar aqui, quem
50
+ // pede o link volta para a tela sem nenhum feedback de que o e-mail saiu.
51
+ notice: ctx.session.flashMessages.get('confirmNotice') ?? null,
52
+ methods,
53
+ preferredId: ctx.session.get(LAST_METHOD_SESSION_KEY) ?? null,
121
54
  });
122
- return ctx.response.redirect(returnTo ?? accountHome(cfg));
123
- }
124
- /**
125
- * Verifica se a conta é "passwordless" (sem hash de senha definido).
126
- * Fail-safe: retorna `false` quando não é possível determinar.
127
- */
128
- async isPasswordless(cfg, accountId) {
129
- try {
130
- // Se o store expõe __getRawRow, verificamos se o hash de senha está vazio.
131
- const row = await cfg.accountStore.__getRawRow?.(accountId);
132
- if (row) {
133
- const pw = row.password;
134
- // Hash vazio ou nulo → passwordless.
135
- if (!pw || pw === '')
136
- return true;
137
- }
138
- return false;
139
- }
140
- catch {
141
- return false;
142
- }
143
55
  }
144
56
  }
@@ -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
  /**
@@ -108,14 +109,31 @@ export default class AccountSessionController {
108
109
  const cfg = service.config;
109
110
  // M6: não basta `forget(ACCOUNT_SESSION_KEY)` — sobravam na sessão
110
111
  // `authkit_sudo_at` (sudo), `authkit_last_seen` e qualquer outro estado
111
- // sensível, com o session id INALTERADO. Regenerar a sessão troca o id E
112
- // descarta todos os dados antigos (sudo/last-seen inclusos), destruindo de
113
- // fato a sessão de privilégio. Mantemos o `forget` explícito por garantia
114
- // (belt-and-braces) caso o store de sessão não suporte regenerate.
112
+ // sensível, com o session id INALTERADO. Regenerar troca o id do cookie,
113
+ // o que invalida o identificador antigo.
114
+ //
115
+ // ATENÇÃO ao que `regenerate()` NÃO faz: ele NÃO descarta os dados. O
116
+ // AdonisJS MIGRA o conteúdo da sessão para o id novo (é o mesmo
117
+ // comportamento descrito no M5 do login, e é por isso que o estado de
118
+ // pré-login sobrevive lá). Quem apaga estado aqui é o `forget` explícito —
119
+ // não o `regenerate`.
120
+ //
121
+ // Consequência de projeto: TODO dado sensível guardado na sessão precisa
122
+ // ser VINCULADO À CONTA que o gravou (ex.: `accountId` no pendente do
123
+ // magic link de sudo, na vinculação do challenge de passkey e na marca de
124
+ // sudo — `SUDO_ACCOUNT_SESSION_KEY`). Confiar que ele "some no logout" é
125
+ // falso — num navegador compartilhado ele sobrevive ao logout de A e ao
126
+ // login de B.
127
+ //
128
+ // A marca de sudo era a EXCEÇÃO à própria regra declarada aqui: só o
129
+ // timestamp, sem dono. Passou a ser vinculada — por isso este `forget`
130
+ // continua sendo só do `ACCOUNT_SESSION_KEY`: trocada a conta, a marca de
131
+ // sudo remanescente já não vale para ninguém.
115
132
  ctx.session.forget(ACCOUNT_SESSION_KEY);
116
133
  await ctx.session.regenerate();
117
134
  // Opt-in: espelha o logout no guard de @adonisjs/auth (ver adonisAuth em define_config.ts).
118
135
  await syncAdonisAuthLogout(ctx, cfg);
119
- return ctx.response.redirect('/account/login');
136
+ // Destino configurável (`accountLoginUrl`): default `/account/login`.
137
+ return ctx.response.redirect(getAccountLoginUrl());
120
138
  }
121
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 };