@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
@@ -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
+ }
@@ -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) {
@@ -1,10 +1,24 @@
1
1
  import '../../augmentations.js';
2
2
  import type { HttpContext } from '@adonisjs/core/http';
3
3
  /**
4
- * Listagem de OAuth clients. Mostra os clients ESTÁTICOS da config; clients
5
- * registrados dinamicamente (RFC 7591) vivem no adapter OIDC e não são listados
6
- * aqui a view informa isso quando o registro dinâmico está ligado.
4
+ * CRUD de clients OIDC no console admin. Mostra os clients ESTÁTICOS da config
5
+ * (somente leitura, rotulados) lado a lado com os clients DINÂMICOS persistidos
6
+ * no adapter (registro dinâmico/RFC 7591 + os criados aqui). Quando o adapter não
7
+ * suporta enumeração (`listClients`), a seção dinâmica degrada graciosamente —
8
+ * espelhando o padrão da tela de auditoria.
7
9
  */
8
10
  export default class AdminClientsController {
9
11
  index(ctx: HttpContext): Promise<any>;
12
+ /** Formulário de criação. */
13
+ create(ctx: HttpContext): Promise<any>;
14
+ /** Persiste um client novo; mostra o secret UMA vez via flash. */
15
+ store(ctx: HttpContext): Promise<void>;
16
+ /** Formulário de edição de um client persistido. */
17
+ edit(ctx: HttpContext): Promise<any>;
18
+ /** Atualiza metadata editável (NÃO o secret). */
19
+ update(ctx: HttpContext): Promise<void>;
20
+ /** Regenera o secret de um client confidencial; mostra o novo valor UMA vez. */
21
+ regenerateSecret(ctx: HttpContext): Promise<void>;
22
+ /** Remove um client persistido. */
23
+ destroy(ctx: HttpContext): Promise<void>;
10
24
  }
@@ -1,24 +1,178 @@
1
1
  import '../../augmentations.js';
2
+ import { ACCOUNT_SESSION_KEY } from '../../middleware/account_auth.js';
3
+ import { AdminClientsService, } from '../../admin_clients_service.js';
4
+ const VALID_GRANTS = ['authorization_code', 'refresh_token', 'client_credentials'];
5
+ const VALID_AUTH_METHODS = [
6
+ 'client_secret_basic',
7
+ 'client_secret_post',
8
+ 'none',
9
+ ];
10
+ /** Normaliza um textarea (1 item por linha) numa lista sem vazios nem duplicatas. */
11
+ function parseLines(raw) {
12
+ return Array.from(new Set(String(raw ?? '')
13
+ .split(/\r?\n/)
14
+ .map((l) => l.trim())
15
+ .filter((l) => l.length > 0)));
16
+ }
17
+ /** Lê os grants marcados no form (checkboxes); cai no default quando nenhum. */
18
+ function parseGrants(ctx) {
19
+ const raw = ctx.request.input('grant_types', []);
20
+ const arr = Array.isArray(raw) ? raw : [raw];
21
+ const filtered = arr.filter((g) => VALID_GRANTS.includes(g));
22
+ return filtered.length ? filtered : ['authorization_code', 'refresh_token'];
23
+ }
24
+ function parseAuthMethod(ctx) {
25
+ const raw = ctx.request.input('token_endpoint_auth_method', 'client_secret_basic');
26
+ return (VALID_AUTH_METHODS.includes(raw)
27
+ ? raw
28
+ : 'client_secret_basic');
29
+ }
30
+ function readInput(ctx) {
31
+ return {
32
+ clientId: ctx.request.input('client_id', '').trim() || undefined,
33
+ redirectUris: parseLines(ctx.request.input('redirect_uris')),
34
+ postLogoutRedirectUris: parseLines(ctx.request.input('post_logout_redirect_uris')),
35
+ grantTypes: parseGrants(ctx),
36
+ tokenEndpointAuthMethod: parseAuthMethod(ctx),
37
+ };
38
+ }
2
39
  /**
3
- * Listagem de OAuth clients. Mostra os clients ESTÁTICOS da config; clients
4
- * registrados dinamicamente (RFC 7591) vivem no adapter OIDC e não são listados
5
- * aqui a view informa isso quando o registro dinâmico está ligado.
40
+ * CRUD de clients OIDC no console admin. Mostra os clients ESTÁTICOS da config
41
+ * (somente leitura, rotulados) lado a lado com os clients DINÂMICOS persistidos
42
+ * no adapter (registro dinâmico/RFC 7591 + os criados aqui). Quando o adapter não
43
+ * suporta enumeração (`listClients`), a seção dinâmica degrada graciosamente —
44
+ * espelhando o padrão da tela de auditoria.
6
45
  */
7
46
  export default class AdminClientsController {
8
47
  async index(ctx) {
9
48
  const service = await ctx.containerResolver.make('authkit.server');
10
49
  const cfg = service.config;
11
50
  const render = cfg.render;
51
+ const admin = new AdminClientsService(service);
52
+ const dynamicSupported = admin.canList;
53
+ const dynamicClients = dynamicSupported ? await admin.list() : [];
54
+ const createdSecret = ctx.session.flashMessages.get('createdClientSecret');
12
55
  return render(ctx, 'admin/clients', {
13
56
  csrfToken: ctx.request.csrfToken,
14
57
  dynamicEnabled: cfg.dynamicRegistration.enabled,
15
- clients: cfg.clients.map((c) => ({
58
+ dynamicSupported,
59
+ createdSecret: createdSecret ?? null,
60
+ staticClients: cfg.clients.map((c) => ({
16
61
  clientId: c.clientId,
17
62
  confidential: !!c.clientSecret,
18
63
  grants: c.grants ?? ['authorization_code', 'refresh_token'],
19
64
  redirectUris: c.redirectUris ?? [],
20
65
  postLogoutRedirectUris: c.postLogoutRedirectUris ?? [],
21
66
  })),
67
+ dynamicClients,
68
+ });
69
+ }
70
+ /** Formulário de criação. */
71
+ async create(ctx) {
72
+ const service = await ctx.containerResolver.make('authkit.server');
73
+ const render = service.config.render;
74
+ return render(ctx, 'admin/client_form', {
75
+ csrfToken: ctx.request.csrfToken,
76
+ mode: 'create',
77
+ client: {
78
+ clientId: '',
79
+ redirectUris: [],
80
+ postLogoutRedirectUris: [],
81
+ grants: ['authorization_code', 'refresh_token'],
82
+ tokenEndpointAuthMethod: 'client_secret_basic',
83
+ },
84
+ });
85
+ }
86
+ /** Persiste um client novo; mostra o secret UMA vez via flash. */
87
+ async store(ctx) {
88
+ const service = await ctx.containerResolver.make('authkit.server');
89
+ const cfg = service.config;
90
+ const admin = new AdminClientsService(service);
91
+ const input = readInput(ctx);
92
+ const created = await admin.create(input);
93
+ if (created.clientSecret) {
94
+ ctx.session.flash('createdClientSecret', {
95
+ clientId: created.clientId,
96
+ clientSecret: created.clientSecret,
97
+ });
98
+ }
99
+ await cfg.audit?.record({
100
+ type: 'client.created',
101
+ clientId: created.clientId,
102
+ actorId: ctx.session.get(ACCOUNT_SESSION_KEY) ?? null,
103
+ ip: ctx.request.ip?.() ?? null,
104
+ });
105
+ return ctx.response.redirect('/admin/clients');
106
+ }
107
+ /** Formulário de edição de um client persistido. */
108
+ async edit(ctx) {
109
+ const service = await ctx.containerResolver.make('authkit.server');
110
+ const render = service.config.render;
111
+ const admin = new AdminClientsService(service);
112
+ const clientId = ctx.request.param('id');
113
+ const client = await admin.find(clientId);
114
+ if (!client)
115
+ return ctx.response.redirect('/admin/clients');
116
+ return render(ctx, 'admin/client_form', {
117
+ csrfToken: ctx.request.csrfToken,
118
+ mode: 'edit',
119
+ client,
120
+ });
121
+ }
122
+ /** Atualiza metadata editável (NÃO o secret). */
123
+ async update(ctx) {
124
+ const service = await ctx.containerResolver.make('authkit.server');
125
+ const cfg = service.config;
126
+ const admin = new AdminClientsService(service);
127
+ const clientId = ctx.request.param('id');
128
+ const existing = await admin.find(clientId);
129
+ if (!existing)
130
+ return ctx.response.redirect('/admin/clients');
131
+ const input = { ...readInput(ctx), clientId };
132
+ await admin.update(clientId, input);
133
+ await cfg.audit?.record({
134
+ type: 'client.updated',
135
+ clientId,
136
+ actorId: ctx.session.get(ACCOUNT_SESSION_KEY) ?? null,
137
+ ip: ctx.request.ip?.() ?? null,
138
+ });
139
+ return ctx.response.redirect('/admin/clients');
140
+ }
141
+ /** Regenera o secret de um client confidencial; mostra o novo valor UMA vez. */
142
+ async regenerateSecret(ctx) {
143
+ const service = await ctx.containerResolver.make('authkit.server');
144
+ const cfg = service.config;
145
+ const admin = new AdminClientsService(service);
146
+ const clientId = ctx.request.param('id');
147
+ try {
148
+ const secret = await admin.regenerateSecret(clientId);
149
+ ctx.session.flash('createdClientSecret', { clientId, clientSecret: secret });
150
+ await cfg.audit?.record({
151
+ type: 'client.updated',
152
+ clientId,
153
+ actorId: ctx.session.get(ACCOUNT_SESSION_KEY) ?? null,
154
+ ip: ctx.request.ip?.() ?? null,
155
+ metadata: { action: 'regenerate_secret' },
156
+ });
157
+ }
158
+ catch {
159
+ // client inexistente ou public — sem secret a regenerar; volta silenciosamente.
160
+ }
161
+ return ctx.response.redirect('/admin/clients');
162
+ }
163
+ /** Remove um client persistido. */
164
+ async destroy(ctx) {
165
+ const service = await ctx.containerResolver.make('authkit.server');
166
+ const cfg = service.config;
167
+ const admin = new AdminClientsService(service);
168
+ const clientId = ctx.request.param('id');
169
+ await admin.delete(clientId);
170
+ await cfg.audit?.record({
171
+ type: 'client.deleted',
172
+ clientId,
173
+ actorId: ctx.session.get(ACCOUNT_SESSION_KEY) ?? null,
174
+ ip: ctx.request.ip?.() ?? null,
22
175
  });
176
+ return ctx.response.redirect('/admin/clients');
23
177
  }
24
178
  }
@@ -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
+ }
@@ -0,0 +1,64 @@
1
+ import '../../augmentations.js';
2
+ import { ACCOUNT_SESSION_KEY } from '../../middleware/account_auth.js';
3
+ import { AdminSessionsService } from '../../admin_sessions_service.js';
4
+ /**
5
+ * Inspeção/revogação das sessões e grants ativos de uma conta no console admin.
6
+ * Lista as `Session` (logins do IdP) + os `Grant` (autorizações por client) da
7
+ * conta, com a contagem de access/refresh tokens por grant. Degrada graciosamente
8
+ * quando o adapter OIDC não enumera (`list`), espelhando o CRUD de clients.
9
+ */
10
+ export default class AdminSessionsController {
11
+ /** GET /admin/users/:id/sessions — lista sessões + grants da conta. */
12
+ async index(ctx) {
13
+ const service = await ctx.containerResolver.make('authkit.server');
14
+ const cfg = service.config;
15
+ const render = cfg.render;
16
+ const accountId = ctx.request.param('id');
17
+ const account = await cfg.accountStore.findById(accountId);
18
+ const sessions = new AdminSessionsService(service);
19
+ const supported = sessions.canList;
20
+ const sessionList = supported ? await sessions.listSessions(accountId) : [];
21
+ const grantList = supported ? await sessions.listGrants(accountId) : [];
22
+ const revoked = ctx.session.flashMessages.get('sessionsRevoked');
23
+ return render(ctx, 'admin/sessions', {
24
+ csrfToken: ctx.request.csrfToken,
25
+ supported,
26
+ accountId,
27
+ email: account?.email ?? '',
28
+ revoked: revoked ?? null,
29
+ sessions: sessionList.map((s) => ({
30
+ id: s.id,
31
+ loginTs: s.loginTs ? new Date(s.loginTs * 1000).toISOString() : '',
32
+ amr: (s.amr ?? []).join(', '),
33
+ })),
34
+ grants: grantList.map((g) => ({
35
+ id: g.id,
36
+ clientId: g.clientId ?? '',
37
+ accessTokens: g.accessTokens,
38
+ refreshTokens: g.refreshTokens,
39
+ })),
40
+ });
41
+ }
42
+ /** POST /admin/users/:id/revoke-sessions — destrói sessões + grants da conta. */
43
+ async revoke(ctx) {
44
+ const service = await ctx.containerResolver.make('authkit.server');
45
+ const cfg = service.config;
46
+ const accountId = ctx.request.param('id');
47
+ const sessions = new AdminSessionsService(service);
48
+ const result = await sessions.revokeAll(accountId);
49
+ await cfg.audit?.record({
50
+ type: 'session.revoked_all',
51
+ accountId,
52
+ actorId: ctx.session.get(ACCOUNT_SESSION_KEY) ?? null,
53
+ ip: ctx.request.ip?.() ?? null,
54
+ metadata: {
55
+ sessions: result.sessions,
56
+ grants: result.grants,
57
+ accessTokens: result.accessTokens,
58
+ refreshTokens: result.refreshTokens,
59
+ },
60
+ });
61
+ ctx.session.flash('sessionsRevoked', result);
62
+ return ctx.response.redirect(`/admin/users/${accountId}/sessions`);
63
+ }
64
+ }
@@ -19,6 +19,17 @@ export default class AuthInteractionController {
19
19
  * OU um recovery code (`recoveryCode`). Em caso de sucesso finaliza a interaction.
20
20
  */
21
21
  mfaVerify(ctx: HttpContext): Promise<any>;
22
+ /**
23
+ * true se o authorize request exige MFA via acr_values (contém o mfaAcr da
24
+ * config de step-up). `acr_values` é a string separada por espaços padrão OIDC.
25
+ */
26
+ private acrRequiresMfa;
27
+ /**
28
+ * Monta o acr/amr do step-up quando o client solicitou o mfaAcr e um 2º fator
29
+ * foi verificado. `method` é o método do segundo fator (totp/recovery/webauthn).
30
+ * Retorna `undefined` quando não há step-up — completeLogin usa o default.
31
+ */
32
+ private stepUpExtra;
22
33
  /** true se o store suporta passkeys E a conta tem ao menos uma registrada. */
23
34
  private hasPasskeys;
24
35
  /**