@adonis-agora/authkit-server 0.90.2 → 1.0.0-rc.1

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 (99) hide show
  1. package/CHANGELOG.md +3654 -0
  2. package/build/commands/clients_create.d.ts +1 -0
  3. package/build/commands/clients_create.js +15 -2
  4. package/build/commands/commands.json +7 -0
  5. package/build/host/ui/admin.html +1 -1
  6. package/build/host/views/account/apps.edge +8 -3
  7. package/build/host/views/partials/styles.edge +1 -1
  8. package/build/index.d.ts +5 -3
  9. package/build/index.js +4 -5
  10. package/build/providers/authkit_server_provider.js +8 -0
  11. package/build/src/accounts/lucid_account_store.d.ts +0 -18
  12. package/build/src/accounts/lucid_account_store.js +7 -48
  13. package/build/src/accounts/lucid_store/mfa.js +1 -1
  14. package/build/src/accounts/lucid_store/organizations.d.ts +1 -1
  15. package/build/src/accounts/lucid_store/password_hygiene.d.ts +2 -3
  16. package/build/src/accounts/lucid_store/password_hygiene.js +2 -3
  17. package/build/src/accounts/lucid_store/shared.d.ts +0 -7
  18. package/build/src/accounts/lucid_store/shared.js +0 -14
  19. package/build/src/accounts/lucid_stores.d.ts +1 -1
  20. package/build/src/adapters/redis_adapter.js +12 -2
  21. package/build/src/console_authorize.d.ts +56 -0
  22. package/build/src/console_authorize.js +85 -0
  23. package/build/src/define_config.d.ts +26 -14
  24. package/build/src/define_config.js +5 -3
  25. package/build/src/doctor/checks.d.ts +10 -5
  26. package/build/src/doctor/checks.js +52 -8
  27. package/build/src/host/account_api/account_api_controller.d.ts +2 -1
  28. package/build/src/host/account_api/account_api_controller.js +5 -11
  29. package/build/src/host/admin_api/api_clients_controller.d.ts +9 -3
  30. package/build/src/host/admin_api/dto.d.ts +3 -1
  31. package/build/src/host/admin_api/dto.js +3 -1
  32. package/build/src/host/admin_clients_service.d.ts +13 -5
  33. package/build/src/host/admin_clients_service.js +28 -6
  34. package/build/src/host/admin_console/admin_shell_controller.d.ts +16 -1
  35. package/build/src/host/admin_console/admin_shell_controller.js +21 -7
  36. package/build/src/host/admin_console/console_clients_controller.d.ts +21 -2
  37. package/build/src/host/admin_console/console_clients_controller.js +9 -0
  38. package/build/src/host/admin_console/console_impersonation_controller.js +3 -8
  39. package/build/src/host/admin_console/console_overview_controller.d.ts +2 -2
  40. package/build/src/host/admin_console/console_overview_controller.js +4 -2
  41. package/build/src/host/admin_console_warning.d.ts +8 -0
  42. package/build/src/host/admin_console_warning.js +18 -0
  43. package/build/src/host/admin_validators.d.ts +15 -11
  44. package/build/src/host/admin_validators.js +16 -9
  45. package/build/src/host/auth_host_config.d.ts +5 -0
  46. package/build/src/host/client_names.d.ts +9 -2
  47. package/build/src/host/client_names.js +11 -3
  48. package/build/src/host/consent_params.d.ts +7 -0
  49. package/build/src/host/consent_params.js +27 -0
  50. package/build/src/host/console_denied_page.d.ts +6 -0
  51. package/build/src/host/console_denied_page.js +18 -0
  52. package/build/src/host/console_sso.d.ts +24 -0
  53. package/build/src/host/console_sso.js +70 -0
  54. package/build/src/host/controllers/account_apps_controller.js +4 -3
  55. package/build/src/host/controllers/account_confirm_controller.d.ts +0 -7
  56. package/build/src/host/controllers/account_confirm_controller.js +0 -6
  57. package/build/src/host/controllers/account_orgs_controller.d.ts +0 -50
  58. package/build/src/host/controllers/account_orgs_controller.js +7 -113
  59. package/build/src/host/controllers/account_security_controller.js +7 -11
  60. package/build/src/host/controllers/account_session_controller.js +10 -1
  61. package/build/src/host/controllers/agent_consent_controller.js +23 -5
  62. package/build/src/host/controllers/console_sso_controller.d.ts +12 -0
  63. package/build/src/host/controllers/console_sso_controller.js +61 -0
  64. package/build/src/host/controllers/interaction_controller.js +9 -3
  65. package/build/src/host/controllers/poppy_consent_controller.js +33 -7
  66. package/build/src/host/controllers/registration_controller.js +7 -2
  67. package/build/src/host/i18n.d.ts +0 -4
  68. package/build/src/host/i18n.js +0 -6
  69. package/build/src/host/impersonation.d.ts +7 -15
  70. package/build/src/host/impersonation.js +7 -16
  71. package/build/src/host/interaction_recovery.js +4 -1
  72. package/build/src/host/login_attempt.js +5 -1
  73. package/build/src/host/middleware/account_auth.js +6 -0
  74. package/build/src/host/rate_limit.js +5 -1
  75. package/build/src/host/register_auth_host.d.ts +8 -0
  76. package/build/src/host/register_auth_host.js +116 -11
  77. package/build/src/host/renderers/inertia_renderer.d.ts +9 -21
  78. package/build/src/host/renderers/inertia_renderer.js +5 -6
  79. package/build/src/host/screen_props.d.ts +309 -0
  80. package/build/src/host/screen_props.js +11 -0
  81. package/build/src/host/sudo_mode.d.ts +5 -11
  82. package/build/src/host/sudo_mode.js +5 -11
  83. package/build/src/host/ui-dist/assets/index-D0EIeyKK.js +67 -0
  84. package/build/src/host/ui-dist/index.html +1 -1
  85. package/build/src/password/password_manager.js +5 -0
  86. package/build/src/provider/build_provider.js +6 -34
  87. package/build/src/provider/oidc_service.d.ts +0 -2
  88. package/build/src/provider/oidc_service.js +1 -12
  89. package/build/src/provider/token_exchange.d.ts +1 -3
  90. package/build/src/provider/token_exchange.js +1 -1
  91. package/build/stubs/config/authkit_react.stub +3 -3
  92. package/package.json +12 -11
  93. package/skills/authkit-interactions/SKILL.md +32 -5
  94. package/stubs/config/authkit_react.stub +3 -3
  95. package/build/src/commands/import_clients.d.ts +0 -40
  96. package/build/src/commands/import_clients.js +0 -81
  97. package/build/src/host/ui-dist/assets/index-DUVBDAyh.js +0 -67
  98. package/build/src/mixins/with_mfa.d.ts +0 -35
  99. package/build/src/mixins/with_mfa.js +0 -21
@@ -44,7 +44,11 @@ export default class PoppyConsentController {
44
44
  return redirectExact(ctx.response, outcome.url);
45
45
  if (outcome.kind === 'error') {
46
46
  ctx.response.status(400);
47
- return render(ctx, 'agents/done', { status: 'invalid', agentName: null, scopes: [] });
47
+ return render(ctx, 'agents/done', {
48
+ status: 'invalid',
49
+ agentName: null,
50
+ scopes: [],
51
+ });
48
52
  }
49
53
  const account = await service.config.accountStore.findById(accountId);
50
54
  return render(ctx, 'agents/consent', {
@@ -72,7 +76,11 @@ export default class PoppyConsentController {
72
76
  const cfg = service.config;
73
77
  const render = cfg.render;
74
78
  if (impersonationState(ctx).active) {
75
- return render(ctx, 'agents/done', { status: 'expired', agentName: null, scopes: [] });
79
+ return render(ctx, 'agents/done', {
80
+ status: 'expired',
81
+ agentName: null,
82
+ scopes: [],
83
+ });
76
84
  }
77
85
  const accountId = ctx.session.get(ACCOUNT_SESSION_KEY);
78
86
  const decided = await poppy.decideAuthorization({
@@ -82,7 +90,11 @@ export default class PoppyConsentController {
82
90
  scopes: chosenScopes(ctx),
83
91
  });
84
92
  if (!decided) {
85
- return render(ctx, 'agents/done', { status: 'expired', agentName: null, scopes: [] });
93
+ return render(ctx, 'agents/done', {
94
+ status: 'expired',
95
+ agentName: null,
96
+ scopes: [],
97
+ });
86
98
  }
87
99
  await cfg.audit?.record({
88
100
  type: decided.approved ? 'agent.delegation_approved' : 'agent.delegation_denied',
@@ -110,14 +122,20 @@ export default class PoppyConsentController {
110
122
  error: null,
111
123
  };
112
124
  if (impersonationState(ctx).active) {
113
- return render(ctx, 'agents/consent', { ...base, error: 'agents.consent.impersonating' });
125
+ return render(ctx, 'agents/consent', {
126
+ ...base,
127
+ error: 'agents.consent.impersonating',
128
+ });
114
129
  }
115
130
  const input = ctx.request.input('user_code');
116
131
  if (!input)
117
132
  return render(ctx, 'agents/consent', base);
118
133
  const pending = await poppy.pendingDevice(input);
119
134
  if (!pending) {
120
- return render(ctx, 'agents/consent', { ...base, error: 'agents.consent.invalid_code' });
135
+ return render(ctx, 'agents/consent', {
136
+ ...base,
137
+ error: 'agents.consent.invalid_code',
138
+ });
121
139
  }
122
140
  const accountId = ctx.session.get(ACCOUNT_SESSION_KEY);
123
141
  const account = await service.config.accountStore.findById(accountId);
@@ -143,7 +161,11 @@ export default class PoppyConsentController {
143
161
  const cfg = service.config;
144
162
  const render = cfg.render;
145
163
  if (impersonationState(ctx).active) {
146
- return render(ctx, 'agents/done', { status: 'expired', agentName: null, scopes: [] });
164
+ return render(ctx, 'agents/done', {
165
+ status: 'expired',
166
+ agentName: null,
167
+ scopes: [],
168
+ });
147
169
  }
148
170
  const accountId = ctx.session.get(ACCOUNT_SESSION_KEY);
149
171
  const userCode = String(ctx.request.input('user_code') ?? '');
@@ -155,7 +177,11 @@ export default class PoppyConsentController {
155
177
  scopes: chosenScopes(ctx),
156
178
  });
157
179
  if (!decided || !pending) {
158
- return render(ctx, 'agents/done', { status: 'expired', agentName: null, scopes: [] });
180
+ return render(ctx, 'agents/done', {
181
+ status: 'expired',
182
+ agentName: null,
183
+ scopes: [],
184
+ });
159
185
  }
160
186
  await cfg.audit?.record({
161
187
  type: decided.approved ? 'agent.delegation_approved' : 'agent.delegation_denied',
@@ -450,7 +450,10 @@ export default class AuthRegistrationController {
450
450
  const cfg = service.config;
451
451
  const render = cfg.render;
452
452
  const token = ctx.request.qs().token ?? '';
453
- return render(ctx, 'reset', { token, csrfToken: ctx.request.csrfToken });
453
+ return render(ctx, 'reset', {
454
+ token,
455
+ csrfToken: ctx.request.csrfToken,
456
+ });
454
457
  }
455
458
  /** POST /auth/reset-password — redefine a senha. */
456
459
  async reset(ctx) {
@@ -552,6 +555,8 @@ export default class AuthRegistrationController {
552
555
  ip: ctx.request.ip?.() ?? null,
553
556
  });
554
557
  }
555
- return render(ctx, 'verify-email', { verified: ok });
558
+ return render(ctx, 'verify-email', {
559
+ verified: ok,
560
+ });
556
561
  }
557
562
  }
@@ -346,7 +346,6 @@ export declare const DEFAULT_MESSAGES: {
346
346
  'admin.clients.grants': string;
347
347
  'admin.clients.redirect_uris': string;
348
348
  'admin.clients.dynamic_notice': string;
349
- 'admin.clients.static_section': string;
350
349
  'admin.clients.dynamic_section': string;
351
350
  'admin.clients.dynamic_empty': string;
352
351
  'admin.clients.dynamic_not_supported': string;
@@ -377,7 +376,6 @@ export declare const DEFAULT_MESSAGES: {
377
376
  'admin.clients.field_backchannel_uri_help': string;
378
377
  'admin.clients.field_backchannel_session_required': string;
379
378
  'admin.clients.field_backchannel_session_required_help': string;
380
- 'admin.clients.static_deprecated_notice': string;
381
379
  'admin.orgs.page_title': string;
382
380
  'admin.orgs.title': string;
383
381
  'admin.orgs.detail_title': string;
@@ -1141,7 +1139,6 @@ export declare const PT_BR_MESSAGES: {
1141
1139
  'admin.clients.grants': string;
1142
1140
  'admin.clients.redirect_uris': string;
1143
1141
  'admin.clients.dynamic_notice': string;
1144
- 'admin.clients.static_section': string;
1145
1142
  'admin.clients.dynamic_section': string;
1146
1143
  'admin.clients.dynamic_empty': string;
1147
1144
  'admin.clients.dynamic_not_supported': string;
@@ -1172,7 +1169,6 @@ export declare const PT_BR_MESSAGES: {
1172
1169
  'admin.clients.field_backchannel_uri_help': string;
1173
1170
  'admin.clients.field_backchannel_session_required': string;
1174
1171
  'admin.clients.field_backchannel_session_required_help': string;
1175
- 'admin.clients.static_deprecated_notice': string;
1176
1172
  'admin.audit.page_title': string;
1177
1173
  'admin.audit.title': string;
1178
1174
  'admin.audit.type_placeholder': string;
@@ -367,7 +367,6 @@ export const DEFAULT_MESSAGES = {
367
367
  'admin.clients.grants': 'Grants: {grants}',
368
368
  'admin.clients.redirect_uris': 'Redirects: {uris}',
369
369
  'admin.clients.dynamic_notice': 'Dynamic client registration (RFC 7591) is on — clients registered via /reg are persisted in the adapter and appear in the dynamic section below.',
370
- 'admin.clients.static_section': 'Static clients (config)',
371
370
  'admin.clients.dynamic_section': 'Dynamic clients (adapter)',
372
371
  'admin.clients.dynamic_empty': 'No dynamic clients persisted.',
373
372
  'admin.clients.dynamic_not_supported': 'The configured OIDC adapter does not support client enumeration — dynamic management is unavailable.',
@@ -398,8 +397,6 @@ export const DEFAULT_MESSAGES = {
398
397
  'admin.clients.field_backchannel_uri_help': 'OIDC Back-Channel Logout endpoint of the RP. Leave blank if not needed.',
399
398
  'admin.clients.field_backchannel_session_required': 'Require sid in logout_token',
400
399
  'admin.clients.field_backchannel_session_required_help': 'When checked, the IdP includes the session ID (sid) in every logout_token sent to this client.',
401
- 'admin.clients.static_deprecated_notice': 'These clients are defined statically in config and are deprecated. ' +
402
- 'Migrate them to the adapter/DB with:',
403
400
  // Console admin — organizations.
404
401
  'admin.orgs.page_title': 'Organizations',
405
402
  'admin.orgs.title': 'Organizations',
@@ -1250,7 +1247,6 @@ export const PT_BR_MESSAGES = {
1250
1247
  'admin.clients.grants': 'Grants: {grants}',
1251
1248
  'admin.clients.redirect_uris': 'Redirects: {uris}',
1252
1249
  '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.',
1253
- 'admin.clients.static_section': 'Clients estáticos (config)',
1254
1250
  'admin.clients.dynamic_section': 'Clients dinâmicos (adapter)',
1255
1251
  'admin.clients.dynamic_empty': 'Nenhum client dinâmico persistido.',
1256
1252
  'admin.clients.dynamic_not_supported': 'O adapter OIDC configurado não suporta enumeração de clients — a gestão dinâmica fica indisponível.',
@@ -1281,8 +1277,6 @@ export const PT_BR_MESSAGES = {
1281
1277
  'admin.clients.field_backchannel_uri_help': 'Endpoint de OIDC Back-Channel Logout do RP. Deixe em branco se não usar.',
1282
1278
  'admin.clients.field_backchannel_session_required': 'Exigir sid no logout_token',
1283
1279
  'admin.clients.field_backchannel_session_required_help': 'Quando marcado, o IdP inclui o session ID (sid) em cada logout_token enviado a este client.',
1284
- 'admin.clients.static_deprecated_notice': 'Estes clients estão definidos estaticamente no config e estão depreciados. ' +
1285
- 'Migre-os para o adapter/DB com:',
1286
1280
  // Console admin — auditoria.
1287
1281
  'admin.audit.page_title': 'Auditoria',
1288
1282
  'admin.audit.title': 'Log de auditoria',
@@ -18,16 +18,13 @@ export interface ImpersonationPanel {
18
18
  curl: string;
19
19
  }
20
20
  /**
21
- * Um client candidato a hospedar o token-exchange. Modelado estruturalmente para
22
- * aceitar tanto os clients ESTÁTICOS do config (`ResolvedServerConfig['clients']`,
23
- * que trazem o secret) quanto os clients de RUNTIME lidos do adapter pelo
24
- * `AdminClientsService` (que NÃO trazem o secret — ele não é recuperável).
21
+ * Um client candidato a hospedar o token-exchange, na forma que o
22
+ * `AdminClientsService` lê do adapter (sem o secret — ele não é recuperável).
25
23
  */
26
24
  export interface ImpersonationClientLike {
27
25
  clientId: string;
28
- clientSecret?: string;
29
- grants?: string[];
30
- /** Runtime clients reportam confidencialidade sem expor o secret. */
26
+ grantTypes?: string[];
27
+ /** Client confidencial: o curl sai com o placeholder do secret. */
31
28
  confidential?: boolean;
32
29
  }
33
30
  /**
@@ -35,12 +32,7 @@ export interface ImpersonationClientLike {
35
32
  * client candidato tem o grant token-exchange habilitado (sem ele o fluxo não
36
33
  * funciona) — a UI então mostra a instrução de habilitar o grant.
37
34
  *
38
- * ⚠️ `clients` é OBRIGATÓRIO na prática. `cfg.clients` é sempre `[]` num host
39
- * real: clients são 100% de runtime (console admin / Admin API / `authkit:clients:create`),
40
- * e o config resolvido nunca os carrega — é o que o `checkClients` do doctor
41
- * afirma. Passar só o config faria o painel devolver `null` SEMPRE, que foi
42
- * exatamente o bug: o botão do console nunca teve um client para oferecer.
43
- * O fallback para `cfg.clients` existe só para hosts que ainda declaram clients
44
- * estáticos e para os testes que os declaram.
35
+ * `clients` são os clients do adapter (console admin / Admin API /
36
+ * `authkit:clients:create`), por exemplo `new AdminClientsService(service).list()`.
45
37
  */
46
- export declare function buildImpersonationPanel(cfg: Pick<ResolvedServerConfig, 'issuer' | 'clients'>, targetId: string, clients?: readonly ImpersonationClientLike[]): ImpersonationPanel | null;
38
+ export declare function buildImpersonationPanel(cfg: Pick<ResolvedServerConfig, 'issuer'>, targetId: string, clients: readonly ImpersonationClientLike[]): ImpersonationPanel | null;
@@ -7,27 +7,18 @@ const SECRET_PLACEHOLDER = '<CLIENT_SECRET>';
7
7
  * client candidato tem o grant token-exchange habilitado (sem ele o fluxo não
8
8
  * funciona) — a UI então mostra a instrução de habilitar o grant.
9
9
  *
10
- * ⚠️ `clients` é OBRIGATÓRIO na prática. `cfg.clients` é sempre `[]` num host
11
- * real: clients são 100% de runtime (console admin / Admin API / `authkit:clients:create`),
12
- * e o config resolvido nunca os carrega — é o que o `checkClients` do doctor
13
- * afirma. Passar só o config faria o painel devolver `null` SEMPRE, que foi
14
- * exatamente o bug: o botão do console nunca teve um client para oferecer.
15
- * O fallback para `cfg.clients` existe só para hosts que ainda declaram clients
16
- * estáticos e para os testes que os declaram.
10
+ * `clients` são os clients do adapter (console admin / Admin API /
11
+ * `authkit:clients:create`), por exemplo `new AdminClientsService(service).list()`.
17
12
  */
18
13
  export function buildImpersonationPanel(cfg, targetId, clients) {
19
- const candidates = clients && clients.length > 0 ? clients : (cfg.clients ?? []);
20
- const client = candidates.find((c) => (c.grants ?? []).includes(TOKEN_EXCHANGE));
14
+ const client = clients.find((c) => (c.grantTypes ?? []).includes(TOKEN_EXCHANGE));
21
15
  if (!client)
22
16
  return null;
23
17
  const tokenEndpoint = `${cfg.issuer.replace(/\/+$/, '')}/token`;
24
- // Confidencial = tem secret conhecido, ou o adapter marcou como confidencial.
25
- // No segundo caso o secret NÃO é recuperável (o adapter guarda o hash/valor
26
- // opaco e a lib mostra o secret uma única vez na criação), então o curl sai
27
- // com um placeholder em vez de mentir com uma string vazia.
28
- const confidential = client.confidential ?? client.clientSecret !== undefined;
29
- const auth = confidential
30
- ? ` -u '${client.clientId}:${client.clientSecret ?? SECRET_PLACEHOLDER}' \\\n`
18
+ // O secret NÃO é recuperável (a lib mostra o secret uma única vez na criação),
19
+ // então o curl de um client confidencial sai com um placeholder.
20
+ const auth = client.confidential
21
+ ? ` -u '${client.clientId}:${SECRET_PLACEHOLDER}' \\\n`
31
22
  : ` -d 'client_id=${client.clientId}' \\\n`;
32
23
  const curl = `curl -X POST '${tokenEndpoint}' \\\n${auth} -d 'grant_type=${TOKEN_EXCHANGE}' \\\n -d 'subject_token=<ADMIN_ACCESS_TOKEN>' \\\n -d 'subject_token_type=${ACCESS_TOKEN_TYPE}' \\\n -d 'requested_subject=${targetId}' \\\n -d 'scope=openid profile email'`;
33
24
  return {
@@ -87,7 +87,10 @@ export async function recoverLostInteraction(ctx) {
87
87
  return ctx.response.redirect(loginUrl);
88
88
  }
89
89
  ctx.response.status(400);
90
- const body = await render(ctx, 'session-expired', { loginUrl, brand });
90
+ const body = await render(ctx, 'session-expired', {
91
+ loginUrl,
92
+ brand,
93
+ });
91
94
  // ESCREVE o body na resposta. Diferente do dispatch normal de rota — onde o
92
95
  // `useReturnValue`/`canWriteResponseBody` do http-server envia o valor
93
96
  // retornado — o caminho de exception handler DESCARTA o retorno de
@@ -231,7 +231,11 @@ export async function attemptPasswordLogin(cfg, input) {
231
231
  if (lock.locked) {
232
232
  return { ok: false, locked: true, retryAfterSec: lock.retryAfterSec };
233
233
  }
234
- const account = await cfg.accountStore.verifyCredentials(email, password);
234
+ // A form posted without a password (e-mail only, typical when password login is
235
+ // off) is a failed attempt like a wrong password, never a crash in the hasher.
236
+ const account = typeof password === 'string' && password !== ''
237
+ ? await cfg.accountStore.verifyCredentials(email, password)
238
+ : null;
235
239
  if (!account) {
236
240
  // `clientId` só entra no evento quando o fluxo o fornece (interaction).
237
241
  await cfg.audit?.record(input.clientId !== undefined
@@ -7,6 +7,12 @@ export default class AccountAuthMiddleware {
7
7
  async handle(ctx, next) {
8
8
  // Sessão do console — ou, com `accountSession.acceptIdpSession`, a do IdP (SSO).
9
9
  if (!(await ensureConsoleSession(ctx))) {
10
+ // JSON callers get a status, not an HTML login page.
11
+ if (ctx.request?.accepts?.(['html', 'json']) === 'json') {
12
+ return ctx.response
13
+ .status(401)
14
+ .send({ error: { code: 'unauthenticated', message: 'Sign in to continue.' } });
15
+ }
10
16
  // Destino configurável (`accountLoginUrl`): default `/account/login`.
11
17
  return ctx.response.redirect(getAccountLoginUrl());
12
18
  }
@@ -159,7 +159,11 @@ export function withFriendly429(throttle) {
159
159
  ctx.response.status(429);
160
160
  if (retryAfter)
161
161
  ctx.response.header('Retry-After', String(retryAfter));
162
- const body = await render(ctx, 'throttled', { brand, retryAfter, retryUrl });
162
+ const body = await render(ctx, 'throttled', {
163
+ brand,
164
+ retryAfter,
165
+ retryUrl,
166
+ });
163
167
  const res = ctx.response;
164
168
  if (body !== undefined && !res.hasLazyBody && body !== ctx.response) {
165
169
  res.send(body);
@@ -5,6 +5,12 @@ import type { PolicyRouteOption } from './config_locks.js';
5
5
  import type { SudoMethod } from './sudo/types.js';
6
6
  /** Chave da sessão Adonis que registra o timestamp da última atividade (idle timeout). */
7
7
  export declare const ACCOUNT_LAST_SEEN_KEY = "authkit_last_seen";
8
+ /**
9
+ * `true` when the caller wants JSON (an `Accept` that prefers `application/json`,
10
+ * as `fetch` from the React client sends): a JSON API answers it with a status,
11
+ * not with a redirect to an HTML login page.
12
+ */
13
+ export declare function wantsJson(ctx: any): boolean;
8
14
  /**
9
15
  * Guard do console admin (B6). Como o `accountGuard`, é uma closure inline (forma
10
16
  * confiável do `.use()` num grupo). Exige:
@@ -14,6 +20,8 @@ export declare const ACCOUNT_LAST_SEEN_KEY = "authkit_last_seen";
14
20
  * (senão → `accountHome(cfg)`, default /account/security — NÃO revela a
15
21
  * existência do /admin, e cai numa tela que o host controla via `accountHome`;
16
22
  * se a tela default estiver desmontada, aponte `config.accountHome` para uma montada).
23
+ * 3. `config.admin.authorize` liberando (403/401/redirect conforme o resultado). Omitido:
24
+ * aberto fora de produção, 403 em produção.
17
25
  * As roles permitidas são resolvidas em runtime do `authkit.server` (config lazy).
18
26
  */
19
27
  export declare const adminGuard: (ctx: any, next: () => Promise<void>) => Promise<any>;
@@ -1,3 +1,5 @@
1
+ import { getBootedApp } from '../../services/booted_app.js';
2
+ import { resolveConsoleAuthorize, } from '../console_authorize.js';
1
3
  import { resolveRateLimit } from '../define_config.js';
2
4
  import { accountHome } from './account_home.js';
3
5
  import { getAccountLoginUrl, setAccountLoginUrl } from './account_login_url.js';
@@ -7,7 +9,9 @@ import { ACCOUNT_SESSION_KEY } from './account_session_key.js';
7
9
  import { adminApiGuard } from './admin_api/admin_api_guard.js';
8
10
  import { normalizeAdminApiPrefix, normalizeAdminPrefix, setAdminApiPrefix, setAdminPrefix, } from './admin_prefix.js';
9
11
  import { getAuthHostConfig, markAuthHostAutoMounted, wasAuthHostAutoMounted, } from './auth_host_config.js';
10
- import { ensureConsoleSession } from './idp_session_bridge.js';
12
+ import { consoleDeniedPage } from './console_denied_page.js';
13
+ import { CONSOLE_SSO_CALLBACK_PATH, CONSOLE_SSO_START_PATH } from './console_sso.js';
14
+ import { endBridgedIdpSession, ensureConsoleSession } from './idp_session_bridge.js';
11
15
  import { POPPY_WEB_SESSION_KEY } from './poppy_session_key.js';
12
16
  import { createAuthThrottles } from './rate_limit.js';
13
17
  import { redirectExact } from './redirect_exact.js';
@@ -67,7 +71,10 @@ async function checkAndRefreshIdle(ctx) {
67
71
  const lastSeen = ctx.session?.get(ACCOUNT_LAST_SEEN_KEY);
68
72
  const now = Date.now();
69
73
  if (lastSeen !== undefined && now - lastSeen > idleMs) {
70
- // Idle excedido: encerra a sessão.
74
+ // Idle excedido: encerra a sessão. Console aberto pela sessão do IdP (SSO):
75
+ // encerra também essa sessão, senão a ponte reabriria o console no próximo
76
+ // request e o idle timeout não teria efeito.
77
+ await endBridgedIdpSession(ctx);
71
78
  ctx.session?.forget(ACCOUNT_SESSION_KEY);
72
79
  ctx.session?.forget(ACCOUNT_LAST_SEEN_KEY);
73
80
  return true;
@@ -119,6 +126,28 @@ function isPoppyBrowser(ctx) {
119
126
  const marker = ctx.session?.get?.(POPPY_WEB_SESSION_KEY);
120
127
  return typeof marker === 'object' && marker !== null && typeof marker.sessionId === 'string';
121
128
  }
129
+ /**
130
+ * `true` when the caller wants JSON (an `Accept` that prefers `application/json`,
131
+ * as `fetch` from the React client sends): a JSON API answers it with a status,
132
+ * not with a redirect to an HTML login page.
133
+ */
134
+ export function wantsJson(ctx) {
135
+ try {
136
+ return ctx.request?.accepts?.(['html', 'json']) === 'json';
137
+ }
138
+ catch {
139
+ return false;
140
+ }
141
+ }
142
+ /** 401 for a JSON caller, otherwise the redirect to the login. */
143
+ function unauthenticated(ctx, redirectTo) {
144
+ if (wantsJson(ctx)) {
145
+ return ctx.response
146
+ .status(401)
147
+ .send({ error: { code: 'unauthenticated', message: 'Sign in to continue.' } });
148
+ }
149
+ return redirectExact(ctx.response, redirectTo);
150
+ }
122
151
  const accountGuard = async (ctx, next) => {
123
152
  // O navegador de um personal agent Poppy (§5) não entra no console de conta nem
124
153
  // nas telas de consentimento: a sessão é do agente, não do usuário.
@@ -126,12 +155,12 @@ const accountGuard = async (ctx, next) => {
126
155
  return ctx.response.status(403).send('');
127
156
  // Sessão do console — ou, com `accountSession.acceptIdpSession`, a do IdP (SSO).
128
157
  if (!(await ensureConsoleSession(ctx))) {
129
- return redirectExact(ctx.response, buildLoginRedirect(ctx));
158
+ return unauthenticated(ctx, buildLoginRedirect(ctx));
130
159
  }
131
160
  // Idle timeout: encerra e redireciona com query param de motivo.
132
161
  const idleExpired = await checkAndRefreshIdle(ctx);
133
162
  if (idleExpired) {
134
- return redirectExact(ctx.response, buildLoginRedirect(ctx, 'reason=idle'));
163
+ return unauthenticated(ctx, buildLoginRedirect(ctx, 'reason=idle'));
135
164
  }
136
165
  return next();
137
166
  };
@@ -144,6 +173,8 @@ const accountGuard = async (ctx, next) => {
144
173
  * (senão → `accountHome(cfg)`, default /account/security — NÃO revela a
145
174
  * existência do /admin, e cai numa tela que o host controla via `accountHome`;
146
175
  * se a tela default estiver desmontada, aponte `config.accountHome` para uma montada).
176
+ * 3. `config.admin.authorize` liberando (403/401/redirect conforme o resultado). Omitido:
177
+ * aberto fora de produção, 403 em produção.
147
178
  * As roles permitidas são resolvidas em runtime do `authkit.server` (config lazy).
148
179
  */
149
180
  export const adminGuard = async (ctx, next) => {
@@ -162,12 +193,12 @@ export const adminGuard = async (ctx, next) => {
162
193
  const accountId = ctx.session?.get(ACCOUNT_SESSION_KEY);
163
194
  if (!accountId) {
164
195
  // `/account/login` é sempre o login da conta — NÃO muda com o prefixo admin.
165
- return redirectExact(ctx.response, buildLoginRedirect(ctx));
196
+ return unauthenticated(ctx, buildLoginRedirect(ctx));
166
197
  }
167
198
  // Idle timeout: também protege o console admin.
168
199
  const idleExpired = await checkAndRefreshIdle(ctx);
169
200
  if (idleExpired) {
170
- return redirectExact(ctx.response, buildLoginRedirect(ctx, 'reason=idle'));
201
+ return unauthenticated(ctx, buildLoginRedirect(ctx, 'reason=idle'));
171
202
  }
172
203
  const allowed = cfg.admin.roles;
173
204
  const account = await cfg.accountStore.findById(accountId);
@@ -179,10 +210,68 @@ export const adminGuard = async (ctx, next) => {
179
210
  if (!isAdmin) {
180
211
  // Evita vazar a existência do console admin: redireciona para o accountHome
181
212
  // (sem mostrar a URL do admin).
182
- return ctx.response.redirect(accountHome(cfg));
213
+ if (wantsJson(ctx)) {
214
+ return ctx.response
215
+ .status(403)
216
+ .send({ error: { code: 'forbidden', message: 'Admin role required.' } });
217
+ }
218
+ return redirectExact(ctx.response, accountHome(cfg));
183
219
  }
220
+ // `admin.authorize`, after the roles: both must pass. Omitted, the console is open
221
+ // outside production and closed (403) in production.
222
+ const decision = cfg.admin.authorize
223
+ ? await adminAuthorizeChecker(cfg.admin.authorize)(ctx)
224
+ : appInProduction()
225
+ ? { allowed: false, status: 403 }
226
+ : { allowed: true };
227
+ if (!decision.allowed)
228
+ return consoleDenied(ctx, decision);
184
229
  return next();
185
230
  };
231
+ /**
232
+ * Whether the app runs in production. Unknown (the provider did not register the
233
+ * app) counts as production, so an omitted `admin.authorize` fails closed.
234
+ */
235
+ function appInProduction() {
236
+ try {
237
+ return getBootedApp().inProduction !== false;
238
+ }
239
+ catch {
240
+ return true;
241
+ }
242
+ }
243
+ /** The response for an `admin.authorize` denial: same rules in every `@adonis-agora` console. */
244
+ function consoleDenied(ctx, decision) {
245
+ const json = wantsJson(ctx);
246
+ if (decision.redirectTo) {
247
+ if (json) {
248
+ return ctx.response
249
+ .status(401)
250
+ .send({ error: 'unauthenticated', redirectTo: decision.redirectTo });
251
+ }
252
+ return redirectExact(ctx.response, decision.redirectTo);
253
+ }
254
+ if (decision.status === 401) {
255
+ if (json)
256
+ return ctx.response.status(401).send({ error: 'unauthenticated' });
257
+ ctx.response.header('Content-Type', 'text/html; charset=utf-8');
258
+ return ctx.response.status(401).send(consoleDeniedPage(401));
259
+ }
260
+ if (json)
261
+ return ctx.response.status(403).send({ error: 'forbidden' });
262
+ ctx.response.header('Content-Type', 'text/html; charset=utf-8');
263
+ return ctx.response.status(403).send(consoleDeniedPage(403));
264
+ }
265
+ /** One checker per configured `admin.authorize`, so a lazy import runs once. */
266
+ const adminAuthorizeCheckers = new WeakMap();
267
+ function adminAuthorizeChecker(authorize) {
268
+ let checker = adminAuthorizeCheckers.get(authorize);
269
+ if (!checker) {
270
+ checker = resolveConsoleAuthorize(authorize);
271
+ adminAuthorizeCheckers.set(authorize, checker);
272
+ }
273
+ return checker;
274
+ }
186
275
  const C = {
187
276
  oidc: () => import('../controllers/oidc_callback_controller.js'),
188
277
  authorizationServerMetadata: () => import('../controllers/authorization_server_metadata_controller.js'),
@@ -191,6 +280,7 @@ const C = {
191
280
  social: () => import('./controllers/social_controller.js'),
192
281
  patIntrospection: () => import('./controllers/pat_introspection_controller.js'),
193
282
  accountSession: () => import('./controllers/account_session_controller.js'),
283
+ consoleSso: () => import('./controllers/console_sso_controller.js'),
194
284
  accountTokens: () => import('./controllers/account_tokens_controller.js'),
195
285
  accountSecurity: () => import('./controllers/account_security_controller.js'),
196
286
  accountApps: () => import('./controllers/account_apps_controller.js'),
@@ -353,6 +443,17 @@ export function registerAuthHost(router, opts = {}) {
353
443
  if (accountLoginUrlOpt !== undefined) {
354
444
  setAccountLoginUrl(accountLoginUrlOpt);
355
445
  }
446
+ // Console sem porta de entrada: SSO desligado (`acceptIdpSession: false`) deixa só o
447
+ // login próprio do console, que é por senha; com a senha desligada ninguém entra em
448
+ // `/account/*` nem no console admin. Só passa com um `accountLoginUrl` do host (aqui
449
+ // ou em `routes.accountLoginUrl`) — por isso a checagem é no registro, não no config.
450
+ if (hostCfg?.consoleNeedsLoginUrl && !accountLoginUrlOpt) {
451
+ throw new Error('authkit: `accountSession.acceptIdpSession: false` com `authMethods: { password: false }` ' +
452
+ 'deixa o console de conta e o console admin sem nenhum jeito de entrar (o login próprio ' +
453
+ 'do console é por senha). Remova `acceptIdpSession: false` (o default entra pelo login ' +
454
+ 'do IdP) ou passe `accountLoginUrl` (no `registerAuthHost` ou em `routes`) apontando ' +
455
+ 'para um login do seu app.');
456
+ }
356
457
  // Montagem por tela do console de conta. `undefined` → tudo montado;
357
458
  // `false` → nada; objeto → cada flag ausente default `true`.
358
459
  const accountOpt = opts.account ?? routesCfg?.account;
@@ -479,6 +580,13 @@ export function registerAuthHost(router, opts = {}) {
479
580
  // Provider OIDC (wildcard + root) — o que registerOidcRoutes fazia.
480
581
  router.any(`${mount}/*`, [C.oidc]).as('authkit.oidc.wildcard');
481
582
  router.any(mount, [C.oidc]).as('authkit.oidc.root');
583
+ // Login do console pelo IdP (SSO; client interno `authkit-console`, ver
584
+ // `console_sso.ts`). Sempre montado: com `acceptIdpSession: false` o início só
585
+ // redireciona para o login próprio do console.
586
+ router.get(CONSOLE_SSO_START_PATH, [C.consoleSso, 'start']).as('authkit.console_sso.start');
587
+ router
588
+ .get(CONSOLE_SSO_CALLBACK_PATH, [C.consoleSso, 'callback'])
589
+ .as('authkit.console_sso.callback');
482
590
  // Interaction (login multi-step + consent + signup).
483
591
  // Cada rota passa por `keep(key, …)`: entra em `AuthHostRouteMap.routes`/`urls` e
484
592
  // recebe o `extraMiddleware` do host (depois do throttle da lib).
@@ -770,10 +878,6 @@ export function registerAuthHost(router, opts = {}) {
770
878
  C.accountOrgs,
771
879
  'revokeInvitation',
772
880
  ]);
773
- // JSON endpoints for React hooks (authkit-react).
774
- router.get(`${orgsPath}/json`, [C.accountOrgs, 'listJson']);
775
- router.get(`${orgsPath}/invitations/json`, [C.accountOrgs, 'listInvitationsJson']);
776
- router.get(`${orgsPath}/:id/json`, [C.accountOrgs, 'showJson']);
777
881
  }
778
882
  // ─── Account self-service JSON API (authkit-react TanStack hooks) ─────
779
883
  // ⚠️ ORDER MATTERS: fixed-segment routes before parameterised ones.
@@ -925,6 +1029,7 @@ export function registerAuthHost(router, opts = {}) {
925
1029
  // Clients OIDC.
926
1030
  router.get(`${ap}/api/clients`, [C.consoleClients, 'index']);
927
1031
  router.post(`${ap}/api/clients`, [C.consoleClients, 'store']);
1032
+ router.get(`${ap}/api/clients/:id`, [C.consoleClients, 'show']);
928
1033
  router.patch(`${ap}/api/clients/:id`, [C.consoleClients, 'update']);
929
1034
  router.delete(`${ap}/api/clients/:id`, [C.consoleClients, 'destroy']);
930
1035
  router.post(`${ap}/api/clients/:id/regenerate-secret`, [
@@ -1,4 +1,5 @@
1
1
  import type { HttpContext } from '@adonisjs/core/http';
2
+ import type { AuthkitScreen } from '../screen_props.js';
2
3
  /**
3
4
  * Opções do renderer Inertia/React.
4
5
  */
@@ -11,18 +12,10 @@ export interface InertiaRendererOptions {
11
12
  /**
12
13
  * Lista de nomes de view que o host criou como páginas React (allowlist).
13
14
  *
14
- * Quando fornecida, **somente** as views listadas passam pelo Inertia; qualquer
15
- * outra view (não listada, ou prefixada com `admin/`) recai silenciosamente no
16
- * renderer Edge built-in da lib — sem erro 500.
17
- *
18
- * ### ⚠️ Atenção: sem `views`, qualquer tela nova da lib quebra hosts SSR
19
- *
20
- * Se `views` for omitido, o comportamento legado é preservado (todas as views
21
- * são enviadas ao Inertia) para compatibilidade retroativa. Nesse modo, qualquer
22
- * tela nova adicionada pela lib gerará um erro SSR no host ("Cannot read
23
- * properties of undefined (reading 'default')") porque o host não terá criado a
24
- * página React correspondente. **Recomenda-se sempre fornecer `views`** com
25
- * exatamente as páginas que o scaffold (`node ace configure`) gerou.
15
+ * **Somente** as views listadas passam pelo Inertia; qualquer outra view (não
16
+ * listada, ou prefixada com `admin/`) é renderizada pelo renderer Edge built-in
17
+ * da lib. Liste exatamente as páginas que o scaffold (`node ace configure`)
18
+ * gerou, mais as que você criar.
26
19
  *
27
20
  * A ordem do array é irrelevante — `views` é tratado como um *conjunto*
28
21
  * (allowlist). Os nomes têm autocomplete via {@link AuthkitScreen}.
@@ -38,14 +31,9 @@ export interface InertiaRendererOptions {
38
31
  * ],
39
32
  * })
40
33
  */
41
- views?: AuthkitScreen[];
34
+ views: readonly AuthkitScreen[];
42
35
  }
43
- /**
44
- * Nomes das telas conhecidas da lib — dá autocomplete no array `views`.
45
- * O `(string & {})` mantém o tipo aberto: telas custom (ou de versões mais
46
- * novas da lib) continuam aceitas sem erro de compilação.
47
- */
48
- export type AuthkitScreen = 'login' | 'signup' | 'consent' | 'forgot' | 'reset' | 'verify-email' | 'mfa-challenge' | 'otp-unlock' | 'maintenance' | 'session-expired' | 'account/login' | 'account/tokens' | 'account/mfa' | 'account/security' | 'account/apps' | 'account/orgs' | 'account/confirm' | 'account/email-confirmed' | (string & {});
36
+ export type { AuthkitScreen } from '../screen_props.js';
49
37
  /**
50
38
  * Renderer do seam para hosts Inertia/React.
51
39
  *
@@ -53,8 +41,8 @@ export type AuthkitScreen = 'login' | 'signup' | 'consent' | 'forgot' | 'reset'
53
41
  * (O console admin não passa por aqui: é a SPA self-contained da lib,
54
42
  * servida pelo shell controller.)
55
43
  *
56
- * Quando `views` é fornecido (allowlist), apenas as views listadas vão ao Inertia;
57
- * qualquer outra view recai no Edge renderer built-in sem erro.
44
+ * Apenas as views listadas em `views` (allowlist) vão ao Inertia; qualquer outra
45
+ * view é renderizada pelo Edge renderer built-in.
58
46
  *
59
47
  * ---
60
48
  *
@@ -24,8 +24,8 @@ async function resolveMessagesFromCtx(ctx) {
24
24
  * (O console admin não passa por aqui: é a SPA self-contained da lib,
25
25
  * servida pelo shell controller.)
26
26
  *
27
- * Quando `views` é fornecido (allowlist), apenas as views listadas vão ao Inertia;
28
- * qualquer outra view recai no Edge renderer built-in sem erro.
27
+ * Apenas as views listadas em `views` (allowlist) vão ao Inertia; qualquer outra
28
+ * view é renderizada pelo Edge renderer built-in.
29
29
  *
30
30
  * ---
31
31
  *
@@ -59,11 +59,10 @@ async function resolveMessagesFromCtx(ctx) {
59
59
  * ```
60
60
  */
61
61
  export function inertiaRenderer(opts) {
62
- const allowed = opts.views ? new Set(opts.views) : null;
62
+ const allowed = new Set(opts.views);
63
63
  return async (ctx, view, props) => {
64
- // Allowlist: se `views` foi fornecido e a view não está na lista,
65
- // usa o fallback Edge silenciosamente (evita SSR crash por página inexistente no host).
66
- if (allowed !== null && !allowed.has(view)) {
64
+ // Allowlist: view fora da lista vai ao Edge (o host não tem a página React).
65
+ if (!allowed.has(view)) {
67
66
  return renderEdgeView(ctx, view, props);
68
67
  }
69
68
  // Happy path: view vai ao Inertia.