@adonis-agora/authkit-server 0.75.0 → 0.77.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -0
- package/build/host/views/account/apps.edge +30 -0
- package/build/host/views/agents/consent.edge +60 -0
- package/build/host/views/agents/done.edge +24 -0
- package/build/host/views/consent.edge +9 -1
- package/build/host/views/mfa-challenge.edge +42 -3
- package/build/host/views/partials/styles.edge +1 -1
- package/build/index.d.ts +18 -1
- package/build/index.js +9 -1
- package/build/providers/authkit_server_provider.js +75 -50
- package/build/src/accounts/account_store.d.ts +2 -0
- package/build/src/accounts/lucid_account_store.js +2 -0
- package/build/src/adapters/adapter_contract.d.ts +2 -0
- package/build/src/adapters/database_adapter.d.ts +1 -0
- package/build/src/adapters/database_adapter.js +26 -0
- package/build/src/adapters/redis_adapter.d.ts +1 -0
- package/build/src/adapters/redis_adapter.js +30 -0
- package/build/src/agents/agent_identity.d.ts +33 -0
- package/build/src/agents/agent_identity.js +156 -0
- package/build/src/agents/config.d.ts +99 -0
- package/build/src/agents/config.js +101 -0
- package/build/src/agents/delegation_service.d.ts +154 -0
- package/build/src/agents/delegation_service.js +394 -0
- package/build/src/agents/delegation_store.d.ts +93 -0
- package/build/src/agents/delegation_store.js +222 -0
- package/build/src/agents/middleware.d.ts +73 -0
- package/build/src/agents/middleware.js +113 -0
- package/build/src/agents/protocol.d.ts +62 -0
- package/build/src/agents/protocol.js +51 -0
- package/build/src/agents/runtime.d.ts +36 -0
- package/build/src/agents/runtime.js +65 -0
- package/build/src/agents/signer.d.ts +34 -0
- package/build/src/agents/signer.js +70 -0
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/audit/audit_sink.js +5 -0
- package/build/src/controllers/authorization_server_metadata_controller.d.ts +10 -0
- package/build/src/controllers/authorization_server_metadata_controller.js +20 -0
- package/build/src/define_config.d.ts +50 -3
- package/build/src/define_config.js +42 -1
- package/build/src/host/account_api/account_api_controller.d.ts +12 -0
- package/build/src/host/account_api/account_api_controller.js +40 -0
- package/build/src/host/admin_api/dto.d.ts +1 -1
- package/build/src/host/admin_sessions_service.d.ts +2 -0
- package/build/src/host/admin_sessions_service.js +18 -0
- package/build/src/host/auth_host_config.d.ts +4 -0
- package/build/src/host/client_names.d.ts +11 -0
- package/build/src/host/client_names.js +12 -0
- package/build/src/host/controllers/account_apps_controller.d.ts +2 -0
- package/build/src/host/controllers/account_apps_controller.js +39 -3
- package/build/src/host/controllers/account_orgs_controller.js +2 -1
- package/build/src/host/controllers/account_security_controller.js +8 -4
- package/build/src/host/controllers/account_session_controller.js +2 -1
- package/build/src/host/controllers/agent_consent_controller.d.ts +17 -0
- package/build/src/host/controllers/agent_consent_controller.js +138 -0
- package/build/src/host/controllers/agent_oauth_controller.d.ts +23 -0
- package/build/src/host/controllers/agent_oauth_controller.js +110 -0
- package/build/src/host/controllers/interaction_controller.d.ts +13 -1
- package/build/src/host/controllers/interaction_controller.js +380 -24
- package/build/src/host/csrf.d.ts +8 -18
- package/build/src/host/csrf.js +28 -2
- package/build/src/host/custom_login.d.ts +36 -0
- package/build/src/host/custom_login.js +76 -0
- package/build/src/host/custom_login_completion.d.ts +4 -0
- package/build/src/host/custom_login_completion.js +33 -0
- package/build/src/host/custom_mfa.d.ts +77 -0
- package/build/src/host/custom_mfa.js +268 -0
- package/build/src/host/i18n.d.ts +58 -0
- package/build/src/host/i18n.js +64 -6
- package/build/src/host/idp_session_bridge.d.ts +5 -3
- package/build/src/host/idp_session_bridge.js +5 -3
- package/build/src/host/oidc_rp_guard.d.ts +5 -1
- package/build/src/host/oidc_rp_guard.js +15 -2
- package/build/src/host/persistent_rp_session.d.ts +8 -0
- package/build/src/host/persistent_rp_session.js +74 -0
- package/build/src/host/redirect_exact.d.ts +12 -0
- package/build/src/host/redirect_exact.js +17 -0
- package/build/src/host/register_auth_host.js +60 -7
- package/build/src/host/request_url.d.ts +10 -0
- package/build/src/host/request_url.js +17 -0
- package/build/src/host/sudo/methods/magic_link.js +2 -1
- package/build/src/host/sudo/runtime.js +3 -2
- package/build/src/host/sudo_mode.js +4 -4
- package/build/src/host/whatsapp_code_sender.d.ts +20 -0
- package/build/src/host/whatsapp_code_sender.js +9 -0
- package/build/src/host/whatsapp_senders/http.d.ts +5 -0
- package/build/src/host/whatsapp_senders/http.js +33 -0
- package/build/src/host/whatsapp_senders/meta_whatsapp_code_sender.d.ts +20 -0
- package/build/src/host/whatsapp_senders/meta_whatsapp_code_sender.js +51 -0
- package/build/src/host/whatsapp_senders/whatsmiau_code_sender.d.ts +14 -0
- package/build/src/host/whatsapp_senders/whatsmiau_code_sender.js +36 -0
- package/build/src/mcp/mcp_oauth.d.ts +85 -0
- package/build/src/mcp/mcp_oauth.js +154 -0
- package/build/src/provider/build_provider.js +15 -1
- package/build/src/provider/oidc_service.d.ts +8 -0
- package/build/src/provider/oidc_service.js +16 -2
- package/build/src/schema/ensure.js +92 -0
- package/build/stubs/ui/react/pages/consent.tsx +17 -1
- package/build/stubs/ui/react/pages/mfa-challenge.tsx +207 -37
- package/package.json +2 -1
- package/stubs/ui/react/pages/consent.tsx +17 -1
- package/stubs/ui/react/pages/mfa-challenge.tsx +207 -37
|
@@ -9,14 +9,20 @@ export default function AuthkitConsent({
|
|
|
9
9
|
params,
|
|
10
10
|
csrfToken,
|
|
11
11
|
brand,
|
|
12
|
+
clientName,
|
|
13
|
+
scopes = [],
|
|
12
14
|
}: {
|
|
13
15
|
uid: string;
|
|
14
16
|
params: { client_id: string };
|
|
15
17
|
csrfToken: string;
|
|
16
18
|
brand?: AuthBrand;
|
|
19
|
+
/** O nome que o client registrou (ex.: "Claude Code"); sem ele, o `client_id`. */
|
|
20
|
+
clientName?: string;
|
|
21
|
+
/** Os escopos pedidos, com o rótulo de cada um. */
|
|
22
|
+
scopes?: { id: string; label: string }[];
|
|
17
23
|
}) {
|
|
18
24
|
const accent = brand?.accent ?? '#111827';
|
|
19
|
-
const appName =
|
|
25
|
+
const appName = clientName ?? params.client_id;
|
|
20
26
|
|
|
21
27
|
return (
|
|
22
28
|
<AuthShell brand={brand}>
|
|
@@ -26,6 +32,16 @@ export default function AuthkitConsent({
|
|
|
26
32
|
<p className="mt-2 text-sm text-gray-600">
|
|
27
33
|
O app <strong>{appName}</strong> quer acessar sua conta.
|
|
28
34
|
</p>
|
|
35
|
+
{scopes.length > 0 && (
|
|
36
|
+
<>
|
|
37
|
+
<p className="mt-4 text-sm font-medium text-gray-900">Ele vai poder:</p>
|
|
38
|
+
<ul className="mt-2 list-inside list-disc space-y-1 text-sm text-gray-600">
|
|
39
|
+
{scopes.map((scope) => (
|
|
40
|
+
<li key={scope.id}>{scope.label}</li>
|
|
41
|
+
))}
|
|
42
|
+
</ul>
|
|
43
|
+
</>
|
|
44
|
+
)}
|
|
29
45
|
|
|
30
46
|
<button
|
|
31
47
|
type="submit"
|
|
@@ -2,34 +2,124 @@
|
|
|
2
2
|
exports({ to: app.makePath('inertia/pages/authkit/mfa-challenge.tsx') });
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
-
import
|
|
5
|
+
import { useRef, useState } from 'react';
|
|
6
|
+
import AuthShell, { type AuthBrand } from '../../components/auth_shell';
|
|
7
|
+
|
|
8
|
+
interface CustomMethod {
|
|
9
|
+
id: string;
|
|
10
|
+
factorId: string;
|
|
11
|
+
label: string;
|
|
12
|
+
fields?: {
|
|
13
|
+
name: string;
|
|
14
|
+
label: string;
|
|
15
|
+
type?: 'text' | 'password';
|
|
16
|
+
inputMode?: 'numeric' | 'text';
|
|
17
|
+
autoComplete?: string;
|
|
18
|
+
}[];
|
|
19
|
+
requiresBegin: boolean;
|
|
20
|
+
started: boolean;
|
|
21
|
+
beginUrl: string;
|
|
22
|
+
verifyUrl: string;
|
|
23
|
+
}
|
|
6
24
|
|
|
7
25
|
const inputClass =
|
|
8
|
-
'w-full rounded-lg border border-gray-300 px-3 py-2 text-
|
|
26
|
+
'w-full rounded-lg border border-gray-300 px-3 py-2 text-base outline-none transition focus:border-gray-900 focus:ring-2 focus:ring-gray-900';
|
|
27
|
+
const buttonClass =
|
|
28
|
+
'w-full rounded-lg bg-gray-900 px-3 py-2.5 text-sm font-semibold text-white transition hover:opacity-90 disabled:opacity-50';
|
|
29
|
+
const secondaryButtonClass =
|
|
30
|
+
'w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:opacity-50';
|
|
9
31
|
|
|
10
32
|
export default function AuthkitMfaChallenge({
|
|
11
33
|
uid,
|
|
12
34
|
csrfToken,
|
|
13
35
|
error,
|
|
36
|
+
brand,
|
|
37
|
+
messages = {},
|
|
38
|
+
noEnrollment = false,
|
|
39
|
+
otpLocked = false,
|
|
40
|
+
totpAvailable = true,
|
|
41
|
+
passkeyAvailable = false,
|
|
42
|
+
trustedDevicesEnabled = false,
|
|
43
|
+
trustedDeviceDays = 30,
|
|
44
|
+
customMfaMethods = [],
|
|
45
|
+
completedMfaMethods = [],
|
|
46
|
+
requiredMfaFactors = 2,
|
|
14
47
|
}: {
|
|
15
48
|
uid: string;
|
|
16
49
|
csrfToken: string;
|
|
17
50
|
error?: string;
|
|
51
|
+
brand?: AuthBrand;
|
|
52
|
+
messages?: Record<string, string>;
|
|
53
|
+
noEnrollment?: boolean;
|
|
54
|
+
otpLocked?: boolean;
|
|
55
|
+
totpAvailable?: boolean;
|
|
56
|
+
passkeyAvailable?: boolean;
|
|
57
|
+
trustedDevicesEnabled?: boolean;
|
|
58
|
+
trustedDeviceDays?: number;
|
|
59
|
+
customMfaMethods?: CustomMethod[];
|
|
60
|
+
completedMfaMethods?: string[];
|
|
61
|
+
requiredMfaFactors?: number;
|
|
18
62
|
}) {
|
|
63
|
+
const [passkeyPending, setPasskeyPending] = useState(false);
|
|
64
|
+
const [passkeyError, setPasskeyError] = useState(false);
|
|
65
|
+
const [trustDevice, setTrustDevice] = useState(false);
|
|
66
|
+
const passkeyForm = useRef<HTMLFormElement>(null);
|
|
67
|
+
const passkeyResponse = useRef<HTMLInputElement>(null);
|
|
68
|
+
const t = (key: string, fallback: string) => messages[`mfa_challenge.${key}`] ?? fallback;
|
|
69
|
+
const showTotp = !noEnrollment && !otpLocked && totpAvailable;
|
|
70
|
+
const showPasskey = !noEnrollment && passkeyAvailable;
|
|
71
|
+
const remainingMethods = noEnrollment
|
|
72
|
+
? []
|
|
73
|
+
: customMfaMethods.filter((method) => !completedMfaMethods.includes(method.id));
|
|
74
|
+
|
|
75
|
+
async function authenticatePasskey() {
|
|
76
|
+
setPasskeyPending(true);
|
|
77
|
+
setPasskeyError(false);
|
|
78
|
+
try {
|
|
79
|
+
const result = await fetch(`/auth/interaction/${uid}/passkey/options`, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { 'content-type': 'application/json', 'x-csrf-token': csrfToken },
|
|
82
|
+
body: JSON.stringify({}),
|
|
83
|
+
});
|
|
84
|
+
if (!result.ok) throw new Error('Passkey options failed');
|
|
85
|
+
const moduleUrl = '/authkit/assets/webauthn.js';
|
|
86
|
+
const { startAuthentication } = await import(/* @vite-ignore */ moduleUrl);
|
|
87
|
+
const assertion = await startAuthentication({ optionsJSON: await result.json() });
|
|
88
|
+
if (!passkeyResponse.current || !passkeyForm.current) throw new Error('Missing passkey form');
|
|
89
|
+
passkeyResponse.current.value = JSON.stringify(assertion);
|
|
90
|
+
passkeyForm.current.submit();
|
|
91
|
+
} catch {
|
|
92
|
+
setPasskeyError(true);
|
|
93
|
+
setPasskeyPending(false);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
19
97
|
return (
|
|
20
|
-
<AuthShell>
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
98
|
+
<AuthShell brand={brand}>
|
|
99
|
+
<h1 className="text-xl font-semibold text-gray-900">
|
|
100
|
+
{t('title', 'Verificação multifator')}
|
|
101
|
+
</h1>
|
|
102
|
+
<p className="mt-1 text-sm text-gray-500">
|
|
103
|
+
{t('intro', 'Verifique sua identidade usando um método disponível.')}
|
|
104
|
+
</p>
|
|
105
|
+
{requiredMfaFactors > 2 && (
|
|
106
|
+
<p className="mt-3 text-sm text-gray-600" role="status">
|
|
107
|
+
{t('progress', '{completed} de {required} fatores verificados.')
|
|
108
|
+
.replace('{completed}', String(1 + completedMfaMethods.length))
|
|
109
|
+
.replace('{required}', String(requiredMfaFactors))}
|
|
26
110
|
</p>
|
|
111
|
+
)}
|
|
112
|
+
{error && (
|
|
113
|
+
<p className="mt-4 text-sm text-red-600" role="alert">
|
|
114
|
+
{error}
|
|
115
|
+
</p>
|
|
116
|
+
)}
|
|
27
117
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
118
|
+
{showTotp && (
|
|
119
|
+
<form method="POST" action={`/auth/interaction/${uid}/mfa`} className="mt-6">
|
|
120
|
+
<input type="hidden" name="_csrf" value={csrfToken} />
|
|
31
121
|
<label htmlFor="code" className="mb-1 block text-sm font-medium text-gray-700">
|
|
32
|
-
Código
|
|
122
|
+
{t('code_label', 'Código')}
|
|
33
123
|
</label>
|
|
34
124
|
<input
|
|
35
125
|
id="code"
|
|
@@ -40,33 +130,113 @@ export default function AuthkitMfaChallenge({
|
|
|
40
130
|
maxLength={6}
|
|
41
131
|
className={inputClass}
|
|
42
132
|
/>
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900"
|
|
61
|
-
/>
|
|
62
|
-
<button
|
|
63
|
-
type="submit"
|
|
64
|
-
className="mt-3 w-full rounded-lg border border-gray-300 py-2.5 text-sm font-semibold text-gray-700 transition hover:bg-gray-50"
|
|
65
|
-
>
|
|
66
|
-
Entrar com código de recuperação
|
|
133
|
+
{trustedDevicesEnabled && (
|
|
134
|
+
<label className="mt-4 flex items-center gap-2 text-sm text-gray-600">
|
|
135
|
+
<input
|
|
136
|
+
type="checkbox"
|
|
137
|
+
name="trustDevice"
|
|
138
|
+
value="on"
|
|
139
|
+
checked={trustDevice}
|
|
140
|
+
onChange={(event) => setTrustDevice(event.target.checked)}
|
|
141
|
+
/>
|
|
142
|
+
{t('trust_device', 'Confiar neste dispositivo por {days} dias').replace(
|
|
143
|
+
'{days}',
|
|
144
|
+
String(trustedDeviceDays),
|
|
145
|
+
)}
|
|
146
|
+
</label>
|
|
147
|
+
)}
|
|
148
|
+
<button type="submit" className={`mt-6 ${buttonClass}`}>
|
|
149
|
+
{t('submit', 'Verificar')}
|
|
67
150
|
</button>
|
|
68
151
|
</form>
|
|
69
|
-
|
|
152
|
+
)}
|
|
153
|
+
|
|
154
|
+
{remainingMethods.map((method) => (
|
|
155
|
+
<section key={method.id} className="mt-6">
|
|
156
|
+
<h2 className="text-sm font-semibold text-gray-900">{method.label}</h2>
|
|
157
|
+
{method.requiresBegin && !method.started ? (
|
|
158
|
+
<form method="POST" action={method.beginUrl} className="mt-3">
|
|
159
|
+
<input type="hidden" name="_csrf" value={csrfToken} />
|
|
160
|
+
<button type="submit" className={secondaryButtonClass}>
|
|
161
|
+
{t('start', 'Iniciar verificação')}
|
|
162
|
+
</button>
|
|
163
|
+
</form>
|
|
164
|
+
) : (
|
|
165
|
+
<form method="POST" action={method.verifyUrl} className="mt-3 space-y-3">
|
|
166
|
+
<input type="hidden" name="_csrf" value={csrfToken} />
|
|
167
|
+
{(method.fields ?? []).map((field) => (
|
|
168
|
+
<div key={field.name}>
|
|
169
|
+
<label
|
|
170
|
+
htmlFor={`custom-${method.id}-${field.name}`}
|
|
171
|
+
className="mb-1 block text-sm font-medium text-gray-700"
|
|
172
|
+
>
|
|
173
|
+
{field.label}
|
|
174
|
+
</label>
|
|
175
|
+
<input
|
|
176
|
+
id={`custom-${method.id}-${field.name}`}
|
|
177
|
+
name={field.name}
|
|
178
|
+
type={field.type ?? 'text'}
|
|
179
|
+
inputMode={field.inputMode ?? 'text'}
|
|
180
|
+
autoComplete={field.autoComplete ?? 'off'}
|
|
181
|
+
className={inputClass}
|
|
182
|
+
/>
|
|
183
|
+
</div>
|
|
184
|
+
))}
|
|
185
|
+
<button type="submit" className={buttonClass}>
|
|
186
|
+
{t('submit', 'Verificar')}
|
|
187
|
+
</button>
|
|
188
|
+
</form>
|
|
189
|
+
)}
|
|
190
|
+
</section>
|
|
191
|
+
))}
|
|
192
|
+
|
|
193
|
+
{showPasskey && (
|
|
194
|
+
<div className="mt-4">
|
|
195
|
+
<form ref={passkeyForm} method="POST" action={`/auth/interaction/${uid}/passkey/verify`}>
|
|
196
|
+
<input type="hidden" name="_csrf" value={csrfToken} />
|
|
197
|
+
<input ref={passkeyResponse} type="hidden" name="response" />
|
|
198
|
+
<input type="hidden" name="trustDevice" value={trustDevice ? 'on' : ''} />
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
onClick={authenticatePasskey}
|
|
202
|
+
disabled={passkeyPending}
|
|
203
|
+
aria-busy={passkeyPending}
|
|
204
|
+
className={secondaryButtonClass}
|
|
205
|
+
>
|
|
206
|
+
{t('passkey_button', 'Usar passkey')}
|
|
207
|
+
</button>
|
|
208
|
+
</form>
|
|
209
|
+
{passkeyError && (
|
|
210
|
+
<p className="mt-3 text-sm text-red-600" role="alert">
|
|
211
|
+
{t('passkey_error', 'Não foi possível autenticar com a passkey. Tente novamente.')}
|
|
212
|
+
</p>
|
|
213
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
)}
|
|
216
|
+
|
|
217
|
+
{showTotp && (
|
|
218
|
+
<details className="mt-6 text-sm text-gray-600">
|
|
219
|
+
<summary className="cursor-pointer hover:underline">
|
|
220
|
+
{t('recovery_summary', 'Usar um código de recuperação')}
|
|
221
|
+
</summary>
|
|
222
|
+
<form method="POST" action={`/auth/interaction/${uid}/mfa`} className="mt-3">
|
|
223
|
+
<input type="hidden" name="_csrf" value={csrfToken} />
|
|
224
|
+
<label htmlFor="recovery-code" className="mb-1 block text-sm font-medium text-gray-700">
|
|
225
|
+
{t('recovery_summary', 'Usar um código de recuperação')}
|
|
226
|
+
</label>
|
|
227
|
+
<input
|
|
228
|
+
id="recovery-code"
|
|
229
|
+
name="recoveryCode"
|
|
230
|
+
autoComplete="off"
|
|
231
|
+
placeholder="xxxxx-xxxxx"
|
|
232
|
+
className={inputClass}
|
|
233
|
+
/>
|
|
234
|
+
<button type="submit" className={`mt-3 ${secondaryButtonClass}`}>
|
|
235
|
+
{t('recovery_submit', 'Entrar com código de recuperação')}
|
|
236
|
+
</button>
|
|
237
|
+
</form>
|
|
238
|
+
</details>
|
|
239
|
+
)}
|
|
70
240
|
</AuthShell>
|
|
71
241
|
);
|
|
72
242
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adonis-agora/authkit-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.77.0",
|
|
4
4
|
"description": "AdonisJS OIDC/OAuth2 provider (Identity Provider) toolkit: ejectable auth server with sessions, rate-limiting, MFA/TOTP, audit log, federated logout and OpenTelemetry metrics.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "dudousxd",
|
|
@@ -153,6 +153,7 @@
|
|
|
153
153
|
"react-dom": "19.2.8",
|
|
154
154
|
"react-error-boundary": "6.1.5",
|
|
155
155
|
"recharts": "3.10.1",
|
|
156
|
+
"reflect-metadata": "0.2.2",
|
|
156
157
|
"tempura": "^0.4.1",
|
|
157
158
|
"typescript": "7.0.2",
|
|
158
159
|
"vite": "8.2.2"
|
|
@@ -9,14 +9,20 @@ export default function AuthkitConsent({
|
|
|
9
9
|
params,
|
|
10
10
|
csrfToken,
|
|
11
11
|
brand,
|
|
12
|
+
clientName,
|
|
13
|
+
scopes = [],
|
|
12
14
|
}: {
|
|
13
15
|
uid: string;
|
|
14
16
|
params: { client_id: string };
|
|
15
17
|
csrfToken: string;
|
|
16
18
|
brand?: AuthBrand;
|
|
19
|
+
/** O nome que o client registrou (ex.: "Claude Code"); sem ele, o `client_id`. */
|
|
20
|
+
clientName?: string;
|
|
21
|
+
/** Os escopos pedidos, com o rótulo de cada um. */
|
|
22
|
+
scopes?: { id: string; label: string }[];
|
|
17
23
|
}) {
|
|
18
24
|
const accent = brand?.accent ?? '#111827';
|
|
19
|
-
const appName =
|
|
25
|
+
const appName = clientName ?? params.client_id;
|
|
20
26
|
|
|
21
27
|
return (
|
|
22
28
|
<AuthShell brand={brand}>
|
|
@@ -26,6 +32,16 @@ export default function AuthkitConsent({
|
|
|
26
32
|
<p className="mt-2 text-sm text-gray-600">
|
|
27
33
|
O app <strong>{appName}</strong> quer acessar sua conta.
|
|
28
34
|
</p>
|
|
35
|
+
{scopes.length > 0 && (
|
|
36
|
+
<>
|
|
37
|
+
<p className="mt-4 text-sm font-medium text-gray-900">Ele vai poder:</p>
|
|
38
|
+
<ul className="mt-2 list-inside list-disc space-y-1 text-sm text-gray-600">
|
|
39
|
+
{scopes.map((scope) => (
|
|
40
|
+
<li key={scope.id}>{scope.label}</li>
|
|
41
|
+
))}
|
|
42
|
+
</ul>
|
|
43
|
+
</>
|
|
44
|
+
)}
|
|
29
45
|
|
|
30
46
|
<button
|
|
31
47
|
type="submit"
|
|
@@ -2,34 +2,124 @@
|
|
|
2
2
|
exports({ to: app.makePath('inertia/pages/authkit/mfa-challenge.tsx') });
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
-
import
|
|
5
|
+
import { useRef, useState } from 'react';
|
|
6
|
+
import AuthShell, { type AuthBrand } from '../../components/auth_shell';
|
|
7
|
+
|
|
8
|
+
interface CustomMethod {
|
|
9
|
+
id: string;
|
|
10
|
+
factorId: string;
|
|
11
|
+
label: string;
|
|
12
|
+
fields?: {
|
|
13
|
+
name: string;
|
|
14
|
+
label: string;
|
|
15
|
+
type?: 'text' | 'password';
|
|
16
|
+
inputMode?: 'numeric' | 'text';
|
|
17
|
+
autoComplete?: string;
|
|
18
|
+
}[];
|
|
19
|
+
requiresBegin: boolean;
|
|
20
|
+
started: boolean;
|
|
21
|
+
beginUrl: string;
|
|
22
|
+
verifyUrl: string;
|
|
23
|
+
}
|
|
6
24
|
|
|
7
25
|
const inputClass =
|
|
8
|
-
'w-full rounded-lg border border-gray-300 px-3 py-2 text-
|
|
26
|
+
'w-full rounded-lg border border-gray-300 px-3 py-2 text-base outline-none transition focus:border-gray-900 focus:ring-2 focus:ring-gray-900';
|
|
27
|
+
const buttonClass =
|
|
28
|
+
'w-full rounded-lg bg-gray-900 px-3 py-2.5 text-sm font-semibold text-white transition hover:opacity-90 disabled:opacity-50';
|
|
29
|
+
const secondaryButtonClass =
|
|
30
|
+
'w-full rounded-lg border border-gray-300 px-3 py-2.5 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:opacity-50';
|
|
9
31
|
|
|
10
32
|
export default function AuthkitMfaChallenge({
|
|
11
33
|
uid,
|
|
12
34
|
csrfToken,
|
|
13
35
|
error,
|
|
36
|
+
brand,
|
|
37
|
+
messages = {},
|
|
38
|
+
noEnrollment = false,
|
|
39
|
+
otpLocked = false,
|
|
40
|
+
totpAvailable = true,
|
|
41
|
+
passkeyAvailable = false,
|
|
42
|
+
trustedDevicesEnabled = false,
|
|
43
|
+
trustedDeviceDays = 30,
|
|
44
|
+
customMfaMethods = [],
|
|
45
|
+
completedMfaMethods = [],
|
|
46
|
+
requiredMfaFactors = 2,
|
|
14
47
|
}: {
|
|
15
48
|
uid: string;
|
|
16
49
|
csrfToken: string;
|
|
17
50
|
error?: string;
|
|
51
|
+
brand?: AuthBrand;
|
|
52
|
+
messages?: Record<string, string>;
|
|
53
|
+
noEnrollment?: boolean;
|
|
54
|
+
otpLocked?: boolean;
|
|
55
|
+
totpAvailable?: boolean;
|
|
56
|
+
passkeyAvailable?: boolean;
|
|
57
|
+
trustedDevicesEnabled?: boolean;
|
|
58
|
+
trustedDeviceDays?: number;
|
|
59
|
+
customMfaMethods?: CustomMethod[];
|
|
60
|
+
completedMfaMethods?: string[];
|
|
61
|
+
requiredMfaFactors?: number;
|
|
18
62
|
}) {
|
|
63
|
+
const [passkeyPending, setPasskeyPending] = useState(false);
|
|
64
|
+
const [passkeyError, setPasskeyError] = useState(false);
|
|
65
|
+
const [trustDevice, setTrustDevice] = useState(false);
|
|
66
|
+
const passkeyForm = useRef<HTMLFormElement>(null);
|
|
67
|
+
const passkeyResponse = useRef<HTMLInputElement>(null);
|
|
68
|
+
const t = (key: string, fallback: string) => messages[`mfa_challenge.${key}`] ?? fallback;
|
|
69
|
+
const showTotp = !noEnrollment && !otpLocked && totpAvailable;
|
|
70
|
+
const showPasskey = !noEnrollment && passkeyAvailable;
|
|
71
|
+
const remainingMethods = noEnrollment
|
|
72
|
+
? []
|
|
73
|
+
: customMfaMethods.filter((method) => !completedMfaMethods.includes(method.id));
|
|
74
|
+
|
|
75
|
+
async function authenticatePasskey() {
|
|
76
|
+
setPasskeyPending(true);
|
|
77
|
+
setPasskeyError(false);
|
|
78
|
+
try {
|
|
79
|
+
const result = await fetch(`/auth/interaction/${uid}/passkey/options`, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { 'content-type': 'application/json', 'x-csrf-token': csrfToken },
|
|
82
|
+
body: JSON.stringify({}),
|
|
83
|
+
});
|
|
84
|
+
if (!result.ok) throw new Error('Passkey options failed');
|
|
85
|
+
const moduleUrl = '/authkit/assets/webauthn.js';
|
|
86
|
+
const { startAuthentication } = await import(/* @vite-ignore */ moduleUrl);
|
|
87
|
+
const assertion = await startAuthentication({ optionsJSON: await result.json() });
|
|
88
|
+
if (!passkeyResponse.current || !passkeyForm.current) throw new Error('Missing passkey form');
|
|
89
|
+
passkeyResponse.current.value = JSON.stringify(assertion);
|
|
90
|
+
passkeyForm.current.submit();
|
|
91
|
+
} catch {
|
|
92
|
+
setPasskeyError(true);
|
|
93
|
+
setPasskeyPending(false);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
19
97
|
return (
|
|
20
|
-
<AuthShell>
|
|
21
|
-
<
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
98
|
+
<AuthShell brand={brand}>
|
|
99
|
+
<h1 className="text-xl font-semibold text-gray-900">
|
|
100
|
+
{t('title', 'Verificação multifator')}
|
|
101
|
+
</h1>
|
|
102
|
+
<p className="mt-1 text-sm text-gray-500">
|
|
103
|
+
{t('intro', 'Verifique sua identidade usando um método disponível.')}
|
|
104
|
+
</p>
|
|
105
|
+
{requiredMfaFactors > 2 && (
|
|
106
|
+
<p className="mt-3 text-sm text-gray-600" role="status">
|
|
107
|
+
{t('progress', '{completed} de {required} fatores verificados.')
|
|
108
|
+
.replace('{completed}', String(1 + completedMfaMethods.length))
|
|
109
|
+
.replace('{required}', String(requiredMfaFactors))}
|
|
26
110
|
</p>
|
|
111
|
+
)}
|
|
112
|
+
{error && (
|
|
113
|
+
<p className="mt-4 text-sm text-red-600" role="alert">
|
|
114
|
+
{error}
|
|
115
|
+
</p>
|
|
116
|
+
)}
|
|
27
117
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
118
|
+
{showTotp && (
|
|
119
|
+
<form method="POST" action={`/auth/interaction/${uid}/mfa`} className="mt-6">
|
|
120
|
+
<input type="hidden" name="_csrf" value={csrfToken} />
|
|
31
121
|
<label htmlFor="code" className="mb-1 block text-sm font-medium text-gray-700">
|
|
32
|
-
Código
|
|
122
|
+
{t('code_label', 'Código')}
|
|
33
123
|
</label>
|
|
34
124
|
<input
|
|
35
125
|
id="code"
|
|
@@ -40,33 +130,113 @@ export default function AuthkitMfaChallenge({
|
|
|
40
130
|
maxLength={6}
|
|
41
131
|
className={inputClass}
|
|
42
132
|
/>
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900"
|
|
61
|
-
/>
|
|
62
|
-
<button
|
|
63
|
-
type="submit"
|
|
64
|
-
className="mt-3 w-full rounded-lg border border-gray-300 py-2.5 text-sm font-semibold text-gray-700 transition hover:bg-gray-50"
|
|
65
|
-
>
|
|
66
|
-
Entrar com código de recuperação
|
|
133
|
+
{trustedDevicesEnabled && (
|
|
134
|
+
<label className="mt-4 flex items-center gap-2 text-sm text-gray-600">
|
|
135
|
+
<input
|
|
136
|
+
type="checkbox"
|
|
137
|
+
name="trustDevice"
|
|
138
|
+
value="on"
|
|
139
|
+
checked={trustDevice}
|
|
140
|
+
onChange={(event) => setTrustDevice(event.target.checked)}
|
|
141
|
+
/>
|
|
142
|
+
{t('trust_device', 'Confiar neste dispositivo por {days} dias').replace(
|
|
143
|
+
'{days}',
|
|
144
|
+
String(trustedDeviceDays),
|
|
145
|
+
)}
|
|
146
|
+
</label>
|
|
147
|
+
)}
|
|
148
|
+
<button type="submit" className={`mt-6 ${buttonClass}`}>
|
|
149
|
+
{t('submit', 'Verificar')}
|
|
67
150
|
</button>
|
|
68
151
|
</form>
|
|
69
|
-
|
|
152
|
+
)}
|
|
153
|
+
|
|
154
|
+
{remainingMethods.map((method) => (
|
|
155
|
+
<section key={method.id} className="mt-6">
|
|
156
|
+
<h2 className="text-sm font-semibold text-gray-900">{method.label}</h2>
|
|
157
|
+
{method.requiresBegin && !method.started ? (
|
|
158
|
+
<form method="POST" action={method.beginUrl} className="mt-3">
|
|
159
|
+
<input type="hidden" name="_csrf" value={csrfToken} />
|
|
160
|
+
<button type="submit" className={secondaryButtonClass}>
|
|
161
|
+
{t('start', 'Iniciar verificação')}
|
|
162
|
+
</button>
|
|
163
|
+
</form>
|
|
164
|
+
) : (
|
|
165
|
+
<form method="POST" action={method.verifyUrl} className="mt-3 space-y-3">
|
|
166
|
+
<input type="hidden" name="_csrf" value={csrfToken} />
|
|
167
|
+
{(method.fields ?? []).map((field) => (
|
|
168
|
+
<div key={field.name}>
|
|
169
|
+
<label
|
|
170
|
+
htmlFor={`custom-${method.id}-${field.name}`}
|
|
171
|
+
className="mb-1 block text-sm font-medium text-gray-700"
|
|
172
|
+
>
|
|
173
|
+
{field.label}
|
|
174
|
+
</label>
|
|
175
|
+
<input
|
|
176
|
+
id={`custom-${method.id}-${field.name}`}
|
|
177
|
+
name={field.name}
|
|
178
|
+
type={field.type ?? 'text'}
|
|
179
|
+
inputMode={field.inputMode ?? 'text'}
|
|
180
|
+
autoComplete={field.autoComplete ?? 'off'}
|
|
181
|
+
className={inputClass}
|
|
182
|
+
/>
|
|
183
|
+
</div>
|
|
184
|
+
))}
|
|
185
|
+
<button type="submit" className={buttonClass}>
|
|
186
|
+
{t('submit', 'Verificar')}
|
|
187
|
+
</button>
|
|
188
|
+
</form>
|
|
189
|
+
)}
|
|
190
|
+
</section>
|
|
191
|
+
))}
|
|
192
|
+
|
|
193
|
+
{showPasskey && (
|
|
194
|
+
<div className="mt-4">
|
|
195
|
+
<form ref={passkeyForm} method="POST" action={`/auth/interaction/${uid}/passkey/verify`}>
|
|
196
|
+
<input type="hidden" name="_csrf" value={csrfToken} />
|
|
197
|
+
<input ref={passkeyResponse} type="hidden" name="response" />
|
|
198
|
+
<input type="hidden" name="trustDevice" value={trustDevice ? 'on' : ''} />
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
onClick={authenticatePasskey}
|
|
202
|
+
disabled={passkeyPending}
|
|
203
|
+
aria-busy={passkeyPending}
|
|
204
|
+
className={secondaryButtonClass}
|
|
205
|
+
>
|
|
206
|
+
{t('passkey_button', 'Usar passkey')}
|
|
207
|
+
</button>
|
|
208
|
+
</form>
|
|
209
|
+
{passkeyError && (
|
|
210
|
+
<p className="mt-3 text-sm text-red-600" role="alert">
|
|
211
|
+
{t('passkey_error', 'Não foi possível autenticar com a passkey. Tente novamente.')}
|
|
212
|
+
</p>
|
|
213
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
)}
|
|
216
|
+
|
|
217
|
+
{showTotp && (
|
|
218
|
+
<details className="mt-6 text-sm text-gray-600">
|
|
219
|
+
<summary className="cursor-pointer hover:underline">
|
|
220
|
+
{t('recovery_summary', 'Usar um código de recuperação')}
|
|
221
|
+
</summary>
|
|
222
|
+
<form method="POST" action={`/auth/interaction/${uid}/mfa`} className="mt-3">
|
|
223
|
+
<input type="hidden" name="_csrf" value={csrfToken} />
|
|
224
|
+
<label htmlFor="recovery-code" className="mb-1 block text-sm font-medium text-gray-700">
|
|
225
|
+
{t('recovery_summary', 'Usar um código de recuperação')}
|
|
226
|
+
</label>
|
|
227
|
+
<input
|
|
228
|
+
id="recovery-code"
|
|
229
|
+
name="recoveryCode"
|
|
230
|
+
autoComplete="off"
|
|
231
|
+
placeholder="xxxxx-xxxxx"
|
|
232
|
+
className={inputClass}
|
|
233
|
+
/>
|
|
234
|
+
<button type="submit" className={`mt-3 ${secondaryButtonClass}`}>
|
|
235
|
+
{t('recovery_submit', 'Entrar com código de recuperação')}
|
|
236
|
+
</button>
|
|
237
|
+
</form>
|
|
238
|
+
</details>
|
|
239
|
+
)}
|
|
70
240
|
</AuthShell>
|
|
71
241
|
);
|
|
72
242
|
}
|