@dudousxd/adonis-authkit-server 0.1.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 (174) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -0
  3. package/build/assets/grafana/authkit-dashboard.json +118 -0
  4. package/build/commands/commands.json +30 -0
  5. package/build/commands/configure.d.ts +2 -0
  6. package/build/commands/configure.js +42 -0
  7. package/build/commands/eject.d.ts +11 -0
  8. package/build/commands/eject.js +96 -0
  9. package/build/commands/main.d.ts +12 -0
  10. package/build/commands/main.js +38 -0
  11. package/build/commands/ui_preset.d.ts +4 -0
  12. package/build/commands/ui_preset.js +32 -0
  13. package/build/database/migrations/make_authkit_oidc_table.d.ts +6 -0
  14. package/build/database/migrations/make_authkit_oidc_table.js +19 -0
  15. package/build/host/views/account/login.edge +29 -0
  16. package/build/host/views/account/mfa.edge +151 -0
  17. package/build/host/views/account/tokens.edge +70 -0
  18. package/build/host/views/admin/audit.edge +72 -0
  19. package/build/host/views/admin/clients.edge +51 -0
  20. package/build/host/views/admin/dashboard.edge +58 -0
  21. package/build/host/views/admin/users.edge +76 -0
  22. package/build/host/views/consent.edge +19 -0
  23. package/build/host/views/forgot.edge +30 -0
  24. package/build/host/views/login.edge +91 -0
  25. package/build/host/views/mfa-challenge.edge +88 -0
  26. package/build/host/views/reset.edge +29 -0
  27. package/build/host/views/signup.edge +44 -0
  28. package/build/host/views/verify-email.edge +16 -0
  29. package/build/index.d.ts +42 -0
  30. package/build/index.js +28 -0
  31. package/build/providers/authkit_server_provider.d.ts +19 -0
  32. package/build/providers/authkit_server_provider.js +81 -0
  33. package/build/src/accounts/account_store.d.ts +136 -0
  34. package/build/src/accounts/account_store.js +1 -0
  35. package/build/src/accounts/lucid_account_store.d.ts +75 -0
  36. package/build/src/accounts/lucid_account_store.js +396 -0
  37. package/build/src/adapters/adapter_contract.d.ts +18 -0
  38. package/build/src/adapters/adapter_contract.js +1 -0
  39. package/build/src/adapters/database_adapter.d.ts +15 -0
  40. package/build/src/adapters/database_adapter.js +63 -0
  41. package/build/src/adapters/factory.d.ts +30 -0
  42. package/build/src/adapters/factory.js +43 -0
  43. package/build/src/adapters/redis_adapter.d.ts +16 -0
  44. package/build/src/adapters/redis_adapter.js +95 -0
  45. package/build/src/audit/audit_sink.d.ts +54 -0
  46. package/build/src/audit/audit_sink.js +1 -0
  47. package/build/src/audit/lucid_audit_sink.d.ts +10 -0
  48. package/build/src/audit/lucid_audit_sink.js +60 -0
  49. package/build/src/controllers/oidc_callback_controller.d.ts +22 -0
  50. package/build/src/controllers/oidc_callback_controller.js +33 -0
  51. package/build/src/define_config.d.ts +261 -0
  52. package/build/src/define_config.js +115 -0
  53. package/build/src/host/account_lockout.d.ts +86 -0
  54. package/build/src/host/account_lockout.js +185 -0
  55. package/build/src/host/augmentations.d.ts +1 -0
  56. package/build/src/host/augmentations.js +1 -0
  57. package/build/src/host/branding.d.ts +17 -0
  58. package/build/src/host/branding.js +8 -0
  59. package/build/src/host/controllers/account_mfa_controller.d.ts +30 -0
  60. package/build/src/host/controllers/account_mfa_controller.js +157 -0
  61. package/build/src/host/controllers/account_session_controller.d.ts +7 -0
  62. package/build/src/host/controllers/account_session_controller.js +50 -0
  63. package/build/src/host/controllers/account_tokens_controller.d.ts +7 -0
  64. package/build/src/host/controllers/account_tokens_controller.js +55 -0
  65. package/build/src/host/controllers/admin/admin_audit_controller.d.ts +10 -0
  66. package/build/src/host/controllers/admin/admin_audit_controller.js +56 -0
  67. package/build/src/host/controllers/admin/admin_clients_controller.d.ts +10 -0
  68. package/build/src/host/controllers/admin/admin_clients_controller.js +24 -0
  69. package/build/src/host/controllers/admin/admin_dashboard_controller.d.ts +10 -0
  70. package/build/src/host/controllers/admin/admin_dashboard_controller.js +27 -0
  71. package/build/src/host/controllers/admin/admin_users_controller.d.ts +11 -0
  72. package/build/src/host/controllers/admin/admin_users_controller.js +53 -0
  73. package/build/src/host/controllers/interaction_controller.d.ts +44 -0
  74. package/build/src/host/controllers/interaction_controller.js +304 -0
  75. package/build/src/host/controllers/pat_introspection_controller.d.ts +22 -0
  76. package/build/src/host/controllers/pat_introspection_controller.js +46 -0
  77. package/build/src/host/controllers/registration_controller.d.ts +18 -0
  78. package/build/src/host/controllers/registration_controller.js +169 -0
  79. package/build/src/host/controllers/social_controller.d.ts +8 -0
  80. package/build/src/host/controllers/social_controller.js +82 -0
  81. package/build/src/host/default_mailer.d.ts +39 -0
  82. package/build/src/host/default_mailer.js +141 -0
  83. package/build/src/host/email_templates.d.ts +35 -0
  84. package/build/src/host/email_templates.js +66 -0
  85. package/build/src/host/i18n.d.ts +178 -0
  86. package/build/src/host/i18n.js +208 -0
  87. package/build/src/host/middleware/account_auth.d.ts +7 -0
  88. package/build/src/host/middleware/account_auth.js +11 -0
  89. package/build/src/host/rate_limit.d.ts +32 -0
  90. package/build/src/host/rate_limit.js +87 -0
  91. package/build/src/host/register_auth_host.d.ts +41 -0
  92. package/build/src/host/register_auth_host.js +133 -0
  93. package/build/src/host/renderers/edge_renderer.d.ts +3 -0
  94. package/build/src/host/renderers/edge_renderer.js +29 -0
  95. package/build/src/host/renderers/inertia_renderer.d.ts +5 -0
  96. package/build/src/host/renderers/inertia_renderer.js +26 -0
  97. package/build/src/host/validators.d.ts +39 -0
  98. package/build/src/host/validators.js +13 -0
  99. package/build/src/keys/jwks_manager.d.ts +6 -0
  100. package/build/src/keys/jwks_manager.js +11 -0
  101. package/build/src/mixins/with_audit_log.d.ts +19 -0
  102. package/build/src/mixins/with_audit_log.js +41 -0
  103. package/build/src/mixins/with_auth_user.d.ts +18 -0
  104. package/build/src/mixins/with_auth_user.js +39 -0
  105. package/build/src/mixins/with_credentials.d.ts +20 -0
  106. package/build/src/mixins/with_credentials.js +29 -0
  107. package/build/src/mixins/with_mfa.d.ts +31 -0
  108. package/build/src/mixins/with_mfa.js +39 -0
  109. package/build/src/mixins/with_personal_access_token.d.ts +19 -0
  110. package/build/src/mixins/with_personal_access_token.js +44 -0
  111. package/build/src/mixins/with_provider_identity.d.ts +20 -0
  112. package/build/src/mixins/with_provider_identity.js +32 -0
  113. package/build/src/mixins/with_webauthn_credential.d.ts +37 -0
  114. package/build/src/mixins/with_webauthn_credential.js +49 -0
  115. package/build/src/observability/metrics_controller.d.ts +5 -0
  116. package/build/src/observability/metrics_controller.js +24 -0
  117. package/build/src/observability/metrics_service.d.ts +2 -0
  118. package/build/src/observability/metrics_service.js +7 -0
  119. package/build/src/observability/otel_recorder.d.ts +10 -0
  120. package/build/src/observability/otel_recorder.js +59 -0
  121. package/build/src/observability/wire_provider_events.d.ts +12 -0
  122. package/build/src/observability/wire_provider_events.js +19 -0
  123. package/build/src/pat/lucid_pat_store.d.ts +6 -0
  124. package/build/src/pat/lucid_pat_store.js +62 -0
  125. package/build/src/pat/pat_store.d.ts +31 -0
  126. package/build/src/pat/pat_store.js +1 -0
  127. package/build/src/pat/pat_tokens.d.ts +4 -0
  128. package/build/src/pat/pat_tokens.js +9 -0
  129. package/build/src/provider/build_provider.d.ts +8 -0
  130. package/build/src/provider/build_provider.js +101 -0
  131. package/build/src/provider/interaction_actions.d.ts +21 -0
  132. package/build/src/provider/interaction_actions.js +32 -0
  133. package/build/src/provider/oidc_service.d.ts +17 -0
  134. package/build/src/provider/oidc_service.js +84 -0
  135. package/build/src/provider/token_exchange.d.ts +15 -0
  136. package/build/src/provider/token_exchange.js +72 -0
  137. package/build/src/register_routes.d.ts +16 -0
  138. package/build/src/register_routes.js +21 -0
  139. package/build/stubs/config/authkit.stub +29 -0
  140. package/build/stubs/main.d.ts +1 -0
  141. package/build/stubs/main.js +2 -0
  142. package/build/stubs/models/auth_user.stub +13 -0
  143. package/build/stubs/ui/edge/views/consent.edge +13 -0
  144. package/build/stubs/ui/edge/views/login.edge +19 -0
  145. package/build/stubs/ui/react/components/auth_shell.tsx +67 -0
  146. package/build/stubs/ui/react/pages/account/login.tsx +56 -0
  147. package/build/stubs/ui/react/pages/account/mfa.tsx +132 -0
  148. package/build/stubs/ui/react/pages/account/tokens.tsx +88 -0
  149. package/build/stubs/ui/react/pages/consent.tsx +39 -0
  150. package/build/stubs/ui/react/pages/forgot.tsx +44 -0
  151. package/build/stubs/ui/react/pages/login.tsx +171 -0
  152. package/build/stubs/ui/react/pages/mfa-challenge.tsx +72 -0
  153. package/build/stubs/ui/react/pages/reset.tsx +58 -0
  154. package/build/stubs/ui/react/pages/signup.tsx +78 -0
  155. package/build/stubs/ui/react/pages/verify-email.tsx +24 -0
  156. package/build/types.d.ts +7 -0
  157. package/build/types.js +1 -0
  158. package/package.json +108 -0
  159. package/stubs/config/authkit.stub +29 -0
  160. package/stubs/main.ts +2 -0
  161. package/stubs/models/auth_user.stub +13 -0
  162. package/stubs/ui/edge/views/consent.edge +13 -0
  163. package/stubs/ui/edge/views/login.edge +19 -0
  164. package/stubs/ui/react/components/auth_shell.tsx +67 -0
  165. package/stubs/ui/react/pages/account/login.tsx +56 -0
  166. package/stubs/ui/react/pages/account/mfa.tsx +132 -0
  167. package/stubs/ui/react/pages/account/tokens.tsx +88 -0
  168. package/stubs/ui/react/pages/consent.tsx +39 -0
  169. package/stubs/ui/react/pages/forgot.tsx +44 -0
  170. package/stubs/ui/react/pages/login.tsx +171 -0
  171. package/stubs/ui/react/pages/mfa-challenge.tsx +72 -0
  172. package/stubs/ui/react/pages/reset.tsx +58 -0
  173. package/stubs/ui/react/pages/signup.tsx +78 -0
  174. package/stubs/ui/react/pages/verify-email.tsx +24 -0
@@ -0,0 +1,185 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ let limiterServicePromise;
10
+ /**
11
+ * Importa o service do limiter do HOST de forma preguiçosa e fail-safe (mesmo
12
+ * padrão de `rate_limit.ts`). Resolve `null` quando o limiter não está disponível.
13
+ */
14
+ async function loadLimiter() {
15
+ if (!limiterServicePromise) {
16
+ const specifier = '@adonisjs/limiter/services/main';
17
+ limiterServicePromise = import(__rewriteRelativeImportExtension(specifier))
18
+ .then((mod) => mod.default ?? null)
19
+ .catch(() => null);
20
+ }
21
+ return limiterServicePromise;
22
+ }
23
+ /**
24
+ * Permite reapontar/limpar o loader do limiter (usado em testes).
25
+ * @internal
26
+ */
27
+ export function __setLockoutLimiterLoaderForTests(fn) {
28
+ if (fn) {
29
+ limiterServicePromise = fn();
30
+ }
31
+ else {
32
+ limiterServicePromise = undefined;
33
+ }
34
+ }
35
+ /** Normaliza o email para virar chave estável (lowercase + trim). */
36
+ function normalizeEmail(email) {
37
+ return (email ?? '').trim().toLowerCase();
38
+ }
39
+ /**
40
+ * Backoff progressivo PURO (sem I/O — fácil de testar): a duração do lock cresce
41
+ * com o número de locks já ocorridos para a chave: `base * 2^(lockCount-1)`,
42
+ * limitada a `maxLockoutSec`. `lockCount` é 1-based (1 = primeiro lock).
43
+ */
44
+ export function computeLockoutSec(lockCount, cfg) {
45
+ const n = Math.max(1, lockCount);
46
+ const grown = cfg.baseLockoutSec * 2 ** (n - 1);
47
+ return Math.min(grown, cfg.maxLockoutSec);
48
+ }
49
+ /**
50
+ * Helper de lockout amarrado a uma config resolvida. Cada método é fail-safe:
51
+ * resolve o limiter preguiçosamente e degrada para no-op quando ele está ausente
52
+ * ou quando `cfg.enabled === false`.
53
+ */
54
+ export class AccountLockout {
55
+ cfg;
56
+ constructor(cfg) {
57
+ this.cfg = cfg;
58
+ }
59
+ failKey(email) {
60
+ return `authkit_lockout_fail:${email}`;
61
+ }
62
+ lockKey(email) {
63
+ return `authkit_lockout:${email}`;
64
+ }
65
+ countKey(email) {
66
+ return `authkit_lockout_count:${email}`;
67
+ }
68
+ /**
69
+ * Resolve o limiter (ou `null`). Retorna `null` também quando lockout está
70
+ * desligado por config — assim o caminho de no-op é o mesmo.
71
+ */
72
+ async limiter() {
73
+ if (!this.cfg.enabled)
74
+ return null;
75
+ return loadLimiter();
76
+ }
77
+ /** Store de contagem de falhas (janela deslizante de `windowSec`). */
78
+ failStore(limiter) {
79
+ const opts = { requests: this.cfg.maxAttempts, duration: this.cfg.windowSec };
80
+ return this.cfg.store ? limiter.use(this.cfg.store, opts) : limiter.use(opts);
81
+ }
82
+ /**
83
+ * Store da contagem de locks. A janela é longa (mantém o histórico de locks por
84
+ * um tempo para o backoff progressivo crescer entre locks sucessivos).
85
+ */
86
+ countStore(limiter) {
87
+ const opts = { requests: 1_000_000, duration: this.cfg.maxLockoutSec * 4 };
88
+ return this.cfg.store ? limiter.use(this.cfg.store, opts) : limiter.use(opts);
89
+ }
90
+ /**
91
+ * Store dedicado a marcar a chave como bloqueada. `requests: 1` + `block(...)`
92
+ * com a duração progressiva; `isBlocked`/`availableIn` consultam o estado.
93
+ */
94
+ lockStore(limiter) {
95
+ const opts = { requests: 1, duration: this.cfg.maxLockoutSec };
96
+ return this.cfg.store ? limiter.use(this.cfg.store, opts) : limiter.use(opts);
97
+ }
98
+ /** `true`/retryAfter quando a conta está bloqueada. Fail-safe: `{ locked: false }`. */
99
+ async isLocked(email) {
100
+ const key = normalizeEmail(email);
101
+ if (!key)
102
+ return { locked: false };
103
+ const limiter = await this.limiter();
104
+ if (!limiter)
105
+ return { locked: false };
106
+ try {
107
+ const store = this.lockStore(limiter);
108
+ const blocked = await store.isBlocked(this.lockKey(key));
109
+ if (!blocked)
110
+ return { locked: false };
111
+ const retryAfterSec = await store.availableIn(this.lockKey(key));
112
+ return { locked: true, retryAfterSec };
113
+ }
114
+ catch {
115
+ // Falha do limiter NUNCA derruba o login — degrada para "não bloqueado".
116
+ return { locked: false };
117
+ }
118
+ }
119
+ /**
120
+ * Registra uma falha de login. Ao cruzar `maxAttempts` dentro da janela, marca
121
+ * a chave como bloqueada com TTL progressivo e incrementa a contagem de locks.
122
+ * Emite `account.locked` UMA vez (na transição), não a cada tentativa bloqueada.
123
+ */
124
+ async recordFailure(email, audit) {
125
+ const key = normalizeEmail(email);
126
+ if (!key)
127
+ return;
128
+ const limiter = await this.limiter();
129
+ if (!limiter)
130
+ return;
131
+ try {
132
+ // Se já está bloqueada, não conta de novo nem reemite o evento.
133
+ const lockStore = this.lockStore(limiter);
134
+ if (await lockStore.isBlocked(this.lockKey(key)))
135
+ return;
136
+ const failStore = this.failStore(limiter);
137
+ const res = await failStore.increment(this.failKey(key));
138
+ // `consumed` = falhas acumuladas na janela. Bloqueia ao atingir o teto.
139
+ const consumed = res?.consumed ?? 0;
140
+ if (consumed < this.cfg.maxAttempts)
141
+ return;
142
+ // Transição para bloqueado: incrementa a contagem de locks e calcula o TTL.
143
+ const countStore = this.countStore(limiter);
144
+ const countRes = await countStore.increment(this.countKey(key));
145
+ const lockCount = countRes?.consumed ?? 1;
146
+ const lockoutSec = computeLockoutSec(lockCount, this.cfg);
147
+ await lockStore.block(this.lockKey(key), lockoutSec);
148
+ // Zera o contador de falhas (a contagem recomeça após o lock).
149
+ await failStore.delete(this.failKey(key));
150
+ await audit?.sink?.record({
151
+ type: 'account.locked',
152
+ email: key,
153
+ ip: audit?.ip ?? null,
154
+ metadata: { lockCount, lockoutSec },
155
+ });
156
+ }
157
+ catch {
158
+ // Best-effort: nunca propaga erro do limiter para o caminho da request.
159
+ }
160
+ }
161
+ /**
162
+ * Limpa o estado de falhas após um login bem-sucedido. Remove o contador de
163
+ * falhas e o lock; mantém a contagem de locks (histórico para o backoff) — ela
164
+ * expira naturalmente pela janela longa do `countStore`.
165
+ */
166
+ async clearFailures(email) {
167
+ const key = normalizeEmail(email);
168
+ if (!key)
169
+ return;
170
+ const limiter = await this.limiter();
171
+ if (!limiter)
172
+ return;
173
+ try {
174
+ await this.failStore(limiter).delete(this.failKey(key));
175
+ await this.lockStore(limiter).delete(this.lockKey(key));
176
+ }
177
+ catch {
178
+ // no-op fail-safe
179
+ }
180
+ }
181
+ }
182
+ /** Fabrica o helper de lockout a partir da config resolvida. */
183
+ export function createAccountLockout(cfg) {
184
+ return new AccountLockout(cfg);
185
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,17 @@
1
+ export interface ClientBrand {
2
+ appName: string;
3
+ accent: string;
4
+ accentSoft: string;
5
+ tagline: string;
6
+ company?: string;
7
+ audienceLabel?: string;
8
+ }
9
+ export interface BrandingConfig {
10
+ company: string;
11
+ clients: Record<string, Omit<ClientBrand, 'company' | 'audienceLabel'>>;
12
+ default: Omit<ClientBrand, 'company' | 'audienceLabel'>;
13
+ firstParty: string[];
14
+ audienceLabels?: Record<string, string>;
15
+ }
16
+ export declare function isFirstParty(cfg: BrandingConfig, clientId: string | undefined): boolean;
17
+ export declare function brandFor(cfg: BrandingConfig, clientId: string | undefined, audience?: string): ClientBrand;
@@ -0,0 +1,8 @@
1
+ export function isFirstParty(cfg, clientId) {
2
+ return !!clientId && cfg.firstParty.includes(clientId);
3
+ }
4
+ export function brandFor(cfg, clientId, audience) {
5
+ const base = (clientId && cfg.clients[clientId]) || cfg.default;
6
+ const label = audience ? cfg.audienceLabels?.[audience] : undefined;
7
+ return { ...base, company: cfg.company, ...(label ? { audienceLabel: label } : {}) };
8
+ }
@@ -0,0 +1,30 @@
1
+ import '../augmentations.js';
2
+ import type { HttpContext } from '@adonisjs/core/http';
3
+ /**
4
+ * Console de MFA da conta (atrás do account_auth middleware). Enrollment TOTP
5
+ * com QR, confirmação com código, exibição única dos recovery codes e disable.
6
+ */
7
+ export default class AccountMfaController {
8
+ /** GET /account/mfa — estado atual; oferece enroll se desligado. */
9
+ index(ctx: HttpContext): Promise<any>;
10
+ /**
11
+ * POST /account/mfa/passkeys/options — gera as opções de registro de passkey
12
+ * (JSON), guarda o challenge na sessão e devolve as opções para o browser.
13
+ */
14
+ passkeyRegisterOptions(ctx: HttpContext): Promise<any>;
15
+ /**
16
+ * POST /account/mfa/passkeys/verify — verifica a resposta de registro do browser
17
+ * contra o challenge guardado; em caso de sucesso persiste a credencial e habilita MFA.
18
+ */
19
+ passkeyRegisterVerify(ctx: HttpContext): Promise<void | {
20
+ ok: boolean;
21
+ }>;
22
+ /** POST /account/mfa/passkeys/:id/remove — remove uma passkey da conta. */
23
+ passkeyRemove(ctx: HttpContext): Promise<void>;
24
+ /** POST /account/mfa/enroll — gera segredo pendente + QR e mostra a confirmação. */
25
+ enroll(ctx: HttpContext): Promise<any>;
26
+ /** POST /account/mfa/confirm — confirma o código; sucesso = ativa e mostra recovery codes. */
27
+ confirm(ctx: HttpContext): Promise<any>;
28
+ /** POST /account/mfa/disable — desliga o MFA. */
29
+ disable(ctx: HttpContext): Promise<void>;
30
+ }
@@ -0,0 +1,157 @@
1
+ import '../augmentations.js';
2
+ import QRCode from 'qrcode';
3
+ import { ACCOUNT_SESSION_KEY } from '../middleware/account_auth.js';
4
+ import { translate } from '../i18n.js';
5
+ /** Desafio WebAuthn pendente (registro) guardado na sessão entre begin/finish. */
6
+ const PASSKEY_REG_CHALLENGE_KEY = 'authkit_passkey_reg_challenge';
7
+ /**
8
+ * Console de MFA da conta (atrás do account_auth middleware). Enrollment TOTP
9
+ * com QR, confirmação com código, exibição única dos recovery codes e disable.
10
+ */
11
+ export default class AccountMfaController {
12
+ /** GET /account/mfa — estado atual; oferece enroll se desligado. */
13
+ async index(ctx) {
14
+ const service = await ctx.containerResolver.make('authkit.server');
15
+ const cfg = service.config;
16
+ const render = cfg.render;
17
+ const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
18
+ const state = (await cfg.accountStore.getMfaState?.(userId)) ?? { enabled: false };
19
+ const recoveryCodes = ctx.session.flashMessages.get('recoveryCodes');
20
+ // Passkeys disponíveis quando o store as suporta (model de credenciais wired).
21
+ const passkeysSupported = typeof cfg.accountStore.listPasskeys === 'function';
22
+ const passkeys = passkeysSupported ? await cfg.accountStore.listPasskeys(userId) : [];
23
+ return render(ctx, 'account/mfa', {
24
+ csrfToken: ctx.request.csrfToken,
25
+ enabled: state.enabled,
26
+ recoveryCodes: recoveryCodes ?? null,
27
+ passkeysSupported,
28
+ passkeys,
29
+ });
30
+ }
31
+ /**
32
+ * POST /account/mfa/passkeys/options — gera as opções de registro de passkey
33
+ * (JSON), guarda o challenge na sessão e devolve as opções para o browser.
34
+ */
35
+ async passkeyRegisterOptions(ctx) {
36
+ const service = await ctx.containerResolver.make('authkit.server');
37
+ const cfg = service.config;
38
+ const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
39
+ const generated = await cfg.accountStore.generatePasskeyRegistrationOptions?.(userId);
40
+ if (!generated) {
41
+ return ctx.response.notFound({ message: 'Passkeys indisponíveis' });
42
+ }
43
+ ctx.session.put(PASSKEY_REG_CHALLENGE_KEY, generated.challenge);
44
+ return generated.options;
45
+ }
46
+ /**
47
+ * POST /account/mfa/passkeys/verify — verifica a resposta de registro do browser
48
+ * contra o challenge guardado; em caso de sucesso persiste a credencial e habilita MFA.
49
+ */
50
+ async passkeyRegisterVerify(ctx) {
51
+ const service = await ctx.containerResolver.make('authkit.server');
52
+ const cfg = service.config;
53
+ const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
54
+ const challenge = ctx.session.get(PASSKEY_REG_CHALLENGE_KEY);
55
+ if (!challenge) {
56
+ return ctx.response.badRequest({ message: 'Desafio expirado' });
57
+ }
58
+ const body = ctx.request.input('response', ctx.request.body());
59
+ const ok = (await cfg.accountStore.verifyPasskeyRegistration?.(userId, body, challenge)) ?? false;
60
+ ctx.session.forget(PASSKEY_REG_CHALLENGE_KEY);
61
+ if (!ok) {
62
+ return ctx.response.badRequest({ message: translate(cfg.messages, 'errors.invalid_code') });
63
+ }
64
+ await cfg.audit?.record({
65
+ type: 'mfa.enabled',
66
+ accountId: userId,
67
+ ip: ctx.request.ip?.() ?? null,
68
+ metadata: { method: 'webauthn' },
69
+ });
70
+ await cfg.audit?.record({
71
+ type: 'passkey.registered',
72
+ accountId: userId,
73
+ ip: ctx.request.ip?.() ?? null,
74
+ });
75
+ return { ok: true };
76
+ }
77
+ /** POST /account/mfa/passkeys/:id/remove — remove uma passkey da conta. */
78
+ async passkeyRemove(ctx) {
79
+ const service = await ctx.containerResolver.make('authkit.server');
80
+ const cfg = service.config;
81
+ const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
82
+ const credentialId = ctx.request.param('id');
83
+ await cfg.accountStore.removePasskey?.(userId, credentialId);
84
+ await cfg.audit?.record({
85
+ type: 'passkey.removed',
86
+ accountId: userId,
87
+ ip: ctx.request.ip?.() ?? null,
88
+ metadata: { credentialId },
89
+ });
90
+ return ctx.response.redirect('/account/mfa');
91
+ }
92
+ /** POST /account/mfa/enroll — gera segredo pendente + QR e mostra a confirmação. */
93
+ async enroll(ctx) {
94
+ const service = await ctx.containerResolver.make('authkit.server');
95
+ const cfg = service.config;
96
+ const render = cfg.render;
97
+ const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
98
+ const started = await cfg.accountStore.startTotpEnrollment?.(userId);
99
+ if (!started) {
100
+ return ctx.response.redirect('/account/mfa');
101
+ }
102
+ // QR renderizado server-side como data-URL e passado como prop.
103
+ const qrDataUrl = await QRCode.toDataURL(started.otpauthUri);
104
+ return render(ctx, 'account/mfa', {
105
+ csrfToken: ctx.request.csrfToken,
106
+ enabled: false,
107
+ enrolling: true,
108
+ secret: started.secret,
109
+ qrDataUrl,
110
+ recoveryCodes: null,
111
+ });
112
+ }
113
+ /** POST /account/mfa/confirm — confirma o código; sucesso = ativa e mostra recovery codes. */
114
+ async confirm(ctx) {
115
+ const service = await ctx.containerResolver.make('authkit.server');
116
+ const cfg = service.config;
117
+ const render = cfg.render;
118
+ const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
119
+ const { code } = ctx.request.only(['code']);
120
+ const result = (await cfg.accountStore.confirmTotpEnrollment?.(userId, code)) ?? { ok: false };
121
+ if (!result.ok) {
122
+ // Reenvia o passo de confirmação com erro SEM regenerar o segredo pendente
123
+ // (o usuário já escaneou o QR; um novo segredo invalidaria o app autenticador).
124
+ // Mostra só o campo de código para nova tentativa.
125
+ return render(ctx, 'account/mfa', {
126
+ csrfToken: ctx.request.csrfToken,
127
+ enabled: false,
128
+ enrolling: true,
129
+ secret: null,
130
+ qrDataUrl: null,
131
+ error: translate(cfg.messages, 'errors.invalid_code'),
132
+ recoveryCodes: null,
133
+ });
134
+ }
135
+ await cfg.audit?.record({
136
+ type: 'mfa.enabled',
137
+ accountId: userId,
138
+ ip: ctx.request.ip?.() ?? null,
139
+ });
140
+ // Mostra os recovery codes UMA vez (flash) e volta pro estado "ativado".
141
+ ctx.session.flash('recoveryCodes', result.recoveryCodes ?? []);
142
+ return ctx.response.redirect('/account/mfa');
143
+ }
144
+ /** POST /account/mfa/disable — desliga o MFA. */
145
+ async disable(ctx) {
146
+ const service = await ctx.containerResolver.make('authkit.server');
147
+ const cfg = service.config;
148
+ const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
149
+ await cfg.accountStore.disableMfa?.(userId);
150
+ await cfg.audit?.record({
151
+ type: 'mfa.disabled',
152
+ accountId: userId,
153
+ ip: ctx.request.ip?.() ?? null,
154
+ });
155
+ return ctx.response.redirect('/account/mfa');
156
+ }
157
+ }
@@ -0,0 +1,7 @@
1
+ import '../augmentations.js';
2
+ import type { HttpContext } from '@adonisjs/core/http';
3
+ export default class AccountSessionController {
4
+ show(ctx: HttpContext): Promise<any>;
5
+ login(ctx: HttpContext): Promise<any>;
6
+ logout(ctx: HttpContext): Promise<void>;
7
+ }
@@ -0,0 +1,50 @@
1
+ import '../augmentations.js';
2
+ import { ACCOUNT_SESSION_KEY } from '../middleware/account_auth.js';
3
+ import { translate } from '../i18n.js';
4
+ import { createAccountLockout } from '../account_lockout.js';
5
+ export default class AccountSessionController {
6
+ async show(ctx) {
7
+ const service = await ctx.containerResolver.make('authkit.server');
8
+ const cfg = service.config;
9
+ const render = cfg.render;
10
+ if (ctx.session.get(ACCOUNT_SESSION_KEY)) {
11
+ return ctx.response.redirect('/account/tokens');
12
+ }
13
+ return render(ctx, 'account/login', { csrfToken: ctx.request.csrfToken });
14
+ }
15
+ async login(ctx) {
16
+ const service = await ctx.containerResolver.make('authkit.server');
17
+ const cfg = service.config;
18
+ const render = cfg.render;
19
+ const { email, password } = ctx.request.only(['email', 'password']);
20
+ const ip = ctx.request.ip?.() ?? null;
21
+ const lockout = createAccountLockout(cfg.lockout);
22
+ // Bloqueio progressivo: se a conta está travada, nem verifica a senha.
23
+ const lock = await lockout.isLocked(email);
24
+ if (lock.locked) {
25
+ return render(ctx, 'account/login', {
26
+ csrfToken: ctx.request.csrfToken,
27
+ error: translate(cfg.messages, 'errors.account_locked', {
28
+ seconds: lock.retryAfterSec ?? 0,
29
+ }),
30
+ });
31
+ }
32
+ const acc = await cfg.accountStore.verifyCredentials(email, password);
33
+ if (!acc) {
34
+ await cfg.audit?.record({ type: 'login.failure', email, ip });
35
+ await lockout.recordFailure(email, { sink: cfg.audit, ip });
36
+ return render(ctx, 'account/login', {
37
+ csrfToken: ctx.request.csrfToken,
38
+ error: translate(cfg.messages, 'errors.invalid_credentials'),
39
+ });
40
+ }
41
+ await lockout.clearFailures(email);
42
+ ctx.session.put(ACCOUNT_SESSION_KEY, acc.id);
43
+ await cfg.audit?.record({ type: 'login.success', accountId: acc.id, email, ip });
44
+ return ctx.response.redirect('/account/tokens');
45
+ }
46
+ async logout(ctx) {
47
+ ctx.session.forget(ACCOUNT_SESSION_KEY);
48
+ return ctx.response.redirect('/account/login');
49
+ }
50
+ }
@@ -0,0 +1,7 @@
1
+ import '../augmentations.js';
2
+ import type { HttpContext } from '@adonisjs/core/http';
3
+ export default class AccountTokensController {
4
+ index(ctx: HttpContext): Promise<any>;
5
+ store(ctx: HttpContext): Promise<void>;
6
+ destroy(ctx: HttpContext): Promise<void>;
7
+ }
@@ -0,0 +1,55 @@
1
+ import '../augmentations.js';
2
+ import { ACCOUNT_SESSION_KEY } from '../middleware/account_auth.js';
3
+ export default class AccountTokensController {
4
+ async index(ctx) {
5
+ const service = await ctx.containerResolver.make('authkit.server');
6
+ const cfg = service.config;
7
+ const render = cfg.render;
8
+ const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
9
+ const tokens = await cfg.patStore.listForAccount(userId);
10
+ const createdToken = ctx.session.flashMessages.get('createdToken');
11
+ return render(ctx, 'account/tokens', {
12
+ csrfToken: ctx.request.csrfToken,
13
+ createdToken: createdToken ?? null,
14
+ tokens: tokens.map((t) => ({
15
+ id: t.id,
16
+ name: t.name,
17
+ scopes: t.scopes,
18
+ audience: t.audience,
19
+ lastUsedAt: t.lastUsedAt,
20
+ createdAt: t.createdAt,
21
+ })),
22
+ });
23
+ }
24
+ async store(ctx) {
25
+ const service = await ctx.containerResolver.make('authkit.server');
26
+ const cfg = service.config;
27
+ const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
28
+ const { name } = ctx.request.only(['name']);
29
+ const { token, pat } = await cfg.patStore.issue({ accountId: userId, name: name || 'Token' });
30
+ ctx.session.flash('createdToken', token);
31
+ await cfg.audit?.record({
32
+ type: 'pat.issued',
33
+ accountId: userId,
34
+ ip: ctx.request.ip?.() ?? null,
35
+ metadata: { patId: pat.id, name: pat.name },
36
+ });
37
+ return ctx.response.redirect('/account/tokens');
38
+ }
39
+ async destroy(ctx) {
40
+ const service = await ctx.containerResolver.make('authkit.server');
41
+ const cfg = service.config;
42
+ const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
43
+ const patId = ctx.request.param('id');
44
+ const revoked = await cfg.patStore.revoke(userId, patId);
45
+ if (revoked) {
46
+ await cfg.audit?.record({
47
+ type: 'pat.revoked',
48
+ accountId: userId,
49
+ ip: ctx.request.ip?.() ?? null,
50
+ metadata: { patId },
51
+ });
52
+ }
53
+ return ctx.response.redirect('/account/tokens');
54
+ }
55
+ }
@@ -0,0 +1,10 @@
1
+ import '../../augmentations.js';
2
+ import type { HttpContext } from '@adonisjs/core/http';
3
+ /**
4
+ * Log de auditoria do IdP, paginado e filtrável por tipo e subject (accountId).
5
+ * Degrada graciosamente quando o sink configurado não suporta consulta: a view
6
+ * mostra "consulta não suportada" em vez de uma lista.
7
+ */
8
+ export default class AdminAuditController {
9
+ index(ctx: HttpContext): Promise<any>;
10
+ }
@@ -0,0 +1,56 @@
1
+ import '../../augmentations.js';
2
+ const PAGE_SIZE = 20;
3
+ /**
4
+ * Log de auditoria do IdP, paginado e filtrável por tipo e subject (accountId).
5
+ * Degrada graciosamente quando o sink configurado não suporta consulta: a view
6
+ * mostra "consulta não suportada" em vez de uma lista.
7
+ */
8
+ export default class AdminAuditController {
9
+ async index(ctx) {
10
+ const service = await ctx.containerResolver.make('authkit.server');
11
+ const cfg = service.config;
12
+ const render = cfg.render;
13
+ const supported = typeof cfg.audit?.list === 'function';
14
+ const type = ctx.request.input('type', '').trim();
15
+ const subject = ctx.request.input('subject', '').trim();
16
+ const page = Math.max(1, Number.parseInt(ctx.request.input('page', '1'), 10) || 1);
17
+ if (!supported) {
18
+ return render(ctx, 'admin/audit', {
19
+ csrfToken: ctx.request.csrfToken,
20
+ supported: false,
21
+ type,
22
+ subject,
23
+ page: 1,
24
+ totalPages: 1,
25
+ total: 0,
26
+ events: [],
27
+ });
28
+ }
29
+ const result = await cfg.audit.list({
30
+ page,
31
+ limit: PAGE_SIZE,
32
+ type: type || undefined,
33
+ subject: subject || undefined,
34
+ });
35
+ const totalPages = Math.max(1, Math.ceil(result.total / PAGE_SIZE));
36
+ return render(ctx, 'admin/audit', {
37
+ csrfToken: ctx.request.csrfToken,
38
+ supported: true,
39
+ type,
40
+ subject,
41
+ page,
42
+ totalPages,
43
+ total: result.total,
44
+ events: result.data.map((e) => ({
45
+ id: e.id,
46
+ type: e.type,
47
+ accountId: e.accountId ?? '',
48
+ email: e.email ?? '',
49
+ clientId: e.clientId ?? '',
50
+ actorId: e.actorId ?? '',
51
+ ip: e.ip ?? '',
52
+ createdAt: e.createdAt ? String(e.createdAt) : '',
53
+ })),
54
+ });
55
+ }
56
+ }
@@ -0,0 +1,10 @@
1
+ import '../../augmentations.js';
2
+ import type { HttpContext } from '@adonisjs/core/http';
3
+ /**
4
+ * Listagem de OAuth clients. Mostra os clients ESTÁTICOS da config; clients
5
+ * registrados dinamicamente (RFC 7591) vivem no adapter OIDC e não são listados
6
+ * aqui — a view informa isso quando o registro dinâmico está ligado.
7
+ */
8
+ export default class AdminClientsController {
9
+ index(ctx: HttpContext): Promise<any>;
10
+ }
@@ -0,0 +1,24 @@
1
+ import '../../augmentations.js';
2
+ /**
3
+ * Listagem de OAuth clients. Mostra os clients ESTÁTICOS da config; clients
4
+ * registrados dinamicamente (RFC 7591) vivem no adapter OIDC e não são listados
5
+ * aqui — a view informa isso quando o registro dinâmico está ligado.
6
+ */
7
+ export default class AdminClientsController {
8
+ async index(ctx) {
9
+ const service = await ctx.containerResolver.make('authkit.server');
10
+ const cfg = service.config;
11
+ const render = cfg.render;
12
+ return render(ctx, 'admin/clients', {
13
+ csrfToken: ctx.request.csrfToken,
14
+ dynamicEnabled: cfg.dynamicRegistration.enabled,
15
+ clients: cfg.clients.map((c) => ({
16
+ clientId: c.clientId,
17
+ confidential: !!c.clientSecret,
18
+ grants: c.grants ?? ['authorization_code', 'refresh_token'],
19
+ redirectUris: c.redirectUris ?? [],
20
+ postLogoutRedirectUris: c.postLogoutRedirectUris ?? [],
21
+ })),
22
+ });
23
+ }
24
+ }
@@ -0,0 +1,10 @@
1
+ import '../../augmentations.js';
2
+ import type { HttpContext } from '@adonisjs/core/http';
3
+ /**
4
+ * Dashboard do console admin: contagens-resumo (usuários, clients estáticos) e
5
+ * os eventos de auditoria mais recentes. Degrada graciosamente quando o sink de
6
+ * auditoria não suporta consulta (`list` ausente).
7
+ */
8
+ export default class AdminDashboardController {
9
+ index(ctx: HttpContext): Promise<any>;
10
+ }