@adonis-agora/authkit-server 0.65.0 → 0.65.2

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 (30) hide show
  1. package/build/host/views/account/confirm.edge +3 -34
  2. package/build/host/views/account/mfa.edge +8 -34
  3. package/build/host/views/login.edge +10 -66
  4. package/build/host/views/mfa-challenge.edge +5 -33
  5. package/build/host/views/partials/submit_lock.edge +5 -15
  6. package/build/src/host/admin_console/console_keys_controller.d.ts +1 -6
  7. package/build/src/host/admin_console/console_keys_controller.js +19 -0
  8. package/build/src/host/admin_console/console_sessions_controller.d.ts +2 -14
  9. package/build/src/host/admin_console/console_sessions_controller.js +24 -0
  10. package/build/src/host/admin_console/console_users_controller.d.ts +3 -16
  11. package/build/src/host/admin_console/console_users_controller.js +37 -0
  12. package/build/src/host/assets/passkey_autofill.js +48 -0
  13. package/build/src/host/assets/passkey_button.js +46 -0
  14. package/build/src/host/assets/passkey_register.js +43 -0
  15. package/build/src/host/assets/submit_lock.js +39 -0
  16. package/build/src/host/assets/webauthn_confirm.js +39 -0
  17. package/build/src/host/controllers/passkey_autofill_asset_controller.d.ts +18 -0
  18. package/build/src/host/controllers/passkey_autofill_asset_controller.js +30 -0
  19. package/build/src/host/controllers/passkey_button_asset_controller.d.ts +17 -0
  20. package/build/src/host/controllers/passkey_button_asset_controller.js +24 -0
  21. package/build/src/host/controllers/passkey_register_asset_controller.d.ts +16 -0
  22. package/build/src/host/controllers/passkey_register_asset_controller.js +23 -0
  23. package/build/src/host/controllers/static_asset_controller.d.ts +21 -0
  24. package/build/src/host/controllers/static_asset_controller.js +29 -0
  25. package/build/src/host/controllers/submit_lock_asset_controller.d.ts +17 -0
  26. package/build/src/host/controllers/submit_lock_asset_controller.js +24 -0
  27. package/build/src/host/controllers/webauthn_confirm_asset_controller.d.ts +14 -0
  28. package/build/src/host/controllers/webauthn_confirm_asset_controller.js +21 -0
  29. package/build/src/host/register_auth_host.js +21 -0
  30. package/package.json +2 -2
@@ -98,40 +98,9 @@
98
98
  é emitido quando existe ao menos um bloco desses na tela.
99
99
  --}}
100
100
  @if(methods && methods.some((m) => m.kind === 'webauthn'))
101
- <script type="module">
102
- import { startAuthentication } from '/authkit/assets/webauthn.js'
103
-
104
- for (const block of document.querySelectorAll('[data-authkit-webauthn]')) {
105
- const form = block.querySelector('[data-webauthn-form]')
106
- const field = block.querySelector('[data-webauthn-response]')
107
- const errorBox = block.querySelector('[data-webauthn-error]')
108
-
109
- block.querySelector('[data-webauthn-start]')?.addEventListener('click', async () => {
110
- try {
111
- // Endpoint de options DERIVADO do descritor, não hardcoded.
112
- const optionsResponse = await fetch(form.getAttribute('action') + '/options', {
113
- method: 'POST',
114
- headers: { 'x-csrf-token': form.querySelector('input[name="_csrf"]').value },
115
- })
116
- if (!optionsResponse.ok) throw new Error('authkit: options recusadas')
117
-
118
- const optionsJSON = await optionsResponse.json()
119
- const assertion = await startAuthentication({ optionsJSON })
120
- // O handler lê `request.input('response')` como STRING e faz
121
- // JSON.parse — daí o stringify aqui.
122
- field.value = JSON.stringify(assertion)
123
- form.submit()
124
- } catch (error) {
125
- // Falha no handshake (usuário cancelou, options recusadas, sem
126
- // autenticador). Sem esta mensagem o botão simplesmente não faz
127
- // nada e o usuário não tem como saber que deve tentar outro método.
128
- console.error(error)
129
- errorBox.textContent = errorBox.getAttribute('data-message')
130
- errorBox.classList.remove('hidden')
131
- }
132
- })
133
- }
134
- </script>
101
+ {{-- M12: script inline sem nonce é bloqueado por CSP `script-src 'self'` — extraído
102
+ para asset same-origin (mesmo tratamento do bundle WebAuthn/splash de logout). --}}
103
+ <script type="module" src="/authkit/assets/webauthn_confirm.js"></script>
135
104
  @end
136
105
  </body>
137
106
  </html>
@@ -106,6 +106,9 @@
106
106
  @end
107
107
 
108
108
  <button type="button" id="passkey-add"
109
+ data-csrf="{{ csrfToken }}"
110
+ data-options-url="{{ accountPaths?.mfa ?? '/account/mfa' }}/passkeys/options"
111
+ data-verify-url="{{ accountPaths?.mfa ?? '/account/mfa' }}/passkeys/verify"
109
112
  class="mt-4 rounded-lg border border-gray-300 px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50">
110
113
  {{ t('mfa.passkey.add') }}
111
114
  </button>
@@ -115,39 +118,10 @@
115
118
  </div>
116
119
 
117
120
  @if(passkeysSupported)
118
- <script type="module">
119
- import { startRegistration } from '/authkit/assets/webauthn.js'
120
- const btn = document.getElementById('passkey-add')
121
- const errEl = document.getElementById('passkey-error')
122
- const csrf = '{{ csrfToken }}'
123
- btn?.addEventListener('click', async () => {
124
- errEl.classList.add('hidden')
125
- btn.disabled = true
126
- try {
127
- // 1) Opções de registro (challenge guardado na sessão server-side).
128
- const optsRes = await fetch('{{ accountPaths?.mfa ?? '/account/mfa' }}/passkeys/options', {
129
- method: 'POST',
130
- headers: { 'content-type': 'application/json', 'x-csrf-token': csrf },
131
- body: JSON.stringify({}),
132
- })
133
- if (!optsRes.ok) throw new Error('options')
134
- const optionsJSON = await optsRes.json()
135
- // 2) Cerimônia de registro no authenticator.
136
- const attResp = await startRegistration({ optionsJSON })
137
- // 3) Verifica/persiste no servidor.
138
- const verifyRes = await fetch('{{ accountPaths?.mfa ?? '/account/mfa' }}/passkeys/verify', {
139
- method: 'POST',
140
- headers: { 'content-type': 'application/json', 'x-csrf-token': csrf },
141
- body: JSON.stringify({ response: attResp }),
142
- })
143
- if (!verifyRes.ok) throw new Error('verify')
144
- // 4) Recarrega para mostrar a nova passkey.
145
- window.location.reload()
146
- } catch (e) {
147
- errEl.classList.remove('hidden')
148
- btn.disabled = false
149
- }
150
- })
151
- </script>
121
+ {{-- M12: script inline sem nonce é bloqueado por CSP `script-src 'self'` — extraído
122
+ para asset same-origin (mesmo tratamento do bundle WebAuthn/splash de logout).
123
+ csrf/URLs vão em `data-*` no botão acima (atributo HTML, escapado pelo Edge)
124
+ em vez de interpolados como string literal dentro do `<script>`. --}}
125
+ <script type="module" src="/authkit/assets/passkey_register.js"></script>
152
126
  @end
153
127
  </body></html>
@@ -70,49 +70,14 @@
70
70
  Sucesso → submete o verify e completa o login sem precisar da senha.
71
71
  Fail-safe total: browser sem suporte, abort ou erro = silêncio, login normal segue. --}}
72
72
  @if(authMethods && authMethods.passkeyAutofill && authMethods.passkey)
73
- <form id="autofill-passkey-form" method="POST" action="/auth/interaction/{{ uid }}/passkey/verify" class="hidden">
73
+ <form id="autofill-passkey-form" method="POST" action="/auth/interaction/{{ uid }}/passkey/verify"
74
+ data-options-url="/auth/interaction/{{ uid }}/passkey/options" class="hidden">
74
75
  <input type="hidden" name="_csrf" value="{{ csrfToken }}">
75
76
  <input type="hidden" name="response" id="autofill-passkey-response">
76
77
  </form>
77
- <script type="module">
78
- (async () => {
79
- try {
80
- // SSR-safe: só roda no browser.
81
- if (typeof window === 'undefined' || typeof navigator === 'undefined') return
82
- if (!window.PublicKeyCredential) return
83
- // Detecta suporte a conditional mediation.
84
- const supported = await PublicKeyCredential.isConditionalMediationAvailable?.()
85
- if (!supported) return
86
-
87
- const { startAuthentication } = await import('/authkit/assets/webauthn.js')
88
- const csrf = document.querySelector('input[name="_csrf"]')?.value ?? ''
89
- const optRes = await fetch('/auth/interaction/{{ uid }}/passkey/options', {
90
- method: 'POST',
91
- headers: { 'content-type': 'application/json', 'x-csrf-token': csrf },
92
- body: JSON.stringify({}),
93
- })
94
- if (!optRes.ok) return
95
- const optionsJSON = await optRes.json()
96
- // Remove flag interna antes de passar ao browser.
97
- delete optionsJSON._discoverable
98
-
99
- // AbortController: cancela ao navegar/desmontar.
100
- const ac = new AbortController()
101
- window.addEventListener('beforeunload', () => ac.abort(), { once: true })
102
-
103
- const assertion = await startAuthentication({
104
- optionsJSON,
105
- useBrowserAutofill: true,
106
- verifyBrowserAutofillInput: true,
107
- }, ac.signal)
108
-
109
- document.getElementById('autofill-passkey-response').value = JSON.stringify(assertion)
110
- document.getElementById('autofill-passkey-form').submit()
111
- } catch {
112
- // Fail-safe: abort, suporte ausente ou qualquer erro = login normal.
113
- }
114
- })()
115
- </script>
78
+ {{-- M12: script inline sem nonce é bloqueado por CSP `script-src 'self'` — extraído
79
+ para asset same-origin (mesmo tratamento do bundle WebAuthn/splash de logout). --}}
80
+ <script type="module" src="/authkit/assets/passkey_autofill.js"></script>
116
81
  @end
117
82
  </div>
118
83
  @else
@@ -233,7 +198,8 @@
233
198
  {{-- Passwordless: "entrar com passkey" ANTES da senha. Reusa as cerimônias
234
199
  de passkey do MFA (begin/finish) liberadas no estágio de senha. --}}
235
200
  @if(passkeyFirstAvailable)
236
- <form id="passkey-form" method="POST" action="/auth/interaction/{{ uid }}/passkey/verify" class="mt-4">
201
+ <form id="passkey-form" method="POST" action="/auth/interaction/{{ uid }}/passkey/verify"
202
+ data-options-url="/auth/interaction/{{ uid }}/passkey/options" class="mt-4">
237
203
  <input type="hidden" name="_csrf" value="{{ csrfToken }}">
238
204
  <input type="hidden" name="response" id="passkey-response">
239
205
  </form>
@@ -242,31 +208,9 @@
242
208
  {{ t('login.passkey_button') }}
243
209
  </button>
244
210
  <p id="passkey-error" class="mt-3 hidden text-sm text-red-600">{{ t('mfa_challenge.passkey_error') }}</p>
245
- <script type="module">
246
- import { startAuthentication } from '/authkit/assets/webauthn.js'
247
- const btn = document.getElementById('passkey-button')
248
- const errEl = document.getElementById('passkey-error')
249
- const csrf = document.querySelector('#passkey-form input[name="_csrf"]').value
250
- btn?.addEventListener('click', async () => {
251
- errEl.classList.add('hidden')
252
- btn.disabled = true
253
- try {
254
- const res = await fetch('/auth/interaction/{{ uid }}/passkey/options', {
255
- method: 'POST',
256
- headers: { 'content-type': 'application/json', 'x-csrf-token': csrf },
257
- body: JSON.stringify({}),
258
- })
259
- if (!res.ok) throw new Error('options')
260
- const optionsJSON = await res.json()
261
- const assertion = await startAuthentication({ optionsJSON })
262
- document.getElementById('passkey-response').value = JSON.stringify(assertion)
263
- document.getElementById('passkey-form').submit()
264
- } catch (e) {
265
- errEl.classList.remove('hidden')
266
- btn.disabled = false
267
- }
268
- })
269
- </script>
211
+ {{-- M12: script inline sem nonce é bloqueado por CSP `script-src 'self'` — extraído
212
+ para asset same-origin (compartilhado com mfa-challenge.edge). --}}
213
+ <script type="module" src="/authkit/assets/passkey_button.js"></script>
270
214
  @end
271
215
  </div>
272
216
  @end
@@ -43,7 +43,8 @@
43
43
  @if(passkeyAvailable && !noEnrollment)
44
44
  {{-- Passkey como alternativa ao código TOTP. O form é submetido por página
45
45
  inteira (não fetch) para que o 303 de volta ao client navegue o browser. --}}
46
- <form id="passkey-form" method="POST" action="/auth/interaction/{{ uid }}/passkey/verify" class="mt-4">
46
+ <form id="passkey-form" method="POST" action="/auth/interaction/{{ uid }}/passkey/verify"
47
+ data-options-url="/auth/interaction/{{ uid }}/passkey/options" class="mt-4">
47
48
  <input type="hidden" name="_csrf" value="{{ csrfToken }}">
48
49
  <input type="hidden" name="response" id="passkey-response">
49
50
  <input type="hidden" name="trustDevice" id="passkey-trust" value="">
@@ -72,37 +73,8 @@
72
73
  </div>
73
74
 
74
75
  @if(passkeyAvailable)
75
- <script type="module">
76
- import { startAuthentication } from '/authkit/assets/webauthn.js'
77
- const btn = document.getElementById('passkey-button')
78
- const errEl = document.getElementById('passkey-error')
79
- const csrf = document.querySelector('#passkey-form input[name="_csrf"]').value
80
- btn?.addEventListener('click', async () => {
81
- errEl.classList.add('hidden')
82
- btn.disabled = true
83
- try {
84
- // 1) Busca as opções de autenticação (challenge guardado na sessão server-side).
85
- const res = await fetch('/auth/interaction/{{ uid }}/passkey/options', {
86
- method: 'POST',
87
- headers: { 'content-type': 'application/json', 'x-csrf-token': csrf },
88
- body: JSON.stringify({}),
89
- })
90
- if (!res.ok) throw new Error('options')
91
- const optionsJSON = await res.json()
92
- // 2) Cerimônia no authenticator.
93
- const assertion = await startAuthentication({ optionsJSON })
94
- // 3) Submete a assertion como POST de página inteira (segue o 303).
95
- document.getElementById('passkey-response').value = JSON.stringify(assertion)
96
- // Carrega a escolha de "confiar neste dispositivo" para o form da passkey.
97
- const trustChk = document.querySelector('input[name="trustDevice"][type="checkbox"]')
98
- const trustHidden = document.getElementById('passkey-trust')
99
- if (trustHidden) trustHidden.value = trustChk && trustChk.checked ? 'on' : ''
100
- document.getElementById('passkey-form').submit()
101
- } catch (e) {
102
- errEl.classList.remove('hidden')
103
- btn.disabled = false
104
- }
105
- })
106
- </script>
76
+ {{-- M12: script inline sem nonce é bloqueado por CSP `script-src 'self'` — extraído
77
+ para asset same-origin (compartilhado com login.edge). --}}
78
+ <script type="module" src="/authkit/assets/passkey_button.js"></script>
107
79
  @end
108
80
  </body></html>
@@ -23,22 +23,12 @@
23
23
 
24
24
  Incluído view a view (não no partial de styles, que é GERADO por
25
25
  scripts/build_host_css.mjs e sobrescreveria a linha).
26
+
27
+ M12: era `<script>` INLINE — bloqueado por CSP `script-src 'self'` sem
28
+ nonce/hash (mesmo bug do splash de logout). Extraído para asset same-origin;
29
+ o texto do script não mudou, só o lugar onde mora.
26
30
  --}}
27
- <script>
28
- document.addEventListener('submit', function (event) {
29
- const form = event.target
30
- if (!(form instanceof HTMLFormElement)) return
31
- setTimeout(function () {
32
- if (event.defaultPrevented) return
33
- form
34
- .querySelectorAll('button[type="submit"], button:not([type]), input[type="submit"]')
35
- .forEach(function (el) {
36
- el.disabled = true
37
- })
38
- form.setAttribute('aria-busy', 'true')
39
- }, 0)
40
- })
41
- </script>
31
+ <script src="/authkit/assets/submit_lock.js"></script>
42
32
  <style>
43
33
  form[aria-busy] button[type='submit'],
44
34
  form[aria-busy] button:not([type]),
@@ -15,10 +15,5 @@ export default class ConsoleKeysController {
15
15
  /** GET {prefix}/api/keys — status da chave de assinatura managed (idade + política + ETA). */
16
16
  status(ctx: HttpContext): Promise<void | import("../key_rotation_actions.js").KeysStatus>;
17
17
  /** POST {prefix}/api/keys/rotate — { retire?, keep? } → rotaciona agora. */
18
- rotate(ctx: HttpContext): Promise<void | {
19
- rotated: true;
20
- newKid: string;
21
- retiredKids: string[];
22
- keptKids: string[];
23
- }>;
18
+ rotate(ctx: HttpContext): Promise<void | {}>;
24
19
  }
@@ -2,9 +2,25 @@ import '../augmentations.js';
2
2
  import { apiError } from '../admin_api/dto.js';
3
3
  import { buildKeysStatus, rotateNow } from '../key_rotation_actions.js';
4
4
  import { resolveRuntimeSettings } from '../runtime_settings.js';
5
+ import { requireSudo } from '../sudo_mode.js';
5
6
  function notSupported(ctx) {
6
7
  return ctx.response.status(501).send(apiError('not_implemented', 'jwks não é managed+store.'));
7
8
  }
9
+ /**
10
+ * Gate de sudo (M9): rotacionar a chave de assinatura managed é destrutivo o
11
+ * bastante (invalida tokens/JWKS na hora, dependendo da política de retire)
12
+ * para exigir reconfirmação recente de identidade — mesmo padrão JSON 403 de
13
+ * `console_users_controller.ts`/`console_sessions_controller.ts`.
14
+ */
15
+ async function gateSudo(ctx) {
16
+ const settings = await resolveRuntimeSettings(ctx);
17
+ const result = await requireSudo(ctx, settings);
18
+ if (result === true)
19
+ return null;
20
+ return ctx.response
21
+ .status(403)
22
+ .send(apiError('sudo_required', 'Identity confirmation required.'));
23
+ }
8
24
  /**
9
25
  * Endpoints JSON de gestão da chave de assinatura managed para o console admin React.
10
26
  *
@@ -34,6 +50,9 @@ export default class ConsoleKeysController {
34
50
  (await svc.keystoreAgeDays?.().catch(() => null)) === null) {
35
51
  return notSupported(ctx);
36
52
  }
53
+ const denied = await gateSudo(ctx);
54
+ if (denied)
55
+ return denied;
37
56
  return rotateNow(svc, ctx.request.body());
38
57
  }
39
58
  }
@@ -73,19 +73,7 @@ export default class ConsoleSessionsController {
73
73
  }[];
74
74
  }>;
75
75
  /** POST {prefix}/api/users/:id/revoke-sessions */
76
- userRevokeSessions(ctx: HttpContext): Promise<void | {
77
- sessions: number;
78
- grants: number;
79
- accessTokens: number;
80
- refreshTokens: number;
81
- ok: boolean;
82
- }>;
76
+ userRevokeSessions(ctx: HttpContext): Promise<void | {}>;
83
77
  /** POST {prefix}/api/sessions/revoke-all?accountId= */
84
- revokeAll(ctx: HttpContext): Promise<{
85
- sessions: number;
86
- grants: number;
87
- accessTokens: number;
88
- refreshTokens: number;
89
- ok: boolean;
90
- }>;
78
+ revokeAll(ctx: HttpContext): Promise<{}>;
91
79
  }
@@ -3,7 +3,25 @@ import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
3
3
  import { apiError, grantDto, sessionDto } from '../admin_api/dto.js';
4
4
  import { AdminSessionsService } from '../admin_sessions_service.js';
5
5
  import { sessionAccountValidator } from '../admin_validators.js';
6
+ import { resolveRuntimeSettings } from '../runtime_settings.js';
6
7
  import { enrichSessionsWithContext } from '../session_context.js';
8
+ import { requireSudo } from '../sudo_mode.js';
9
+ /**
10
+ * Gate de sudo (M9): revogar TODAS as sessões/grants de uma conta é
11
+ * destrutivo (derruba todo device logado, inclusive o próprio, se o alvo for
12
+ * o admin) — exige reconfirmação recente de identidade. Mesmo padrão de
13
+ * `console_users_controller.ts` (JSON 403 em vez do redirect que
14
+ * `requireSudo` monta para telas HTML).
15
+ */
16
+ async function gateSudo(ctx) {
17
+ const settings = await resolveRuntimeSettings(ctx);
18
+ const result = await requireSudo(ctx, settings);
19
+ if (result === true)
20
+ return null;
21
+ return ctx.response
22
+ .status(403)
23
+ .send(apiError('sudo_required', 'Identity confirmation required.'));
24
+ }
7
25
  /**
8
26
  * Endpoints JSON de sessões/grants do console admin React.
9
27
  *
@@ -68,6 +86,9 @@ export default class ConsoleSessionsController {
68
86
  }
69
87
  /** POST {prefix}/api/users/:id/revoke-sessions */
70
88
  async userRevokeSessions(ctx) {
89
+ const denied = await gateSudo(ctx);
90
+ if (denied)
91
+ return denied;
71
92
  const accountId = ctx.params.id.trim();
72
93
  if (!accountId) {
73
94
  return ctx.response.badRequest(apiError('invalid_request', 'O parâmetro id é obrigatório.'));
@@ -95,6 +116,9 @@ export default class ConsoleSessionsController {
95
116
  }
96
117
  /** POST {prefix}/api/sessions/revoke-all?accountId= */
97
118
  async revokeAll(ctx) {
119
+ const denied = await gateSudo(ctx);
120
+ if (denied)
121
+ return denied;
98
122
  const service = await ctx.containerResolver.make('authkit.server');
99
123
  const cfg = service.config;
100
124
  const actorId = ctx.session?.get(ACCOUNT_SESSION_KEY) ?? null;
@@ -80,14 +80,7 @@ export default class ConsoleUsersController {
80
80
  disabled: boolean;
81
81
  }>;
82
82
  /** POST {prefix}/api/users/:id/disable */
83
- disable(ctx: HttpContext): Promise<void | {
84
- id: string;
85
- email: string;
86
- name: string | null;
87
- avatarUrl: string | null;
88
- globalRoles: string[];
89
- disabled: boolean;
90
- }>;
83
+ disable(ctx: HttpContext): Promise<void | {}>;
91
84
  /** POST {prefix}/api/users/:id/enable */
92
85
  enable(ctx: HttpContext): Promise<void | {
93
86
  id: string;
@@ -98,13 +91,7 @@ export default class ConsoleUsersController {
98
91
  disabled: boolean;
99
92
  }>;
100
93
  /** POST {prefix}/api/users/:id/reset-password */
101
- resetPassword(ctx: HttpContext): Promise<void | {
102
- ok: boolean;
103
- email: string;
104
- }>;
94
+ resetPassword(ctx: HttpContext): Promise<void | {}>;
105
95
  /** DELETE {prefix}/api/users/:id */
106
- destroy(ctx: HttpContext): Promise<void | {
107
- ok: boolean;
108
- deleted: string;
109
- }>;
96
+ destroy(ctx: HttpContext): Promise<void | {}>;
110
97
  }
@@ -8,7 +8,29 @@ import { adminUserCreateValidator, adminUserRolesValidator } from '../admin_vali
8
8
  import { resolveRuntimeSettings } from '../runtime_settings.js';
9
9
  import { resolveEffectiveRolesCatalog } from '../runtime_toggles.js';
10
10
  import { enrichSessionsWithContext } from '../session_context.js';
11
+ import { requireSudo } from '../sudo_mode.js';
11
12
  const PAGE_SIZE = 20;
13
+ /**
14
+ * Gate de sudo (M9) para ações destrutivas do console (delete user, disable,
15
+ * reset-password): exige confirmação de identidade RECENTE do próprio admin
16
+ * autenticado (mesma infra usada pelo self-service — `requireSudo` +
17
+ * `/account/confirm`), não apenas a sessão de admin já autenticada.
18
+ *
19
+ * Difere do uso em telas HTML (`account_security_controller`): esta é uma API
20
+ * JSON consumida pela SPA do console, então o resultado "sudo ausente" vira
21
+ * 403 JSON em vez de seguir o redirect que `requireSudo` monta internamente
22
+ * (mesmo padrão de `account_api_controller.ts`, que descarta aquele redirect e
23
+ * responde o próprio 403).
24
+ */
25
+ async function gateSudo(ctx) {
26
+ const settings = await resolveRuntimeSettings(ctx);
27
+ const result = await requireSudo(ctx, settings);
28
+ if (result === true)
29
+ return null;
30
+ return ctx.response
31
+ .status(403)
32
+ .send(apiError('sudo_required', 'Identity confirmation required.'));
33
+ }
12
34
  /**
13
35
  * Endpoints JSON de usuários do console admin React.
14
36
  *
@@ -138,6 +160,11 @@ export default class ConsoleUsersController {
138
160
  }
139
161
  /** POST {prefix}/api/users/:id/disable */
140
162
  async disable(ctx) {
163
+ // Sudo (M9): desabilitar uma conta é destrutivo o bastante para exigir
164
+ // reconfirmação — mas `enable()` (reverter) não passa por aqui de propósito.
165
+ const denied = await gateSudo(ctx);
166
+ if (denied)
167
+ return denied;
141
168
  return this.#setStatus(ctx, true);
142
169
  }
143
170
  /** POST {prefix}/api/users/:id/enable */
@@ -170,6 +197,11 @@ export default class ConsoleUsersController {
170
197
  }
171
198
  /** POST {prefix}/api/users/:id/reset-password */
172
199
  async resetPassword(ctx) {
200
+ // Sudo (M9): dispara um e-mail de reset em nome do admin — reconfirmação
201
+ // recente antes de agir.
202
+ const denied = await gateSudo(ctx);
203
+ if (denied)
204
+ return denied;
173
205
  const service = await ctx.containerResolver.make('authkit.server');
174
206
  const cfg = service.config;
175
207
  const actorId = ctx.session?.get(ACCOUNT_SESSION_KEY) ?? null;
@@ -187,6 +219,11 @@ export default class ConsoleUsersController {
187
219
  }
188
220
  /** DELETE {prefix}/api/users/:id */
189
221
  async destroy(ctx) {
222
+ // Sudo (M9): deleção é irreversível (cascade) — a ação destrutiva por
223
+ // excelência que o audit apontou sem gate nenhum.
224
+ const denied = await gateSudo(ctx);
225
+ if (denied)
226
+ return denied;
190
227
  const service = await ctx.containerResolver.make('authkit.server');
191
228
  const cfg = service.config;
192
229
  const actorId = ctx.session?.get(ACCOUNT_SESSION_KEY) ?? null;
@@ -0,0 +1,48 @@
1
+ // Passkey autofill (conditional mediation) do login.edge — extraído do
2
+ // `<script type="module">` inline (M12: CSP `script-src 'self'` bloqueia
3
+ // script inline sem nonce/hash). A URL de options vem de `data-options-url`
4
+ // no form (renderizada pelo Edge como atributo HTML, não interpolada dentro
5
+ // do JS) em vez de `{{ uid }}` cru no meio do fetch.
6
+ (async () => {
7
+ try {
8
+ // SSR-safe: só roda no browser.
9
+ if (typeof window === 'undefined' || typeof navigator === 'undefined') return;
10
+
11
+ const form = document.getElementById('autofill-passkey-form');
12
+ if (!form || !window.PublicKeyCredential) return;
13
+
14
+ // Detecta suporte a conditional mediation.
15
+ const supported = await PublicKeyCredential.isConditionalMediationAvailable?.();
16
+ if (!supported) return;
17
+
18
+ const { startAuthentication } = await import('/authkit/assets/webauthn.js');
19
+ const csrf = form.querySelector('input[name="_csrf"]')?.value ?? '';
20
+ const optRes = await fetch(form.dataset.optionsUrl, {
21
+ method: 'POST',
22
+ headers: { 'content-type': 'application/json', 'x-csrf-token': csrf },
23
+ body: JSON.stringify({}),
24
+ });
25
+ if (!optRes.ok) return;
26
+ const optionsJSON = await optRes.json();
27
+ // Remove flag interna antes de passar ao browser.
28
+ delete optionsJSON._discoverable;
29
+
30
+ // AbortController: cancela ao navegar/desmontar.
31
+ const ac = new AbortController();
32
+ window.addEventListener('beforeunload', () => ac.abort(), { once: true });
33
+
34
+ const assertion = await startAuthentication(
35
+ {
36
+ optionsJSON,
37
+ useBrowserAutofill: true,
38
+ verifyBrowserAutofillInput: true,
39
+ },
40
+ ac.signal,
41
+ );
42
+
43
+ document.getElementById('autofill-passkey-response').value = JSON.stringify(assertion);
44
+ form.submit();
45
+ } catch {
46
+ // Fail-safe: abort, suporte ausente ou qualquer erro = login normal.
47
+ }
48
+ })();
@@ -0,0 +1,46 @@
1
+ // Botão "entrar com passkey" — compartilhado por login.edge (passwordless
2
+ // antes da senha) e mfa-challenge.edge (2º fator). Extraído do
3
+ // `<script type="module">` inline (M12: CSP `script-src 'self'` bloqueia
4
+ // script inline sem nonce/hash). A URL de options vem de `data-options-url`
5
+ // no form `#passkey-form` (atributo HTML, escapado pelo Edge) em vez de
6
+ // `{{ uid }}` cru interpolado dentro do JS.
7
+ //
8
+ // O bloco "trustDevice" só existe em mfa-challenge.edge (checkbox "confiar
9
+ // neste dispositivo" + hidden `#passkey-trust`); em login.edge os dois
10
+ // seletores dão `null` e o bloco vira no-op — por isso um único arquivo serve
11
+ // as duas views sem branch de view.
12
+ import { startAuthentication } from '/authkit/assets/webauthn.js';
13
+
14
+ const form = document.getElementById('passkey-form');
15
+ const btn = document.getElementById('passkey-button');
16
+ const errEl = document.getElementById('passkey-error');
17
+
18
+ if (form && btn) {
19
+ const csrf = form.querySelector('input[name="_csrf"]')?.value ?? '';
20
+
21
+ btn.addEventListener('click', async () => {
22
+ errEl?.classList.add('hidden');
23
+ btn.disabled = true;
24
+ try {
25
+ const res = await fetch(form.dataset.optionsUrl, {
26
+ method: 'POST',
27
+ headers: { 'content-type': 'application/json', 'x-csrf-token': csrf },
28
+ body: JSON.stringify({}),
29
+ });
30
+ if (!res.ok) throw new Error('options');
31
+ const optionsJSON = await res.json();
32
+ const assertion = await startAuthentication({ optionsJSON });
33
+ document.getElementById('passkey-response').value = JSON.stringify(assertion);
34
+
35
+ // Só em mfa-challenge.edge: propaga a escolha de "confiar neste dispositivo".
36
+ const trustChk = document.querySelector('input[name="trustDevice"][type="checkbox"]');
37
+ const trustHidden = document.getElementById('passkey-trust');
38
+ if (trustHidden) trustHidden.value = trustChk?.checked ? 'on' : '';
39
+
40
+ form.submit();
41
+ } catch (_e) {
42
+ errEl?.classList.remove('hidden');
43
+ btn.disabled = false;
44
+ }
45
+ });
46
+ }
@@ -0,0 +1,43 @@
1
+ // Registro de passkey (account/mfa.edge, "adicionar chave de acesso").
2
+ // Extraído do `<script type="module">` inline (M12: CSP `script-src 'self'`
3
+ // bloqueia script inline sem nonce/hash). csrf/URLs vêm de atributos
4
+ // `data-*` no botão `#passkey-add` (Edge escapa atributos HTML — ao contrário
5
+ // de dentro de um `<script>`, onde entidades não são decodificadas) em vez de
6
+ // interpolados como string literal dentro do JS.
7
+ import { startRegistration } from '/authkit/assets/webauthn.js';
8
+
9
+ const btn = document.getElementById('passkey-add');
10
+ const errEl = document.getElementById('passkey-error');
11
+
12
+ btn?.addEventListener('click', async () => {
13
+ const csrf = btn.dataset.csrf ?? '';
14
+ const optionsUrl = btn.dataset.optionsUrl;
15
+ const verifyUrl = btn.dataset.verifyUrl;
16
+
17
+ errEl?.classList.add('hidden');
18
+ btn.disabled = true;
19
+ try {
20
+ // 1) Opções de registro (challenge guardado na sessão server-side).
21
+ const optsRes = await fetch(optionsUrl, {
22
+ method: 'POST',
23
+ headers: { 'content-type': 'application/json', 'x-csrf-token': csrf },
24
+ body: JSON.stringify({}),
25
+ });
26
+ if (!optsRes.ok) throw new Error('options');
27
+ const optionsJSON = await optsRes.json();
28
+ // 2) Cerimônia de registro no authenticator.
29
+ const attResp = await startRegistration({ optionsJSON });
30
+ // 3) Verifica/persiste no servidor.
31
+ const verifyRes = await fetch(verifyUrl, {
32
+ method: 'POST',
33
+ headers: { 'content-type': 'application/json', 'x-csrf-token': csrf },
34
+ body: JSON.stringify({ response: attResp }),
35
+ });
36
+ if (!verifyRes.ok) throw new Error('verify');
37
+ // 4) Recarrega para mostrar a nova passkey.
38
+ window.location.reload();
39
+ } catch (_e) {
40
+ errEl?.classList.remove('hidden');
41
+ btn.disabled = false;
42
+ }
43
+ });
@@ -0,0 +1,39 @@
1
+ // Trava anti-duplo-submit dos forms clássicos (`method="POST"` com
2
+ // navegação) — incluída em TODA view do host-kit via
3
+ // `partials/submit_lock.edge`. Extraída do `<script>` inline (M12: CSP
4
+ // `script-src 'self'` bloqueia script inline sem nonce/hash). Sem nenhuma
5
+ // interpolação por request — o texto é idêntico ao que estava inline, só
6
+ // mudou de lugar.
7
+ //
8
+ // Por quê: os endpoints de interaction/conta disparam ações custosas (envio
9
+ // de e-mail, cerimônia WebAuthn, verificação de código) e, sem feedback, o
10
+ // usuário clica de novo — cada clique vira um POST duplicado e, nas rotas
11
+ // throttled, derruba o usuário no 429 por mera impaciência.
12
+ //
13
+ // Como:
14
+ // - Listener DELEGADO no `document` — cobre todo form, inclusive os montados
15
+ // depois (sem precisar marcar forms um a um).
16
+ // - A trava é agendada com `setTimeout(0)`: roda DEPOIS do dispatch do
17
+ // `submit`, quando o entry-list do form já foi construído (um botão de
18
+ // submit NOMEADO preserva seu valor no POST) e handlers que chamam
19
+ // `preventDefault` já rodaram (aí `defaultPrevented` evita travar à toa —
20
+ // sem navegação, sem botão morto).
21
+ // - Validação nativa que barra o submit nem dispara o evento — a trava nunca
22
+ // liga num form inválido.
23
+ // - `form.submit()` (JS puro, ex.: auto-submit de passkey) NÃO dispara
24
+ // `submit` — esses forms seguem destravados de propósito.
25
+ // - Não há "destravar": a resposta (redirect ou re-render de erro) chega como
26
+ // documento NOVO e a página remonta.
27
+ document.addEventListener('submit', (event) => {
28
+ const form = event.target;
29
+ if (!(form instanceof HTMLFormElement)) return;
30
+ setTimeout(() => {
31
+ if (event.defaultPrevented) return;
32
+ form
33
+ .querySelectorAll('button[type="submit"], button:not([type]), input[type="submit"]')
34
+ .forEach((el) => {
35
+ el.disabled = true;
36
+ });
37
+ form.setAttribute('aria-busy', 'true');
38
+ }, 0);
39
+ });
@@ -0,0 +1,39 @@
1
+ // Verificação WebAuthn genérica de account/confirm.edge — UM script para
2
+ // TODOS os blocos `kind: 'webauthn'` da tela (o import do
3
+ // @simplewebauthn/browser é caro e repeti-lo por método não traria nada).
4
+ // Extraído do `<script type="module">` inline (M12: CSP `script-src 'self'`
5
+ // bloqueia script inline sem nonce/hash). Já não interpolava nada por request
6
+ // (endpoint sai do `action` do form, csrf do hidden, mensagem de
7
+ // `data-message`) — só precisou sair do `<script>` inline para o asset.
8
+ import { startAuthentication } from '/authkit/assets/webauthn.js';
9
+
10
+ for (const block of document.querySelectorAll('[data-authkit-webauthn]')) {
11
+ const form = block.querySelector('[data-webauthn-form]');
12
+ const field = block.querySelector('[data-webauthn-response]');
13
+ const errorBox = block.querySelector('[data-webauthn-error]');
14
+
15
+ block.querySelector('[data-webauthn-start]')?.addEventListener('click', async () => {
16
+ try {
17
+ // Endpoint de options DERIVADO do descritor, não hardcoded.
18
+ const optionsResponse = await fetch(form.getAttribute('action') + '/options', {
19
+ method: 'POST',
20
+ headers: { 'x-csrf-token': form.querySelector('input[name="_csrf"]').value },
21
+ });
22
+ if (!optionsResponse.ok) throw new Error('authkit: options recusadas');
23
+
24
+ const optionsJSON = await optionsResponse.json();
25
+ const assertion = await startAuthentication({ optionsJSON });
26
+ // O handler lê `request.input('response')` como STRING e faz
27
+ // JSON.parse — daí o stringify aqui.
28
+ field.value = JSON.stringify(assertion);
29
+ form.submit();
30
+ } catch (error) {
31
+ // Falha no handshake (usuário cancelou, options recusadas, sem
32
+ // autenticador). Sem esta mensagem o botão simplesmente não faz
33
+ // nada e o usuário não tem como saber que deve tentar outro método.
34
+ console.error(error);
35
+ errorBox.textContent = errorBox.getAttribute('data-message');
36
+ errorBox.classList.remove('hidden');
37
+ }
38
+ });
39
+ }
@@ -0,0 +1,18 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ /**
3
+ * GET /authkit/assets/passkey_autofill.js
4
+ *
5
+ * Serve o script de autofill de passkey (conditional mediation) do
6
+ * `login.edge` a partir do próprio host — M12: `login.edge` embutia esse
7
+ * script como `<script type="module">` INLINE, bloqueado por CSP
8
+ * `script-src 'self'` (sem nonce/hash). Mesmo tratamento same-origin de
9
+ * `logout_asset_controller.ts`/`webauthn_asset_controller.ts`.
10
+ *
11
+ * SEM AUTENTICAÇÃO, e é intencional: carregado na tela de login, antes de
12
+ * existir qualquer sessão.
13
+ */
14
+ export default class PasskeyAutofillAssetController {
15
+ handle(ctx: HttpContext): Promise<unknown>;
16
+ }
17
+ /** @internal Limpa o cache do asset — só para os testes exercitarem 200 e 404 no mesmo processo. */
18
+ export declare function resetPasskeyAutofillAssetCache(): void;
@@ -0,0 +1,30 @@
1
+ import { createStaticAssetHandler } from './static_asset_controller.js';
2
+ /**
3
+ * URL do asset, ANCORADA em `import.meta.url` (nunca relativa ao cwd) — mesmo
4
+ * motivo de `webauthn_asset_controller.ts`: `pnpm deploy --legacy` remonta a
5
+ * árvore de `node_modules` num diretório novo, e um caminho relativo ao cwd
6
+ * do host apontaria para o lugar errado em produção.
7
+ */
8
+ const ASSET_URL = new URL('../assets/passkey_autofill.js', import.meta.url);
9
+ const handler = createStaticAssetHandler(ASSET_URL);
10
+ /**
11
+ * GET /authkit/assets/passkey_autofill.js
12
+ *
13
+ * Serve o script de autofill de passkey (conditional mediation) do
14
+ * `login.edge` a partir do próprio host — M12: `login.edge` embutia esse
15
+ * script como `<script type="module">` INLINE, bloqueado por CSP
16
+ * `script-src 'self'` (sem nonce/hash). Mesmo tratamento same-origin de
17
+ * `logout_asset_controller.ts`/`webauthn_asset_controller.ts`.
18
+ *
19
+ * SEM AUTENTICAÇÃO, e é intencional: carregado na tela de login, antes de
20
+ * existir qualquer sessão.
21
+ */
22
+ export default class PasskeyAutofillAssetController {
23
+ async handle(ctx) {
24
+ return handler.handle(ctx);
25
+ }
26
+ }
27
+ /** @internal Limpa o cache do asset — só para os testes exercitarem 200 e 404 no mesmo processo. */
28
+ export function resetPasskeyAutofillAssetCache() {
29
+ handler.resetCache();
30
+ }
@@ -0,0 +1,17 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ /**
3
+ * GET /authkit/assets/passkey_button.js
4
+ *
5
+ * Serve o botão "entrar com passkey" a partir do próprio host — compartilhado
6
+ * por `login.edge` (passwordless antes da senha) e `mfa-challenge.edge` (2º
7
+ * fator). M12: as duas views embutiam esse script como `<script
8
+ * type="module">` INLINE, bloqueado por CSP `script-src 'self'`.
9
+ *
10
+ * SEM AUTENTICAÇÃO, e é intencional: carregado nas telas de login/MFA, antes
11
+ * (ou durante) o handshake de autenticação.
12
+ */
13
+ export default class PasskeyButtonAssetController {
14
+ handle(ctx: HttpContext): Promise<unknown>;
15
+ }
16
+ /** @internal Limpa o cache do asset — só para os testes exercitarem 200 e 404 no mesmo processo. */
17
+ export declare function resetPasskeyButtonAssetCache(): void;
@@ -0,0 +1,24 @@
1
+ import { createStaticAssetHandler } from './static_asset_controller.js';
2
+ /** Ver o docblock de `passkey_autofill_asset_controller.ts` sobre `import.meta.url`. */
3
+ const ASSET_URL = new URL('../assets/passkey_button.js', import.meta.url);
4
+ const handler = createStaticAssetHandler(ASSET_URL);
5
+ /**
6
+ * GET /authkit/assets/passkey_button.js
7
+ *
8
+ * Serve o botão "entrar com passkey" a partir do próprio host — compartilhado
9
+ * por `login.edge` (passwordless antes da senha) e `mfa-challenge.edge` (2º
10
+ * fator). M12: as duas views embutiam esse script como `<script
11
+ * type="module">` INLINE, bloqueado por CSP `script-src 'self'`.
12
+ *
13
+ * SEM AUTENTICAÇÃO, e é intencional: carregado nas telas de login/MFA, antes
14
+ * (ou durante) o handshake de autenticação.
15
+ */
16
+ export default class PasskeyButtonAssetController {
17
+ async handle(ctx) {
18
+ return handler.handle(ctx);
19
+ }
20
+ }
21
+ /** @internal Limpa o cache do asset — só para os testes exercitarem 200 e 404 no mesmo processo. */
22
+ export function resetPasskeyButtonAssetCache() {
23
+ handler.resetCache();
24
+ }
@@ -0,0 +1,16 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ /**
3
+ * GET /authkit/assets/passkey_register.js
4
+ *
5
+ * Serve o script de registro de passkey (account/mfa.edge, "adicionar chave
6
+ * de acesso") a partir do próprio host. M12: essa view embutia o script como
7
+ * `<script type="module">` INLINE, bloqueado por CSP `script-src 'self'`.
8
+ *
9
+ * Fica FORA do prefixo do console admin (opt-in): a tela de MFA da conta
10
+ * (`/account/mfa`) existe em qualquer host, com ou sem console.
11
+ */
12
+ export default class PasskeyRegisterAssetController {
13
+ handle(ctx: HttpContext): Promise<unknown>;
14
+ }
15
+ /** @internal Limpa o cache do asset — só para os testes exercitarem 200 e 404 no mesmo processo. */
16
+ export declare function resetPasskeyRegisterAssetCache(): void;
@@ -0,0 +1,23 @@
1
+ import { createStaticAssetHandler } from './static_asset_controller.js';
2
+ /** Ver o docblock de `passkey_autofill_asset_controller.ts` sobre `import.meta.url`. */
3
+ const ASSET_URL = new URL('../assets/passkey_register.js', import.meta.url);
4
+ const handler = createStaticAssetHandler(ASSET_URL);
5
+ /**
6
+ * GET /authkit/assets/passkey_register.js
7
+ *
8
+ * Serve o script de registro de passkey (account/mfa.edge, "adicionar chave
9
+ * de acesso") a partir do próprio host. M12: essa view embutia o script como
10
+ * `<script type="module">` INLINE, bloqueado por CSP `script-src 'self'`.
11
+ *
12
+ * Fica FORA do prefixo do console admin (opt-in): a tela de MFA da conta
13
+ * (`/account/mfa`) existe em qualquer host, com ou sem console.
14
+ */
15
+ export default class PasskeyRegisterAssetController {
16
+ async handle(ctx) {
17
+ return handler.handle(ctx);
18
+ }
19
+ }
20
+ /** @internal Limpa o cache do asset — só para os testes exercitarem 200 e 404 no mesmo processo. */
21
+ export function resetPasskeyRegisterAssetCache() {
22
+ handler.resetCache();
23
+ }
@@ -0,0 +1,21 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ /**
3
+ * Fábrica compartilhada do padrão "sirva um JS same-origin, cacheado,
4
+ * memoizado em processo" usado por `webauthn_asset_controller.ts` e
5
+ * `logout_asset_controller.ts` (ver o CHANGELOG `0.61.3` para a origem do
6
+ * padrão: CSP `script-src 'self'` bloqueia `<script>` inline sem nonce/hash, e
7
+ * um asset same-origin é permitido por `'self'` sem depender de nenhum dos
8
+ * dois).
9
+ *
10
+ * Extraída para os assets do M12 (login/MFA/confirm passkey flows +
11
+ * anti-duplo-submit) em vez de copiar a mesma leitura+cache+404 mais cinco
12
+ * vezes — os dois controllers originais continuam como estavam (o contrato
13
+ * público deles, incl. `resetXAssetCache`, é usado pelos testes existentes),
14
+ * mas todo asset NOVO passa por aqui.
15
+ */
16
+ export interface AssetHandler {
17
+ handle(ctx: HttpContext): Promise<unknown>;
18
+ /** Limpa o cache em memória — só para os testes exercitarem os dois ramos (200/404) no mesmo processo. */
19
+ resetCache(): void;
20
+ }
21
+ export declare function createStaticAssetHandler(assetUrl: URL): AssetHandler;
@@ -0,0 +1,29 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ export function createStaticAssetHandler(assetUrl) {
3
+ /** `null` = ainda não lido. `false` = lido e ausente (404 memoizado). */
4
+ let cached = null;
5
+ return {
6
+ async handle(ctx) {
7
+ if (cached === null) {
8
+ try {
9
+ cached = await readFile(assetUrl);
10
+ }
11
+ catch {
12
+ cached = false;
13
+ }
14
+ }
15
+ if (cached === false) {
16
+ // 404 limpo: asset não incluído no pacote (build incompleto). As views
17
+ // que dependem dele degradam sem JS em vez de estourar 500.
18
+ return ctx.response.notFound();
19
+ }
20
+ return ctx.response
21
+ .type('text/javascript')
22
+ .header('Cache-Control', 'public, max-age=31536000, immutable')
23
+ .send(cached);
24
+ },
25
+ resetCache() {
26
+ cached = null;
27
+ },
28
+ };
29
+ }
@@ -0,0 +1,17 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ /**
3
+ * GET /authkit/assets/submit_lock.js
4
+ *
5
+ * Serve a trava anti-duplo-submit incluída em TODA view do host-kit
6
+ * (`partials/submit_lock.edge`) a partir do próprio host. M12: essa partial
7
+ * embutia o script como `<script>` INLINE (sem `type="module"`, mas mesmo
8
+ * bloqueio de CSP `script-src 'self'` sem nonce/hash).
9
+ *
10
+ * Carregado em toda tela do fluxo (login, signup, MFA, conta) — SEM
11
+ * AUTENTICAÇÃO, e é intencional, pelo mesmo motivo do bundle WebAuthn.
12
+ */
13
+ export default class SubmitLockAssetController {
14
+ handle(ctx: HttpContext): Promise<unknown>;
15
+ }
16
+ /** @internal Limpa o cache do asset — só para os testes exercitarem 200 e 404 no mesmo processo. */
17
+ export declare function resetSubmitLockAssetCache(): void;
@@ -0,0 +1,24 @@
1
+ import { createStaticAssetHandler } from './static_asset_controller.js';
2
+ /** Ver o docblock de `passkey_autofill_asset_controller.ts` sobre `import.meta.url`. */
3
+ const ASSET_URL = new URL('../assets/submit_lock.js', import.meta.url);
4
+ const handler = createStaticAssetHandler(ASSET_URL);
5
+ /**
6
+ * GET /authkit/assets/submit_lock.js
7
+ *
8
+ * Serve a trava anti-duplo-submit incluída em TODA view do host-kit
9
+ * (`partials/submit_lock.edge`) a partir do próprio host. M12: essa partial
10
+ * embutia o script como `<script>` INLINE (sem `type="module"`, mas mesmo
11
+ * bloqueio de CSP `script-src 'self'` sem nonce/hash).
12
+ *
13
+ * Carregado em toda tela do fluxo (login, signup, MFA, conta) — SEM
14
+ * AUTENTICAÇÃO, e é intencional, pelo mesmo motivo do bundle WebAuthn.
15
+ */
16
+ export default class SubmitLockAssetController {
17
+ async handle(ctx) {
18
+ return handler.handle(ctx);
19
+ }
20
+ }
21
+ /** @internal Limpa o cache do asset — só para os testes exercitarem 200 e 404 no mesmo processo. */
22
+ export function resetSubmitLockAssetCache() {
23
+ handler.resetCache();
24
+ }
@@ -0,0 +1,14 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ /**
3
+ * GET /authkit/assets/webauthn_confirm.js
4
+ *
5
+ * Serve o script de verificação WebAuthn genérica de `account/confirm.edge`
6
+ * (um handler para todos os blocos `kind: 'webauthn'` da tela) a partir do
7
+ * próprio host. M12: essa view embutia o script como `<script
8
+ * type="module">` INLINE, bloqueado por CSP `script-src 'self'`.
9
+ */
10
+ export default class WebauthnConfirmAssetController {
11
+ handle(ctx: HttpContext): Promise<unknown>;
12
+ }
13
+ /** @internal Limpa o cache do asset — só para os testes exercitarem 200 e 404 no mesmo processo. */
14
+ export declare function resetWebauthnConfirmAssetCache(): void;
@@ -0,0 +1,21 @@
1
+ import { createStaticAssetHandler } from './static_asset_controller.js';
2
+ /** Ver o docblock de `passkey_autofill_asset_controller.ts` sobre `import.meta.url`. */
3
+ const ASSET_URL = new URL('../assets/webauthn_confirm.js', import.meta.url);
4
+ const handler = createStaticAssetHandler(ASSET_URL);
5
+ /**
6
+ * GET /authkit/assets/webauthn_confirm.js
7
+ *
8
+ * Serve o script de verificação WebAuthn genérica de `account/confirm.edge`
9
+ * (um handler para todos os blocos `kind: 'webauthn'` da tela) a partir do
10
+ * próprio host. M12: essa view embutia o script como `<script
11
+ * type="module">` INLINE, bloqueado por CSP `script-src 'self'`.
12
+ */
13
+ export default class WebauthnConfirmAssetController {
14
+ async handle(ctx) {
15
+ return handler.handle(ctx);
16
+ }
17
+ }
18
+ /** @internal Limpa o cache do asset — só para os testes exercitarem 200 e 404 no mesmo processo. */
19
+ export function resetWebauthnConfirmAssetCache() {
20
+ handler.resetCache();
21
+ }
@@ -182,6 +182,11 @@ const C = {
182
182
  accountConfirm: () => import('./controllers/account_confirm_controller.js'),
183
183
  webauthnAsset: () => import('./controllers/webauthn_asset_controller.js'),
184
184
  logoutAsset: () => import('./controllers/logout_asset_controller.js'),
185
+ passkeyAutofillAsset: () => import('./controllers/passkey_autofill_asset_controller.js'),
186
+ passkeyButtonAsset: () => import('./controllers/passkey_button_asset_controller.js'),
187
+ passkeyRegisterAsset: () => import('./controllers/passkey_register_asset_controller.js'),
188
+ webauthnConfirmAsset: () => import('./controllers/webauthn_confirm_asset_controller.js'),
189
+ submitLockAsset: () => import('./controllers/submit_lock_asset_controller.js'),
185
190
  // Console React JSON API (session-authed, under {prefix}/api/*).
186
191
  consoleShell: () => import('./admin_console/admin_shell_controller.js'),
187
192
  consoleOverview: () => import('./admin_console/console_overview_controller.js'),
@@ -392,6 +397,22 @@ export function registerAuthHost(router, opts = {}) {
392
397
  // pode usar script inline: o CSP bloqueia sem nonce/hash e o logout fica
393
398
  // preso em "Saindo / Encerrando sua sessão…".
394
399
  router.get('/authkit/assets/logout.js', [C.logoutAsset]).as('authkit.assets.logout');
400
+ // M12: as views abaixo embutiam `<script type="module">`/`<script>` INLINE
401
+ // — bloqueado por CSP `script-src 'self'` sem nonce/hash, mesmo bug do
402
+ // logout acima. Mesmo tratamento: extraídos para assets same-origin.
403
+ router
404
+ .get('/authkit/assets/passkey_autofill.js', [C.passkeyAutofillAsset])
405
+ .as('authkit.assets.passkeyAutofill');
406
+ router
407
+ .get('/authkit/assets/passkey_button.js', [C.passkeyButtonAsset])
408
+ .as('authkit.assets.passkeyButton');
409
+ router
410
+ .get('/authkit/assets/passkey_register.js', [C.passkeyRegisterAsset])
411
+ .as('authkit.assets.passkeyRegister');
412
+ router
413
+ .get('/authkit/assets/webauthn_confirm.js', [C.webauthnConfirmAsset])
414
+ .as('authkit.assets.webauthnConfirm');
415
+ router.get('/authkit/assets/submit_lock.js', [C.submitLockAsset]).as('authkit.assets.submitLock');
395
416
  // Provider OIDC (wildcard + root) — o que registerOidcRoutes fazia.
396
417
  router.any(`${mount}/*`, [C.oidc]).as('authkit.oidc.wildcard');
397
418
  router.any(mount, [C.oidc]).as('authkit.oidc.root');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "0.65.0",
3
+ "version": "0.65.2",
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",
@@ -66,7 +66,7 @@
66
66
  "@adonisjs/drive": "^4.0.0",
67
67
  "@adonisjs/lock": "^2.1.0",
68
68
  "@adonisjs/lucid": "^22.4.2",
69
- "@adonisjs/redis": "^9.2.0 || ^10.0.0",
69
+ "@adonisjs/redis": "^9.2.0 || ^10.0.0 || ^11.0.0",
70
70
  "@adonisjs/session": "^8.1.0",
71
71
  "@adonisjs/shield": "^9.0.0",
72
72
  "@vinejs/vine": "^4.4.0",