@dudousxd/adonis-authkit-server 0.1.1 → 0.3.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 (50) hide show
  1. package/build/host/views/admin/client_form.edge +83 -0
  2. package/build/host/views/admin/clients.edge +68 -3
  3. package/build/index.d.ts +3 -2
  4. package/build/index.js +2 -1
  5. package/build/src/accounts/account_store.d.ts +74 -17
  6. package/build/src/accounts/account_store.js +12 -1
  7. package/build/src/accounts/lucid_account_store.d.ts +12 -27
  8. package/build/src/accounts/lucid_account_store.js +38 -365
  9. package/build/src/accounts/lucid_store/core.d.ts +8 -0
  10. package/build/src/accounts/lucid_store/core.js +108 -0
  11. package/build/src/accounts/lucid_store/mfa.d.ts +8 -0
  12. package/build/src/accounts/lucid_store/mfa.js +77 -0
  13. package/build/src/accounts/lucid_store/provider_identity.d.ts +8 -0
  14. package/build/src/accounts/lucid_store/provider_identity.js +41 -0
  15. package/build/src/accounts/lucid_store/shared.d.ts +48 -0
  16. package/build/src/accounts/lucid_store/shared.js +15 -0
  17. package/build/src/accounts/lucid_store/webauthn.d.ts +8 -0
  18. package/build/src/accounts/lucid_store/webauthn.js +135 -0
  19. package/build/src/adapters/adapter_contract.d.ts +12 -0
  20. package/build/src/adapters/database_adapter.d.ts +8 -1
  21. package/build/src/adapters/database_adapter.js +17 -0
  22. package/build/src/adapters/redis_adapter.d.ts +8 -1
  23. package/build/src/adapters/redis_adapter.js +26 -0
  24. package/build/src/audit/audit_sink.d.ts +1 -1
  25. package/build/src/define_config.d.ts +6 -0
  26. package/build/src/define_config.js +20 -5
  27. package/build/src/host/admin_clients_service.d.ts +65 -0
  28. package/build/src/host/admin_clients_service.js +136 -0
  29. package/build/src/host/controllers/account_mfa_controller.js +2 -1
  30. package/build/src/host/controllers/account_session_controller.js +10 -18
  31. package/build/src/host/controllers/admin/admin_clients_controller.d.ts +17 -3
  32. package/build/src/host/controllers/admin/admin_clients_controller.js +158 -4
  33. package/build/src/host/controllers/interaction_controller.js +13 -32
  34. package/build/src/host/controllers/social_controller.js +7 -0
  35. package/build/src/host/i18n.d.ts +27 -0
  36. package/build/src/host/i18n.js +28 -1
  37. package/build/src/host/login_attempt.d.ts +39 -0
  38. package/build/src/host/login_attempt.js +37 -0
  39. package/build/src/host/register_auth_host.d.ts +13 -0
  40. package/build/src/host/register_auth_host.js +17 -2
  41. package/build/src/mixins/json_column.d.ts +38 -0
  42. package/build/src/mixins/json_column.js +31 -0
  43. package/build/src/mixins/with_audit_log.js +2 -4
  44. package/build/src/mixins/with_auth_user.js +2 -4
  45. package/build/src/mixins/with_mfa.js +2 -6
  46. package/build/src/mixins/with_personal_access_token.js +2 -4
  47. package/build/src/mixins/with_webauthn_credential.js +6 -8
  48. package/build/src/provider/oidc_service.d.ts +15 -0
  49. package/build/src/provider/oidc_service.js +27 -0
  50. package/package.json +1 -1
@@ -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
  }
@@ -1,7 +1,8 @@
1
1
  import '../augmentations.js';
2
2
  import { brandFor, isFirstParty } from '../branding.js';
3
3
  import { translate } from '../i18n.js';
4
- import { createAccountLockout } from '../account_lockout.js';
4
+ import { attemptPasswordLogin } from '../login_attempt.js';
5
+ import { supportsPasskeys } from '../../accounts/account_store.js';
5
6
  const SESSION_KEY = 'authkit_login_email';
6
7
  /** accountId aguardando o 2º fator depois da senha verificada. */
7
8
  const MFA_PENDING_KEY = 'authkit_mfa_pending';
@@ -80,35 +81,12 @@ export default class AuthInteractionController {
80
81
  const { password } = ctx.request.only(['password']);
81
82
  const ip = ctx.request.ip?.() ?? null;
82
83
  const clientId = details.params.client_id ?? null;
83
- const lockout = createAccountLockout(cfg.lockout);
84
- // Bloqueio progressivo (keyed por email da sessão): se travada, não verifica a senha.
85
- const lock = await lockout.isLocked(email);
86
- if (lock.locked) {
87
- const found = await cfg.accountStore.findByEmail(email);
88
- const account = found
89
- ? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
90
- : null;
91
- return render(ctx, 'login', {
92
- uid: ctx.request.param('uid'),
93
- csrfToken: ctx.request.csrfToken,
94
- step: 'password',
95
- email,
96
- account,
97
- error: translate(cfg.messages, 'errors.account_locked', {
98
- seconds: lock.retryAfterSec ?? 0,
99
- }),
100
- brand,
101
- });
102
- }
103
84
  // Verificamos as credenciais ANTES de finalizar a interaction, porque com MFA
104
85
  // ligado precisamos exigir o 2º fator e NÃO podemos chamar interactionFinished
105
- // ainda. (`service.interactions.login` verifica E finaliza num passo por
106
- // isso fazemos a verificação aqui via accountStore e finalizamos depois.)
107
- const acc = await cfg.accountStore.verifyCredentials(email, password);
108
- if (!acc) {
109
- await cfg.audit?.record({ type: 'login.failure', email, ip, clientId });
110
- await lockout.recordFailure(email, { sink: cfg.audit, ip });
111
- // Re-render password step with error (keep email in session)
86
+ // ainda. A sequência verificação + lockout + auditoria de falha é centralizada
87
+ // em attemptPasswordLogin; a renderização (lookup p/ personalização) fica aqui.
88
+ const result = await attemptPasswordLogin(cfg, { email, password, ip, clientId });
89
+ if (!result.ok) {
112
90
  const found = await cfg.accountStore.findByEmail(email);
113
91
  const account = found
114
92
  ? { fullName: found.name ?? null, globalRoles: found.globalRoles ?? [] }
@@ -119,12 +97,15 @@ export default class AuthInteractionController {
119
97
  step: 'password',
120
98
  email,
121
99
  account,
122
- error: translate(cfg.messages, 'errors.invalid_credentials'),
100
+ error: result.locked
101
+ ? translate(cfg.messages, 'errors.account_locked', {
102
+ seconds: result.retryAfterSec ?? 0,
103
+ })
104
+ : translate(cfg.messages, 'errors.invalid_credentials'),
123
105
  brand,
124
106
  });
125
107
  }
126
- // Senha correta: limpa o contador de falhas (o lockout protege a etapa de senha).
127
- await lockout.clearFailures(email);
108
+ const acc = result.account;
128
109
  // MFA gate: se a conta tem TOTP ativo, NÃO finaliza a interaction agora —
129
110
  // guarda o accountId pendente na sessão e renderiza o desafio do 2º fator.
130
111
  const mfa = (await cfg.accountStore.getMfaState?.(acc.id)) ?? { enabled: false };
@@ -204,7 +185,7 @@ export default class AuthInteractionController {
204
185
  }
205
186
  /** true se o store suporta passkeys E a conta tem ao menos uma registrada. */
206
187
  async hasPasskeys(cfg, accountId) {
207
- if (typeof cfg.accountStore.listPasskeys !== 'function')
188
+ if (!supportsPasskeys(cfg.accountStore))
208
189
  return false;
209
190
  const list = await cfg.accountStore.listPasskeys(accountId);
210
191
  return Array.isArray(list) && list.length > 0;
@@ -1,5 +1,6 @@
1
1
  import '../augmentations.js';
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { supportsProviderIdentity } from '../../accounts/account_store.js';
3
4
  const UID_SESSION_KEY = 'authkit_social_uid';
4
5
  /**
5
6
  * `AllyService.use()` é tipado contra a interface `SocialProviders`, que só é
@@ -37,6 +38,12 @@ export default class AuthSocialController {
37
38
  const cfg = service.config;
38
39
  const store = cfg.accountStore;
39
40
  const email = profile.email ?? undefined;
41
+ // Account linking exige a capacidade de provider-identity (model wired no store).
42
+ // Ausente → não há como ligar a identidade; volta ao login em vez de quebrar.
43
+ if (!supportsProviderIdentity(store)) {
44
+ ctx.session.forget(UID_SESSION_KEY);
45
+ return ctx.response.redirect(backToLogin);
46
+ }
40
47
  // Precedência do account linking:
41
48
  // 1. Identidade de provider já ligada → loga essa conta.
42
49
  // 2. Senão, e-mail conhecido → acha por e-mail e LIGA a identidade (linking).
@@ -148,6 +148,33 @@ export declare const DEFAULT_MESSAGES: {
148
148
  'admin.clients.grants': string;
149
149
  'admin.clients.redirect_uris': string;
150
150
  'admin.clients.dynamic_notice': string;
151
+ 'admin.clients.static_section': string;
152
+ 'admin.clients.dynamic_section': string;
153
+ 'admin.clients.dynamic_empty': string;
154
+ 'admin.clients.dynamic_not_supported': string;
155
+ 'admin.clients.new': string;
156
+ 'admin.clients.new_title': string;
157
+ 'admin.clients.edit_title': string;
158
+ 'admin.clients.edit': string;
159
+ 'admin.clients.delete': string;
160
+ 'admin.clients.delete_confirm': string;
161
+ 'admin.clients.regenerate_secret': string;
162
+ 'admin.clients.regenerate_confirm': string;
163
+ 'admin.clients.back': string;
164
+ 'admin.clients.cancel': string;
165
+ 'admin.clients.save': string;
166
+ 'admin.clients.create': string;
167
+ 'admin.clients.secret_once_title': string;
168
+ 'admin.clients.secret_once_notice': string;
169
+ 'admin.clients.field_client_id': string;
170
+ 'admin.clients.field_client_id_placeholder': string;
171
+ 'admin.clients.field_client_id_help': string;
172
+ 'admin.clients.field_redirect_uris': string;
173
+ 'admin.clients.field_redirect_uris_help': string;
174
+ 'admin.clients.field_post_logout_uris': string;
175
+ 'admin.clients.field_post_logout_uris_help': string;
176
+ 'admin.clients.field_grant_types': string;
177
+ 'admin.clients.field_auth_method': string;
151
178
  'admin.audit.page_title': string;
152
179
  'admin.audit.title': string;
153
180
  'admin.audit.type_placeholder': string;
@@ -153,7 +153,34 @@ export const DEFAULT_MESSAGES = {
153
153
  'admin.clients.public': 'Público',
154
154
  'admin.clients.grants': 'Grants: {grants}',
155
155
  '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.',
156
+ '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.',
157
+ 'admin.clients.static_section': 'Clients estáticos (config)',
158
+ 'admin.clients.dynamic_section': 'Clients dinâmicos (adapter)',
159
+ 'admin.clients.dynamic_empty': 'Nenhum client dinâmico persistido.',
160
+ 'admin.clients.dynamic_not_supported': 'O adapter OIDC configurado não suporta enumeração de clients — a gestão dinâmica fica indisponível.',
161
+ 'admin.clients.new': 'Novo client',
162
+ 'admin.clients.new_title': 'Novo client OIDC',
163
+ 'admin.clients.edit_title': 'Editar client OIDC',
164
+ 'admin.clients.edit': 'Editar',
165
+ 'admin.clients.delete': 'Excluir',
166
+ 'admin.clients.delete_confirm': 'Excluir este client? Esta ação não pode ser desfeita.',
167
+ 'admin.clients.regenerate_secret': 'Regenerar secret',
168
+ 'admin.clients.regenerate_confirm': 'Regenerar o secret? O secret atual deixará de funcionar imediatamente.',
169
+ 'admin.clients.back': 'Voltar',
170
+ 'admin.clients.cancel': 'Cancelar',
171
+ 'admin.clients.save': 'Salvar',
172
+ 'admin.clients.create': 'Criar client',
173
+ 'admin.clients.secret_once_title': 'Guarde o client_secret agora',
174
+ 'admin.clients.secret_once_notice': 'Este é o único momento em que o secret é exibido. Copie-o agora — ele não pode ser recuperado depois.',
175
+ 'admin.clients.field_client_id': 'Client ID',
176
+ 'admin.clients.field_client_id_placeholder': 'deixe em branco para gerar automaticamente',
177
+ 'admin.clients.field_client_id_help': 'Opcional. Se vazio, um identificador aleatório será gerado.',
178
+ 'admin.clients.field_redirect_uris': 'Redirect URIs',
179
+ 'admin.clients.field_redirect_uris_help': 'Uma URI por linha.',
180
+ 'admin.clients.field_post_logout_uris': 'Post-logout redirect URIs',
181
+ 'admin.clients.field_post_logout_uris_help': 'Uma URI por linha (opcional).',
182
+ 'admin.clients.field_grant_types': 'Grant types',
183
+ 'admin.clients.field_auth_method': 'Token endpoint auth method',
157
184
  // Console admin — auditoria.
158
185
  'admin.audit.page_title': 'Auditoria',
159
186
  'admin.audit.title': 'Log de auditoria',
@@ -0,0 +1,39 @@
1
+ import type { AuthAccount } from '../accounts/account_store.js';
2
+ import type { ResolvedServerConfig } from '../define_config.js';
3
+ /** Entrada de uma tentativa de login por senha (keyed por email). */
4
+ export interface PasswordLoginInput {
5
+ email: string;
6
+ password: string;
7
+ /** IP da request (para auditoria + lockout). null quando indisponível. */
8
+ ip: string | null;
9
+ /**
10
+ * client_id da interaction OIDC, quando existir. Só é incluído no evento de
11
+ * auditoria nos fluxos que o têm (interaction); ausente no console de conta.
12
+ */
13
+ clientId?: string | null;
14
+ }
15
+ /** Resultado de {@link attemptPasswordLogin}. */
16
+ export type PasswordLoginResult = {
17
+ ok: true;
18
+ account: AuthAccount;
19
+ } | {
20
+ ok: false;
21
+ locked: boolean;
22
+ retryAfterSec?: number;
23
+ };
24
+ /**
25
+ * Sequência canônica de login por senha + bloqueio progressivo, compartilhada
26
+ * pelos dois fluxos que pedem senha (interaction OIDC e console de conta):
27
+ *
28
+ * 1. `lockout.isLocked(email)` — se travada, NÃO verifica a senha; devolve
29
+ * `{ ok: false, locked: true, retryAfterSec }` (o controller renderiza o erro).
30
+ * 2. `verifyCredentials(email, password)` — em falha: emite `login.failure`
31
+ * (com `clientId` apenas quando fornecido), registra a falha no lockout
32
+ * (`{ sink: cfg.audit, ip }`) e devolve `{ ok: false, locked: false }`.
33
+ * 3. Em sucesso: limpa o contador de falhas e devolve `{ ok: true, account }`.
34
+ *
35
+ * O evento `login.success` e a finalização da sessão/interaction ficam a cargo de
36
+ * cada controller (os fluxos diferem: MFA gate na interaction, sessão no console),
37
+ * assim como toda a renderização.
38
+ */
39
+ export declare function attemptPasswordLogin(cfg: ResolvedServerConfig, input: PasswordLoginInput): Promise<PasswordLoginResult>;
@@ -0,0 +1,37 @@
1
+ import { createAccountLockout } from './account_lockout.js';
2
+ /**
3
+ * Sequência canônica de login por senha + bloqueio progressivo, compartilhada
4
+ * pelos dois fluxos que pedem senha (interaction OIDC e console de conta):
5
+ *
6
+ * 1. `lockout.isLocked(email)` — se travada, NÃO verifica a senha; devolve
7
+ * `{ ok: false, locked: true, retryAfterSec }` (o controller renderiza o erro).
8
+ * 2. `verifyCredentials(email, password)` — em falha: emite `login.failure`
9
+ * (com `clientId` apenas quando fornecido), registra a falha no lockout
10
+ * (`{ sink: cfg.audit, ip }`) e devolve `{ ok: false, locked: false }`.
11
+ * 3. Em sucesso: limpa o contador de falhas e devolve `{ ok: true, account }`.
12
+ *
13
+ * O evento `login.success` e a finalização da sessão/interaction ficam a cargo de
14
+ * cada controller (os fluxos diferem: MFA gate na interaction, sessão no console),
15
+ * assim como toda a renderização.
16
+ */
17
+ export async function attemptPasswordLogin(cfg, input) {
18
+ const { email, password, ip } = input;
19
+ const lockout = createAccountLockout(cfg.lockout);
20
+ // Bloqueio progressivo (keyed por email): se travada, não verifica a senha.
21
+ const lock = await lockout.isLocked(email);
22
+ if (lock.locked) {
23
+ return { ok: false, locked: true, retryAfterSec: lock.retryAfterSec };
24
+ }
25
+ const account = await cfg.accountStore.verifyCredentials(email, password);
26
+ if (!account) {
27
+ // `clientId` só entra no evento quando o fluxo o fornece (interaction).
28
+ await cfg.audit?.record(input.clientId !== undefined
29
+ ? { type: 'login.failure', email, ip, clientId: input.clientId }
30
+ : { type: 'login.failure', email, ip });
31
+ await lockout.recordFailure(email, { sink: cfg.audit, ip });
32
+ return { ok: false, locked: false };
33
+ }
34
+ // Senha correta: limpa o contador de falhas (o lockout protege a etapa de senha).
35
+ await lockout.clearFailures(email);
36
+ return { ok: true, account };
37
+ }
@@ -3,12 +3,25 @@ import type { AuthSocialConfig, RateLimitConfigInput } from '../define_config.js
3
3
  /**
4
4
  * Guard do console admin (B6). Como o `accountGuard`, é uma closure inline (forma
5
5
  * confiável do `.use()` num grupo). Exige:
6
+ * 0. `config.admin.enabled` ligado (senão → 404; ver nota de flag-drift abaixo);
6
7
  * 1. sessão de conta ativa (senão → /account/login);
7
8
  * 2. a conta logada com pelo menos UMA das `config.admin.roles` nas roles globais
8
9
  * (senão → /account/tokens, evitando vazar a existência do /admin).
9
10
  * As roles permitidas são resolvidas em runtime do `authkit.server` (config lazy).
10
11
  */
11
12
  export declare const adminGuard: (ctx: any, next: () => Promise<void>) => Promise<any>;
13
+ /**
14
+ * Opções de montagem das rotas do host-kit.
15
+ *
16
+ * NOTA (flag-drift): vários campos aqui (`social`, `rateLimit`, `admin`) ESPELHAM
17
+ * o config (`config/authkit.ts`) porque a decisão de MONTAR as rotas acontece em
18
+ * tempo de registro, antes de o config (lazy) resolver. Eles controlam apenas se
19
+ * as rotas existem; a fonte de verdade do COMPORTAMENTO continua sendo o config
20
+ * resolvido. Se um flag aqui divergir do config (ex.: `admin: true` aqui com
21
+ * `admin.enabled: false` no config), os guards das rotas são a rede de segurança
22
+ * (o `adminGuard` 404a quando `config.admin.enabled` é false). Mantenha-os em
23
+ * sincronia; os guards garantem que a divergência não vire um bypass.
24
+ */
12
25
  export interface AuthHostOptions {
13
26
  /** Onde o provider OIDC é montado (default '/oidc'). Deve casar com o final do issuer. */
14
27
  mountPath: string;
@@ -16,18 +16,25 @@ const accountGuard = async (ctx, next) => {
16
16
  /**
17
17
  * Guard do console admin (B6). Como o `accountGuard`, é uma closure inline (forma
18
18
  * confiável do `.use()` num grupo). Exige:
19
+ * 0. `config.admin.enabled` ligado (senão → 404; ver nota de flag-drift abaixo);
19
20
  * 1. sessão de conta ativa (senão → /account/login);
20
21
  * 2. a conta logada com pelo menos UMA das `config.admin.roles` nas roles globais
21
22
  * (senão → /account/tokens, evitando vazar a existência do /admin).
22
23
  * As roles permitidas são resolvidas em runtime do `authkit.server` (config lazy).
23
24
  */
24
25
  export const adminGuard = async (ctx, next) => {
26
+ const service = await ctx.containerResolver.make('authkit.server');
27
+ const cfg = service.config;
28
+ // Defesa contra flag-drift: as rotas são montadas com `admin: true` em tempo de
29
+ // registro, ANTES de o config resolver. Se o config tiver `admin.enabled: false`,
30
+ // as rotas existem mas o console deve estar desligado — 404 (não vaza a existência).
31
+ if (!cfg.admin.enabled) {
32
+ return ctx.response.notFound();
33
+ }
25
34
  const accountId = ctx.session?.get(ACCOUNT_SESSION_KEY);
26
35
  if (!accountId) {
27
36
  return ctx.response.redirect('/account/login');
28
37
  }
29
- const service = await ctx.containerResolver.make('authkit.server');
30
- const cfg = service.config;
31
38
  const allowed = cfg.admin.roles;
32
39
  const account = await cfg.accountStore.findById(accountId);
33
40
  const roles = account?.globalRoles ?? [];
@@ -126,6 +133,14 @@ export function registerAuthHost(router, opts) {
126
133
  router.get('/admin/users', [C.adminUsers, 'index']);
127
134
  router.post('/admin/users/:id/roles', [C.adminUsers, 'updateRoles']);
128
135
  router.get('/admin/clients', [C.adminClients, 'index']);
136
+ // CRUD de clients OIDC (adapter-backed). `/new` ANTES de `:id` p/ não casar
137
+ // "new" como id; todas as escritas são POST (com _csrf na view).
138
+ router.get('/admin/clients/new', [C.adminClients, 'create']);
139
+ router.post('/admin/clients', [C.adminClients, 'store']);
140
+ router.get('/admin/clients/:id/edit', [C.adminClients, 'edit']);
141
+ router.post('/admin/clients/:id/edit', [C.adminClients, 'update']);
142
+ router.post('/admin/clients/:id/regenerate-secret', [C.adminClients, 'regenerateSecret']);
143
+ router.post('/admin/clients/:id/delete', [C.adminClients, 'destroy']);
129
144
  router.get('/admin/audit', [C.adminAudit, 'index']);
130
145
  })
131
146
  .use([adminGuard]);
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Serializer canônico de coluna JSON para os mixins. Centraliza o par
3
+ * `prepare`/`consume` (`JSON.stringify`/`JSON.parse`) que cada mixin escrevia à
4
+ * mão com tratamentos de null/default ligeiramente diferentes.
5
+ *
6
+ * As opções parametrizam exatamente as variações observadas entre os mixins, de
7
+ * modo que o comportamento por coluna fica IDÊNTICO ao código original:
8
+ *
9
+ * - `fallback`: valor devolvido por `consume` quando a coluna é null/undefined
10
+ * (ex.: `[]` para `globalRoles`/`scopes`; `null` para `recoveryCodes`/`metadata`).
11
+ * - `emptyOnWrite`: o que `prepare` grava quando o valor é "vazio" (null/undefined,
12
+ * ou — quando `treatEmptyArrayAsEmpty` — um array de length 0). `'null'` grava
13
+ * `null` na coluna; `'serialize'` ainda serializa (ex.: `globalRoles` grava
14
+ * `"[]"`). Default: `'null'`.
15
+ * - `treatEmptyArrayAsEmpty`: quando true, um array vazio também conta como "vazio"
16
+ * no write (caso do `transports`, que grava null em `[]`). Default: false.
17
+ * - `passthroughParsed`: quando true, `consume` aceita um valor JÁ desserializado
18
+ * (array/objeto) e o devolve sem reparsear — alguns dialetos/drivers entregam o
19
+ * JSON já decodificado. Default: false.
20
+ */
21
+ export interface JsonColumnOptions<T> {
22
+ /** Valor devolvido por `consume` quando a coluna está null/undefined. */
23
+ fallback: T;
24
+ /** O que `prepare` grava para valores vazios. Default: 'null'. */
25
+ emptyOnWrite?: 'null' | 'serialize';
26
+ /** Trata array vazio como "vazio" no write (grava null). Default: false. */
27
+ treatEmptyArrayAsEmpty?: boolean;
28
+ /** Aceita valor já desserializado em `consume` (passthrough). Default: false. */
29
+ passthroughParsed?: boolean;
30
+ }
31
+ /**
32
+ * Devolve o par `{ prepare, consume }` para usar num `@column({...})`.
33
+ * `T` é o tipo lógico da coluna (ex.: `string[]` ou `Record<string, unknown>`).
34
+ */
35
+ export declare function jsonColumn<T>(opts: JsonColumnOptions<T>): {
36
+ prepare: (value: T | null | undefined) => string | null;
37
+ consume: (value: unknown) => T;
38
+ };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Devolve o par `{ prepare, consume }` para usar num `@column({...})`.
3
+ * `T` é o tipo lógico da coluna (ex.: `string[]` ou `Record<string, unknown>`).
4
+ */
5
+ export function jsonColumn(opts) {
6
+ const emptyOnWrite = opts.emptyOnWrite ?? 'null';
7
+ const treatEmptyArrayAsEmpty = opts.treatEmptyArrayAsEmpty ?? false;
8
+ const passthroughParsed = opts.passthroughParsed ?? false;
9
+ const isEmpty = (value) => {
10
+ if (value === null || value === undefined)
11
+ return true;
12
+ if (treatEmptyArrayAsEmpty && Array.isArray(value) && value.length === 0)
13
+ return true;
14
+ return false;
15
+ };
16
+ return {
17
+ prepare: (value) => {
18
+ if (isEmpty(value)) {
19
+ return emptyOnWrite === 'serialize' ? JSON.stringify(opts.fallback) : null;
20
+ }
21
+ return JSON.stringify(value);
22
+ },
23
+ consume: (value) => {
24
+ if (value === null || value === undefined)
25
+ return opts.fallback;
26
+ if (passthroughParsed && Array.isArray(value))
27
+ return value;
28
+ return JSON.parse(value);
29
+ },
30
+ };
31
+ }
@@ -5,6 +5,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
5
5
  return c > 3 && r && Object.defineProperty(target, key, r), r;
6
6
  };
7
7
  import { column } from '@adonisjs/lucid/orm';
8
+ import { jsonColumn } from './json_column.js';
8
9
  export function withAuditLog() {
9
10
  return (superclass) => {
10
11
  class AuditLogMixin extends superclass {
@@ -28,10 +29,7 @@ export function withAuditLog() {
28
29
  column()
29
30
  ], AuditLogMixin.prototype, "ip", void 0);
30
31
  __decorate([
31
- column({
32
- prepare: (value) => value ? JSON.stringify(value) : null,
33
- consume: (value) => (value ? JSON.parse(value) : null),
34
- })
32
+ column(jsonColumn({ fallback: null }))
35
33
  ], AuditLogMixin.prototype, "metadata", void 0);
36
34
  __decorate([
37
35
  column.dateTime({ autoCreate: true })
@@ -6,6 +6,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
6
6
  };
7
7
  import { column, beforeSave } from '@adonisjs/lucid/orm';
8
8
  import { Scrypt } from '@adonisjs/core/hash/drivers/scrypt';
9
+ import { jsonColumn } from './json_column.js';
9
10
  const hasher = new Scrypt({});
10
11
  export function withAuthUser() {
11
12
  return (superclass) => {
@@ -26,10 +27,7 @@ export function withAuthUser() {
26
27
  column({ serializeAs: null })
27
28
  ], AuthUserMixin.prototype, "password", void 0);
28
29
  __decorate([
29
- column({
30
- prepare: (value) => JSON.stringify(value ?? []),
31
- consume: (value) => (value ? JSON.parse(value) : []),
32
- })
30
+ column(jsonColumn({ fallback: [], emptyOnWrite: 'serialize' }))
33
31
  ], AuthUserMixin.prototype, "globalRoles", void 0);
34
32
  __decorate([
35
33
  beforeSave()