@adonis-agora/authkit-server 1.0.0-rc.6 → 1.0.0-rc.8

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.
@@ -1,3 +1,5 @@
1
+ import { getBootedApp } from '../../services/booted_app.js';
2
+ import { registerAgoraConsole } from '../console_switcher.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';
@@ -6,6 +8,7 @@ import { resolveAccountRoles } from './account_roles.js';
6
8
  import { ACCOUNT_SESSION_KEY } from './account_session_key.js';
7
9
  import { adminApiGuard } from './admin_api/admin_api_guard.js';
8
10
  import { adminAuthorizeChecker, appInProduction } from './admin_authorize.js';
11
+ import { adminConsoleEntry } from './admin_console/admin_console_entry.js';
9
12
  import { normalizeAdminApiPrefix, normalizeAdminPrefix, setAdminApiPrefix, setAdminPrefix, } from './admin_prefix.js';
10
13
  import { getAuthHostConfig, markAuthHostAutoMounted, wasAuthHostAutoMounted, } from './auth_host_config.js';
11
14
  import { consoleDeniedPage } from './console_denied_page.js';
@@ -86,6 +89,23 @@ async function checkAndRefreshIdle(ctx) {
86
89
  return false;
87
90
  }
88
91
  }
92
+ /** Read-only twin of {@link checkAndRefreshIdle}: whether the idle timeout passed. Never writes. */
93
+ async function isIdleExpired(ctx) {
94
+ try {
95
+ const runtimeSettings = await resolveRuntimeSettings(ctx);
96
+ if (!runtimeSettings)
97
+ return false;
98
+ const policy = await resolveEffectiveSessionPolicy(runtimeSettings);
99
+ const idleMs = policy.idleTimeoutMinutes * 60 * 1000;
100
+ if (idleMs <= 0)
101
+ return false;
102
+ const lastSeen = ctx.session?.get(ACCOUNT_LAST_SEEN_KEY);
103
+ return lastSeen !== undefined && Date.now() - lastSeen > idleMs;
104
+ }
105
+ catch {
106
+ return false;
107
+ }
108
+ }
89
109
  /**
90
110
  * Constrói a URL de redirect para o login da conta, preservando o destino
91
111
  * original como `return_to` (URL-encoded). A URL de destino é o path + query
@@ -187,45 +207,65 @@ const accountGuard = async (ctx, next) => {
187
207
  * As roles permitidas são resolvidas em runtime do `authkit.server` (config lazy).
188
208
  */
189
209
  export const adminGuard = async (ctx, next) => {
210
+ const access = await evaluateAdminAccess(ctx);
211
+ switch (access.kind) {
212
+ case 'allowed':
213
+ return next();
214
+ case 'disabled':
215
+ // Defesa contra flag-drift: as rotas são montadas com `admin: true` em tempo de
216
+ // registro, ANTES de o config resolver. Se o config tiver `admin.enabled: false`,
217
+ // as rotas existem mas o console deve estar desligado — 404 (não vaza a existência).
218
+ return ctx.response.notFound();
219
+ case 'agent':
220
+ return ctx.response.status(403).send('');
221
+ case 'signed-out':
222
+ // `/account/login` é sempre o login da conta — NÃO muda com o prefixo admin.
223
+ return unauthenticated(ctx, buildLoginRedirect(ctx));
224
+ case 'idle':
225
+ return unauthenticated(ctx, buildLoginRedirect(ctx, 'reason=idle'));
226
+ case 'not-admin':
227
+ // Evita vazar a existência do console admin: redireciona para o accountHome
228
+ // (sem mostrar a URL do admin).
229
+ if (wantsJson(ctx)) {
230
+ return ctx.response
231
+ .status(403)
232
+ .send({ error: { code: 'forbidden', message: 'Admin role required.' } });
233
+ }
234
+ return redirectExact(ctx.response, accountHome(access.cfg));
235
+ case 'denied':
236
+ return consoleDenied(ctx, access.decision);
237
+ }
238
+ };
239
+ /**
240
+ * The admin console gate: the session, then `admin.roles`, then `admin.authorize`.
241
+ *
242
+ * `probe: true` is the console switcher asking from another console: it reads the idle timeout
243
+ * without refreshing or ending the session.
244
+ */
245
+ async function evaluateAdminAccess(ctx, options = {}) {
190
246
  const service = await ctx.containerResolver.make('authkit.server');
191
247
  const cfg = service.config;
192
- // Defesa contra flag-drift: as rotas são montadas com `admin: true` em tempo de
193
- // registro, ANTES de o config resolver. Se o config tiver `admin.enabled: false`,
194
- // as rotas existem mas o console deve estar desligado — 404 (não vaza a existência).
195
- if (!cfg.admin.enabled) {
196
- return ctx.response.notFound();
197
- }
248
+ if (!cfg.admin.enabled)
249
+ return { kind: 'disabled' };
198
250
  if (isAgentBrowser(ctx))
199
- return ctx.response.status(403).send('');
251
+ return { kind: 'agent' };
200
252
  // Com `accountSession.acceptIdpSession`, a sessão do IdP também abre o console.
201
253
  await ensureConsoleSession(ctx);
202
254
  const accountId = ctx.session?.get(ACCOUNT_SESSION_KEY);
203
- if (!accountId) {
204
- // `/account/login` é sempre o login da conta — NÃO muda com o prefixo admin.
205
- return unauthenticated(ctx, buildLoginRedirect(ctx));
206
- }
255
+ if (!accountId)
256
+ return { kind: 'signed-out' };
207
257
  // Idle timeout: também protege o console admin.
208
- const idleExpired = await checkAndRefreshIdle(ctx);
209
- if (idleExpired) {
210
- return unauthenticated(ctx, buildLoginRedirect(ctx, 'reason=idle'));
211
- }
258
+ const idleExpired = options.probe ? await isIdleExpired(ctx) : await checkAndRefreshIdle(ctx);
259
+ if (idleExpired)
260
+ return { kind: 'idle' };
212
261
  const allowed = cfg.admin.roles;
213
262
  const account = await cfg.accountStore.findById(accountId);
214
263
  // Resolve roles through the host's role authority (`resolveTokenRoles`) when set — the same source
215
264
  // the token claim is minted from — so an app-role admin reaches the console. Falls back to the
216
265
  // account's stored `globalRoles` when no hook is configured.
217
266
  const roles = account ? await resolveAccountRoles(cfg, account) : [];
218
- const isAdmin = roles.some((r) => allowed.includes(r));
219
- if (!isAdmin) {
220
- // Evita vazar a existência do console admin: redireciona para o accountHome
221
- // (sem mostrar a URL do admin).
222
- if (wantsJson(ctx)) {
223
- return ctx.response
224
- .status(403)
225
- .send({ error: { code: 'forbidden', message: 'Admin role required.' } });
226
- }
227
- return redirectExact(ctx.response, accountHome(cfg));
228
- }
267
+ if (!roles.some((r) => allowed.includes(r)))
268
+ return { kind: 'not-admin', cfg };
229
269
  // `admin.authorize`, after the roles: both must pass. Omitted, the console is open
230
270
  // outside production and closed (403) in production.
231
271
  const decision = cfg.admin.authorize
@@ -234,9 +274,45 @@ export const adminGuard = async (ctx, next) => {
234
274
  ? { allowed: false, status: 403 }
235
275
  : { allowed: true };
236
276
  if (!decision.allowed)
237
- return consoleDenied(ctx, decision);
238
- return next();
239
- };
277
+ return { kind: 'denied', decision };
278
+ return { kind: 'allowed' };
279
+ }
280
+ /**
281
+ * The admin console gate as the console switcher reads it: `{ allowed }`, never a response, never a
282
+ * throw. Used as the `authorize` of the admin console's `'agora.consoles'` entry.
283
+ */
284
+ export async function adminConsoleDecision(ctx) {
285
+ try {
286
+ const access = await evaluateAdminAccess(ctx, { probe: true });
287
+ if (access.kind === 'allowed')
288
+ return { allowed: true };
289
+ const unauthenticated = access.kind === 'signed-out' ||
290
+ access.kind === 'idle' ||
291
+ (access.kind === 'denied' && access.decision.status === 401);
292
+ return { allowed: false, reason: unauthenticated ? 'unauthenticated' : 'forbidden' };
293
+ }
294
+ catch {
295
+ return { allowed: false, reason: 'forbidden' };
296
+ }
297
+ }
298
+ /**
299
+ * Registers the admin console in the shared `'agora.consoles'` registry, so every other
300
+ * `@adonis-agora` console's switcher can link to it. A no-op when the provider did not register the
301
+ * app (tests that mount routes on a bare router).
302
+ */
303
+ function announceAdminConsole(adminPrefix) {
304
+ let app;
305
+ try {
306
+ app = getBootedApp();
307
+ }
308
+ catch {
309
+ return;
310
+ }
311
+ void (async () => {
312
+ const logger = (await app.container.make('logger').catch(() => undefined));
313
+ await registerAgoraConsole(app, adminConsoleEntry(adminPrefix, adminConsoleDecision), logger);
314
+ })().catch(() => { });
315
+ }
240
316
  /** The response for an `admin.authorize` denial: same rules in every `@adonis-agora` console. */
241
317
  function consoleDenied(ctx, decision) {
242
318
  const json = wantsJson(ctx);
@@ -286,6 +362,7 @@ const C = {
286
362
  // Console React JSON API (session-authed, under {prefix}/api/*).
287
363
  consoleShell: () => import('./admin_console/admin_shell_controller.js'),
288
364
  consoleOverview: () => import('./admin_console/console_overview_controller.js'),
365
+ consoleSwitcher: () => import('./admin_console/console_switcher_controller.js'),
289
366
  consoleUsers: () => import('./admin_console/console_users_controller.js'),
290
367
  consoleSessions: () => import('./admin_console/console_sessions_controller.js'),
291
368
  consoleClients: () => import('./admin_console/console_clients_controller.js'),
@@ -936,6 +1013,8 @@ export function registerAuthHost(router, opts = {}) {
936
1013
  resolvedAdminPrefix = ap;
937
1014
  // Persiste no singleton de processo para que controllers e views usem o mesmo prefixo.
938
1015
  setAdminPrefix(ap);
1016
+ // Announce the console to the other `@adonis-agora` consoles (the switcher).
1017
+ announceAdminConsole(ap);
939
1018
  router
940
1019
  .group(() => {
941
1020
  // Console admin (SPA React self-contained): todas as rotas GET servem o
@@ -949,6 +1028,10 @@ export function registerAuthHost(router, opts = {}) {
949
1028
  // ─── JSON API do console (session-authed via adminGuard upstream) ─────
950
1029
  // GET {ap}/api/overview
951
1030
  router.get(`${ap}/api/overview`, [C.consoleOverview, 'handle']);
1031
+ // Console switcher: the other `@adonis-agora` consoles this viewer may open, and the
1032
+ // global search across them (search BEFORE the list: fixed path first).
1033
+ router.get(`${ap}/api/consoles/search`, [C.consoleSwitcher, 'search']);
1034
+ router.get(`${ap}/api/consoles`, [C.consoleSwitcher, 'index']);
952
1035
  // Usuários.
953
1036
  router.get(`${ap}/api/users`, [C.consoleUsers, 'index']);
954
1037
  router.get(`${ap}/api/users/:id`, [C.consoleUsers, 'show']);