@dudousxd/adonis-authkit-server 0.5.0 → 0.6.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 (46) hide show
  1. package/README.md +23 -2
  2. package/build/host/views/account/apps.edge +58 -0
  3. package/build/host/views/account/security.edge +53 -0
  4. package/build/host/views/account/tokens.edge +1 -0
  5. package/build/host/views/admin/users.edge +62 -2
  6. package/build/host/views/login.edge +55 -0
  7. package/build/host/views/mfa-challenge.edge +12 -0
  8. package/build/index.d.ts +8 -2
  9. package/build/index.js +4 -1
  10. package/build/src/accounts/account_store.d.ts +80 -2
  11. package/build/src/accounts/account_store.js +12 -0
  12. package/build/src/accounts/lucid_account_store.js +8 -0
  13. package/build/src/accounts/lucid_store/core.d.ts +2 -2
  14. package/build/src/accounts/lucid_store/core.js +33 -0
  15. package/build/src/accounts/lucid_store/mfa.js +4 -1
  16. package/build/src/accounts/lucid_store/status_profile.d.ts +21 -0
  17. package/build/src/accounts/lucid_store/status_profile.js +66 -0
  18. package/build/src/audit/audit_sink.d.ts +1 -1
  19. package/build/src/define_config.d.ts +53 -0
  20. package/build/src/define_config.js +14 -1
  21. package/build/src/doctor/checks.js +32 -32
  22. package/build/src/events/dispatcher.d.ts +45 -0
  23. package/build/src/events/dispatcher.js +92 -0
  24. package/build/src/host/admin_sessions_service.d.ts +8 -0
  25. package/build/src/host/admin_sessions_service.js +19 -0
  26. package/build/src/host/controllers/account_apps_controller.d.ts +15 -0
  27. package/build/src/host/controllers/account_apps_controller.js +61 -0
  28. package/build/src/host/controllers/account_security_controller.d.ts +9 -0
  29. package/build/src/host/controllers/account_security_controller.js +52 -2
  30. package/build/src/host/controllers/account_session_controller.js +3 -1
  31. package/build/src/host/controllers/admin/admin_users_controller.d.ts +13 -0
  32. package/build/src/host/controllers/admin/admin_users_controller.js +133 -0
  33. package/build/src/host/controllers/interaction_controller.d.ts +32 -0
  34. package/build/src/host/controllers/interaction_controller.js +169 -6
  35. package/build/src/host/default_mailer.d.ts +8 -0
  36. package/build/src/host/default_mailer.js +28 -0
  37. package/build/src/host/i18n.d.ts +90 -0
  38. package/build/src/host/i18n.js +98 -0
  39. package/build/src/host/login_attempt.d.ts +1 -0
  40. package/build/src/host/login_attempt.js +11 -0
  41. package/build/src/host/register_auth_host.js +18 -1
  42. package/build/src/host/trusted_device.d.ts +61 -0
  43. package/build/src/host/trusted_device.js +65 -0
  44. package/build/src/host/validators.d.ts +35 -0
  45. package/build/src/host/validators.js +14 -0
  46. package/package.json +1 -1
package/README.md CHANGED
@@ -1,7 +1,28 @@
1
1
  # @dudousxd/adonis-authkit-server
2
2
 
3
- Authorization Server OpenID Connect para AdonisJS — um wrapper idiomático em volta do
4
- [`oidc-provider`](https://github.com/panva/node-oidc-provider).
3
+ OpenID Connect / OAuth2 Authorization Server (Identity Provider) for AdonisJS — an idiomatic
4
+ wrapper around [`oidc-provider`](https://github.com/panva/node-oidc-provider).
5
+
6
+ ## Features
7
+
8
+ - **OIDC / OAuth2 AS** — authorization code + PKCE, refresh tokens (rotated), token exchange,
9
+ discovery, JWKS (managed + rotatable), revocation, introspection.
10
+ - **MFA** — TOTP, WebAuthn passkeys, recovery codes, trusted-device skip.
11
+ - **Passwordless** — magic-link email login and passkey-first login.
12
+ - **Protocol extensions** — Device Flow (RFC 8628), DPoP (RFC 9449), PAR (RFC 9126), step-up
13
+ auth via `acr_values`, Dynamic Client Registration (RFC 7591/7592).
14
+ - **Admin console** — user CRUD (+ disable), client CRUD, sessions, audit log (opt-in,
15
+ role-gated).
16
+ - **Account console** — self-service apps/consent, security (password/email/sessions),
17
+ profile.
18
+ - **Tokens & federation** — Personal Access Tokens, admin impersonation, back-channel logout,
19
+ RP-initiated logout.
20
+ - **Hardening** — progressive account lockout, per-IP rate-limiting, audit logging with an
21
+ events/webhook fan-out, new-login email alerts.
22
+ - **Operability** — i18n (English default, pt-BR built in), OpenTelemetry metrics, the
23
+ `authkit:doctor` and `authkit:rotate-keys` commands.
24
+
25
+ > The remaining sections are in Portuguese pending a full translation pass.
5
26
 
6
27
  ## Instalação
7
28
 
@@ -0,0 +1,58 @@
1
+ <!doctype html>
2
+ <html lang="pt-br"><head><meta charset="utf-8"><title>{{ t('account.apps.page_title') }}</title>
3
+ <script src="https://cdn.tailwindcss.com"></script></head>
4
+ <body class="min-h-screen bg-gray-100 p-4">
5
+ <div class="mx-auto max-w-2xl">
6
+ <div class="flex items-center justify-between py-6">
7
+ <div>
8
+ <div class="text-xs font-semibold uppercase tracking-widest text-gray-400">{{ t('common.brand_eyebrow') }}</div>
9
+ <h1 class="text-xl font-semibold text-gray-900">{{ t('account.apps.title') }}</h1>
10
+ <p class="mt-1 text-sm text-gray-500">{{ t('account.apps.intro') }}</p>
11
+ </div>
12
+ <form method="POST" action="/account/logout">
13
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
14
+ <button type="submit" class="text-sm text-gray-500 hover:underline">{{ t('account.apps.logout') }}</button>
15
+ </form>
16
+ </div>
17
+
18
+ <nav class="mb-6 flex gap-4 text-sm font-medium">
19
+ <a href="/account/tokens" class="text-gray-500 hover:underline">{{ t('account.tokens.title') }}</a>
20
+ <a href="/account/security" class="text-gray-500 hover:underline">{{ t('account.security.title') }}</a>
21
+ <a href="/account/apps" class="text-gray-900 underline">{{ t('account.apps.title') }}</a>
22
+ </nav>
23
+
24
+ @if(revoked)
25
+ <div class="mb-6 rounded-lg border border-emerald-300 bg-emerald-50 p-4">
26
+ <p class="text-sm font-medium text-emerald-900">{{ revoked }}</p>
27
+ </div>
28
+ @end
29
+
30
+ @if(!supported)
31
+ <div class="rounded-xl bg-white p-6 text-sm text-gray-500 shadow-sm ring-1 ring-black/5">
32
+ {{ t('account.apps.not_supported') }}
33
+ </div>
34
+ @else
35
+ <div class="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-black/5">
36
+ @if(apps.length === 0)
37
+ <p class="p-6 text-sm text-gray-500">{{ t('account.apps.empty') }}</p>
38
+ @else
39
+ @each(app in apps)
40
+ <div class="flex items-center justify-between border-b border-gray-100 p-4 last:border-0">
41
+ <div>
42
+ <p class="text-sm font-medium text-gray-900">{{ app.name }}</p>
43
+ <p class="text-xs text-gray-500">{{ t('account.apps.tokens', { accessTokens: app.accessTokens, refreshTokens: app.refreshTokens }) }}</p>
44
+ </div>
45
+ <form method="POST" action="/account/apps/{{ app.clientId }}/revoke"
46
+ onsubmit="return confirm('{{ t('account.apps.revoke_confirm') }}')">
47
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
48
+ <button type="submit" class="rounded-lg border border-red-300 px-3 py-1.5 text-sm font-semibold text-red-700 hover:bg-red-50">
49
+ {{ t('account.apps.revoke') }}
50
+ </button>
51
+ </form>
52
+ </div>
53
+ @end
54
+ @end
55
+ </div>
56
+ @end
57
+ </div>
58
+ </body></html>
@@ -17,6 +17,41 @@
17
17
  </form>
18
18
  </div>
19
19
 
20
+ <nav class="mb-6 flex gap-4 text-sm font-medium">
21
+ <a href="/account/tokens" class="text-gray-500 hover:underline">{{ t('account.tokens.title') }}</a>
22
+ <a href="/account/security" class="text-gray-900 underline">{{ t('account.security.title') }}</a>
23
+ <a href="/account/apps" class="text-gray-500 hover:underline">{{ t('account.apps.title') }}</a>
24
+ </nav>
25
+
26
+ @if(profileUpdated)
27
+ <div class="mb-6 rounded-lg border border-emerald-300 bg-emerald-50 p-4">
28
+ <p class="text-sm font-medium text-emerald-900">{{ profileUpdated }}</p>
29
+ </div>
30
+ @end
31
+
32
+ @if(profileSupported)
33
+ <div class="mb-6 rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
34
+ <h2 class="mb-1 text-sm font-semibold text-gray-900">{{ t('account.profile.section') }}</h2>
35
+ <p class="mb-4 text-xs text-gray-500">{{ t('account.profile.intro') }}</p>
36
+ <form method="POST" action="/account/security/profile" class="space-y-4">
37
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
38
+ <div>
39
+ <label class="mb-1 block text-sm font-medium text-gray-700">{{ t('account.profile.name_label') }}</label>
40
+ <input name="name" type="text" value="{{ name }}" maxlength="255"
41
+ class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900" />
42
+ </div>
43
+ <div>
44
+ <label class="mb-1 block text-sm font-medium text-gray-700">{{ t('account.profile.avatar_label') }}</label>
45
+ <input name="avatarUrl" type="url" value="{{ avatarUrl }}"
46
+ class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900" />
47
+ </div>
48
+ <button type="submit" class="rounded-lg bg-gray-900 px-4 py-2 text-sm font-semibold text-white">
49
+ {{ t('account.profile.submit') }}
50
+ </button>
51
+ </form>
52
+ </div>
53
+ @end
54
+
20
55
  @if(!supported)
21
56
  <div class="rounded-xl bg-white p-6 text-sm text-gray-500 shadow-sm ring-1 ring-black/5">
22
57
  {{ t('account.security.not_supported') }}
@@ -79,5 +114,23 @@
79
114
  </form>
80
115
  </div>
81
116
  @end
117
+
118
+ @if(trustedDevicesEnabled)
119
+ @if(trustedDevicesRevoked)
120
+ <div class="mt-6 rounded-lg border border-emerald-300 bg-emerald-50 p-4">
121
+ <p class="text-sm font-medium text-emerald-900">{{ trustedDevicesRevoked }}</p>
122
+ </div>
123
+ @end
124
+ <div class="mt-6 rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5">
125
+ <h2 class="mb-1 text-sm font-semibold text-gray-900">{{ t('account.security.trusted_devices_section') }}</h2>
126
+ <p class="mb-4 text-xs text-gray-500">{{ t('account.security.trusted_devices_intro') }}</p>
127
+ <form method="POST" action="/account/security/trusted-devices/revoke">
128
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
129
+ <button type="submit" class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-semibold text-gray-700 hover:bg-gray-50">
130
+ {{ t('account.security.trusted_devices_revoke') }}
131
+ </button>
132
+ </form>
133
+ </div>
134
+ @end
82
135
  </div>
83
136
  </body></html>
@@ -10,6 +10,7 @@
10
10
  </div>
11
11
  <div class="flex items-center gap-4">
12
12
  <a href="/account/security" class="text-sm text-gray-500 hover:underline">{{ t('account.tokens.security') }}</a>
13
+ <a href="/account/apps" class="text-sm text-gray-500 hover:underline">{{ t('account.apps.title') }}</a>
13
14
  <form method="POST" action="/account/logout">
14
15
  <input type="hidden" name="_csrf" value="{{ csrfToken }}">
15
16
  <button type="submit" class="text-sm text-gray-500 hover:underline">{{ t('account.tokens.logout') }}</button>
@@ -21,6 +21,35 @@
21
21
  <a href="/admin/audit" class="text-gray-500 hover:underline">{{ t('admin.nav.audit') }}</a>
22
22
  </nav>
23
23
 
24
+ @if(error)
25
+ <div class="mb-4 rounded-lg border border-red-300 bg-red-50 p-3"><p class="text-sm font-medium text-red-900">{{ error }}</p></div>
26
+ @end
27
+ @if(created)
28
+ <div class="mb-4 rounded-lg border border-emerald-300 bg-emerald-50 p-3"><p class="text-sm font-medium text-emerald-900">{{ created }}</p></div>
29
+ @end
30
+ @if(resetSent)
31
+ <div class="mb-4 rounded-lg border border-blue-300 bg-blue-50 p-3"><p class="text-sm font-medium text-blue-900">{{ resetSent }}</p></div>
32
+ @end
33
+ @if(statusChanged)
34
+ <div class="mb-4 rounded-lg border border-amber-300 bg-amber-50 p-3"><p class="text-sm font-medium text-amber-900">{{ statusChanged }}</p></div>
35
+ @end
36
+
37
+ <div class="mb-6 rounded-xl bg-white p-4 shadow-sm ring-1 ring-black/5">
38
+ <h2 class="mb-3 text-sm font-semibold text-gray-900">{{ t('admin.users.create_section') }}</h2>
39
+ <form method="POST" action="/admin/users" class="grid gap-2 sm:grid-cols-4">
40
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
41
+ <input name="email" type="email" required placeholder="{{ t('admin.users.create_email_placeholder') }}"
42
+ class="rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900" />
43
+ <input name="name" type="text" placeholder="{{ t('admin.users.create_name_placeholder') }}"
44
+ class="rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900" />
45
+ <input name="password" type="password" minlength="8" placeholder="{{ t('admin.users.create_password_placeholder') }}"
46
+ class="rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900" />
47
+ <button type="submit" class="rounded-lg bg-gray-900 px-4 text-sm font-semibold text-white">
48
+ {{ t('admin.users.create_submit') }}
49
+ </button>
50
+ </form>
51
+ </div>
52
+
24
53
  <form method="GET" action="/admin/users" class="mb-6 flex gap-2">
25
54
  <input name="search" value="{{ search }}" placeholder="{{ t('admin.users.search_placeholder') }}"
26
55
  class="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900" />
@@ -37,12 +66,43 @@
37
66
  <div class="border-b border-gray-100 p-4 last:border-0">
38
67
  <div class="flex items-center justify-between">
39
68
  <div>
40
- <p class="text-sm font-medium text-gray-900">{{ user.email }}</p>
69
+ <p class="text-sm font-medium text-gray-900">
70
+ {{ user.email }}
71
+ @if(user.disabled)
72
+ <span class="ml-2 rounded bg-red-100 px-2 py-0.5 text-xs font-semibold text-red-700">{{ t('admin.users.disabled_badge') }}</span>
73
+ @end
74
+ </p>
41
75
  @if(user.name)
42
76
  <p class="text-xs text-gray-500">{{ user.name }}</p>
43
77
  @end
44
78
  </div>
45
- <a href="/admin/users/{{ user.id }}/sessions" class="text-sm text-gray-500 hover:underline">{{ t('admin.users.sessions') }}</a>
79
+ <div class="flex items-center gap-3 text-sm">
80
+ <a href="/admin/users/{{ user.id }}/sessions" class="text-gray-500 hover:underline">{{ t('admin.users.sessions') }}</a>
81
+ <form method="POST" action="/admin/users/{{ user.id }}/reset-password" class="inline">
82
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
83
+ <input type="hidden" name="search" value="{{ search }}">
84
+ <input type="hidden" name="page" value="{{ page }}">
85
+ <button type="submit" class="text-gray-500 hover:underline">{{ t('admin.users.reset_password') }}</button>
86
+ </form>
87
+ @if(statusSupported)
88
+ @if(user.disabled)
89
+ <form method="POST" action="/admin/users/{{ user.id }}/enable" class="inline">
90
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
91
+ <input type="hidden" name="search" value="{{ search }}">
92
+ <input type="hidden" name="page" value="{{ page }}">
93
+ <button type="submit" class="text-emerald-700 hover:underline">{{ t('admin.users.enable') }}</button>
94
+ </form>
95
+ @else
96
+ <form method="POST" action="/admin/users/{{ user.id }}/disable" class="inline"
97
+ onsubmit="return confirm('{{ t('admin.users.disable_confirm') }}')">
98
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
99
+ <input type="hidden" name="search" value="{{ search }}">
100
+ <input type="hidden" name="page" value="{{ page }}">
101
+ <button type="submit" class="text-red-700 hover:underline">{{ t('admin.users.disable') }}</button>
102
+ </form>
103
+ @end
104
+ @end
105
+ </div>
46
106
  </div>
47
107
  <form method="POST" action="/admin/users/{{ user.id }}/roles" class="mt-3 flex gap-2">
48
108
  <input type="hidden" name="_csrf" value="{{ csrfToken }}">
@@ -86,6 +86,61 @@
86
86
  <a href="/auth/forgot-password" class="hover:underline">{{ t('login.forgot_password') }}</a>
87
87
  </div>
88
88
  </form>
89
+
90
+ {{-- Passwordless: confirmação de magic link enviado (anti-enumeração). --}}
91
+ @if(magicLinkSent)
92
+ <p class="mt-4 rounded-lg bg-green-50 px-3 py-2 text-sm text-green-700">{{ t('login.magic_link_sent') }}</p>
93
+ @end
94
+
95
+ {{-- Passwordless: "me envie um link de login" (mesma sessão/e-mail; não pede senha). --}}
96
+ @if(magicLinkAvailable && !magicLinkSent)
97
+ <form method="POST" action="/auth/interaction/{{ uid }}/magic" class="mt-4">
98
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
99
+ <button type="submit"
100
+ class="w-full rounded-lg border border-gray-300 py-2.5 text-sm font-semibold text-gray-700 transition hover:bg-gray-50">
101
+ {{ t('login.magic_link_button') }}
102
+ </button>
103
+ </form>
104
+ @end
105
+
106
+ {{-- Passwordless: "entrar com passkey" ANTES da senha. Reusa as cerimônias
107
+ de passkey do MFA (begin/finish) liberadas no estágio de senha. --}}
108
+ @if(passkeyFirstAvailable)
109
+ <form id="passkey-form" method="POST" action="/auth/interaction/{{ uid }}/passkey/verify" class="mt-4">
110
+ <input type="hidden" name="_csrf" value="{{ csrfToken }}">
111
+ <input type="hidden" name="response" id="passkey-response">
112
+ </form>
113
+ <button type="button" id="passkey-button"
114
+ class="mt-4 w-full rounded-lg border border-gray-300 py-2.5 text-sm font-semibold text-gray-700 transition hover:bg-gray-50">
115
+ {{ t('login.passkey_button') }}
116
+ </button>
117
+ <p id="passkey-error" class="mt-3 hidden text-sm text-red-600">{{ t('mfa_challenge.passkey_error') }}</p>
118
+ <script type="module">
119
+ import { startAuthentication } from 'https://cdn.jsdelivr.net/npm/@simplewebauthn/browser@13/dist/bundle/index.js'
120
+ const btn = document.getElementById('passkey-button')
121
+ const errEl = document.getElementById('passkey-error')
122
+ const csrf = document.querySelector('#passkey-form input[name="_csrf"]').value
123
+ btn?.addEventListener('click', async () => {
124
+ errEl.classList.add('hidden')
125
+ btn.disabled = true
126
+ try {
127
+ const res = await fetch('/auth/interaction/{{ uid }}/passkey/options', {
128
+ method: 'POST',
129
+ headers: { 'content-type': 'application/json', 'x-csrf-token': csrf },
130
+ body: JSON.stringify({}),
131
+ })
132
+ if (!res.ok) throw new Error('options')
133
+ const optionsJSON = await res.json()
134
+ const assertion = await startAuthentication({ optionsJSON })
135
+ document.getElementById('passkey-response').value = JSON.stringify(assertion)
136
+ document.getElementById('passkey-form').submit()
137
+ } catch (e) {
138
+ errEl.classList.remove('hidden')
139
+ btn.disabled = false
140
+ }
141
+ })
142
+ </script>
143
+ @end
89
144
  </div>
90
145
  @end
91
146
  </body></html>
@@ -24,6 +24,13 @@
24
24
  class="w-full rounded-lg border border-gray-300 px-3 py-2 text-center text-lg tracking-[0.4em] outline-none transition focus:border-gray-900 focus:ring-2 focus:ring-gray-900" />
25
25
  </div>
26
26
 
27
+ @if(trustedDevicesEnabled)
28
+ <label class="mt-4 flex items-center gap-2 text-sm text-gray-600">
29
+ <input type="checkbox" name="trustDevice" value="on" class="h-4 w-4 rounded border-gray-300">
30
+ {{ t('mfa_challenge.trust_device', { days: trustedDeviceDays }) }}
31
+ </label>
32
+ @end
33
+
27
34
  <button type="submit"
28
35
  class="mt-6 w-full rounded-lg bg-gray-900 py-2.5 text-sm font-semibold text-white transition hover:opacity-90">
29
36
  {{ t('mfa_challenge.submit') }}
@@ -37,6 +44,7 @@
37
44
  <form id="passkey-form" method="POST" action="/auth/interaction/{{ uid }}/passkey/verify" class="mt-4">
38
45
  <input type="hidden" name="_csrf" value="{{ csrfToken }}">
39
46
  <input type="hidden" name="response" id="passkey-response">
47
+ <input type="hidden" name="trustDevice" id="passkey-trust" value="">
40
48
  </form>
41
49
  <button type="button" id="passkey-button"
42
50
  class="mt-4 w-full rounded-lg border border-gray-300 py-2.5 text-sm font-semibold text-gray-700 transition hover:bg-gray-50">
@@ -83,6 +91,10 @@
83
91
  const assertion = await startAuthentication({ optionsJSON })
84
92
  // 3) Submete a assertion como POST de página inteira (segue o 303).
85
93
  document.getElementById('passkey-response').value = JSON.stringify(assertion)
94
+ // Carrega a escolha de "confiar neste dispositivo" para o form da passkey.
95
+ const trustChk = document.querySelector('input[name="trustDevice"][type="checkbox"]')
96
+ const trustHidden = document.getElementById('passkey-trust')
97
+ if (trustHidden) trustHidden.value = trustChk && trustChk.checked ? 'on' : ''
86
98
  document.getElementById('passkey-form').submit()
87
99
  } catch (e) {
88
100
  errEl.classList.remove('hidden')
package/build/index.d.ts CHANGED
@@ -8,10 +8,14 @@ export { registerOidcRoutes } from './src/register_routes.js';
8
8
  export type { AuthServerConfigInput, ResolvedServerConfig, DynamicRegistrationConfigInput, ResolvedDynamicRegistrationConfig, AdminConfigInput, ResolvedAdminConfig, } from './src/define_config.js';
9
9
  export { resolveAdmin, resolveWebauthn, resolveDynamicRegistration } from './src/define_config.js';
10
10
  export type { WebauthnConfigInput, ResolvedWebauthnConfig } from './src/define_config.js';
11
+ export { resolvePasswordless } from './src/define_config.js';
12
+ export type { PasswordlessConfigInput, ResolvedPasswordlessConfig, } from './src/define_config.js';
13
+ export { resolveTrustedDevices, isTrustedDeviceValid, buildTrustedDevicePayload, TRUSTED_DEVICE_COOKIE, } from './src/host/trusted_device.js';
14
+ export type { TrustedDevicesConfigInput, ResolvedTrustedDevicesConfig, TrustedDevicePayload, } from './src/host/trusted_device.js';
11
15
  export { lucidAccountStore } from './src/accounts/lucid_account_store.js';
12
16
  export type { LucidAccountStoreOptions, AccountSecretEncrypter, } from './src/accounts/lucid_account_store.js';
13
- export type { AccountStore, CoreAccountStore, AdminCapability, MfaCapability, WebauthnCapability, ProviderIdentityCapability, AccountSecurityCapability, AuthAccount, CreateAccountInput, LinkProviderIdentityInput, ListAccountsParams, Paginated, PasskeySummary, } from './src/accounts/account_store.js';
14
- export { supportsMfa, supportsPasskeys, supportsProviderIdentity, supportsAccountSecurity, } from './src/accounts/account_store.js';
17
+ export type { AccountStore, CoreAccountStore, AdminCapability, MfaCapability, WebauthnCapability, ProviderIdentityCapability, AccountSecurityCapability, MagicLinkCapability, AuthAccount, CreateAccountInput, LinkProviderIdentityInput, ListAccountsParams, Paginated, PasskeySummary, } from './src/accounts/account_store.js';
18
+ export { supportsMfa, supportsPasskeys, supportsProviderIdentity, supportsAccountSecurity, supportsMagicLink, } from './src/accounts/account_store.js';
15
19
  export { withProviderIdentity } from './src/mixins/with_provider_identity.js';
16
20
  export type { ProviderIdentityRow, ProviderIdentityClass, } from './src/mixins/with_provider_identity.js';
17
21
  export { withWebauthnCredential } from './src/mixins/with_webauthn_credential.js';
@@ -22,6 +26,8 @@ export { withPersonalAccessToken } from './src/mixins/with_personal_access_token
22
26
  export { lucidAuditSink } from './src/audit/lucid_audit_sink.js';
23
27
  export type { AuditSink, AuditEvent, AuditEventType, StoredAuditEvent, ListAuditParams, AuditPage, } from './src/audit/audit_sink.js';
24
28
  export { withAuditLog } from './src/mixins/with_audit_log.js';
29
+ export { composeAuditSink, resolveEvents, buildWebhookBody, signWebhookBody, } from './src/events/dispatcher.js';
30
+ export type { EventsConfigInput, ResolvedEventsConfig } from './src/events/dispatcher.js';
25
31
  export { inertiaRenderer } from './src/host/renderers/inertia_renderer.js';
26
32
  export { edgeRenderer } from './src/host/renderers/edge_renderer.js';
27
33
  export { brandFor, isFirstParty } from './src/host/branding.js';
package/build/index.js CHANGED
@@ -6,14 +6,17 @@ export { withMfa } from './src/mixins/with_mfa.js';
6
6
  export { OidcService } from './src/provider/oidc_service.js';
7
7
  export { registerOidcRoutes } from './src/register_routes.js';
8
8
  export { resolveAdmin, resolveWebauthn, resolveDynamicRegistration } from './src/define_config.js';
9
+ export { resolvePasswordless } from './src/define_config.js';
10
+ export { resolveTrustedDevices, isTrustedDeviceValid, buildTrustedDevicePayload, TRUSTED_DEVICE_COOKIE, } from './src/host/trusted_device.js';
9
11
  export { lucidAccountStore } from './src/accounts/lucid_account_store.js';
10
- export { supportsMfa, supportsPasskeys, supportsProviderIdentity, supportsAccountSecurity, } from './src/accounts/account_store.js';
12
+ export { supportsMfa, supportsPasskeys, supportsProviderIdentity, supportsAccountSecurity, supportsMagicLink, } from './src/accounts/account_store.js';
11
13
  export { withProviderIdentity } from './src/mixins/with_provider_identity.js';
12
14
  export { withWebauthnCredential } from './src/mixins/with_webauthn_credential.js';
13
15
  export { lucidPatStore } from './src/pat/lucid_pat_store.js';
14
16
  export { withPersonalAccessToken } from './src/mixins/with_personal_access_token.js';
15
17
  export { lucidAuditSink } from './src/audit/lucid_audit_sink.js';
16
18
  export { withAuditLog } from './src/mixins/with_audit_log.js';
19
+ export { composeAuditSink, resolveEvents, buildWebhookBody, signWebhookBody, } from './src/events/dispatcher.js';
17
20
  export { inertiaRenderer } from './src/host/renderers/inertia_renderer.js';
18
21
  export { edgeRenderer } from './src/host/renderers/edge_renderer.js';
19
22
  export { brandFor, isFirstParty } from './src/host/branding.js';
@@ -126,6 +126,43 @@ export interface AccountSecurityCapability {
126
126
  ok: false;
127
127
  }>;
128
128
  }
129
+ /**
130
+ * Status da conta (habilitar/desabilitar) — usado pelo console admin para
131
+ * suspender uma conta sem apagá-la. É uma CAPACIDADE opcional: stores sem suporte
132
+ * omitem os métodos e a UI esconde os botões de disable/enable. Quando suportada,
133
+ * os fluxos de login (interaction OIDC + console de conta) DEVEM rejeitar contas
134
+ * desabilitadas (ver `attemptPasswordLogin`).
135
+ *
136
+ * O store default (Lucid) implementa via uma coluna `disabled_at` (timestamp
137
+ * nullable) NO model — quando a coluna existe (probe em `$columnsDefinitions`); se
138
+ * o model não a tiver, a capacidade fica genuinamente ausente (nenhum método é
139
+ * montado) e a UI esconde os botões. Hosts adicionam a coluna por migração própria
140
+ * (mesmo padrão documentado das demais colunas opcionais).
141
+ */
142
+ export interface AccountStatusCapability {
143
+ /** Desabilita a conta (impede login). No-op se a conta não existe. */
144
+ disableAccount(accountId: string): Promise<void>;
145
+ /** Reabilita a conta. No-op se a conta não existe. */
146
+ enableAccount(accountId: string): Promise<void>;
147
+ /** Indica se a conta está desabilitada (false se a conta não existe). */
148
+ isDisabled(accountId: string): Promise<boolean>;
149
+ }
150
+ /**
151
+ * Edição do perfil próprio (console de conta): nome e avatar. CAPACIDADE opcional;
152
+ * stores sem suporte omitem o método e a UI esconde a seção. O store default
153
+ * (Lucid) grava nas colunas `full_name`/`avatar_url` do model — apenas as que
154
+ * existirem (probe em `$columnsDefinitions`).
155
+ */
156
+ export interface ProfileCapability {
157
+ /**
158
+ * Atualiza o perfil da conta. Campos `undefined` são deixados como estão;
159
+ * `null` limpa o valor. Retorna a conta atualizada ou null se inexistente.
160
+ */
161
+ updateProfile(accountId: string, patch: {
162
+ name?: string | null;
163
+ avatarUrl?: string | null;
164
+ }): Promise<AuthAccount | null>;
165
+ }
129
166
  /**
130
167
  * Account linking por identidade de provider (Google, GitHub, …).
131
168
  * `(provider, providerUserId)` é a chave estável vinda do provider OAuth — não
@@ -144,9 +181,16 @@ export interface ProviderIdentityCapability {
144
181
  * flow trata a ausência como "MFA desligado".
145
182
  */
146
183
  export interface MfaCapability {
147
- /** Estado do MFA da conta (se o desafio TOTP deve ser exigido no login). */
184
+ /**
185
+ * Estado do MFA da conta (se o desafio TOTP deve ser exigido no login).
186
+ * `enabledAt` (epoch ms) é o instante em que o MFA foi (re)enrolado, usado pelo
187
+ * mecanismo de "trusted devices": um cookie de confiança emitido ANTES desse
188
+ * instante é considerado inválido (re-enrolar MFA revoga a confiança). Pode ser
189
+ * `null`/ausente quando o MFA não está ativo ou o store não rastreia o instante.
190
+ */
148
191
  getMfaState(accountId: string): Promise<{
149
192
  enabled: boolean;
193
+ enabledAt?: number | null;
150
194
  }>;
151
195
  /**
152
196
  * Inicia o enrollment TOTP: gera um segredo PENDENTE (mfaEnabledAt continua
@@ -218,6 +262,34 @@ export interface WebauthnCapability {
218
262
  /** Remove uma passkey (por credential id) da conta. */
219
263
  removePasskey(accountId: string, credentialId: string): Promise<void>;
220
264
  }
265
+ /**
266
+ * Login sem senha por "magic link" — um token de uso único e curta duração
267
+ * enviado por e-mail. CAPACIDADE opcional: stores sem suporte omitem os métodos e
268
+ * a UI esconde o botão "me envie um link".
269
+ *
270
+ * O store default (Lucid) reaproveita as colunas de reset de senha
271
+ * (`passwordResetToken` / `passwordResetExpiresAt`) codificando o token com o
272
+ * prefixo `ml:` — assim NÃO exige migração nova (mesmo padrão do `ec:` da troca de
273
+ * e-mail). O tradeoff é que um magic link e um reset de senha pendentes não
274
+ * coexistem (mesma coluna); na prática são fluxos distintos no tempo. Consumir um
275
+ * magic link NÃO altera a senha.
276
+ */
277
+ export interface MagicLinkCapability {
278
+ /**
279
+ * Emite um magic link para o e-mail. Retorna o token + a conta, ou null se a
280
+ * conta não existe (o controller SEMPRE renderiza "link enviado" para não vazar
281
+ * a existência de contas).
282
+ */
283
+ issueMagicLinkToken(email: string): Promise<{
284
+ token: string;
285
+ account: AuthAccount;
286
+ } | null>;
287
+ /**
288
+ * Consome (single-use) um magic link. Retorna a conta autenticada ou null se o
289
+ * token é inválido/expirado. NÃO altera a senha.
290
+ */
291
+ consumeMagicLinkToken(token: string): Promise<AuthAccount | null>;
292
+ }
221
293
  /**
222
294
  * Store de contas usado pela config. É o núcleo SEMPRE presente
223
295
  * ({@link CoreAccountStore}) + as capacidades opcionais (MFA, WebAuthn, account
@@ -227,7 +299,7 @@ export interface WebauthnCapability {
227
299
  * presentes-mas-lançando). Use os type guards {@link supportsMfa},
228
300
  * {@link supportsPasskeys}, {@link supportsProviderIdentity} para estreitar.
229
301
  */
230
- export type AccountStore = CoreAccountStore & Partial<MfaCapability & WebauthnCapability & ProviderIdentityCapability & AccountSecurityCapability>;
302
+ export type AccountStore = CoreAccountStore & Partial<MfaCapability & WebauthnCapability & ProviderIdentityCapability & AccountSecurityCapability & AccountStatusCapability & ProfileCapability & MagicLinkCapability>;
231
303
  /** Type guard: o store implementa a capacidade de MFA / TOTP. */
232
304
  export declare function supportsMfa(store: AccountStore): store is AccountStore & MfaCapability;
233
305
  /** Type guard: o store implementa a capacidade de passkeys / WebAuthn. */
@@ -236,3 +308,9 @@ export declare function supportsPasskeys(store: AccountStore): store is AccountS
236
308
  export declare function supportsProviderIdentity(store: AccountStore): store is AccountStore & ProviderIdentityCapability;
237
309
  /** Type guard: o store implementa o self-service de segurança (senha/e-mail). */
238
310
  export declare function supportsAccountSecurity(store: AccountStore): store is AccountStore & AccountSecurityCapability;
311
+ /** Type guard: o store implementa habilitar/desabilitar conta. */
312
+ export declare function supportsAccountStatus(store: AccountStore): store is AccountStore & AccountStatusCapability;
313
+ /** Type guard: o store implementa a edição de perfil (nome/avatar). */
314
+ export declare function supportsProfile(store: AccountStore): store is AccountStore & ProfileCapability;
315
+ /** Type guard: o store implementa login por magic link (passwordless). */
316
+ export declare function supportsMagicLink(store: AccountStore): store is AccountStore & MagicLinkCapability;
@@ -14,3 +14,15 @@ export function supportsProviderIdentity(store) {
14
14
  export function supportsAccountSecurity(store) {
15
15
  return typeof store.changePassword === 'function';
16
16
  }
17
+ /** Type guard: o store implementa habilitar/desabilitar conta. */
18
+ export function supportsAccountStatus(store) {
19
+ return typeof store.disableAccount === 'function';
20
+ }
21
+ /** Type guard: o store implementa a edição de perfil (nome/avatar). */
22
+ export function supportsProfile(store) {
23
+ return typeof store.updateProfile === 'function';
24
+ }
25
+ /** Type guard: o store implementa login por magic link (passwordless). */
26
+ export function supportsMagicLink(store) {
27
+ return typeof store.issueMagicLinkToken === 'function';
28
+ }
@@ -3,6 +3,7 @@ import { buildCore } from './lucid_store/core.js';
3
3
  import { buildMfa } from './lucid_store/mfa.js';
4
4
  import { buildProviderIdentity } from './lucid_store/provider_identity.js';
5
5
  import { buildWebauthn } from './lucid_store/webauthn.js';
6
+ import { buildStatus, buildProfile, hasColumn } from './lucid_store/status_profile.js';
6
7
  /**
7
8
  * Implementação default do {@link AccountStore} sobre um model Lucid composto
8
9
  * de `withAuthUser()` + `withCredentials()` (+ opcionalmente `withMfa()`). O
@@ -53,6 +54,7 @@ export function lucidAccountStore(Model, options = {}) {
53
54
  email: row.email,
54
55
  globalRoles: row.globalRoles ?? [],
55
56
  name: row.fullName ?? undefined,
57
+ avatarUrl: row.avatarUrl ?? undefined,
56
58
  }),
57
59
  };
58
60
  // Núcleo + MFA são sempre presentes. Passkeys e provider-identity só entram
@@ -65,5 +67,11 @@ export function lucidAccountStore(Model, options = {}) {
65
67
  ...(WebauthnCredentialModel
66
68
  ? buildWebauthn(ctx, WebauthnCredentialModel, webauthn, ceremonies)
67
69
  : {}),
70
+ // Status (disable/enable) só quando o model tem a coluna `disabled_at`.
71
+ ...(hasColumn(Model, 'disabledAt') ? buildStatus(ctx) : {}),
72
+ // Perfil (nome/avatar) só quando o model tem ao menos uma das colunas.
73
+ ...(hasColumn(Model, 'fullName') || hasColumn(Model, 'avatarUrl')
74
+ ? buildProfile(ctx)
75
+ : {}),
68
76
  };
69
77
  }
@@ -1,4 +1,4 @@
1
- import type { AccountSecurityCapability, CoreAccountStore } from '../account_store.js';
1
+ import type { AccountSecurityCapability, CoreAccountStore, MagicLinkCapability } from '../account_store.js';
2
2
  import type { LucidStoreContext } from './shared.js';
3
3
  /**
4
4
  * Núcleo SEMPRE presente do {@link CoreAccountStore} sobre um model Lucid:
@@ -6,4 +6,4 @@ import type { LucidStoreContext } from './shared.js';
6
6
  * (listagem paginada + roles globais) e o self-service de segurança
7
7
  * ({@link AccountSecurityCapability}: trocar senha/e-mail).
8
8
  */
9
- export declare function buildCore(ctx: LucidStoreContext): CoreAccountStore & AccountSecurityCapability;
9
+ export declare function buildCore(ctx: LucidStoreContext): CoreAccountStore & AccountSecurityCapability & MagicLinkCapability;
@@ -2,6 +2,8 @@ import { randomBytes } from 'node:crypto';
2
2
  import { DateTime } from 'luxon';
3
3
  /** Prefixo do token de troca de e-mail (reaproveita a coluna emailVerificationToken). */
4
4
  const EMAIL_CHANGE_PREFIX = 'ec:';
5
+ /** Prefixo do magic link (reaproveita as colunas de reset de senha). */
6
+ const MAGIC_LINK_PREFIX = 'ml:';
5
7
  /**
6
8
  * Núcleo SEMPRE presente do {@link CoreAccountStore} sobre um model Lucid:
7
9
  * identidade, cadastro, reset de senha, verificação de e-mail, administração
@@ -46,6 +48,10 @@ export function buildCore(ctx) {
46
48
  return { token, account: toAccount(row) };
47
49
  },
48
50
  async consumePasswordResetToken(token, newPassword) {
51
+ // Magic links (`ml:`) NÃO são tokens de reset de senha — só o fluxo de
52
+ // consumeMagicLinkToken pode consumi-los (não trocam senha).
53
+ if (token.startsWith(MAGIC_LINK_PREFIX))
54
+ return false;
49
55
  const row = await Model.query().where('passwordResetToken', token).first();
50
56
  if (!row)
51
57
  return false;
@@ -57,6 +63,33 @@ export function buildCore(ctx) {
57
63
  await row.save();
58
64
  return true;
59
65
  },
66
+ // ----- Magic link (login passwordless) -----
67
+ async issueMagicLinkToken(email) {
68
+ const row = await Model.query().where('email', email).first();
69
+ if (!row)
70
+ return null;
71
+ // Token `ml:<random>` nas colunas de reset (sem migração); o prefixo o
72
+ // distingue de um token de reset de senha. Curta duração (15 min).
73
+ const token = `${MAGIC_LINK_PREFIX}${randomBytes(32).toString('hex')}`;
74
+ row.passwordResetToken = token;
75
+ row.passwordResetExpiresAt = DateTime.now().plus({ minutes: 15 });
76
+ await row.save();
77
+ return { token, account: toAccount(row) };
78
+ },
79
+ async consumeMagicLinkToken(token) {
80
+ if (!token || !token.startsWith(MAGIC_LINK_PREFIX))
81
+ return null;
82
+ const row = await Model.query().where('passwordResetToken', token).first();
83
+ if (!row)
84
+ return null;
85
+ if (!row.passwordResetExpiresAt || row.passwordResetExpiresAt < DateTime.now())
86
+ return null;
87
+ // Single-use: limpa o token (NÃO altera a senha).
88
+ row.passwordResetToken = null;
89
+ row.passwordResetExpiresAt = null;
90
+ await row.save();
91
+ return toAccount(row);
92
+ },
60
93
  async issueEmailVerificationToken(email) {
61
94
  const row = await Model.query().where('email', email).first();
62
95
  if (!row)
@@ -11,7 +11,10 @@ export function buildMfa(ctx) {
11
11
  return {
12
12
  async getMfaState(accountId) {
13
13
  const row = await Model.find(accountId);
14
- return { enabled: !!row?.mfaEnabledAt };
14
+ // `enabledAt` (epoch ms) habilita o trusted-device check: um cookie de
15
+ // confiança emitido ANTES deste instante é inválido (re-enrolar revoga).
16
+ const enabledAt = row?.mfaEnabledAt ? row.mfaEnabledAt.toMillis() : null;
17
+ return { enabled: !!row?.mfaEnabledAt, enabledAt };
15
18
  },
16
19
  async startTotpEnrollment(accountId) {
17
20
  const row = await Model.find(accountId);