@dudousxd/adonis-authkit-server 0.3.0 → 0.5.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 (58) hide show
  1. package/build/commands/commands.json +28 -0
  2. package/build/commands/doctor.d.ts +10 -0
  3. package/build/commands/doctor.js +66 -0
  4. package/build/commands/rotate_keys.d.ts +10 -0
  5. package/build/commands/rotate_keys.js +53 -0
  6. package/build/host/views/account/email-confirmed.edge +15 -0
  7. package/build/host/views/account/security.edge +83 -0
  8. package/build/host/views/account/tokens.edge +7 -4
  9. package/build/host/views/admin/sessions.edge +89 -0
  10. package/build/host/views/admin/users.edge +1 -0
  11. package/build/host/views/mfa-challenge.edge +29 -23
  12. package/build/index.d.ts +5 -4
  13. package/build/index.js +3 -3
  14. package/build/src/accounts/account_store.d.ts +46 -1
  15. package/build/src/accounts/account_store.js +4 -0
  16. package/build/src/accounts/lucid_store/core.d.ts +5 -4
  17. package/build/src/accounts/lucid_store/core.js +67 -2
  18. package/build/src/adapters/adapter_contract.d.ts +17 -0
  19. package/build/src/adapters/database_adapter.d.ts +9 -5
  20. package/build/src/adapters/database_adapter.js +13 -6
  21. package/build/src/adapters/redis_adapter.d.ts +11 -5
  22. package/build/src/adapters/redis_adapter.js +16 -7
  23. package/build/src/audit/audit_sink.d.ts +1 -1
  24. package/build/src/define_config.d.ts +102 -0
  25. package/build/src/define_config.js +46 -3
  26. package/build/src/doctor/checks.d.ts +51 -0
  27. package/build/src/doctor/checks.js +231 -0
  28. package/build/src/host/admin_clients_service.js +12 -5
  29. package/build/src/host/admin_sessions_service.d.ts +63 -0
  30. package/build/src/host/admin_sessions_service.js +127 -0
  31. package/build/src/host/controllers/account_mfa_controller.js +6 -2
  32. package/build/src/host/controllers/account_security_controller.d.ts +16 -0
  33. package/build/src/host/controllers/account_security_controller.js +119 -0
  34. package/build/src/host/controllers/account_session_controller.js +2 -1
  35. package/build/src/host/controllers/admin/admin_sessions_controller.d.ts +14 -0
  36. package/build/src/host/controllers/admin/admin_sessions_controller.js +64 -0
  37. package/build/src/host/controllers/interaction_controller.d.ts +11 -0
  38. package/build/src/host/controllers/interaction_controller.js +55 -12
  39. package/build/src/host/default_mailer.d.ts +17 -0
  40. package/build/src/host/default_mailer.js +94 -9
  41. package/build/src/host/email_templates.d.ts +4 -0
  42. package/build/src/host/email_templates.js +5 -2
  43. package/build/src/host/i18n.d.ts +358 -11
  44. package/build/src/host/i18n.js +393 -12
  45. package/build/src/host/login_notify.d.ts +20 -0
  46. package/build/src/host/login_notify.js +71 -0
  47. package/build/src/host/register_auth_host.js +12 -0
  48. package/build/src/host/validators.d.ts +32 -0
  49. package/build/src/host/validators.js +14 -0
  50. package/build/src/keys/keystore.d.ts +43 -0
  51. package/build/src/keys/keystore.js +74 -0
  52. package/build/src/observability/metrics_controller.js +4 -4
  53. package/build/src/provider/build_provider.js +23 -0
  54. package/build/src/provider/device_sources.d.ts +6 -0
  55. package/build/src/provider/device_sources.js +65 -0
  56. package/build/src/provider/interaction_actions.d.ts +6 -1
  57. package/build/src/provider/interaction_actions.js +9 -2
  58. package/package.json +2 -2
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Funções puras de verificação para `node ace authkit:doctor`. Não dependem do
3
+ * Ace nem do container — recebem objetos simples para serem testáveis em
4
+ * isolamento. O comando `authkit:doctor` só coleta o ambiente e imprime os
5
+ * resultados destas funções.
6
+ */
7
+ /** Type guard estrutural: o store expõe um método (capacidade presente). */
8
+ function has(store, method) {
9
+ return !!store && typeof store[method] === 'function';
10
+ }
11
+ /** config('authkit') resolve? */
12
+ export function checkConfigResolves(input) {
13
+ if (!input.authkitConfig) {
14
+ return {
15
+ level: 'error',
16
+ message: "config('authkit') não resolveu — config/authkit.ts está ausente ou inválido.",
17
+ };
18
+ }
19
+ return { level: 'ok', message: "config('authkit') resolvido." };
20
+ }
21
+ /** issuer é uma URL válida e seu pathname casa com o mountPath. */
22
+ export function checkIssuer(input) {
23
+ const cfg = input.authkitConfig;
24
+ if (!cfg)
25
+ return [];
26
+ const issuer = cfg.issuer;
27
+ const mountPath = cfg.mountPath ?? '/oidc';
28
+ if (typeof issuer !== 'string' || issuer.length === 0) {
29
+ return [{ level: 'error', message: 'issuer ausente na config.' }];
30
+ }
31
+ let url;
32
+ try {
33
+ url = new URL(issuer);
34
+ }
35
+ catch {
36
+ return [{ level: 'error', message: `issuer não é uma URL válida: "${issuer}".` }];
37
+ }
38
+ const findings = [{ level: 'ok', message: `issuer válido: ${url.origin}${url.pathname}` }];
39
+ const normalize = (p) => (p.endsWith('/') ? p.slice(0, -1) : p) || '/';
40
+ if (normalize(url.pathname) !== normalize(mountPath)) {
41
+ findings.push({
42
+ level: 'warn',
43
+ message: `O pathname do issuer ("${url.pathname}") difere do mountPath ("${mountPath}"). As rotas OIDC podem não casar com as URLs anunciadas no discovery.`,
44
+ });
45
+ }
46
+ return findings;
47
+ }
48
+ /** Pelo menos um client com redirectUris. */
49
+ export function checkClients(input) {
50
+ const cfg = input.authkitConfig;
51
+ if (!cfg)
52
+ return { level: 'error', message: 'sem config para validar clients.' };
53
+ const clients = Array.isArray(cfg.clients) ? cfg.clients : [];
54
+ if (clients.length === 0) {
55
+ return { level: 'error', message: 'nenhum client configurado em `clients`.' };
56
+ }
57
+ const withRedirects = clients.filter((c) => Array.isArray(c?.redirectUris) && c.redirectUris.length > 0);
58
+ if (withRedirects.length === 0) {
59
+ return {
60
+ level: 'error',
61
+ message: `${clients.length} client(s) configurado(s), mas nenhum tem redirectUris.`,
62
+ };
63
+ }
64
+ return { level: 'ok', message: `${withRedirects.length}/${clients.length} client(s) com redirectUris.` };
65
+ }
66
+ /** accountStore presente + quais capacidades implementa. */
67
+ export function checkAccountStore(input) {
68
+ const cfg = input.authkitConfig;
69
+ if (!cfg)
70
+ return [];
71
+ const store = cfg.accountStore;
72
+ if (!store) {
73
+ return [{ level: 'error', message: 'accountStore ausente — obrigatório.' }];
74
+ }
75
+ const findings = [{ level: 'ok', message: 'accountStore presente.' }];
76
+ const caps = [];
77
+ if (has(store, 'getMfaState'))
78
+ caps.push('MFA');
79
+ if (has(store, 'listPasskeys'))
80
+ caps.push('passkeys/WebAuthn');
81
+ if (has(store, 'findByProviderIdentity'))
82
+ caps.push('account-linking');
83
+ if (has(store, 'changePassword'))
84
+ caps.push('account-security');
85
+ findings.push({
86
+ level: 'ok',
87
+ message: caps.length
88
+ ? `Capacidades opcionais: ${caps.join(', ')}.`
89
+ : 'Apenas o núcleo do accountStore (sem MFA/passkeys/linking/security).',
90
+ });
91
+ return findings;
92
+ }
93
+ /** session provider configurado + warn se cookie store com tokenSets grandes. */
94
+ export function checkSession(input) {
95
+ if (!input.peers.session) {
96
+ return [
97
+ {
98
+ level: 'error',
99
+ message: '@adonisjs/session não é importável — instale-o (peer obrigatório).',
100
+ },
101
+ ];
102
+ }
103
+ if (!input.sessionConfig) {
104
+ return [{ level: 'warn', message: "config('session') ausente — o provider de sessão pode não estar configurado." }];
105
+ }
106
+ const findings = [{ level: 'ok', message: 'provider de sessão configurado.' }];
107
+ const driver = input.sessionConfig.store ?? input.sessionConfig.driver;
108
+ if (driver === 'cookie') {
109
+ findings.push({
110
+ level: 'warn',
111
+ message: 'session store = cookie: token sets grandes podem estourar o limite de 4KB do cookie. Prefira `redis`/`file` em produção.',
112
+ });
113
+ }
114
+ return findings;
115
+ }
116
+ /** Hint de exceções de CSRF do shield para o mountPath. */
117
+ export function checkShield(input) {
118
+ if (!input.peers.shield) {
119
+ return { level: 'error', message: '@adonisjs/shield não é importável — instale-o (peer obrigatório).' };
120
+ }
121
+ const mountPath = input.authkitConfig?.mountPath ?? '/oidc';
122
+ return {
123
+ level: 'warn',
124
+ message: `Garanta que as rotas POST do IdP sob "${mountPath}" estejam nas exceções de CSRF do shield (ex.: endpoint /token), senão chamadas server-to-server falham.`,
125
+ };
126
+ }
127
+ /** ally só é necessário quando social está configurado. */
128
+ export function checkAlly(input) {
129
+ const social = input.authkitConfig?.social;
130
+ const usesSocial = !!social && (Array.isArray(social.providers) ? social.providers.length > 0 : Object.keys(social).length > 0);
131
+ if (!usesSocial) {
132
+ return { level: 'ok', message: 'login social não configurado — @adonisjs/ally é opcional.' };
133
+ }
134
+ if (!input.peers.ally) {
135
+ return { level: 'error', message: 'login social configurado, mas @adonisjs/ally não é importável.' };
136
+ }
137
+ return { level: 'ok', message: 'login social configurado e @adonisjs/ally disponível.' };
138
+ }
139
+ /** rateLimit ligado mas @adonisjs/limiter ausente → warn. */
140
+ export function checkRateLimit(input) {
141
+ const cfg = input.authkitConfig;
142
+ const rateLimit = cfg?.rateLimit;
143
+ const enabled = rateLimit === undefined ? true : rateLimit?.enabled !== false;
144
+ if (!enabled) {
145
+ return { level: 'ok', message: 'rate-limiting desligado por config.' };
146
+ }
147
+ if (!input.peers.limiter) {
148
+ return {
149
+ level: 'warn',
150
+ message: 'rate-limiting está ligado (default), mas @adonisjs/limiter não é importável — vira no-op (sem proteção anti-brute-force).',
151
+ };
152
+ }
153
+ return { level: 'ok', message: 'rate-limiting ligado e @adonisjs/limiter disponível.' };
154
+ }
155
+ /** admin.enabled mas sem roles → warn. */
156
+ export function checkAdmin(input) {
157
+ const admin = input.authkitConfig?.admin;
158
+ if (!admin || admin.enabled !== true)
159
+ return null;
160
+ const roles = Array.isArray(admin.roles) ? admin.roles : [];
161
+ if (roles.length === 0) {
162
+ return {
163
+ level: 'warn',
164
+ message: 'console admin ligado, mas sem `admin.roles` — ninguém terá acesso (default ["ADMIN"] não foi resolvido aqui).',
165
+ };
166
+ }
167
+ return { level: 'ok', message: `console admin ligado para roles: ${roles.join(', ')}.` };
168
+ }
169
+ /** webauthn rpId deve casar com o host do issuer. */
170
+ export function checkWebauthn(input) {
171
+ const cfg = input.authkitConfig;
172
+ const webauthn = cfg?.webauthn;
173
+ if (!webauthn || !webauthn.rpId)
174
+ return null;
175
+ const issuer = cfg.issuer;
176
+ if (typeof issuer !== 'string')
177
+ return null;
178
+ let host;
179
+ try {
180
+ host = new URL(issuer).hostname;
181
+ }
182
+ catch {
183
+ return null;
184
+ }
185
+ if (webauthn.rpId !== host) {
186
+ return {
187
+ level: 'warn',
188
+ message: `webauthn.rpId ("${webauthn.rpId}") difere do host do issuer ("${host}") — as passkeys não validarão no browser.`,
189
+ };
190
+ }
191
+ return { level: 'ok', message: `webauthn.rpId casa com o host do issuer (${host}).` };
192
+ }
193
+ /** info sobre rotação quando jwks é managed. */
194
+ export function checkJwks(input) {
195
+ const jwks = input.authkitConfig?.jwks;
196
+ if (!jwks)
197
+ return null;
198
+ if (jwks.source === 'managed') {
199
+ return {
200
+ level: 'ok',
201
+ message: 'jwks managed — rotacione as chaves de assinatura com `node ace authkit:rotate-keys` (use --store para persistir entre boots).',
202
+ };
203
+ }
204
+ return { level: 'ok', message: 'jwks fornecido inline (source=jwks).' };
205
+ }
206
+ /** Roda todos os checks e devolve a lista plana de findings. */
207
+ export function runAllChecks(input) {
208
+ const findings = [];
209
+ findings.push(checkConfigResolves(input));
210
+ findings.push(...checkIssuer(input));
211
+ findings.push(checkClients(input));
212
+ findings.push(...checkAccountStore(input));
213
+ findings.push(...checkSession(input));
214
+ findings.push(checkShield(input));
215
+ findings.push(checkAlly(input));
216
+ findings.push(checkRateLimit(input));
217
+ const admin = checkAdmin(input);
218
+ if (admin)
219
+ findings.push(admin);
220
+ const webauthn = checkWebauthn(input);
221
+ if (webauthn)
222
+ findings.push(webauthn);
223
+ const jwks = checkJwks(input);
224
+ if (jwks)
225
+ findings.push(jwks);
226
+ return findings;
227
+ }
228
+ /** Há algum finding de nível 'error'? (define o exit code). */
229
+ export function hasErrors(findings) {
230
+ return findings.some((f) => f.level === 'error');
231
+ }
@@ -25,14 +25,21 @@ export class AdminClientsService {
25
25
  }
26
26
  /** Indica se o adapter suporta enumeração (capacidade opcional). */
27
27
  get canList() {
28
- return typeof this.#adapter.listClients === 'function';
28
+ return typeof this.#adapter.list === 'function' || typeof this.#adapter.listClients === 'function';
29
29
  }
30
30
  /** Lista os clients persistidos (vazio quando o adapter não enumera; cheque canList). */
31
31
  async list() {
32
- if (!this.#adapter.listClients)
33
- return [];
34
- const rows = await this.#adapter.listClients();
35
- return rows.map((r) => this.#present(r));
32
+ // Prefere a enumeração genérica `list`; cai no `listClients` legado se for o
33
+ // único disponível (adapters customizados antigos).
34
+ if (this.#adapter.list) {
35
+ const rows = await this.#adapter.list();
36
+ return rows.map((r) => this.#present({ clientId: r.id, payload: r.payload }));
37
+ }
38
+ if (this.#adapter.listClients) {
39
+ const rows = await this.#adapter.listClients();
40
+ return rows.map((r) => this.#present(r));
41
+ }
42
+ return [];
36
43
  }
37
44
  /** Lê um client persistido pelo client_id (undefined quando não existe). */
38
45
  async find(clientId) {
@@ -0,0 +1,63 @@
1
+ import type { OidcService } from '../provider/oidc_service.js';
2
+ /** Uma sessão ativa do IdP (login do usuário no provider), apresentada ao admin. */
3
+ export interface AdminSession {
4
+ /** Id do artefato `Session` no adapter. */
5
+ id: string;
6
+ accountId: string;
7
+ /** Epoch (segundos) do login, quando presente no payload. */
8
+ loginTs?: number;
9
+ /** Métodos de autenticação registrados na sessão (amr), quando presentes. */
10
+ amr?: string[];
11
+ }
12
+ /** Um grant (autorização concedida a um client), com a contagem de tokens vivos. */
13
+ export interface AdminGrant {
14
+ /** Id do artefato `Grant` no adapter (== grantId dos tokens). */
15
+ id: string;
16
+ accountId: string;
17
+ clientId?: string;
18
+ /** Tokens de acesso vivos que referenciam este grant. */
19
+ accessTokens: number;
20
+ /** Refresh tokens vivos que referenciam este grant. */
21
+ refreshTokens: number;
22
+ }
23
+ /** Resultado de uma revogação em massa das sessões/grants de uma conta. */
24
+ export interface RevokeResult {
25
+ sessions: number;
26
+ grants: number;
27
+ accessTokens: number;
28
+ refreshTokens: number;
29
+ }
30
+ /**
31
+ * Serviço de inspeção/revogação das SESSÕES e GRANTS ativos de uma conta,
32
+ * persistidos pelo oidc-provider via o MESMO `AdapterClass` (mesmo padrão do
33
+ * {@link AdminClientsService}). Encapsula:
34
+ * - a enumeração via a capacidade opcional `list` do adapter (degrada quando
35
+ * ausente, igual ao CRUD de clients);
36
+ * - a destruição das sessões + grants da conta. Destruir um grant CASCATEIA a
37
+ * invalidação dos tokens no oidc-provider: os consumidores de access/refresh
38
+ * token carregam `Grant.find(token.grantId)` e lançam `InvalidToken('grant not
39
+ * found')` quando o grant some (verificado em oidc-provider v9). Mesmo assim,
40
+ * por garantia (belt-and-braces), também destruímos as linhas de AT/RT que
41
+ * referenciam os grants revogados quando o adapter enumera.
42
+ */
43
+ export declare class AdminSessionsService {
44
+ #private;
45
+ constructor(oidc: OidcService);
46
+ /** Indica se o adapter suporta enumeração (capacidade opcional). */
47
+ get canList(): boolean;
48
+ /** Lista as sessões ativas da conta (vazio quando o adapter não enumera). */
49
+ listSessions(accountId: string): Promise<AdminSession[]>;
50
+ /**
51
+ * Lista os grants da conta, com a contagem de access/refresh tokens vivos que
52
+ * referenciam cada grant (`payload.grantId`). As contagens são baratas (uma
53
+ * enumeração de cada model token), feitas só quando o adapter enumera.
54
+ */
55
+ listGrants(accountId: string): Promise<AdminGrant[]>;
56
+ /**
57
+ * Revoga TODAS as sessões e grants da conta. Destruir os grants já invalida os
58
+ * tokens (cascata via `grant not found`); ainda assim, quando o adapter enumera,
59
+ * destruímos explicitamente as linhas de AT/RT desses grants (belt-and-braces),
60
+ * deixando o store limpo. Retorna as contagens do que foi removido.
61
+ */
62
+ revokeAll(accountId: string): Promise<RevokeResult>;
63
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Serviço de inspeção/revogação das SESSÕES e GRANTS ativos de uma conta,
3
+ * persistidos pelo oidc-provider via o MESMO `AdapterClass` (mesmo padrão do
4
+ * {@link AdminClientsService}). Encapsula:
5
+ * - a enumeração via a capacidade opcional `list` do adapter (degrada quando
6
+ * ausente, igual ao CRUD de clients);
7
+ * - a destruição das sessões + grants da conta. Destruir um grant CASCATEIA a
8
+ * invalidação dos tokens no oidc-provider: os consumidores de access/refresh
9
+ * token carregam `Grant.find(token.grantId)` e lançam `InvalidToken('grant not
10
+ * found')` quando o grant some (verificado em oidc-provider v9). Mesmo assim,
11
+ * por garantia (belt-and-braces), também destruímos as linhas de AT/RT que
12
+ * referenciam os grants revogados quando o adapter enumera.
13
+ */
14
+ export class AdminSessionsService {
15
+ #AdapterClass;
16
+ constructor(oidc) {
17
+ this.#AdapterClass = oidc.config.AdapterClass;
18
+ }
19
+ #adapter(model) {
20
+ return new this.#AdapterClass(model);
21
+ }
22
+ /** Indica se o adapter suporta enumeração (capacidade opcional). */
23
+ get canList() {
24
+ return typeof this.#adapter('Session').list === 'function';
25
+ }
26
+ async #listModel(model) {
27
+ const adapter = this.#adapter(model);
28
+ if (!adapter.list)
29
+ return [];
30
+ return adapter.list();
31
+ }
32
+ /** Lista as sessões ativas da conta (vazio quando o adapter não enumera). */
33
+ async listSessions(accountId) {
34
+ const rows = await this.#listModel('Session');
35
+ return rows
36
+ .filter((r) => r.payload.accountId === accountId)
37
+ .map((r) => ({
38
+ id: r.id,
39
+ accountId,
40
+ loginTs: r.payload.loginTs,
41
+ amr: r.payload.amr ?? undefined,
42
+ }));
43
+ }
44
+ /**
45
+ * Lista os grants da conta, com a contagem de access/refresh tokens vivos que
46
+ * referenciam cada grant (`payload.grantId`). As contagens são baratas (uma
47
+ * enumeração de cada model token), feitas só quando o adapter enumera.
48
+ */
49
+ async listGrants(accountId) {
50
+ const rows = await this.#listModel('Grant');
51
+ const grants = rows.filter((r) => r.payload.accountId === accountId);
52
+ if (grants.length === 0)
53
+ return [];
54
+ const atByGrant = await this.#countByGrant('AccessToken');
55
+ const rtByGrant = await this.#countByGrant('RefreshToken');
56
+ return grants.map((g) => ({
57
+ id: g.id,
58
+ accountId,
59
+ clientId: g.payload.clientId,
60
+ accessTokens: atByGrant.get(g.id) ?? 0,
61
+ refreshTokens: rtByGrant.get(g.id) ?? 0,
62
+ }));
63
+ }
64
+ /** Conta artefatos de um model token agrupados por `grantId`. */
65
+ async #countByGrant(model) {
66
+ const rows = await this.#listModel(model);
67
+ const map = new Map();
68
+ for (const r of rows) {
69
+ const gid = r.payload.grantId;
70
+ if (!gid)
71
+ continue;
72
+ map.set(gid, (map.get(gid) ?? 0) + 1);
73
+ }
74
+ return map;
75
+ }
76
+ /**
77
+ * Revoga TODAS as sessões e grants da conta. Destruir os grants já invalida os
78
+ * tokens (cascata via `grant not found`); ainda assim, quando o adapter enumera,
79
+ * destruímos explicitamente as linhas de AT/RT desses grants (belt-and-braces),
80
+ * deixando o store limpo. Retorna as contagens do que foi removido.
81
+ */
82
+ async revokeAll(accountId) {
83
+ const sessionAdapter = this.#adapter('Session');
84
+ const grantAdapter = this.#adapter('Grant');
85
+ const sessions = await this.listSessions(accountId);
86
+ for (const s of sessions) {
87
+ await sessionAdapter.destroy(s.id);
88
+ }
89
+ const grants = await this.listGrants(accountId);
90
+ const grantIds = new Set(grants.map((g) => g.id));
91
+ let accessTokens = 0;
92
+ let refreshTokens = 0;
93
+ // Belt-and-braces: destrói as linhas de token que referenciam os grants alvo
94
+ // ANTES de destruir os grants (quando o adapter enumera).
95
+ accessTokens = await this.#destroyTokensOfGrants('AccessToken', grantIds);
96
+ refreshTokens = await this.#destroyTokensOfGrants('RefreshToken', grantIds);
97
+ for (const g of grants) {
98
+ // `revokeByGrantId` derruba os artefatos ligados ao grant (no Redis isso
99
+ // limpa a lista do grant; no DB apaga as linhas com `grant_id`); `destroy`
100
+ // remove o próprio artefato `Grant`.
101
+ await grantAdapter.revokeByGrantId(g.id);
102
+ await grantAdapter.destroy(g.id);
103
+ }
104
+ return {
105
+ sessions: sessions.length,
106
+ grants: grants.length,
107
+ accessTokens,
108
+ refreshTokens,
109
+ };
110
+ }
111
+ /** Destrói (quando enumerável) os artefatos de um model token cujos grantId estão em `grantIds`. */
112
+ async #destroyTokensOfGrants(model, grantIds) {
113
+ const adapter = this.#adapter(model);
114
+ if (!adapter.list)
115
+ return 0;
116
+ const rows = await adapter.list();
117
+ let count = 0;
118
+ for (const r of rows) {
119
+ const gid = r.payload.grantId;
120
+ if (gid && grantIds.has(gid)) {
121
+ await adapter.destroy(r.id);
122
+ count++;
123
+ }
124
+ }
125
+ return count;
126
+ }
127
+ }
@@ -39,7 +39,9 @@ export default class AccountMfaController {
39
39
  const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
40
40
  const generated = await cfg.accountStore.generatePasskeyRegistrationOptions?.(userId);
41
41
  if (!generated) {
42
- return ctx.response.notFound({ message: 'Passkeys indisponíveis' });
42
+ return ctx.response.notFound({
43
+ message: translate(cfg.messages, 'errors.passkeys_unavailable'),
44
+ });
43
45
  }
44
46
  ctx.session.put(PASSKEY_REG_CHALLENGE_KEY, generated.challenge);
45
47
  return generated.options;
@@ -54,7 +56,9 @@ export default class AccountMfaController {
54
56
  const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
55
57
  const challenge = ctx.session.get(PASSKEY_REG_CHALLENGE_KEY);
56
58
  if (!challenge) {
57
- return ctx.response.badRequest({ message: 'Desafio expirado' });
59
+ return ctx.response.badRequest({
60
+ message: translate(cfg.messages, 'errors.challenge_expired'),
61
+ });
58
62
  }
59
63
  const body = ctx.request.input('response', ctx.request.body());
60
64
  const ok = (await cfg.accountStore.verifyPasskeyRegistration?.(userId, body, challenge)) ?? false;
@@ -0,0 +1,16 @@
1
+ import '../augmentations.js';
2
+ import type { HttpContext } from '@adonisjs/core/http';
3
+ /**
4
+ * Self-service de segurança da conta (console de conta): trocar a senha e o
5
+ * e-mail. A troca de senha exige a senha ATUAL (verifyCredentials). A troca de
6
+ * e-mail exige a senha atual e dispara um link de confirmação para o NOVO
7
+ * endereço (consumido em GET /account/email/confirm). Degrada graciosamente se o
8
+ * store não suportar a capacidade ({@link supportsAccountSecurity}).
9
+ */
10
+ export default class AccountSecurityController {
11
+ index(ctx: HttpContext): Promise<any>;
12
+ changePassword(ctx: HttpContext): Promise<void>;
13
+ changeEmail(ctx: HttpContext): Promise<void>;
14
+ /** GET /account/email/confirm?token=... — consome o token e aplica o novo e-mail. */
15
+ confirmEmail(ctx: HttpContext): Promise<any>;
16
+ }
@@ -0,0 +1,119 @@
1
+ import '../augmentations.js';
2
+ import { ACCOUNT_SESSION_KEY } from '../middleware/account_auth.js';
3
+ import { supportsAccountSecurity } from '../../accounts/account_store.js';
4
+ import { changePasswordValidator, changeEmailValidator } from '../validators.js';
5
+ import { sendEmailChangeConfirmationEmail } from '../default_mailer.js';
6
+ import { translate } from '../i18n.js';
7
+ /**
8
+ * Self-service de segurança da conta (console de conta): trocar a senha e o
9
+ * e-mail. A troca de senha exige a senha ATUAL (verifyCredentials). A troca de
10
+ * e-mail exige a senha atual e dispara um link de confirmação para o NOVO
11
+ * endereço (consumido em GET /account/email/confirm). Degrada graciosamente se o
12
+ * store não suportar a capacidade ({@link supportsAccountSecurity}).
13
+ */
14
+ export default class AccountSecurityController {
15
+ async index(ctx) {
16
+ const service = await ctx.containerResolver.make('authkit.server');
17
+ const cfg = service.config;
18
+ const render = cfg.render;
19
+ const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
20
+ const account = await cfg.accountStore.findById(userId);
21
+ return render(ctx, 'account/security', {
22
+ csrfToken: ctx.request.csrfToken,
23
+ supported: supportsAccountSecurity(cfg.accountStore),
24
+ email: account?.email ?? '',
25
+ passwordChanged: ctx.session.flashMessages.get('passwordChanged') ?? null,
26
+ emailChangeRequested: ctx.session.flashMessages.get('emailChangeRequested') ?? null,
27
+ emailChanged: ctx.session.flashMessages.get('emailChanged') ?? null,
28
+ error: ctx.session.flashMessages.get('securityError') ?? null,
29
+ });
30
+ }
31
+ async changePassword(ctx) {
32
+ const service = await ctx.containerResolver.make('authkit.server');
33
+ const cfg = service.config;
34
+ const store = cfg.accountStore;
35
+ const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
36
+ if (!supportsAccountSecurity(store)) {
37
+ return ctx.response.redirect('/account/security');
38
+ }
39
+ const { currentPassword, newPassword } = await ctx.request.validateUsing(changePasswordValidator);
40
+ const account = await store.findById(userId);
41
+ // Confirma a senha ATUAL pelo e-mail da conta.
42
+ const verified = account
43
+ ? await store.verifyCredentials(account.email, currentPassword)
44
+ : null;
45
+ if (!verified) {
46
+ ctx.session.flash('securityError', translate(cfg.messages, 'errors.invalid_credentials'));
47
+ return ctx.response.redirect('/account/security');
48
+ }
49
+ await store.changePassword(userId, newPassword);
50
+ await cfg.audit?.record({
51
+ type: 'password.changed',
52
+ accountId: userId,
53
+ ip: ctx.request.ip?.() ?? null,
54
+ });
55
+ ctx.session.flash('passwordChanged', translate(cfg.messages, 'account.security.password_changed'));
56
+ return ctx.response.redirect('/account/security');
57
+ }
58
+ async changeEmail(ctx) {
59
+ const service = await ctx.containerResolver.make('authkit.server');
60
+ const cfg = service.config;
61
+ const store = cfg.accountStore;
62
+ const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
63
+ if (!supportsAccountSecurity(store)) {
64
+ return ctx.response.redirect('/account/security');
65
+ }
66
+ const { currentPassword, newEmail } = await ctx.request.validateUsing(changeEmailValidator);
67
+ const account = await store.findById(userId);
68
+ const verified = account
69
+ ? await store.verifyCredentials(account.email, currentPassword)
70
+ : null;
71
+ if (!verified) {
72
+ ctx.session.flash('securityError', translate(cfg.messages, 'errors.invalid_credentials'));
73
+ return ctx.response.redirect('/account/security');
74
+ }
75
+ const issued = await store.requestEmailChange(userId, newEmail);
76
+ if (!issued) {
77
+ ctx.session.flash('securityError', translate(cfg.messages, 'errors.email_taken'));
78
+ return ctx.response.redirect('/account/security');
79
+ }
80
+ await cfg.audit?.record({
81
+ type: 'email.change_requested',
82
+ accountId: userId,
83
+ email: newEmail,
84
+ ip: ctx.request.ip?.() ?? null,
85
+ });
86
+ const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
87
+ const confirmUrl = `${origin}/account/email/confirm?token=${encodeURIComponent(issued.token)}`;
88
+ // Hook do config tem prioridade (override); senão usa o mailer default do host.
89
+ if (cfg.mail?.onEmailVerification) {
90
+ await cfg.mail.onEmailVerification({ email: newEmail, verifyUrl: confirmUrl, token: issued.token });
91
+ }
92
+ else {
93
+ await sendEmailChangeConfirmationEmail(ctx, { email: newEmail, confirmUrl });
94
+ }
95
+ ctx.session.flash('emailChangeRequested', translate(cfg.messages, 'account.security.email_change_requested', { email: newEmail }));
96
+ return ctx.response.redirect('/account/security');
97
+ }
98
+ /** GET /account/email/confirm?token=... — consome o token e aplica o novo e-mail. */
99
+ async confirmEmail(ctx) {
100
+ const service = await ctx.containerResolver.make('authkit.server');
101
+ const cfg = service.config;
102
+ const store = cfg.accountStore;
103
+ const render = cfg.render;
104
+ if (!supportsAccountSecurity(store)) {
105
+ return render(ctx, 'account/email-confirmed', { ok: false });
106
+ }
107
+ const token = ctx.request.qs().token ?? '';
108
+ const result = await store.confirmEmailChange(token);
109
+ if (result.ok) {
110
+ await cfg.audit?.record({
111
+ type: 'email.changed',
112
+ accountId: result.account.id,
113
+ email: result.newEmail,
114
+ ip: ctx.request.ip?.() ?? null,
115
+ });
116
+ }
117
+ return render(ctx, 'account/email-confirmed', { ok: result.ok });
118
+ }
119
+ }
@@ -2,6 +2,7 @@ import '../augmentations.js';
2
2
  import { ACCOUNT_SESSION_KEY } from '../middleware/account_auth.js';
3
3
  import { translate } from '../i18n.js';
4
4
  import { attemptPasswordLogin } from '../login_attempt.js';
5
+ import { notifyLoginSuccess } from '../login_notify.js';
5
6
  export default class AccountSessionController {
6
7
  async show(ctx) {
7
8
  const service = await ctx.containerResolver.make('authkit.server');
@@ -32,7 +33,7 @@ export default class AccountSessionController {
32
33
  }
33
34
  const acc = result.account;
34
35
  ctx.session.put(ACCOUNT_SESSION_KEY, acc.id);
35
- await cfg.audit?.record({ type: 'login.success', accountId: acc.id, email, ip });
36
+ await notifyLoginSuccess(ctx, cfg, { accountId: acc.id, email, ip });
36
37
  return ctx.response.redirect('/account/tokens');
37
38
  }
38
39
  async logout(ctx) {
@@ -0,0 +1,14 @@
1
+ import '../../augmentations.js';
2
+ import type { HttpContext } from '@adonisjs/core/http';
3
+ /**
4
+ * Inspeção/revogação das sessões e grants ativos de uma conta no console admin.
5
+ * Lista as `Session` (logins do IdP) + os `Grant` (autorizações por client) da
6
+ * conta, com a contagem de access/refresh tokens por grant. Degrada graciosamente
7
+ * quando o adapter OIDC não enumera (`list`), espelhando o CRUD de clients.
8
+ */
9
+ export default class AdminSessionsController {
10
+ /** GET /admin/users/:id/sessions — lista sessões + grants da conta. */
11
+ index(ctx: HttpContext): Promise<any>;
12
+ /** POST /admin/users/:id/revoke-sessions — destrói sessões + grants da conta. */
13
+ revoke(ctx: HttpContext): Promise<void>;
14
+ }