@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.
- package/build/host/views/account/confirm.edge +3 -34
- package/build/host/views/account/mfa.edge +8 -34
- package/build/host/views/login.edge +10 -66
- package/build/host/views/mfa-challenge.edge +5 -33
- package/build/host/views/partials/submit_lock.edge +5 -15
- package/build/src/host/admin_console/console_keys_controller.d.ts +1 -6
- package/build/src/host/admin_console/console_keys_controller.js +19 -0
- package/build/src/host/admin_console/console_sessions_controller.d.ts +2 -14
- package/build/src/host/admin_console/console_sessions_controller.js +24 -0
- package/build/src/host/admin_console/console_users_controller.d.ts +3 -16
- package/build/src/host/admin_console/console_users_controller.js +37 -0
- package/build/src/host/assets/passkey_autofill.js +48 -0
- package/build/src/host/assets/passkey_button.js +46 -0
- package/build/src/host/assets/passkey_register.js +43 -0
- package/build/src/host/assets/submit_lock.js +39 -0
- package/build/src/host/assets/webauthn_confirm.js +39 -0
- package/build/src/host/controllers/passkey_autofill_asset_controller.d.ts +18 -0
- package/build/src/host/controllers/passkey_autofill_asset_controller.js +30 -0
- package/build/src/host/controllers/passkey_button_asset_controller.d.ts +17 -0
- package/build/src/host/controllers/passkey_button_asset_controller.js +24 -0
- package/build/src/host/controllers/passkey_register_asset_controller.d.ts +16 -0
- package/build/src/host/controllers/passkey_register_asset_controller.js +23 -0
- package/build/src/host/controllers/static_asset_controller.d.ts +21 -0
- package/build/src/host/controllers/static_asset_controller.js +29 -0
- package/build/src/host/controllers/submit_lock_asset_controller.d.ts +17 -0
- package/build/src/host/controllers/submit_lock_asset_controller.js +24 -0
- package/build/src/host/controllers/webauthn_confirm_asset_controller.d.ts +14 -0
- package/build/src/host/controllers/webauthn_confirm_asset_controller.js +21 -0
- package/build/src/host/register_auth_host.js +21 -0
- 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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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"
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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"
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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"
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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.
|
|
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",
|