@dudousxd/adonis-authkit-server 0.2.0 → 0.4.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 (61) 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/client_form.edge +83 -0
  10. package/build/host/views/admin/clients.edge +68 -3
  11. package/build/host/views/admin/sessions.edge +89 -0
  12. package/build/host/views/admin/users.edge +1 -0
  13. package/build/host/views/mfa-challenge.edge +29 -23
  14. package/build/index.d.ts +4 -3
  15. package/build/index.js +2 -2
  16. package/build/src/accounts/account_store.d.ts +46 -1
  17. package/build/src/accounts/account_store.js +4 -0
  18. package/build/src/accounts/lucid_store/core.d.ts +5 -4
  19. package/build/src/accounts/lucid_store/core.js +67 -2
  20. package/build/src/adapters/adapter_contract.d.ts +29 -0
  21. package/build/src/adapters/database_adapter.d.ts +12 -1
  22. package/build/src/adapters/database_adapter.js +24 -0
  23. package/build/src/adapters/redis_adapter.d.ts +14 -1
  24. package/build/src/adapters/redis_adapter.js +35 -0
  25. package/build/src/audit/audit_sink.d.ts +1 -1
  26. package/build/src/define_config.d.ts +102 -0
  27. package/build/src/define_config.js +46 -3
  28. package/build/src/doctor/checks.d.ts +51 -0
  29. package/build/src/doctor/checks.js +231 -0
  30. package/build/src/host/admin_clients_service.d.ts +65 -0
  31. package/build/src/host/admin_clients_service.js +143 -0
  32. package/build/src/host/admin_sessions_service.d.ts +63 -0
  33. package/build/src/host/admin_sessions_service.js +127 -0
  34. package/build/src/host/controllers/account_security_controller.d.ts +16 -0
  35. package/build/src/host/controllers/account_security_controller.js +119 -0
  36. package/build/src/host/controllers/account_session_controller.js +2 -1
  37. package/build/src/host/controllers/admin/admin_clients_controller.d.ts +17 -3
  38. package/build/src/host/controllers/admin/admin_clients_controller.js +158 -4
  39. package/build/src/host/controllers/admin/admin_sessions_controller.d.ts +14 -0
  40. package/build/src/host/controllers/admin/admin_sessions_controller.js +64 -0
  41. package/build/src/host/controllers/interaction_controller.d.ts +11 -0
  42. package/build/src/host/controllers/interaction_controller.js +49 -10
  43. package/build/src/host/default_mailer.d.ts +17 -0
  44. package/build/src/host/default_mailer.js +51 -0
  45. package/build/src/host/i18n.d.ts +80 -0
  46. package/build/src/host/i18n.js +86 -1
  47. package/build/src/host/login_notify.d.ts +20 -0
  48. package/build/src/host/login_notify.js +71 -0
  49. package/build/src/host/register_auth_host.js +20 -0
  50. package/build/src/host/validators.d.ts +32 -0
  51. package/build/src/host/validators.js +14 -0
  52. package/build/src/keys/keystore.d.ts +43 -0
  53. package/build/src/keys/keystore.js +74 -0
  54. package/build/src/provider/build_provider.js +23 -0
  55. package/build/src/provider/device_sources.d.ts +6 -0
  56. package/build/src/provider/device_sources.js +65 -0
  57. package/build/src/provider/interaction_actions.d.ts +6 -1
  58. package/build/src/provider/interaction_actions.js +9 -2
  59. package/build/src/provider/oidc_service.d.ts +15 -0
  60. package/build/src/provider/oidc_service.js +27 -0
  61. package/package.json +2 -2
@@ -2,6 +2,7 @@ import '../augmentations.js';
2
2
  import { brandFor, isFirstParty } from '../branding.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
  import { supportsPasskeys } from '../../accounts/account_store.js';
6
7
  const SESSION_KEY = 'authkit_login_email';
7
8
  /** accountId aguardando o 2º fator depois da senha verificada. */
@@ -106,10 +107,25 @@ export default class AuthInteractionController {
106
107
  });
107
108
  }
108
109
  const acc = result.account;
109
- // MFA gate: se a conta tem TOTP ativo, NÃO finaliza a interaction agora —
110
- // guarda o accountId pendente na sessão e renderiza o desafio do 2º fator.
110
+ // Step-up auth (acr_values): o client pode EXIGIR MFA nesta requisição
111
+ // solicitando o `mfaAcr` em acr_values, mesmo que a conta tenha MFA opcional.
112
+ const mfaRequired = this.acrRequiresMfa(cfg, details);
113
+ // MFA gate: força o 2º fator se a conta tem TOTP ativo OU se o client exige MFA
114
+ // via acr. Não finaliza a interaction agora — guarda o accountId pendente.
111
115
  const mfa = (await cfg.accountStore.getMfaState?.(acc.id)) ?? { enabled: false };
112
- if (mfa.enabled) {
116
+ if (mfa.enabled || mfaRequired) {
117
+ if (mfaRequired && !mfa.enabled) {
118
+ // Client exige MFA mas a conta não tem MFA enrolado: bloqueia este login
119
+ // com a instrução de configurar MFA no console (não há 2º fator a desafiar).
120
+ return render(ctx, 'mfa-challenge', {
121
+ uid: ctx.request.param('uid'),
122
+ csrfToken: ctx.request.csrfToken,
123
+ brand,
124
+ passkeyAvailable: false,
125
+ error: translate(cfg.messages, 'mfa_challenge.required_no_enrollment'),
126
+ noEnrollment: true,
127
+ });
128
+ }
113
129
  ctx.session.put(MFA_PENDING_KEY, acc.id);
114
130
  // Passkey disponível como alternativa ao TOTP se o store suporta E a conta
115
131
  // tem ao menos uma credencial registrada.
@@ -123,7 +139,7 @@ export default class AuthInteractionController {
123
139
  }
124
140
  // Sem MFA: finaliza a interaction (escreve o 303 de volta para o client).
125
141
  await service.interactions.completeLogin(ctx, acc.id);
126
- await cfg.audit?.record({ type: 'login.success', accountId: acc.id, email, ip, clientId });
142
+ await notifyLoginSuccess(ctx, cfg, { accountId: acc.id, email, ip, clientId });
127
143
  // Clean up the session key after a successful login.
128
144
  ctx.session.forget(SESSION_KEY);
129
145
  }
@@ -174,14 +190,38 @@ export default class AuthInteractionController {
174
190
  // Sucesso no 2º fator: finaliza a interaction para o accountId pendente.
175
191
  ctx.session.forget(MFA_PENDING_KEY);
176
192
  ctx.session.forget(SESSION_KEY);
177
- await cfg.audit?.record({
178
- type: 'login.success',
193
+ await notifyLoginSuccess(ctx, cfg, {
179
194
  accountId,
180
195
  ip,
181
196
  clientId,
182
197
  metadata: { mfa: usedRecovery ? 'recovery' : 'totp' },
183
198
  });
184
- await service.interactions.completeLogin(ctx, accountId);
199
+ // Step-up: um 2º fator foi de fato verificado — carimba acr/amr no id_token
200
+ // se o client solicitou o mfaAcr nesta requisição.
201
+ await service.interactions.completeLogin(ctx, accountId, this.stepUpExtra(cfg, details, usedRecovery ? 'recovery' : 'totp'));
202
+ }
203
+ /**
204
+ * true se o authorize request exige MFA via acr_values (contém o mfaAcr da
205
+ * config de step-up). `acr_values` é a string separada por espaços padrão OIDC.
206
+ */
207
+ acrRequiresMfa(cfg, details) {
208
+ const mfaAcr = cfg.stepUp?.mfaAcr;
209
+ if (!mfaAcr)
210
+ return false;
211
+ const raw = details?.params?.acr_values;
212
+ if (typeof raw !== 'string' || !raw)
213
+ return false;
214
+ return raw.split(/\s+/).includes(mfaAcr);
215
+ }
216
+ /**
217
+ * Monta o acr/amr do step-up quando o client solicitou o mfaAcr e um 2º fator
218
+ * foi verificado. `method` é o método do segundo fator (totp/recovery/webauthn).
219
+ * Retorna `undefined` quando não há step-up — completeLogin usa o default.
220
+ */
221
+ stepUpExtra(cfg, details, method) {
222
+ if (!this.acrRequiresMfa(cfg, details))
223
+ return undefined;
224
+ return { acr: cfg.stepUp.mfaAcr, amr: ['mfa', method] };
185
225
  }
186
226
  /** true se o store suporta passkeys E a conta tem ao menos uma registrada. */
187
227
  async hasPasskeys(cfg, accountId) {
@@ -261,14 +301,13 @@ export default class AuthInteractionController {
261
301
  }
262
302
  ctx.session.forget(MFA_PENDING_KEY);
263
303
  ctx.session.forget(SESSION_KEY);
264
- await cfg.audit?.record({
265
- type: 'login.success',
304
+ await notifyLoginSuccess(ctx, cfg, {
266
305
  accountId,
267
306
  ip,
268
307
  clientId,
269
308
  metadata: { mfa: 'webauthn' },
270
309
  });
271
- await service.interactions.completeLogin(ctx, accountId);
310
+ await service.interactions.completeLogin(ctx, accountId, this.stepUpExtra(cfg, details, 'webauthn'));
272
311
  }
273
312
  /**
274
313
  * GET /auth/interaction/:uid/switch
@@ -28,6 +28,23 @@ export declare function sendPasswordResetEmail(ctx: HttpContext, data: {
28
28
  email: string;
29
29
  resetUrl: string;
30
30
  }): Promise<void>;
31
+ /**
32
+ * Envia o e-mail de confirmação de TROCA de e-mail para o NOVO endereço.
33
+ * Best-effort: no fallback (sem mail) loga o link; nunca lança.
34
+ */
35
+ export declare function sendEmailChangeConfirmationEmail(ctx: HttpContext, data: {
36
+ email: string;
37
+ confirmUrl: string;
38
+ }): Promise<void>;
39
+ /**
40
+ * Envia o e-mail de alerta de NOVO acesso à conta (login de um IP novo).
41
+ * Best-effort: no fallback (sem mail) loga o evento; nunca lança.
42
+ */
43
+ export declare function sendNewLoginEmail(ctx: HttpContext, data: {
44
+ email: string;
45
+ ip: string;
46
+ when: string;
47
+ }): Promise<void>;
31
48
  /**
32
49
  * Envia o e-mail de verificação pelo mailer default do host.
33
50
  * Best-effort: no fallback (sem mail) loga o link; nunca lança.
@@ -115,6 +115,57 @@ export async function sendPasswordResetEmail(ctx, data) {
115
115
  ctx.logger.error({ err: error, email: data.email }, 'authkit: falha ao enviar e-mail de redefinição de senha');
116
116
  }
117
117
  }
118
+ /**
119
+ * Envia o e-mail de confirmação de TROCA de e-mail para o NOVO endereço.
120
+ * Best-effort: no fallback (sem mail) loga o link; nunca lança.
121
+ */
122
+ export async function sendEmailChangeConfirmationEmail(ctx, data) {
123
+ try {
124
+ const brand = resolveBrand(ctx);
125
+ const content = renderTransactionalEmail({
126
+ brand,
127
+ subject: 'Confirme seu novo e-mail',
128
+ heading: 'Confirme seu novo e-mail',
129
+ intro: `Recebemos um pedido para alterar o e-mail da sua conta em ${brand.appName} para este endereço. Clique no botão abaixo para confirmar a alteração. Se não foi você, ignore este e-mail — nada será alterado.`,
130
+ ctaLabel: 'Confirmar novo e-mail',
131
+ ctaUrl: data.confirmUrl,
132
+ footnote: 'Por segurança, este link expira em breve e só pode ser usado uma vez.',
133
+ });
134
+ const sent = await sendEmail(ctx, data.email, content);
135
+ if (!sent) {
136
+ ctx.logger.info({ confirmUrl: data.confirmUrl, email: data.email }, 'authkit: link de confirmação de troca de e-mail (dev — @adonisjs/mail ausente)');
137
+ }
138
+ }
139
+ catch (error) {
140
+ ctx.logger.error({ err: error, email: data.email }, 'authkit: falha ao enviar confirmação de troca de e-mail');
141
+ }
142
+ }
143
+ /**
144
+ * Envia o e-mail de alerta de NOVO acesso à conta (login de um IP novo).
145
+ * Best-effort: no fallback (sem mail) loga o evento; nunca lança.
146
+ */
147
+ export async function sendNewLoginEmail(ctx, data) {
148
+ try {
149
+ const brand = resolveBrand(ctx);
150
+ const origin = `${ctx.request.protocol()}://${ctx.request.host()}`;
151
+ const content = renderTransactionalEmail({
152
+ brand,
153
+ subject: 'Novo acesso à sua conta',
154
+ heading: 'Novo acesso à sua conta',
155
+ intro: `Detectamos um novo acesso à sua conta em ${brand.appName} a partir do IP ${data.ip} em ${data.when}. Se foi você, nenhuma ação é necessária. Se não reconhece este acesso, altere sua senha imediatamente.`,
156
+ ctaLabel: 'Acessar minha conta',
157
+ ctaUrl: `${origin}/account/security`,
158
+ footnote: 'Você está recebendo este alerta porque um login foi feito de um endereço de IP não visto antes.',
159
+ });
160
+ const sent = await sendEmail(ctx, data.email, content);
161
+ if (!sent) {
162
+ ctx.logger.info({ ip: data.ip, email: data.email }, 'authkit: alerta de novo acesso (dev — @adonisjs/mail ausente)');
163
+ }
164
+ }
165
+ catch (error) {
166
+ ctx.logger.error({ err: error, email: data.email }, 'authkit: falha ao enviar alerta de novo acesso');
167
+ }
168
+ }
118
169
  /**
119
170
  * Envia o e-mail de verificação pelo mailer default do host.
120
171
  * Best-effort: no fallback (sem mail) loga o link; nunca lança.
@@ -90,6 +90,7 @@ export declare const DEFAULT_MESSAGES: {
90
90
  'account.tokens.page_title': string;
91
91
  'account.tokens.title': string;
92
92
  'account.tokens.logout': string;
93
+ 'account.tokens.security': string;
93
94
  'account.tokens.created_notice': string;
94
95
  'account.tokens.name_placeholder': string;
95
96
  'account.tokens.create': string;
@@ -100,6 +101,28 @@ export declare const DEFAULT_MESSAGES: {
100
101
  'account.tokens.scopes': string;
101
102
  'account.tokens.audience': string;
102
103
  'account.tokens.revoke': string;
104
+ 'account.security.page_title': string;
105
+ 'account.security.title': string;
106
+ 'account.security.logout': string;
107
+ 'account.security.current_email': string;
108
+ 'account.security.not_supported': string;
109
+ 'account.security.password_section': string;
110
+ 'account.security.current_password_label': string;
111
+ 'account.security.new_password_label': string;
112
+ 'account.security.change_password_submit': string;
113
+ 'account.security.password_changed': string;
114
+ 'account.security.email_section': string;
115
+ 'account.security.email_intro': string;
116
+ 'account.security.new_email_label': string;
117
+ 'account.security.email_password_label': string;
118
+ 'account.security.change_email_submit': string;
119
+ 'account.security.email_change_requested': string;
120
+ 'account.security.email_changed': string;
121
+ 'account.email_confirmed.page_title': string;
122
+ 'account.email_confirmed.ok_title': string;
123
+ 'account.email_confirmed.ok_body': string;
124
+ 'account.email_confirmed.invalid_title': string;
125
+ 'account.email_confirmed.invalid_body': string;
103
126
  'account.mfa.page_title': string;
104
127
  'account.mfa.title': string;
105
128
  'account.mfa.logout': string;
@@ -140,6 +163,23 @@ export declare const DEFAULT_MESSAGES: {
140
163
  'admin.users.empty': string;
141
164
  'admin.users.roles_placeholder': string;
142
165
  'admin.users.save_roles': string;
166
+ 'admin.users.sessions': string;
167
+ 'admin.sessions.page_title': string;
168
+ 'admin.sessions.title': string;
169
+ 'admin.sessions.account': string;
170
+ 'admin.sessions.back': string;
171
+ 'admin.sessions.not_supported': string;
172
+ 'admin.sessions.revoked_notice': string;
173
+ 'admin.sessions.sessions_section': string;
174
+ 'admin.sessions.sessions_empty': string;
175
+ 'admin.sessions.session_login_ts': string;
176
+ 'admin.sessions.session_amr': string;
177
+ 'admin.sessions.grants_section': string;
178
+ 'admin.sessions.grants_empty': string;
179
+ 'admin.sessions.grant_client': string;
180
+ 'admin.sessions.grant_tokens': string;
181
+ 'admin.sessions.revoke_all': string;
182
+ 'admin.sessions.revoke_confirm': string;
143
183
  'admin.clients.page_title': string;
144
184
  'admin.clients.title': string;
145
185
  'admin.clients.empty': string;
@@ -148,6 +188,33 @@ export declare const DEFAULT_MESSAGES: {
148
188
  'admin.clients.grants': string;
149
189
  'admin.clients.redirect_uris': string;
150
190
  'admin.clients.dynamic_notice': string;
191
+ 'admin.clients.static_section': string;
192
+ 'admin.clients.dynamic_section': string;
193
+ 'admin.clients.dynamic_empty': string;
194
+ 'admin.clients.dynamic_not_supported': string;
195
+ 'admin.clients.new': string;
196
+ 'admin.clients.new_title': string;
197
+ 'admin.clients.edit_title': string;
198
+ 'admin.clients.edit': string;
199
+ 'admin.clients.delete': string;
200
+ 'admin.clients.delete_confirm': string;
201
+ 'admin.clients.regenerate_secret': string;
202
+ 'admin.clients.regenerate_confirm': string;
203
+ 'admin.clients.back': string;
204
+ 'admin.clients.cancel': string;
205
+ 'admin.clients.save': string;
206
+ 'admin.clients.create': string;
207
+ 'admin.clients.secret_once_title': string;
208
+ 'admin.clients.secret_once_notice': string;
209
+ 'admin.clients.field_client_id': string;
210
+ 'admin.clients.field_client_id_placeholder': string;
211
+ 'admin.clients.field_client_id_help': string;
212
+ 'admin.clients.field_redirect_uris': string;
213
+ 'admin.clients.field_redirect_uris_help': string;
214
+ 'admin.clients.field_post_logout_uris': string;
215
+ 'admin.clients.field_post_logout_uris_help': string;
216
+ 'admin.clients.field_grant_types': string;
217
+ 'admin.clients.field_auth_method': string;
151
218
  'admin.audit.page_title': string;
152
219
  'admin.audit.title': string;
153
220
  'admin.audit.type_placeholder': string;
@@ -158,6 +225,19 @@ export declare const DEFAULT_MESSAGES: {
158
225
  'admin.pagination.page': string;
159
226
  'admin.pagination.prev': string;
160
227
  'admin.pagination.next': string;
228
+ 'device.input.title': string;
229
+ 'device.input.intro': string;
230
+ 'device.input.submit': string;
231
+ 'device.input.error_invalid': string;
232
+ 'device.input.error_aborted': string;
233
+ 'device.input.error_generic': string;
234
+ 'device.confirm.title': string;
235
+ 'device.confirm.body': string;
236
+ 'device.confirm.submit': string;
237
+ 'device.confirm.abort': string;
238
+ 'device.success.title': string;
239
+ 'device.success.body': string;
240
+ 'mfa_challenge.required_no_enrollment': string;
161
241
  'errors.invalid_credentials': string;
162
242
  'errors.invalid_code': string;
163
243
  'errors.email_taken': string;
@@ -90,6 +90,7 @@ export const DEFAULT_MESSAGES = {
90
90
  'account.tokens.page_title': 'Tokens de acesso',
91
91
  'account.tokens.title': 'Tokens de acesso',
92
92
  'account.tokens.logout': 'Sair',
93
+ 'account.tokens.security': 'Segurança',
93
94
  'account.tokens.created_notice': 'Token criado — copie agora, não será mostrado de novo:',
94
95
  'account.tokens.name_placeholder': 'Nome do token (ex.: CI deploy)',
95
96
  'account.tokens.create': 'Criar',
@@ -100,6 +101,30 @@ export const DEFAULT_MESSAGES = {
100
101
  'account.tokens.scopes': 'Escopos: {scopes}',
101
102
  'account.tokens.audience': 'Audiência: {audience}',
102
103
  'account.tokens.revoke': 'Revogar',
104
+ // Console de conta — segurança (account/security): senha + e-mail.
105
+ 'account.security.page_title': 'Segurança da conta',
106
+ 'account.security.title': 'Segurança da conta',
107
+ 'account.security.logout': 'Sair',
108
+ 'account.security.current_email': 'E-mail atual: {email}',
109
+ 'account.security.not_supported': 'A troca de senha e e-mail não está disponível nesta instalação.',
110
+ 'account.security.password_section': 'Trocar senha',
111
+ 'account.security.current_password_label': 'Senha atual',
112
+ 'account.security.new_password_label': 'Nova senha',
113
+ 'account.security.change_password_submit': 'Trocar senha',
114
+ 'account.security.password_changed': 'Senha alterada com sucesso.',
115
+ 'account.security.email_section': 'Trocar e-mail',
116
+ 'account.security.email_intro': 'Enviaremos um link de confirmação para o novo endereço. A troca só é aplicada após a confirmação.',
117
+ 'account.security.new_email_label': 'Novo e-mail',
118
+ 'account.security.email_password_label': 'Senha atual',
119
+ 'account.security.change_email_submit': 'Solicitar troca de e-mail',
120
+ 'account.security.email_change_requested': 'Enviamos um link de confirmação para {email}. Clique nele para concluir a troca.',
121
+ 'account.security.email_changed': 'E-mail alterado com sucesso.',
122
+ // Confirmação de troca de e-mail (account/email-confirmed).
123
+ 'account.email_confirmed.page_title': 'Confirmação de e-mail',
124
+ 'account.email_confirmed.ok_title': 'E-mail alterado',
125
+ 'account.email_confirmed.ok_body': 'Seu novo e-mail foi confirmado e já está ativo.',
126
+ 'account.email_confirmed.invalid_title': 'Link inválido',
127
+ 'account.email_confirmed.invalid_body': 'O link de confirmação é inválido ou já foi utilizado.',
103
128
  // Console de conta — MFA (account/mfa).
104
129
  'account.mfa.page_title': 'Verificação em duas etapas',
105
130
  'account.mfa.title': 'Verificação em duas etapas',
@@ -145,6 +170,24 @@ export const DEFAULT_MESSAGES = {
145
170
  'admin.users.empty': 'Nenhum usuário encontrado.',
146
171
  'admin.users.roles_placeholder': 'Papéis (separados por vírgula)',
147
172
  'admin.users.save_roles': 'Salvar papéis',
173
+ 'admin.users.sessions': 'Sessões',
174
+ // Console admin — sessões/grants ativos de uma conta.
175
+ 'admin.sessions.page_title': 'Sessões ativas',
176
+ 'admin.sessions.title': 'Sessões ativas',
177
+ 'admin.sessions.account': 'Conta: {email}',
178
+ 'admin.sessions.back': 'Voltar para usuários',
179
+ 'admin.sessions.not_supported': 'O adapter OIDC configurado não suporta enumeração — a inspeção de sessões fica indisponível.',
180
+ 'admin.sessions.revoked_notice': 'Revogado: {sessions} sessão(ões), {grants} grant(s), {accessTokens} access token(s), {refreshTokens} refresh token(s).',
181
+ 'admin.sessions.sessions_section': 'Sessões (login no IdP)',
182
+ 'admin.sessions.sessions_empty': 'Nenhuma sessão ativa.',
183
+ 'admin.sessions.session_login_ts': 'Login: {date}',
184
+ 'admin.sessions.session_amr': 'Métodos: {amr}',
185
+ 'admin.sessions.grants_section': 'Grants (autorizações por client)',
186
+ 'admin.sessions.grants_empty': 'Nenhum grant ativo.',
187
+ 'admin.sessions.grant_client': 'Client: {clientId}',
188
+ 'admin.sessions.grant_tokens': '{accessTokens} access · {refreshTokens} refresh',
189
+ 'admin.sessions.revoke_all': 'Revogar todas as sessões e grants',
190
+ 'admin.sessions.revoke_confirm': 'Revogar todas as sessões e grants desta conta? O usuário precisará entrar novamente e os tokens emitidos deixarão de funcionar.',
148
191
  // Console admin — clients.
149
192
  'admin.clients.page_title': 'Clients OAuth',
150
193
  'admin.clients.title': 'Clients OAuth',
@@ -153,7 +196,34 @@ export const DEFAULT_MESSAGES = {
153
196
  'admin.clients.public': 'Público',
154
197
  'admin.clients.grants': 'Grants: {grants}',
155
198
  'admin.clients.redirect_uris': 'Redirects: {uris}',
156
- 'admin.clients.dynamic_notice': 'O registro dinâmico de clients está ligado — clients registrados via /reg vivem no adapter e não aparecem nesta lista.',
199
+ 'admin.clients.dynamic_notice': 'O registro dinâmico de clients (RFC 7591) está ligado — clients registrados via /reg são persistidos no adapter e aparecem na seção dinâmica abaixo.',
200
+ 'admin.clients.static_section': 'Clients estáticos (config)',
201
+ 'admin.clients.dynamic_section': 'Clients dinâmicos (adapter)',
202
+ 'admin.clients.dynamic_empty': 'Nenhum client dinâmico persistido.',
203
+ 'admin.clients.dynamic_not_supported': 'O adapter OIDC configurado não suporta enumeração de clients — a gestão dinâmica fica indisponível.',
204
+ 'admin.clients.new': 'Novo client',
205
+ 'admin.clients.new_title': 'Novo client OIDC',
206
+ 'admin.clients.edit_title': 'Editar client OIDC',
207
+ 'admin.clients.edit': 'Editar',
208
+ 'admin.clients.delete': 'Excluir',
209
+ 'admin.clients.delete_confirm': 'Excluir este client? Esta ação não pode ser desfeita.',
210
+ 'admin.clients.regenerate_secret': 'Regenerar secret',
211
+ 'admin.clients.regenerate_confirm': 'Regenerar o secret? O secret atual deixará de funcionar imediatamente.',
212
+ 'admin.clients.back': 'Voltar',
213
+ 'admin.clients.cancel': 'Cancelar',
214
+ 'admin.clients.save': 'Salvar',
215
+ 'admin.clients.create': 'Criar client',
216
+ 'admin.clients.secret_once_title': 'Guarde o client_secret agora',
217
+ 'admin.clients.secret_once_notice': 'Este é o único momento em que o secret é exibido. Copie-o agora — ele não pode ser recuperado depois.',
218
+ 'admin.clients.field_client_id': 'Client ID',
219
+ 'admin.clients.field_client_id_placeholder': 'deixe em branco para gerar automaticamente',
220
+ 'admin.clients.field_client_id_help': 'Opcional. Se vazio, um identificador aleatório será gerado.',
221
+ 'admin.clients.field_redirect_uris': 'Redirect URIs',
222
+ 'admin.clients.field_redirect_uris_help': 'Uma URI por linha.',
223
+ 'admin.clients.field_post_logout_uris': 'Post-logout redirect URIs',
224
+ 'admin.clients.field_post_logout_uris_help': 'Uma URI por linha (opcional).',
225
+ 'admin.clients.field_grant_types': 'Grant types',
226
+ 'admin.clients.field_auth_method': 'Token endpoint auth method',
157
227
  // Console admin — auditoria.
158
228
  'admin.audit.page_title': 'Auditoria',
159
229
  'admin.audit.title': 'Log de auditoria',
@@ -166,6 +236,21 @@ export const DEFAULT_MESSAGES = {
166
236
  'admin.pagination.page': 'Página {page} de {total}',
167
237
  'admin.pagination.prev': 'Anterior',
168
238
  'admin.pagination.next': 'Próxima',
239
+ // Device Authorization Grant (RFC 8628) — telas servidas pelo oidc-provider.
240
+ 'device.input.title': 'Entrar no dispositivo',
241
+ 'device.input.intro': 'Digite o código exibido no seu dispositivo.',
242
+ 'device.input.submit': 'Continuar',
243
+ 'device.input.error_invalid': 'O código informado está incorreto. Tente novamente.',
244
+ 'device.input.error_aborted': 'A solicitação de login foi interrompida.',
245
+ 'device.input.error_generic': 'Ocorreu um erro ao processar sua solicitação.',
246
+ 'device.confirm.title': 'Confirmar dispositivo',
247
+ 'device.confirm.body': 'O código abaixo deve estar sendo exibido no seu dispositivo. Confirme apenas se reconhecê-lo.',
248
+ 'device.confirm.submit': 'Continuar',
249
+ 'device.confirm.abort': 'Cancelar',
250
+ 'device.success.title': 'Login concluído',
251
+ 'device.success.body': 'Login realizado com sucesso. Você já pode voltar ao dispositivo.',
252
+ // Step-up auth (acr_values): cliente exige MFA mas a conta não tem MFA enrolado.
253
+ 'mfa_challenge.required_no_enrollment': 'Este cliente exige verificação em duas etapas. Configure o MFA no console da sua conta para continuar.',
169
254
  // Mensagens de erro/flash produzidas pelos controllers.
170
255
  'errors.invalid_credentials': 'Credenciais inválidas',
171
256
  'errors.invalid_code': 'Código inválido',
@@ -0,0 +1,20 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
2
+ import type { ResolvedServerConfig } from '../define_config.js';
3
+ /** Dados de um login bem-sucedido a auditar/notificar. */
4
+ export interface LoginSuccessInput {
5
+ accountId: string;
6
+ email?: string | null;
7
+ ip?: string | null;
8
+ clientId?: string | null;
9
+ /** Metadata extra a anexar ao evento login.success (ex.: { mfa: 'totp' }). */
10
+ metadata?: Record<string, unknown>;
11
+ }
12
+ /**
13
+ * Centraliza o pós-login bem-sucedido: registra o evento `login.success` e dispara
14
+ * (best-effort) o alerta de NOVO acesso quando o IP nunca foi visto para a conta.
15
+ *
16
+ * É fire-and-forget e FAIL-SAFE: a notificação roda DEPOIS do audit e qualquer erro
17
+ * é engolido — NUNCA bloqueia nem lança no caminho do login. Substitui as chamadas
18
+ * `cfg.audit?.record({ type: 'login.success', ... })` espalhadas pelos controllers.
19
+ */
20
+ export declare function notifyLoginSuccess(ctx: HttpContext, cfg: ResolvedServerConfig, input: LoginSuccessInput): Promise<void>;
@@ -0,0 +1,71 @@
1
+ import { sendNewLoginEmail } from './default_mailer.js';
2
+ /**
3
+ * Centraliza o pós-login bem-sucedido: registra o evento `login.success` e dispara
4
+ * (best-effort) o alerta de NOVO acesso quando o IP nunca foi visto para a conta.
5
+ *
6
+ * É fire-and-forget e FAIL-SAFE: a notificação roda DEPOIS do audit e qualquer erro
7
+ * é engolido — NUNCA bloqueia nem lança no caminho do login. Substitui as chamadas
8
+ * `cfg.audit?.record({ type: 'login.success', ... })` espalhadas pelos controllers.
9
+ */
10
+ export async function notifyLoginSuccess(ctx, cfg, input) {
11
+ const { accountId, email, ip, clientId, metadata } = input;
12
+ // 1) Audit do login.success (mesmo formato de antes).
13
+ await cfg.audit?.record({
14
+ type: 'login.success',
15
+ accountId,
16
+ email: email ?? null,
17
+ ip: ip ?? null,
18
+ clientId: clientId ?? null,
19
+ metadata,
20
+ });
21
+ // 2) Alerta de novo acesso (opt-out via notifications.newLoginEmail: false).
22
+ if (!cfg.notifications.newLoginEmail)
23
+ return;
24
+ // Fire-and-forget: nunca propaga erro pro caminho do login.
25
+ void (async () => {
26
+ // Resolve o e-mail quando o caller não o forneceu (ex.: fluxo de MFA só tem o
27
+ // accountId em escopo). Best-effort.
28
+ let resolvedEmail = email ?? null;
29
+ if (!resolvedEmail) {
30
+ resolvedEmail = (await cfg.accountStore.findById(accountId))?.email ?? null;
31
+ }
32
+ await maybeNotifyNewLogin(ctx, cfg, { accountId, email: resolvedEmail, ip: ip ?? null });
33
+ })().catch((error) => {
34
+ ctx.logger.error({ err: error, accountId }, 'authkit: falha no alerta de novo acesso');
35
+ });
36
+ }
37
+ /**
38
+ * Verifica se o IP é novo para a conta (consultando o audit sink por
39
+ * `login.success` do subject) e, se for, envia o e-mail de alerta + audita
40
+ * `login.new_ip_notified`. Degrada para no-op quando: sem IP, sem e-mail, sem
41
+ * sink consultável (`list`), ou já houve um login.success deste IP antes.
42
+ */
43
+ async function maybeNotifyNewLogin(ctx, cfg, data) {
44
+ const { accountId, email, ip } = data;
45
+ if (!ip || !email)
46
+ return;
47
+ // Sem consulta do histórico não dá pra decidir se o IP é novo → no-op.
48
+ if (typeof cfg.audit?.list !== 'function')
49
+ return;
50
+ // Lê o histórico de login.success do subject. O evento ATUAL já foi gravado por
51
+ // notifyLoginSuccess, então um IP visto antes aparece com count >= 2 para o IP.
52
+ // Buscamos uma página ampla e contamos as ocorrências deste IP.
53
+ const page = await cfg.audit.list({
54
+ type: 'login.success',
55
+ subject: accountId,
56
+ page: 1,
57
+ limit: 200,
58
+ });
59
+ const sameIpCount = page.data.filter((e) => e.ip === ip).length;
60
+ // > 1 significa que já havia um login.success deste IP antes do atual → não é novo.
61
+ if (sameIpCount > 1)
62
+ return;
63
+ const when = new Date().toISOString();
64
+ await sendNewLoginEmail(ctx, { email, ip, when });
65
+ await cfg.audit?.record({
66
+ type: 'login.new_ip_notified',
67
+ accountId,
68
+ email,
69
+ ip,
70
+ });
71
+ }
@@ -52,9 +52,11 @@ const C = {
52
52
  patIntrospection: () => import('./controllers/pat_introspection_controller.js'),
53
53
  accountSession: () => import('./controllers/account_session_controller.js'),
54
54
  accountTokens: () => import('./controllers/account_tokens_controller.js'),
55
+ accountSecurity: () => import('./controllers/account_security_controller.js'),
55
56
  accountMfa: () => import('./controllers/account_mfa_controller.js'),
56
57
  adminDashboard: () => import('./controllers/admin/admin_dashboard_controller.js'),
57
58
  adminUsers: () => import('./controllers/admin/admin_users_controller.js'),
59
+ adminSessions: () => import('./controllers/admin/admin_sessions_controller.js'),
58
60
  adminClients: () => import('./controllers/admin/admin_clients_controller.js'),
59
61
  adminAudit: () => import('./controllers/admin/admin_audit_controller.js'),
60
62
  };
@@ -108,12 +110,19 @@ export function registerAuthHost(router, opts) {
108
110
  router.get('/account/login', [C.accountSession, 'show']);
109
111
  router.post('/account/login', [C.accountSession, 'login']);
110
112
  router.post('/account/logout', [C.accountSession, 'logout']);
113
+ // Confirmação de troca de e-mail (standalone, GET-only — consome o token do link;
114
+ // pode ser aberta em outro dispositivo, então NÃO exige sessão).
115
+ router.get('/account/email/confirm', [C.accountSecurity, 'confirmEmail']);
111
116
  // Rotas de tokens protegidas por AccountAuthMiddleware (redireciona para /account/login se não autenticado).
112
117
  router
113
118
  .group(() => {
114
119
  router.get('/account/tokens', [C.accountTokens, 'index']);
115
120
  router.post('/account/tokens', [C.accountTokens, 'store']);
116
121
  router.post('/account/tokens/:id/revoke', [C.accountTokens, 'destroy']);
122
+ // Segurança da conta: trocar senha + solicitar troca de e-mail.
123
+ router.get('/account/security', [C.accountSecurity, 'index']);
124
+ router.post('/account/security/password', [C.accountSecurity, 'changePassword']);
125
+ router.post('/account/security/email', [C.accountSecurity, 'changeEmail']);
117
126
  // MFA / TOTP (enrollment, confirmação, disable).
118
127
  router.get('/account/mfa', [C.accountMfa, 'index']);
119
128
  router.post('/account/mfa/enroll', [C.accountMfa, 'enroll']);
@@ -132,7 +141,18 @@ export function registerAuthHost(router, opts) {
132
141
  router.get('/admin', [C.adminDashboard, 'index']);
133
142
  router.get('/admin/users', [C.adminUsers, 'index']);
134
143
  router.post('/admin/users/:id/roles', [C.adminUsers, 'updateRoles']);
144
+ // Sessões/grants ativos da conta + revogação em massa.
145
+ router.get('/admin/users/:id/sessions', [C.adminSessions, 'index']);
146
+ router.post('/admin/users/:id/revoke-sessions', [C.adminSessions, 'revoke']);
135
147
  router.get('/admin/clients', [C.adminClients, 'index']);
148
+ // CRUD de clients OIDC (adapter-backed). `/new` ANTES de `:id` p/ não casar
149
+ // "new" como id; todas as escritas são POST (com _csrf na view).
150
+ router.get('/admin/clients/new', [C.adminClients, 'create']);
151
+ router.post('/admin/clients', [C.adminClients, 'store']);
152
+ router.get('/admin/clients/:id/edit', [C.adminClients, 'edit']);
153
+ router.post('/admin/clients/:id/edit', [C.adminClients, 'update']);
154
+ router.post('/admin/clients/:id/regenerate-secret', [C.adminClients, 'regenerateSecret']);
155
+ router.post('/admin/clients/:id/delete', [C.adminClients, 'destroy']);
136
156
  router.get('/admin/audit', [C.adminAudit, 'index']);
137
157
  })
138
158
  .use([adminGuard]);
@@ -37,3 +37,35 @@ export declare const resetPasswordValidator: import("@vinejs/vine").VineValidato
37
37
  password: string;
38
38
  token: string;
39
39
  }>, Record<string, any> | undefined>;
40
+ /**
41
+ * Troca de senha no console de conta. A regra da nova senha espelha o
42
+ * signupValidator (min 8, max 255); a senha atual é confirmada à parte via
43
+ * verifyCredentials.
44
+ */
45
+ export declare const changePasswordValidator: import("@vinejs/vine").VineValidator<import("@vinejs/vine").VineObject<{
46
+ currentPassword: import("@vinejs/vine").VineString;
47
+ newPassword: import("@vinejs/vine").VineString;
48
+ }, {
49
+ currentPassword: string;
50
+ newPassword: string;
51
+ }, {
52
+ currentPassword: string;
53
+ newPassword: string;
54
+ }, {
55
+ currentPassword: string;
56
+ newPassword: string;
57
+ }>, Record<string, any> | undefined>;
58
+ /** Troca de e-mail no console de conta: senha atual + o novo e-mail. */
59
+ export declare const changeEmailValidator: import("@vinejs/vine").VineValidator<import("@vinejs/vine").VineObject<{
60
+ currentPassword: import("@vinejs/vine").VineString;
61
+ newEmail: import("@vinejs/vine").VineString;
62
+ }, {
63
+ newEmail: string;
64
+ currentPassword: string;
65
+ }, {
66
+ newEmail: string;
67
+ currentPassword: string;
68
+ }, {
69
+ newEmail: string;
70
+ currentPassword: string;
71
+ }>, Record<string, any> | undefined>;
@@ -11,3 +11,17 @@ export const resetPasswordValidator = vine.compile(vine.object({
11
11
  token: vine.string().trim().minLength(1),
12
12
  password: vine.string().minLength(8).maxLength(255),
13
13
  }));
14
+ /**
15
+ * Troca de senha no console de conta. A regra da nova senha espelha o
16
+ * signupValidator (min 8, max 255); a senha atual é confirmada à parte via
17
+ * verifyCredentials.
18
+ */
19
+ export const changePasswordValidator = vine.compile(vine.object({
20
+ currentPassword: vine.string().minLength(1),
21
+ newPassword: vine.string().minLength(8).maxLength(255),
22
+ }));
23
+ /** Troca de e-mail no console de conta: senha atual + o novo e-mail. */
24
+ export const changeEmailValidator = vine.compile(vine.object({
25
+ currentPassword: vine.string().minLength(1),
26
+ newEmail: vine.string().trim().email().normalizeEmail(),
27
+ }));