@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,27 @@
1
+ import '../../augmentations.js';
2
+ /**
3
+ * Dashboard do console admin: contagens-resumo (usuários, clients estáticos) e
4
+ * os eventos de auditoria mais recentes. Degrada graciosamente quando o sink de
5
+ * auditoria não suporta consulta (`list` ausente).
6
+ */
7
+ export default class AdminDashboardController {
8
+ async index(ctx) {
9
+ const service = await ctx.containerResolver.make('authkit.server');
10
+ const cfg = service.config;
11
+ const render = cfg.render;
12
+ // Total de usuários (página vazia só para ler o `total`).
13
+ const usersPage = await cfg.accountStore.listAccounts({ page: 1, limit: 1 });
14
+ const clientsCount = cfg.clients.length;
15
+ // Eventos recentes (best-effort: só se o sink suportar consulta).
16
+ const auditSupported = typeof cfg.audit?.list === 'function';
17
+ const recent = auditSupported ? await cfg.audit.list({ page: 1, limit: 5 }) : null;
18
+ return render(ctx, 'admin/dashboard', {
19
+ csrfToken: ctx.request.csrfToken,
20
+ usersTotal: usersPage.total,
21
+ clientsCount,
22
+ auditSupported,
23
+ auditTotal: recent?.total ?? 0,
24
+ recentEvents: recent?.data ?? [],
25
+ });
26
+ }
27
+ }
@@ -0,0 +1,11 @@
1
+ import '../../augmentations.js';
2
+ import type { HttpContext } from '@adonisjs/core/http';
3
+ /**
4
+ * Gestão de usuários do IdP: listagem paginada com busca por e-mail e edição das
5
+ * roles globais de uma conta. As roles são informadas como texto separado por
6
+ * vírgula no formulário e normalizadas aqui.
7
+ */
8
+ export default class AdminUsersController {
9
+ index(ctx: HttpContext): Promise<any>;
10
+ updateRoles(ctx: HttpContext): Promise<void>;
11
+ }
@@ -0,0 +1,53 @@
1
+ import '../../augmentations.js';
2
+ const PAGE_SIZE = 20;
3
+ /**
4
+ * Gestão de usuários do IdP: listagem paginada com busca por e-mail e edição das
5
+ * roles globais de uma conta. As roles são informadas como texto separado por
6
+ * vírgula no formulário e normalizadas aqui.
7
+ */
8
+ export default class AdminUsersController {
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 search = ctx.request.input('search', '').trim();
14
+ const page = Math.max(1, Number.parseInt(ctx.request.input('page', '1'), 10) || 1);
15
+ const result = await cfg.accountStore.listAccounts({ search, page, limit: PAGE_SIZE });
16
+ const totalPages = Math.max(1, Math.ceil(result.total / PAGE_SIZE));
17
+ return render(ctx, 'admin/users', {
18
+ csrfToken: ctx.request.csrfToken,
19
+ search,
20
+ page,
21
+ totalPages,
22
+ total: result.total,
23
+ users: result.data.map((u) => ({
24
+ id: u.id,
25
+ email: u.email,
26
+ name: u.name ?? '',
27
+ roles: u.globalRoles ?? [],
28
+ rolesText: (u.globalRoles ?? []).join(', '),
29
+ })),
30
+ });
31
+ }
32
+ async updateRoles(ctx) {
33
+ const service = await ctx.containerResolver.make('authkit.server');
34
+ const cfg = service.config;
35
+ const accountId = ctx.request.param('id');
36
+ const raw = ctx.request.input('roles', '') ?? '';
37
+ // Normaliza: split por vírgula, trim, remove vazios e duplicatas.
38
+ const roles = Array.from(new Set(raw
39
+ .split(',')
40
+ .map((r) => r.trim())
41
+ .filter((r) => r.length > 0)));
42
+ await cfg.accountStore.setGlobalRoles(accountId, roles);
43
+ const search = ctx.request.input('search', '').trim();
44
+ const page = Math.max(1, Number.parseInt(ctx.request.input('page', '1'), 10) || 1);
45
+ const qs = new URLSearchParams();
46
+ if (search)
47
+ qs.set('search', search);
48
+ if (page > 1)
49
+ qs.set('page', String(page));
50
+ const query = qs.toString();
51
+ return ctx.response.redirect(`/admin/users${query ? `?${query}` : ''}`);
52
+ }
53
+ }
@@ -0,0 +1,44 @@
1
+ import '../augmentations.js';
2
+ import type { HttpContext } from '@adonisjs/core/http';
3
+ export default class AuthInteractionController {
4
+ show(ctx: HttpContext): Promise<any>;
5
+ /**
6
+ * POST /auth/interaction/:uid/identifier
7
+ * Step 1: receive email, store in session, redirect to step 2.
8
+ * ENUMERATION-SAFE: always advances regardless of whether the email exists.
9
+ */
10
+ identifier(ctx: HttpContext): Promise<void>;
11
+ /**
12
+ * POST /auth/interaction/:uid/login
13
+ * Step 2: password submit. Reads email from session (never from form).
14
+ */
15
+ login(ctx: HttpContext): Promise<any>;
16
+ /**
17
+ * POST /auth/interaction/:uid/mfa
18
+ * 2º fator: lê o accountId pendente da sessão e aceita um código TOTP (`code`)
19
+ * OU um recovery code (`recoveryCode`). Em caso de sucesso finaliza a interaction.
20
+ */
21
+ mfaVerify(ctx: HttpContext): Promise<any>;
22
+ /** true se o store suporta passkeys E a conta tem ao menos uma registrada. */
23
+ private hasPasskeys;
24
+ /**
25
+ * POST /auth/interaction/:uid/passkey/options
26
+ * Gera as opções de autenticação por passkey para o accountId pendente do MFA,
27
+ * guarda o challenge na sessão e devolve as opções JSON para o browser.
28
+ */
29
+ passkeyOptions(ctx: HttpContext): Promise<any>;
30
+ /**
31
+ * POST /auth/interaction/:uid/passkey/verify
32
+ * Verifica a resposta de autenticação por passkey contra o challenge guardado;
33
+ * em caso de sucesso FINALIZA a interaction (303 de volta ao client — alternativa
34
+ * ao código TOTP). É um POST de página inteira (form), não fetch: o browser
35
+ * submete o JSON da assertion no campo `response` e segue o redirect normalmente.
36
+ */
37
+ passkeyVerify(ctx: HttpContext): Promise<any>;
38
+ /**
39
+ * GET /auth/interaction/:uid/switch
40
+ * Clears the stored email and redirects back to step 1.
41
+ */
42
+ switchIdentifier(ctx: HttpContext): Promise<void>;
43
+ consent(ctx: HttpContext): Promise<void>;
44
+ }
@@ -0,0 +1,304 @@
1
+ import '../augmentations.js';
2
+ import { brandFor, isFirstParty } from '../branding.js';
3
+ import { translate } from '../i18n.js';
4
+ import { createAccountLockout } from '../account_lockout.js';
5
+ const SESSION_KEY = 'authkit_login_email';
6
+ /** accountId aguardando o 2º fator depois da senha verificada. */
7
+ const MFA_PENDING_KEY = 'authkit_mfa_pending';
8
+ /** Desafio WebAuthn pendente (autenticação) guardado entre begin/finish no login. */
9
+ const PASSKEY_AUTH_CHALLENGE_KEY = 'authkit_passkey_auth_challenge';
10
+ export default class AuthInteractionController {
11
+ async show(ctx) {
12
+ const service = await ctx.containerResolver.make('authkit.server');
13
+ const cfg = service.config;
14
+ const render = cfg.render;
15
+ const details = await service.interactions.details(ctx);
16
+ const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
17
+ if (details.prompt.name === 'consent' && isFirstParty(cfg.branding, details.params.client_id)) {
18
+ // Clients first-party: auto-concede o consent (pula a tela de consent).
19
+ // interactions.consent monta o Grant + interactionFinished e escreve o
20
+ // redirect de volta para o client — opera via provider.interactionDetails,
21
+ // independente do metodo HTTP, entao funciona no GET show().
22
+ return await service.interactions.consent(ctx);
23
+ }
24
+ if (details.prompt.name !== 'login') {
25
+ // Consent or other prompts — unchanged
26
+ return render(ctx, 'consent', {
27
+ uid: details.uid,
28
+ params: details.params,
29
+ csrfToken: ctx.request.csrfToken,
30
+ brand,
31
+ });
32
+ }
33
+ const email = ctx.session.get(SESSION_KEY);
34
+ if (!email) {
35
+ // Step 1: identifier (email only)
36
+ return render(ctx, 'login', {
37
+ uid: details.uid,
38
+ csrfToken: ctx.request.csrfToken,
39
+ step: 'identifier',
40
+ brand,
41
+ });
42
+ }
43
+ // Step 2: password — look up user for personalisation (enumeration-safe: always show step 2)
44
+ const acc = await cfg.accountStore.findByEmail(email);
45
+ const account = acc ? { fullName: acc.name ?? null, globalRoles: acc.globalRoles ?? [] } : null;
46
+ return render(ctx, 'login', {
47
+ uid: details.uid,
48
+ csrfToken: ctx.request.csrfToken,
49
+ step: 'password',
50
+ email,
51
+ account,
52
+ brand,
53
+ });
54
+ }
55
+ /**
56
+ * POST /auth/interaction/:uid/identifier
57
+ * Step 1: receive email, store in session, redirect to step 2.
58
+ * ENUMERATION-SAFE: always advances regardless of whether the email exists.
59
+ */
60
+ async identifier(ctx) {
61
+ const { email } = ctx.request.only(['email']);
62
+ ctx.session.put(SESSION_KEY, email);
63
+ return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
64
+ }
65
+ /**
66
+ * POST /auth/interaction/:uid/login
67
+ * Step 2: password submit. Reads email from session (never from form).
68
+ */
69
+ async login(ctx) {
70
+ const service = await ctx.containerResolver.make('authkit.server');
71
+ const cfg = service.config;
72
+ const render = cfg.render;
73
+ const details = await service.interactions.details(ctx);
74
+ const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
75
+ const email = ctx.session.get(SESSION_KEY);
76
+ if (!email) {
77
+ // Session expired or tampered — send back to step 1
78
+ return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
79
+ }
80
+ const { password } = ctx.request.only(['password']);
81
+ const ip = ctx.request.ip?.() ?? null;
82
+ const clientId = details.params.client_id ?? null;
83
+ const lockout = createAccountLockout(cfg.lockout);
84
+ // Bloqueio progressivo (keyed por email da sessão): se travada, não verifica a senha.
85
+ const lock = await lockout.isLocked(email);
86
+ if (lock.locked) {
87
+ const found = await cfg.accountStore.findByEmail(email);
88
+ const account = found
89
+ ? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
90
+ : null;
91
+ return render(ctx, 'login', {
92
+ uid: ctx.request.param('uid'),
93
+ csrfToken: ctx.request.csrfToken,
94
+ step: 'password',
95
+ email,
96
+ account,
97
+ error: translate(cfg.messages, 'errors.account_locked', {
98
+ seconds: lock.retryAfterSec ?? 0,
99
+ }),
100
+ brand,
101
+ });
102
+ }
103
+ // Verificamos as credenciais ANTES de finalizar a interaction, porque com MFA
104
+ // ligado precisamos exigir o 2º fator e NÃO podemos chamar interactionFinished
105
+ // ainda. (`service.interactions.login` verifica E finaliza num passo só — por
106
+ // isso fazemos a verificação aqui via accountStore e finalizamos depois.)
107
+ const acc = await cfg.accountStore.verifyCredentials(email, password);
108
+ if (!acc) {
109
+ await cfg.audit?.record({ type: 'login.failure', email, ip, clientId });
110
+ await lockout.recordFailure(email, { sink: cfg.audit, ip });
111
+ // Re-render password step with error (keep email in session)
112
+ const found = await cfg.accountStore.findByEmail(email);
113
+ const account = found
114
+ ? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
115
+ : null;
116
+ return render(ctx, 'login', {
117
+ uid: ctx.request.param('uid'),
118
+ csrfToken: ctx.request.csrfToken,
119
+ step: 'password',
120
+ email,
121
+ account,
122
+ error: translate(cfg.messages, 'errors.invalid_credentials'),
123
+ brand,
124
+ });
125
+ }
126
+ // Senha correta: limpa o contador de falhas (o lockout protege a etapa de senha).
127
+ await lockout.clearFailures(email);
128
+ // MFA gate: se a conta tem TOTP ativo, NÃO finaliza a interaction agora —
129
+ // guarda o accountId pendente na sessão e renderiza o desafio do 2º fator.
130
+ const mfa = (await cfg.accountStore.getMfaState?.(acc.id)) ?? { enabled: false };
131
+ if (mfa.enabled) {
132
+ ctx.session.put(MFA_PENDING_KEY, acc.id);
133
+ // Passkey disponível como alternativa ao TOTP se o store suporta E a conta
134
+ // tem ao menos uma credencial registrada.
135
+ const passkeyAvailable = await this.hasPasskeys(cfg, acc.id);
136
+ return render(ctx, 'mfa-challenge', {
137
+ uid: ctx.request.param('uid'),
138
+ csrfToken: ctx.request.csrfToken,
139
+ brand,
140
+ passkeyAvailable,
141
+ });
142
+ }
143
+ // Sem MFA: finaliza a interaction (escreve o 303 de volta para o client).
144
+ await service.interactions.completeLogin(ctx, acc.id);
145
+ await cfg.audit?.record({ type: 'login.success', accountId: acc.id, email, ip, clientId });
146
+ // Clean up the session key after a successful login.
147
+ ctx.session.forget(SESSION_KEY);
148
+ }
149
+ /**
150
+ * POST /auth/interaction/:uid/mfa
151
+ * 2º fator: lê o accountId pendente da sessão e aceita um código TOTP (`code`)
152
+ * OU um recovery code (`recoveryCode`). Em caso de sucesso finaliza a interaction.
153
+ */
154
+ async mfaVerify(ctx) {
155
+ const service = await ctx.containerResolver.make('authkit.server');
156
+ const cfg = service.config;
157
+ const render = cfg.render;
158
+ const details = await service.interactions.details(ctx);
159
+ const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
160
+ const accountId = ctx.session.get(MFA_PENDING_KEY);
161
+ const ip = ctx.request.ip?.() ?? null;
162
+ const clientId = details.params.client_id ?? null;
163
+ if (!accountId) {
164
+ // Sessão expirou/foi adulterada — volta ao início do login.
165
+ return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
166
+ }
167
+ const { code, recoveryCode } = ctx.request.only(['code', 'recoveryCode']);
168
+ let ok = false;
169
+ let usedRecovery = false;
170
+ if (recoveryCode) {
171
+ ok = (await cfg.accountStore.consumeRecoveryCode?.(accountId, recoveryCode)) ?? false;
172
+ usedRecovery = ok;
173
+ }
174
+ else if (code) {
175
+ ok = (await cfg.accountStore.verifyTotp?.(accountId, code)) ?? false;
176
+ }
177
+ if (!ok) {
178
+ await cfg.audit?.record({
179
+ type: 'login.failure',
180
+ accountId,
181
+ ip,
182
+ clientId,
183
+ metadata: { stage: 'mfa' },
184
+ });
185
+ return render(ctx, 'mfa-challenge', {
186
+ uid: ctx.request.param('uid'),
187
+ csrfToken: ctx.request.csrfToken,
188
+ error: translate(cfg.messages, 'errors.invalid_code'),
189
+ brand,
190
+ passkeyAvailable: await this.hasPasskeys(cfg, accountId),
191
+ });
192
+ }
193
+ // Sucesso no 2º fator: finaliza a interaction para o accountId pendente.
194
+ ctx.session.forget(MFA_PENDING_KEY);
195
+ ctx.session.forget(SESSION_KEY);
196
+ await cfg.audit?.record({
197
+ type: 'login.success',
198
+ accountId,
199
+ ip,
200
+ clientId,
201
+ metadata: { mfa: usedRecovery ? 'recovery' : 'totp' },
202
+ });
203
+ await service.interactions.completeLogin(ctx, accountId);
204
+ }
205
+ /** true se o store suporta passkeys E a conta tem ao menos uma registrada. */
206
+ async hasPasskeys(cfg, accountId) {
207
+ if (typeof cfg.accountStore.listPasskeys !== 'function')
208
+ return false;
209
+ const list = await cfg.accountStore.listPasskeys(accountId);
210
+ return Array.isArray(list) && list.length > 0;
211
+ }
212
+ /**
213
+ * POST /auth/interaction/:uid/passkey/options
214
+ * Gera as opções de autenticação por passkey para o accountId pendente do MFA,
215
+ * guarda o challenge na sessão e devolve as opções JSON para o browser.
216
+ */
217
+ async passkeyOptions(ctx) {
218
+ const service = await ctx.containerResolver.make('authkit.server');
219
+ const cfg = service.config;
220
+ const accountId = ctx.session.get(MFA_PENDING_KEY);
221
+ if (!accountId) {
222
+ return ctx.response.badRequest({ message: 'Sessão expirada' });
223
+ }
224
+ const generated = await cfg.accountStore.generatePasskeyAuthenticationOptions?.(accountId);
225
+ if (!generated) {
226
+ return ctx.response.notFound({ message: 'Nenhuma passkey registrada' });
227
+ }
228
+ ctx.session.put(PASSKEY_AUTH_CHALLENGE_KEY, generated.challenge);
229
+ return generated.options;
230
+ }
231
+ /**
232
+ * POST /auth/interaction/:uid/passkey/verify
233
+ * Verifica a resposta de autenticação por passkey contra o challenge guardado;
234
+ * em caso de sucesso FINALIZA a interaction (303 de volta ao client — alternativa
235
+ * ao código TOTP). É um POST de página inteira (form), não fetch: o browser
236
+ * submete o JSON da assertion no campo `response` e segue o redirect normalmente.
237
+ */
238
+ async passkeyVerify(ctx) {
239
+ const service = await ctx.containerResolver.make('authkit.server');
240
+ const cfg = service.config;
241
+ const render = cfg.render;
242
+ const details = await service.interactions.details(ctx);
243
+ const brand = brandFor(cfg.branding, details.params.client_id, details.params.audience);
244
+ const accountId = ctx.session.get(MFA_PENDING_KEY);
245
+ const challenge = ctx.session.get(PASSKEY_AUTH_CHALLENGE_KEY);
246
+ const ip = ctx.request.ip?.() ?? null;
247
+ const clientId = details.params.client_id ?? null;
248
+ if (!accountId || !challenge) {
249
+ // Sessão expirou/foi adulterada — volta ao início do login.
250
+ return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
251
+ }
252
+ // A assertion vem serializada como JSON no campo `response` do form.
253
+ const raw = ctx.request.input('response');
254
+ let parsed = null;
255
+ try {
256
+ parsed = raw ? JSON.parse(raw) : null;
257
+ }
258
+ catch {
259
+ parsed = null;
260
+ }
261
+ const ok = parsed
262
+ ? ((await cfg.accountStore.verifyPasskeyAuthentication?.(accountId, parsed, challenge)) ?? false)
263
+ : false;
264
+ ctx.session.forget(PASSKEY_AUTH_CHALLENGE_KEY);
265
+ if (!ok) {
266
+ await cfg.audit?.record({
267
+ type: 'login.failure',
268
+ accountId,
269
+ ip,
270
+ clientId,
271
+ metadata: { stage: 'mfa', method: 'webauthn' },
272
+ });
273
+ return render(ctx, 'mfa-challenge', {
274
+ uid: ctx.request.param('uid'),
275
+ csrfToken: ctx.request.csrfToken,
276
+ error: translate(cfg.messages, 'mfa_challenge.passkey_error'),
277
+ brand,
278
+ passkeyAvailable: await this.hasPasskeys(cfg, accountId),
279
+ });
280
+ }
281
+ ctx.session.forget(MFA_PENDING_KEY);
282
+ ctx.session.forget(SESSION_KEY);
283
+ await cfg.audit?.record({
284
+ type: 'login.success',
285
+ accountId,
286
+ ip,
287
+ clientId,
288
+ metadata: { mfa: 'webauthn' },
289
+ });
290
+ await service.interactions.completeLogin(ctx, accountId);
291
+ }
292
+ /**
293
+ * GET /auth/interaction/:uid/switch
294
+ * Clears the stored email and redirects back to step 1.
295
+ */
296
+ async switchIdentifier(ctx) {
297
+ ctx.session.forget(SESSION_KEY);
298
+ return ctx.response.redirect(`/auth/interaction/${ctx.request.param('uid')}`);
299
+ }
300
+ async consent(ctx) {
301
+ const service = await ctx.containerResolver.make('authkit.server');
302
+ await service.interactions.consent(ctx);
303
+ }
304
+ }
@@ -0,0 +1,22 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ export default class PatIntrospectionController {
3
+ handle(ctx: HttpContext): Promise<void | {
4
+ active: boolean;
5
+ sub?: undefined;
6
+ email?: undefined;
7
+ name?: undefined;
8
+ roles?: undefined;
9
+ scopes?: undefined;
10
+ audience?: undefined;
11
+ exp?: undefined;
12
+ } | {
13
+ active: boolean;
14
+ sub: any;
15
+ email: any;
16
+ name: any;
17
+ roles: any;
18
+ scopes: any;
19
+ audience: any;
20
+ exp: any;
21
+ }>;
22
+ }
@@ -0,0 +1,46 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ function bearerMatches(header, expected) {
3
+ if (!header || !header.startsWith('Bearer '))
4
+ return false;
5
+ const provided = header.slice(7);
6
+ const a = Buffer.from(provided);
7
+ const b = Buffer.from(expected);
8
+ return a.length === b.length && timingSafeEqual(a, b);
9
+ }
10
+ export default class PatIntrospectionController {
11
+ async handle(ctx) {
12
+ const service = await ctx.containerResolver.make('authkit.server');
13
+ const cfg = service.config;
14
+ const secret = cfg.patIntrospectionSecret;
15
+ if (!secret || !bearerMatches(ctx.request.header('authorization'), secret)) {
16
+ return ctx.response.unauthorized({ error: 'invalid_client' });
17
+ }
18
+ const token = ctx.request.input('token');
19
+ if (!token || typeof token !== 'string') {
20
+ return { active: false };
21
+ }
22
+ const meta = await cfg.patStore.findActiveByToken(token);
23
+ if (!meta)
24
+ return { active: false };
25
+ const account = await cfg.accountStore.findById(meta.accountId);
26
+ if (!account)
27
+ return { active: false };
28
+ await cfg.audit?.record({
29
+ type: 'pat.used',
30
+ accountId: account.id,
31
+ email: account.email,
32
+ ip: ctx.request.ip?.() ?? null,
33
+ metadata: { audience: meta.audience },
34
+ });
35
+ return {
36
+ active: true,
37
+ sub: account.id,
38
+ email: account.email,
39
+ name: account.name ?? null,
40
+ roles: account.globalRoles ?? [],
41
+ scopes: meta.scopes,
42
+ audience: meta.audience,
43
+ exp: meta.exp,
44
+ };
45
+ }
46
+ }
@@ -0,0 +1,18 @@
1
+ import '../augmentations.js';
2
+ import type { HttpContext } from '@adonisjs/core/http';
3
+ export default class AuthRegistrationController {
4
+ /** GET /auth/interaction/:uid/signup — tela de cadastro (dentro do fluxo OIDC). */
5
+ showSignup(ctx: HttpContext): Promise<any>;
6
+ /** POST /auth/interaction/:uid/signup — cria o usuário e finaliza o login. */
7
+ signup(ctx: HttpContext): Promise<any>;
8
+ /** GET /auth/forgot-password — tela standalone. */
9
+ showForgot(ctx: HttpContext): Promise<any>;
10
+ /** POST /auth/forgot-password — gera token e (dev) loga o link. Sempre responde sucesso (não vaza emails). */
11
+ forgot(ctx: HttpContext): Promise<any>;
12
+ /** GET /auth/reset-password?token=... — tela standalone. */
13
+ showReset(ctx: HttpContext): Promise<any>;
14
+ /** POST /auth/reset-password — redefine a senha. */
15
+ reset(ctx: HttpContext): Promise<any>;
16
+ /** GET /auth/verify-email?token=... — consome o token e mostra sucesso/falha. */
17
+ verifyEmail(ctx: HttpContext): Promise<any>;
18
+ }