@adonis-agora/authkit-server 0.76.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.
Files changed (43) hide show
  1. package/README.md +145 -0
  2. package/build/host/views/mfa-challenge.edge +42 -3
  3. package/build/index.d.ts +9 -1
  4. package/build/index.js +5 -1
  5. package/build/src/accounts/account_store.d.ts +2 -0
  6. package/build/src/accounts/lucid_account_store.js +2 -0
  7. package/build/src/adapters/adapter_contract.d.ts +2 -0
  8. package/build/src/adapters/database_adapter.d.ts +1 -0
  9. package/build/src/adapters/database_adapter.js +26 -0
  10. package/build/src/adapters/redis_adapter.d.ts +1 -0
  11. package/build/src/adapters/redis_adapter.js +30 -0
  12. package/build/src/define_config.d.ts +24 -0
  13. package/build/src/define_config.js +8 -0
  14. package/build/src/host/controllers/account_security_controller.js +8 -4
  15. package/build/src/host/controllers/interaction_controller.d.ts +13 -1
  16. package/build/src/host/controllers/interaction_controller.js +352 -24
  17. package/build/src/host/custom_login.d.ts +36 -0
  18. package/build/src/host/custom_login.js +76 -0
  19. package/build/src/host/custom_login_completion.d.ts +4 -0
  20. package/build/src/host/custom_login_completion.js +33 -0
  21. package/build/src/host/custom_mfa.d.ts +77 -0
  22. package/build/src/host/custom_mfa.js +268 -0
  23. package/build/src/host/i18n.d.ts +4 -0
  24. package/build/src/host/i18n.js +10 -6
  25. package/build/src/host/idp_session_bridge.d.ts +5 -3
  26. package/build/src/host/idp_session_bridge.js +5 -3
  27. package/build/src/host/oidc_rp_guard.d.ts +5 -1
  28. package/build/src/host/oidc_rp_guard.js +15 -2
  29. package/build/src/host/persistent_rp_session.d.ts +8 -0
  30. package/build/src/host/persistent_rp_session.js +74 -0
  31. package/build/src/host/register_auth_host.js +12 -0
  32. package/build/src/host/whatsapp_code_sender.d.ts +20 -0
  33. package/build/src/host/whatsapp_code_sender.js +9 -0
  34. package/build/src/host/whatsapp_senders/http.d.ts +5 -0
  35. package/build/src/host/whatsapp_senders/http.js +33 -0
  36. package/build/src/host/whatsapp_senders/meta_whatsapp_code_sender.d.ts +20 -0
  37. package/build/src/host/whatsapp_senders/meta_whatsapp_code_sender.js +51 -0
  38. package/build/src/host/whatsapp_senders/whatsmiau_code_sender.d.ts +14 -0
  39. package/build/src/host/whatsapp_senders/whatsmiau_code_sender.js +36 -0
  40. package/build/src/provider/oidc_service.js +6 -2
  41. package/build/stubs/ui/react/pages/mfa-challenge.tsx +207 -37
  42. package/package.json +2 -1
  43. package/stubs/ui/react/pages/mfa-challenge.tsx +207 -37
package/README.md CHANGED
@@ -163,3 +163,148 @@ OTel para que as séries existam.
163
163
  ## Notas
164
164
  - Access tokens são opacos; ID tokens são JWT (assinados pelo JWKS gerido).
165
165
  - PKCE (S256) é obrigatório; refresh tokens são rotacionados.
166
+
167
+ ### Custom primary login methods
168
+
169
+ Register a class or instance for a host-specific authentication flow. AuthKit resolves classes through the request container, so normal Adonis `@inject()` dependencies work. The host validates and consumes the credential; AuthKit applies account policies, maintenance, MFA, audit, and OIDC session completion.
170
+
171
+ ```ts
172
+ import { inject } from '@adonisjs/core';
173
+ import type { HttpContext } from '@adonisjs/core/http';
174
+ import type { CustomLoginMethod } from '@adonis-agora/authkit-server';
175
+ import PhoneProofs from '#services/phone_proofs';
176
+
177
+ @inject()
178
+ export default class WhatsappLogin implements CustomLoginMethod {
179
+ readonly passwordless = true;
180
+ constructor(private proofs: PhoneProofs) {}
181
+
182
+ async authenticate(ctx: HttpContext) {
183
+ // Host service validates and atomically consumes the proof bound to this
184
+ // browser session and OIDC interaction. Never trust request.accountId.
185
+ const accountId = await this.proofs.consume(ctx);
186
+ return accountId ? { accountId } : null;
187
+ }
188
+ }
189
+ ```
190
+
191
+ ```ts
192
+ // config/authkit.ts
193
+ import WhatsappLogin from '#auth/whatsapp_login';
194
+ export default defineConfig({
195
+ // issuer, adapter, accountStore, ...
196
+ customLoginMethods: { whatsapp: WhatsappLogin },
197
+ });
198
+
199
+ // Native form controller, on the host's POST /auth/interaction/:uid/... route
200
+ return authenticateCustomLogin(ctx, 'whatsapp');
201
+ ```
202
+
203
+ `authenticate` returns `{ accountId }` only after a valid proof, or `null` to deny login. It may also return a host-validated `remember` boolean; persistence is disabled by default and subject to the runtime session policy. The same session policy applies after MFA. Returning an identity does not skip MFA. The method name is retained in the OIDC `amr` claim, including when a second factor follows. Methods default to password-based policy; set `passwordless = true` for OTP, hardware or external proofs that do not use the account password.
204
+
205
+ A method may implement `begin(ctx)` to initiate a challenge. Call `beginCustomLogin(ctx, 'whatsapp')` from the host route. Both dispatchers check the current OIDC interaction and its `:uid` before invoking the method. Method names contain lowercase letters, digits, `:`, `_` or `-`, start with a letter, and have at most 64 characters. Built-in factor names (`pwd`, `email`, `mfa`, `totp`, `webauthn`, `recovery`) are reserved. Registration does not automatically create routes or UI: the host owns validation, CSRF, rate limits, bot protection, delivery, proof expiry, replay protection, account lookup/signup and the response renderer. Use native form navigation for final completion so AuthKit can render MFA or redirect to the relying party.
206
+
207
+ For an arbitrary flow that already verified its credential, `completeCustomLogin(ctx, { accountId, method, passwordless })` is the trusted escape hatch. Do not expose it as an endpoint accepting an account ID from the browser. A phone-only account may represent absent email as an empty string in the legacy `AuthAccount` DTO; OIDC omits `email` and `email_verified` in that case. `AuthAccount.phone`, when supplied, must represent a verified phone identity.
208
+
209
+ Denied proofs and handler exceptions produce a `login.failure` audit event with the method ID and a sanitized reason; proof contents and exception messages are never included. Host-owned challenge/OTP verification endpoints must audit their own earlier failures.
210
+
211
+ ### WhatsApp OTP delivery providers
212
+
213
+ `whatsapp.sender` accepts any `WhatsappCodeSender` instance or class. It is separate from `customLoginMethods`: login classes verify identity, senders only deliver a code. Configuring a sender does not create an OTP store, route, validator, signup flow, or UI.
214
+
215
+ ```ts
216
+ import { inject } from '@adonisjs/core';
217
+ import type { WhatsappCodeInput, WhatsappCodeSender } from '@adonis-agora/authkit-server';
218
+ import MyWhatsappSdk from '#services/my_whatsapp_sdk';
219
+
220
+ @inject()
221
+ class MyProvider implements WhatsappCodeSender {
222
+ constructor(private sdk: MyWhatsappSdk) {}
223
+ async sendCode(input: WhatsappCodeInput): Promise<void> {
224
+ await this.sdk.sendText(input.phone, input.text ?? input.code);
225
+ }
226
+ }
227
+
228
+ export default defineConfig({
229
+ // issuer, adapter, accountStore, ...
230
+ customLoginMethods: { whatsapp: WhatsappLogin },
231
+ whatsapp: { sender: MyProvider }, // or an already-created instance
232
+ });
233
+ ```
234
+
235
+ After generating an OTP, resolve the sender through the request container:
236
+
237
+ ```ts
238
+ const service = await ctx.containerResolver.make('authkit.server');
239
+ const binding = service.config.whatsapp?.sender;
240
+ if (!binding) throw new Error('WhatsApp login is not configured');
241
+ const sender = await resolveWhatsappCodeSender(ctx.containerResolver, binding);
242
+ await sender.sendCode({
243
+ phone: '5511999999999', code, locale: 'pt-BR', expiresInSeconds: 300,
244
+ text: ctx.i18n.t('auth.code_message', { code }),
245
+ });
246
+ ```
247
+
248
+ The sender must reject when delivery fails. The host should activate its stored OTP only after delivery acceptance and enforce expiry, retries, request binding, replay protection and rate limits. Provider acceptance is not a delivery/read receipt. `text` is optional localized copy for providers supporting free-form messages; structured providers receive the original code independently.
249
+
250
+ Native adapters can be supplied as instances:
251
+
252
+ ```ts
253
+ whatsapp: {
254
+ sender: new WhatsmiauCodeSender({ apiKey, instanceName }),
255
+ }
256
+
257
+ whatsapp: {
258
+ sender: new MetaWhatsappCodeSender({
259
+ accessToken, phoneNumberId, apiVersion, templateName,
260
+ languageCode: 'pt_BR',
261
+ }),
262
+ }
263
+ ```
264
+
265
+ Whatsmiau's optional `baseUrl` includes the API version path and defaults to `https://api.whatsmiau.dev/v2`. Meta requires an explicit supported Graph API version and an approved authentication template with an OTP/copy-code button. Template language must exist for the configured template; `languageCode` overrides automatic locale mapping (`pt-BR` → `pt_BR`, `en` → `en_US`, `es` → `es`). Meta template text and displayed expiry are managed in the approved template; `expiresInSeconds` describes the host's verification TTL and does not change template expiry. Both native adapters use bounded requests, reject redirects and discard provider error bodies so credentials/OTPs do not enter error messages.
266
+
267
+
268
+ ### Custom MFA methods (classes or instances)
269
+
270
+ Register additional methods separately from primary login methods. Registration never enrolls an account: `isEnabled` must consult your enrollment store. A method may send a challenge in `begin`, describe its form fields, and verify a proof in `verify`.
271
+
272
+ ```ts
273
+ import { inject } from '@adonisjs/core'
274
+ import type { CustomMfaContext, CustomMfaMethod } from '@adonis-agora/authkit-server'
275
+
276
+ // Enrollment and challenge services belong to your application and are injected by Adonis.
277
+ @inject()
278
+ export class WhatsappMfa implements CustomMfaMethod {
279
+ readonly factorId = 'phone'
280
+ constructor(private enrollment: PhoneEnrollment, private challenges: PhoneChallenges) {}
281
+
282
+ async isEnabled({ accountId }: CustomMfaContext) {
283
+ return this.enrollment.hasVerifiedPhone(accountId)
284
+ }
285
+ async describe() {
286
+ return { label: 'WhatsApp', fields: [{ name: 'code', label: 'Code', inputMode: 'numeric' as const }] }
287
+ }
288
+ async begin({ accountId, challengeId }: CustomMfaContext) {
289
+ await this.challenges.sendWhatsapp(accountId, challengeId)
290
+ }
291
+ async verify({ ctx, accountId, challengeId }: CustomMfaContext) {
292
+ const { code } = await ctx.request.validateUsing(phoneCodeValidator)
293
+ return this.challenges.consume(accountId, challengeId, code)
294
+ }
295
+ }
296
+
297
+ // In defineConfig:
298
+ mfa: {
299
+ methods: { whatsapp: WhatsappMfa, sms: smsMfaInstance },
300
+ requiredFactors: 3,
301
+ }
302
+ ```
303
+
304
+ The sample enrollment/challenge services and Vine validator are host implementations. Bind proofs to both `accountId` and `challengeId`; expire them, hash stored codes, limit sends, and atomically consume successful proofs. Delivery can use the WhatsApp sender interface or any SMS/provider SDK. Proofs and provider credentials must never appear in descriptors.
305
+
306
+ `requiredFactors` counts the primary login plus distinct additional groups (2 by default, configurable from 2 through 8). Omit it to challenge only accounts with an enrolled method; setting it explicitly also requires unenrolled accounts to enroll before they can authenticate. This extension does not supply an enrollment screen. Native TOTP, recovery codes and passkeys remain available; recovery and TOTP share one group. WhatsApp and SMS using the same phone must share `factorId: 'phone'`. A WhatsApp primary login class should also expose that group, preventing the same phone from counting twice. Group names express application policy; they do not prove independent physical authentication factors.
307
+
308
+ AuthKit binds the challenge to the current login interaction and browser session, expires it after ten minutes, caps failed custom proofs at five, and completes OIDC only after the required number of groups. Edge and generated React challenge screens render custom descriptors and optional start buttons. The named POST routes `authkit.mfa.custom.begin` and `authkit.mfa.custom.verify` use the existing login throttling and CSRF pipeline. Public `beginCustomMfa`, `verifyCustomMfa`, and `customMfaViewProps` support host integrations; proof verification alone does not complete authentication. Use the registered routes for policy-controlled completion.
309
+
310
+ Without `mfa` configuration, existing native MFA behavior is preserved. Applications can ship this extension without enabling additional methods.
@@ -12,6 +12,12 @@
12
12
  {{ t('mfa_challenge.intro') }}
13
13
  </p>
14
14
 
15
+ @if(requiredMfaFactors > 2)
16
+ <p class="mt-3 text-sm text-gray-600" role="status">
17
+ {{ t('mfa_challenge.progress', { completed: 1 + (completedMfaMethods || []).length, required: requiredMfaFactors }) }}
18
+ </p>
19
+ @end
20
+
15
21
  @if(error)
16
22
  <p class="mt-4 text-sm text-red-600">{{ error }}</p>
17
23
  @end
@@ -21,7 +27,7 @@
21
27
  `totpAvailable === false` = a conta só tem passkey, sem app autenticador
22
28
  confirmado: pedir um código de 6 dígitos seria pedir o impossível. Ausente
23
29
  (stores que não reportam o estado) mantém o campo visível. --}}
24
- @if(!noEnrollment && totpAvailable !== false)
30
+ @if(!noEnrollment && !otpLocked && totpAvailable !== false)
25
31
  <div class="mt-6">
26
32
  <label for="code" class="mb-1 block text-sm font-medium text-gray-700">{{ t('mfa_challenge.code_label') }}</label>
27
33
  <input id="code" name="code" inputmode="numeric" autocomplete="one-time-code"
@@ -43,6 +49,39 @@
43
49
  @end
44
50
  </form>
45
51
 
52
+ @if(!noEnrollment)
53
+ @each(method in (customMfaMethods || []))
54
+ @if(!(completedMfaMethods || []).includes(method.id))
55
+ <section class="mt-6">
56
+ <h2 class="text-sm font-semibold text-gray-900">{{ method.label }}</h2>
57
+ @if(method.requiresBegin && !method.started)
58
+ <form method="POST" action="{{ method.beginUrl }}" class="mt-3">
59
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
60
+ <button type="submit" class="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">
61
+ {{ t('mfa_challenge.start') }}
62
+ </button>
63
+ </form>
64
+ @else
65
+ <form method="POST" action="{{ method.verifyUrl }}" class="mt-3 space-y-3">
66
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
67
+ @each(field in (method.fields || []))
68
+ <div>
69
+ <label for="custom-{{ method.id }}-{{ field.name }}" class="mb-1 block text-sm font-medium text-gray-700">{{ field.label }}</label>
70
+ <input id="custom-{{ method.id }}-{{ field.name }}" name="{{ field.name }}"
71
+ type="{{ field.type || 'text' }}" inputmode="{{ field.inputMode || 'text' }}" autocomplete="{{ field.autoComplete || 'off' }}"
72
+ class="w-full rounded-lg border border-gray-300 px-3 py-2 text-base outline-hidden focus:border-gray-900 focus:ring-2 focus:ring-gray-900">
73
+ </div>
74
+ @end
75
+ <button type="submit" class="w-full rounded-lg bg-gray-900 px-3 py-2.5 text-sm font-semibold text-white transition hover:opacity-90">
76
+ {{ t('mfa_challenge.submit') }}
77
+ </button>
78
+ </form>
79
+ @end
80
+ </section>
81
+ @end
82
+ @end
83
+ @end
84
+
46
85
  @if(passkeyAvailable && !noEnrollment)
47
86
  {{-- Passkey como alternativa ao código TOTP. O form é submetido por página
48
87
  inteira (não fetch) para que o 303 de volta ao client navegue o browser. --}}
@@ -61,7 +100,7 @@
61
100
 
62
101
  {{-- Códigos de recuperação só existem junto com o TOTP (são gerados no
63
102
  enrollment); sem ele, a seção não tem o que receber. --}}
64
- @if(!noEnrollment && totpAvailable !== false)
103
+ @if(!noEnrollment && !otpLocked && totpAvailable !== false)
65
104
  <details class="mt-6 text-sm text-gray-600">
66
105
  <summary class="cursor-pointer hover:underline">{{ t('mfa_challenge.recovery_summary') }}</summary>
67
106
  <form method="POST" action="/auth/interaction/{{ uid }}/mfa" class="mt-3">
@@ -77,7 +116,7 @@
77
116
  @end
78
117
  </div>
79
118
 
80
- @if(passkeyAvailable)
119
+ @if(passkeyAvailable && !noEnrollment)
81
120
  {{-- M12: script inline sem nonce é bloqueado por CSP `script-src 'self'` — extraído
82
121
  para asset same-origin (compartilhado com login.edge). --}}
83
122
  <script type="module" src="/authkit/assets/passkey_button.js"></script>
package/build/index.d.ts CHANGED
@@ -76,12 +76,16 @@ export { deriveLockedSettingKeys, isSettingLocked, lockedSettingKeys, POLICY_ROU
76
76
  export { consoleLoginUrl, getAccountId, hasAccountSession, realAccountId, } from './src/host/console_session.js';
77
77
  export type { AuthkitCsrfOptions } from './src/host/csrf.js';
78
78
  export { authkitCsrfExceptions } from './src/host/csrf.js';
79
+ export type { CustomLoginIdentity, CustomLoginMethod, CustomLoginMethodBinding, CustomLoginMethodConstructor, CustomLoginMethods, } from './src/host/custom_login.js';
80
+ export { authenticateCustomLogin, beginCustomLogin, completeCustomLogin, } from './src/host/custom_login.js';
81
+ export type { CustomMfaContext, CustomMfaDescription, CustomMfaDescriptor, CustomMfaField, CustomMfaMethod, CustomMfaMethodBinding, CustomMfaMethodConstructor, CustomMfaMethods, } from './src/host/custom_mfa.js';
82
+ export { beginCustomMfa, customMfaViewProps, verifyCustomMfa } from './src/host/custom_mfa.js';
79
83
  export { normalizeEmailIdentifier } from './src/host/email_identifier.js';
80
84
  export type { ResolveGeo } from './src/host/geo.js';
81
85
  export { GEO_RESOLVE_TIMEOUT_MS, resolveGeoSafe } from './src/host/geo.js';
82
86
  export type { AuthMessages, I18nConfig } from './src/host/i18n.js';
83
87
  export { BUILTIN_MESSAGES, DEFAULT_LOCALE, DEFAULT_MESSAGES, PT_BR_MESSAGES, resolveMessages, translate, } from './src/host/i18n.js';
84
- export { ensureConsoleSession } from './src/host/idp_session_bridge.js';
88
+ export { ACCOUNT_IDP_SESSION_KEY, ensureConsoleSession, readIdpSession, } from './src/host/idp_session_bridge.js';
85
89
  export type { ImpersonationClientLike, ImpersonationPanel } from './src/host/impersonation.js';
86
90
  export { buildImpersonationPanel } from './src/host/impersonation.js';
87
91
  export type { ImpersonationStartErrorCode, ImpersonationState, StartImpersonationParams, StopImpersonationOptions, TokenExchangeResult, } from './src/host/impersonation_session.js';
@@ -93,6 +97,8 @@ export { OidcBearerGuard, oidcBearerGuard } from './src/host/oidc_bearer_guard.j
93
97
  export type { OidcRpGuardEvents, OidcRpGuardOptions } from './src/host/oidc_rp_guard.js';
94
98
  export { OidcRpGuard, oidcRpGuard } from './src/host/oidc_rp_guard.js';
95
99
  export { evaluateLoginOtp, generateOtpCode, OTP_LOGIN_DEFAULTS, type OtpLoginConfigInput, type OtpVerifyOutcome, type ResolvedOtpLoginConfig, resolveOtpLoginConfig, } from './src/host/otp_login.js';
100
+ export type { PersistentRpOptions } from './src/host/persistent_rp_session.js';
101
+ export { endRpSession } from './src/host/persistent_rp_session.js';
96
102
  export type { AuthThrottles, ThrottleMiddleware } from './src/host/rate_limit.js';
97
103
  export { createAuthThrottles } from './src/host/rate_limit.js';
98
104
  export type { AccountScreensOptions, AuthHostOptions, AuthHostRouteMap, } from './src/host/register_auth_host.js';
@@ -117,6 +123,8 @@ export type { ParsedUserAgent } from './src/host/user_agent.js';
117
123
  export { parseUserAgent } from './src/host/user_agent.js';
118
124
  export type { ResolvedUserLoginMethods, UserLoginMethodKey, UserLoginMethods, } from './src/host/user_login_methods.js';
119
125
  export { normalizeUserLoginMethods, parseUserLoginMethodsPayload, resolveEffectiveUserLoginMethods, USER_LOGIN_METHOD_KEYS, } from './src/host/user_login_methods.js';
126
+ export type { MetaWhatsappCodeSenderOptions, WhatsappCodeInput, WhatsappCodeSender, WhatsappCodeSenderBinding, WhatsappCodeSenderConstructor, WhatsmiauCodeSenderOptions, } from './src/host/whatsapp_code_sender.js';
127
+ export { MetaWhatsappCodeSender, resolveWhatsappCodeSender, WhatsmiauCodeSender, } from './src/host/whatsapp_code_sender.js';
120
128
  export { MCP_CLIENT_REDIRECTS, MCP_RESOURCE_SCOPES, type McpOAuthConfigInput, type OAuthResourceRegistration, registeredOAuthResources, registerOAuthResource, } from './src/mcp/mcp_oauth.js';
121
129
  export { withAuditLog } from './src/mixins/with_audit_log.js';
122
130
  export { withAuthUser } from './src/mixins/with_auth_user.js';
package/build/index.js CHANGED
@@ -54,10 +54,12 @@ export { assertClientMetadata, ClientMetadataError, redirectUriProblem, } from '
54
54
  export { deriveLockedSettingKeys, isSettingLocked, lockedSettingKeys, POLICY_ROUTE_OPTIONS, resetLockedSettingKeys, SettingLockedError, setLockedSettingKeys, } from './src/host/config_locks.js';
55
55
  export { consoleLoginUrl, getAccountId, hasAccountSession, realAccountId, } from './src/host/console_session.js';
56
56
  export { authkitCsrfExceptions } from './src/host/csrf.js';
57
+ export { authenticateCustomLogin, beginCustomLogin, completeCustomLogin, } from './src/host/custom_login.js';
58
+ export { beginCustomMfa, customMfaViewProps, verifyCustomMfa } from './src/host/custom_mfa.js';
57
59
  export { normalizeEmailIdentifier } from './src/host/email_identifier.js';
58
60
  export { GEO_RESOLVE_TIMEOUT_MS, resolveGeoSafe } from './src/host/geo.js';
59
61
  export { BUILTIN_MESSAGES, DEFAULT_LOCALE, DEFAULT_MESSAGES, PT_BR_MESSAGES, resolveMessages, translate, } from './src/host/i18n.js';
60
- export { ensureConsoleSession } from './src/host/idp_session_bridge.js';
62
+ export { ACCOUNT_IDP_SESSION_KEY, ensureConsoleSession, readIdpSession, } from './src/host/idp_session_bridge.js';
61
63
  export { buildImpersonationPanel } from './src/host/impersonation.js';
62
64
  // Session impersonation — RP-side glue that routes through the IdP's RFC 8693
63
65
  // token-exchange (the IdP validates the admin role + audits). See
@@ -77,6 +79,7 @@ export { OidcBearerGuard, oidcBearerGuard } from './src/host/oidc_bearer_guard.j
77
79
  export { OidcRpGuard, oidcRpGuard } from './src/host/oidc_rp_guard.js';
78
80
  // Login por OTP (código digitável): config + helpers puros.
79
81
  export { evaluateLoginOtp, generateOtpCode, OTP_LOGIN_DEFAULTS, resolveOtpLoginConfig, } from './src/host/otp_login.js';
82
+ export { endRpSession } from './src/host/persistent_rp_session.js';
80
83
  export { createAuthThrottles } from './src/host/rate_limit.js';
81
84
  export { registerAuthHost } from './src/host/register_auth_host.js';
82
85
  export { edgeRenderer } from './src/host/renderers/edge_renderer.js';
@@ -110,6 +113,7 @@ export { buildTrustedDevicePayload, isTrustedDeviceValid, resolveTrustedDevices,
110
113
  export { parseUserAgent } from './src/host/user_agent.js';
111
114
  // Tipos de login POR USUÁRIO (self-service no console de conta).
112
115
  export { normalizeUserLoginMethods, parseUserLoginMethodsPayload, resolveEffectiveUserLoginMethods, USER_LOGIN_METHOD_KEYS, } from './src/host/user_login_methods.js';
116
+ export { MetaWhatsappCodeSender, resolveWhatsappCodeSender, WhatsmiauCodeSender, } from './src/host/whatsapp_code_sender.js';
113
117
  export { MCP_CLIENT_REDIRECTS, MCP_RESOURCE_SCOPES, registeredOAuthResources, registerOAuthResource, } from './src/mcp/mcp_oauth.js';
114
118
  export { withAuditLog } from './src/mixins/with_audit_log.js';
115
119
  export { withAuthUser } from './src/mixins/with_auth_user.js';
@@ -3,6 +3,8 @@ import type { UserLoginMethods } from '../host/user_login_methods.js';
3
3
  export interface AuthAccount {
4
4
  id: string;
5
5
  email: string;
6
+ /** Host-verified phone identity. Omit until possession has been verified. */
7
+ phone?: string;
6
8
  globalRoles?: string[];
7
9
  name?: string;
8
10
  avatarUrl?: string;
@@ -182,6 +182,7 @@ export function lucidAccountStore(Model, options = {}) {
182
182
  // token (e para todo call-site tipado como string).
183
183
  id: String(row.id),
184
184
  email: row.email,
185
+ ...(row.phone && row.phoneVerifiedAt ? { phone: row.phone } : {}),
185
186
  globalRoles: row.globalRoles ?? [],
186
187
  name: row.fullName ?? undefined,
187
188
  avatarUrl: row.avatarUrl ?? undefined,
@@ -328,6 +329,7 @@ export async function lucidAccountStoreAsync(Model, options = {}) {
328
329
  // Ver a nota em `toAccount` acima: `sub` precisa ser string.
329
330
  id: String(row.id),
330
331
  email: row.email,
332
+ ...(row.phone && row.phoneVerifiedAt ? { phone: row.phone } : {}),
331
333
  globalRoles: row.globalRoles ?? [],
332
334
  name: row.fullName ?? undefined,
333
335
  avatarUrl: row.avatarUrl ?? undefined,
@@ -24,6 +24,8 @@ export interface OidcAdapter {
24
24
  findByUid(uid: string): Promise<OidcPayload | undefined>;
25
25
  consume(id: string): Promise<void>;
26
26
  destroy(id: string): Promise<void>;
27
+ /** Extend an existing remembered Session atomically; never recreate revoked records. */
28
+ renewSession?(id: string, expiresIn: number): Promise<boolean>;
27
29
  revokeByGrantId(grantId: string): Promise<void>;
28
30
  /**
29
31
  * Enumeração GENÉRICA dos artefatos do model deste adapter (id + payload). Usada
@@ -6,6 +6,7 @@ export declare class DatabaseAdapter implements OidcAdapter {
6
6
  private db;
7
7
  constructor(name: string, db: Database);
8
8
  upsert(id: string, payload: OidcPayload, expiresIn: number): Promise<void>;
9
+ renewSession(id: string, expiresIn: number): Promise<boolean>;
9
10
  find(id: string): Promise<OidcPayload | undefined>;
10
11
  findByUid(uid: string): Promise<OidcPayload | undefined>;
11
12
  findByUserCode(userCode: string): Promise<OidcPayload | undefined>;
@@ -38,6 +38,32 @@ export class DatabaseAdapter {
38
38
  }
39
39
  return JSON.parse(record.payload);
40
40
  }
41
+ async renewSession(id, expiresIn) {
42
+ if (this.name !== 'Session' || !Number.isSafeInteger(expiresIn) || expiresIn < 1)
43
+ return false;
44
+ const row = await this.#query().where('id', id).first();
45
+ const payload = await this.#parse(row);
46
+ const now = Date.now();
47
+ if (!payload ||
48
+ payload.transient ||
49
+ typeof payload.exp !== 'number' ||
50
+ payload.exp <= now / 1000)
51
+ return false;
52
+ // Compare-and-set: concurrent logout or mutation cannot be undone by renewal.
53
+ const changed = await this.#query()
54
+ .where('id', id)
55
+ .where('payload', row.payload)
56
+ .where('expires_at', '>', new Date(now).toISOString())
57
+ .update({
58
+ payload: JSON.stringify({ ...payload, exp: Math.floor(now / 1000) + expiresIn }),
59
+ expires_at: new Date(now + expiresIn * 1000).toISOString(),
60
+ });
61
+ if (Number(changed) > 0)
62
+ return true;
63
+ // A parallel renewal is harmless; a deletion must remain deleted.
64
+ const current = await this.find(id);
65
+ return Boolean(current && !current.transient && typeof current.exp === 'number' && current.exp > now / 1000);
66
+ }
41
67
  async find(id) {
42
68
  return this.#parse(await this.#query().where('id', id).first());
43
69
  }
@@ -7,6 +7,7 @@ export declare class RedisAdapter implements OidcAdapter {
7
7
  private prefix;
8
8
  constructor(name: string, redis: Redis, prefix: string);
9
9
  upsert(id: string, payload: OidcPayload, expiresIn: number): Promise<void>;
10
+ renewSession(id: string, expiresIn: number): Promise<boolean>;
10
11
  find(id: string): Promise<OidcPayload | undefined>;
11
12
  findByUid(uid: string): Promise<OidcPayload | undefined>;
12
13
  findByUserCode(userCode: string): Promise<OidcPayload | undefined>;
@@ -52,6 +52,36 @@ export class RedisAdapter {
52
52
  multi.expire(key, expiresIn);
53
53
  await multi.exec();
54
54
  }
55
+ async renewSession(id, expiresIn) {
56
+ if (this.name !== 'Session' || !Number.isSafeInteger(expiresIn) || expiresIn < 1)
57
+ return false;
58
+ for (let attempt = 0; attempt < 3; attempt++) {
59
+ const raw = await this.redis.get(this.#key(id));
60
+ if (!raw)
61
+ return false;
62
+ const payload = JSON.parse(raw);
63
+ const now = Math.floor(Date.now() / 1000);
64
+ if (payload.transient || typeof payload.exp !== 'number' || payload.exp <= now)
65
+ return false;
66
+ const updated = JSON.stringify({ ...payload, exp: now + expiresIn });
67
+ const result = await this.redis.eval(`
68
+ local raw = redis.call('GET', KEYS[1])
69
+ if not raw then return 0 end
70
+ if raw ~= ARGV[1] then return 2 end
71
+ redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3])
72
+ if ARGV[4] ~= '' then redis.call('SET', KEYS[2], ARGV[4], 'EX', ARGV[3]) end
73
+ return 1
74
+ `, 2, this.#key(id), payload.uid ? this.#uidKey(payload.uid) : this.#key(id), raw, updated, expiresIn, payload.uid ? id : '');
75
+ if (result !== 2)
76
+ return result === 1;
77
+ }
78
+ // Another request may have renewed while we were comparing the payload.
79
+ const current = await this.find(id);
80
+ return Boolean(current &&
81
+ !current.transient &&
82
+ typeof current.exp === 'number' &&
83
+ current.exp > Date.now() / 1000);
84
+ }
55
85
  async find(id) {
56
86
  const data = await this.redis.get(this.#key(id));
57
87
  if (!data)
@@ -8,12 +8,15 @@ import { type EventsConfigInput } from './events/dispatcher.js';
8
8
  import { type BotProtectionConfigInput, type ResolvedBotProtectionConfig } from './host/bot_protection.js';
9
9
  import { type BrandingConfig } from './host/branding.js';
10
10
  import { type PolicyRouteOption } from './host/config_locks.js';
11
+ import type { CustomLoginMethods } from './host/custom_login.js';
12
+ import type { CustomMfaMethods } from './host/custom_mfa.js';
11
13
  import type { ResolveGeo } from './host/geo.js';
12
14
  import { type AuthMessages, type I18nConfig } from './host/i18n.js';
13
15
  import { type OtpLoginConfigInput, type ResolvedOtpLoginConfig } from './host/otp_login.js';
14
16
  import type { AuthHostOptions } from './host/register_auth_host.js';
15
17
  import type { SudoMethod } from './host/sudo/types.js';
16
18
  import { type ResolvedTrustedDevicesConfig, type TrustedDevicesConfigInput } from './host/trusted_device.js';
19
+ import type { WhatsappCodeSenderBinding } from './host/whatsapp_code_sender.js';
17
20
  import { type McpOAuthConfigInput, type ResolvedMcpOAuthConfig } from './mcp/mcp_oauth.js';
18
21
  import type { PatStore } from './pat/pat_store.js';
19
22
  import { type RedirectUriPolicy, type ResolvedRedirectUriPolicy, type ValidateRegistrationHook } from './provider/registration_policy.js';
@@ -759,6 +762,17 @@ export interface ResolvedInteractionRecoveryConfig {
759
762
  redirectTo?: string;
760
763
  }
761
764
  export interface AuthServerConfigInput {
765
+ /** Host-defined primary authentication classes or instances, keyed by stable method ID. */
766
+ customLoginMethods?: CustomLoginMethods;
767
+ /** Optional enrolled factor registry and total distinct factor count (primary included). */
768
+ mfa?: {
769
+ methods?: CustomMfaMethods;
770
+ requiredFactors?: number;
771
+ };
772
+ /** OTP delivery only. The host remains responsible for issuing and verifying codes. */
773
+ whatsapp?: {
774
+ sender: WhatsappCodeSenderBinding;
775
+ };
762
776
  issuer: string;
763
777
  adapter: AdapterFactory;
764
778
  /**
@@ -1152,6 +1166,16 @@ export interface AuthServerConfigInput {
1152
1166
  };
1153
1167
  }
1154
1168
  export interface ResolvedServerConfig {
1169
+ customLoginMethods?: CustomLoginMethods;
1170
+ /** Optional enrolled factor registry and total distinct factor count (primary included). */
1171
+ mfa?: {
1172
+ methods?: CustomMfaMethods;
1173
+ requiredFactors?: number;
1174
+ };
1175
+ /** OTP delivery only. The host remains responsible for issuing and verifying codes. */
1176
+ whatsapp?: {
1177
+ sender: WhatsappCodeSenderBinding;
1178
+ };
1155
1179
  issuer: string;
1156
1180
  AdapterClass: OidcAdapterClass;
1157
1181
  /**
@@ -273,6 +273,11 @@ export function jwksAutoFallbackWarning(storePath) {
273
273
  return `AuthKit: jwks 'auto' caiu no fallback de disco (${storePath}) — a chave privada de assinatura será persistida em arquivo. Para produção, defina AUTHKIT_JWKS (secret manager) ou configure jwks.store explicitamente.`;
274
274
  }
275
275
  export function defineConfig(config) {
276
+ const requiredFactors = config.mfa?.requiredFactors;
277
+ if (requiredFactors !== undefined &&
278
+ (!Number.isInteger(requiredFactors) || requiredFactors < 2 || requiredFactors > 8)) {
279
+ throw new Error('mfa.requiredFactors must be an integer between 2 and 8');
280
+ }
276
281
  return configProvider.create(async (app) => {
277
282
  const AdapterClass = await config.adapter.resolver(app);
278
283
  // `session.adapter` ausente ⇒ mesma classe do default (back-compat: o
@@ -394,6 +399,9 @@ export function defineConfig(config) {
394
399
  return acc ? { id: acc.id } : null;
395
400
  },
396
401
  accountStore: config.accountStore,
402
+ customLoginMethods: config.customLoginMethods,
403
+ mfa: config.mfa,
404
+ whatsapp: config.whatsapp,
397
405
  patStore: config.patStore,
398
406
  mountPath: config.mountPath ?? '/oidc',
399
407
  accountHome: config.accountHome,
@@ -158,15 +158,19 @@ export default class AccountSecurityController {
158
158
  return ctx.response.redirect(getAccountLoginUrl());
159
159
  }
160
160
  const { currentPassword, confirmEmail } = await ctx.request.validateUsing(deleteAccountValidator);
161
- // Confirmação: senha atual correta OU e-mail digitado batendo com o da conta
162
- // (case-insensitive). Sem nenhuma das duas → recusa (não deleta).
161
+ // Confirmação: senha atual correta OU e-mail digitado batendo com o da conta.
162
+ // Contas sem e-mail podem confirmar o telefone já verificado pelo host.
163
163
  let confirmed = false;
164
164
  if (currentPassword) {
165
165
  confirmed = !!(await store.verifyCredentials(account.email, currentPassword));
166
166
  }
167
167
  if (!confirmed && confirmEmail) {
168
- confirmed =
169
- normalizeEmailIdentifier(confirmEmail) === normalizeEmailIdentifier(account.email);
168
+ confirmed = account.email
169
+ ? normalizeEmailIdentifier(confirmEmail) === normalizeEmailIdentifier(account.email)
170
+ : !!account.phone &&
171
+ /^[+\d\s().-]+$/.test(confirmEmail) &&
172
+ /\d/.test(confirmEmail) &&
173
+ confirmEmail.replace(/\D/g, '') === account.phone.replace(/\D/g, '');
170
174
  }
171
175
  if (!confirmed) {
172
176
  ctx.session.flash('deleteError', translate(cfg.messages, 'account.delete.invalid_confirmation'));
@@ -1,7 +1,14 @@
1
1
  import '../augmentations.js';
2
2
  import type { HttpContext } from '@adonisjs/core/http';
3
+ import { type CustomLoginIdentity } from '../custom_login.js';
3
4
  export default class AuthInteractionController {
4
5
  #private;
6
+ /** Shared completion for trusted host authentication; primary proof is the host's responsibility. */
7
+ completeCustomLogin(ctx: HttpContext, input: CustomLoginIdentity & {
8
+ method: string;
9
+ factorId?: string;
10
+ passwordless?: boolean;
11
+ }): Promise<unknown>;
5
12
  show(ctx: HttpContext): Promise<any>;
6
13
  /**
7
14
  * POST /auth/interaction/:uid/identifier
@@ -29,7 +36,12 @@ export default class AuthInteractionController {
29
36
  * 2º fator: lê o accountId pendente da sessão e aceita um código TOTP (`code`)
30
37
  * OU um recovery code (`recoveryCode`). Em caso de sucesso finaliza a interaction.
31
38
  */
32
- mfaVerify(ctx: HttpContext): Promise<any>;
39
+ private renderMfaChallenge;
40
+ private configuredMfaGate;
41
+ customMfaBegin(ctx: HttpContext): Promise<unknown>;
42
+ customMfaVerify(ctx: HttpContext): Promise<unknown>;
43
+ private finishVerifiedMfa;
44
+ mfaVerify(ctx: HttpContext): Promise<unknown>;
33
45
  /**
34
46
  * true se o authorize request exige MFA via acr_values (contém o mfaAcr da
35
47
  * config de step-up). `acr_values` é a string separada por espaços padrão OIDC.