@adonis-agora/authkit-server 0.85.0 → 0.86.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.
@@ -74,7 +74,7 @@ export default class ConsoleUsersController {
74
74
  roles: string[];
75
75
  invited: boolean;
76
76
  }>;
77
- /** PATCH {prefix}/api/users/:id/roles */
77
+ /** PATCH {prefix}/api/users/:id/roles — body `{ roles }`. */
78
78
  updateRoles(ctx: HttpContext): Promise<void | {
79
79
  id: string;
80
80
  email: string;
@@ -84,6 +84,22 @@ export default class ConsoleUsersController {
84
84
  disabled: boolean;
85
85
  roles: string[];
86
86
  }>;
87
+ /**
88
+ * PATCH {prefix}/api/users/:id — body `{ globalRoles }`, a mesma forma do
89
+ * `PATCH /users/:id` da Admin API. É o que o `client.admin.users.update` do
90
+ * authkit-react chama, e o que o botão "Salvar papéis" do console usa: sem
91
+ * esta rota ele dava 404. Só papéis por aqui; nome e avatar não se editam no
92
+ * console.
93
+ */
94
+ update(ctx: HttpContext): Promise<void | {
95
+ id: string;
96
+ email: string;
97
+ name: string | null;
98
+ avatarUrl: string | null;
99
+ globalRoles: string[];
100
+ disabled: boolean;
101
+ roles: string[];
102
+ }>;
87
103
  /** POST {prefix}/api/users/:id/disable */
88
104
  disable(ctx: HttpContext): Promise<void | {}>;
89
105
  /** POST {prefix}/api/users/:id/enable */
@@ -6,7 +6,7 @@ import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
6
6
  import { AdminUsersService } from '../admin_api/admin_users_service.js';
7
7
  import { apiError, grantDto, rolesManagedExternallyError, sessionDto, userDtoWithRoles, } from '../admin_api/dto.js';
8
8
  import { AdminSessionsService } from '../admin_sessions_service.js';
9
- import { adminUserCreateValidator, adminUserRolesValidator } from '../admin_validators.js';
9
+ import { adminUserCreateValidator, adminUserRolesValidator, adminUserUpdateValidator, } from '../admin_validators.js';
10
10
  import { resolveRuntimeSettings } from '../runtime_settings.js';
11
11
  import { resolveEffectiveRolesCatalog } from '../runtime_toggles.js';
12
12
  import { enrichSessionsWithContext } from '../session_context.js';
@@ -125,8 +125,23 @@ export default class ConsoleUsersController {
125
125
  ctx.response.status(201);
126
126
  return { ...(await userDtoWithRoles(cfg, result.account)), invited: result.invited };
127
127
  }
128
- /** PATCH {prefix}/api/users/:id/roles */
128
+ /** PATCH {prefix}/api/users/:id/roles — body `{ roles }`. */
129
129
  async updateRoles(ctx) {
130
+ const { roles } = await ctx.request.validateUsing(adminUserRolesValidator);
131
+ return this.#setRoles(ctx, roles);
132
+ }
133
+ /**
134
+ * PATCH {prefix}/api/users/:id — body `{ globalRoles }`, a mesma forma do
135
+ * `PATCH /users/:id` da Admin API. É o que o `client.admin.users.update` do
136
+ * authkit-react chama, e o que o botão "Salvar papéis" do console usa: sem
137
+ * esta rota ele dava 404. Só papéis por aqui; nome e avatar não se editam no
138
+ * console.
139
+ */
140
+ async update(ctx) {
141
+ const { globalRoles } = await ctx.request.validateUsing(adminUserUpdateValidator);
142
+ return this.#setRoles(ctx, globalRoles);
143
+ }
144
+ async #setRoles(ctx, rolesInput) {
130
145
  const service = await ctx.containerResolver.make('authkit.server');
131
146
  const cfg = service.config;
132
147
  const id = ctx.request.param('id');
@@ -139,7 +154,6 @@ export default class ConsoleUsersController {
139
154
  return ctx.response.status(409).send(rolesManagedExternallyError());
140
155
  }
141
156
  const actorId = ctx.session?.get(ACCOUNT_SESSION_KEY) ?? null;
142
- const { roles: rolesInput } = await ctx.request.validateUsing(adminUserRolesValidator);
143
157
  const roles = Array.from(new Set((rolesInput ?? []).map((r) => r.trim()).filter(Boolean)));
144
158
  const users = new AdminUsersService(cfg);
145
159
  // Proteções de escalonamento/lockout: último admin + auto-rebaixamento.
@@ -29,6 +29,21 @@ export declare const adminGuard: (ctx: any, next: () => Promise<void>) => Promis
29
29
  * (o `adminGuard` 404a quando `config.admin.enabled` é false). Mantenha-os em
30
30
  * sincronia; os guards garantem que a divergência não vire um bypass.
31
31
  */
32
+ /**
33
+ * Rotas do fluxo de login que o host pode querer reforçar por conta própria
34
+ * (throttle mais apertado, WAF, log de auditoria, feature flag…).
35
+ *
36
+ * A lib continua aplicando o throttle padrão (`rateLimit`), mas estas são as
37
+ * rotas em que o host tem contexto para ir além — ex.: limitar `/identifier`
38
+ * por e-mail em vez de só por IP.
39
+ */
40
+ export type AuthHostRouteKey = 'interaction' | 'identifier' | 'login' | 'mfa' | 'mfaCustomBegin' | 'mfaCustomVerify' | 'passwordExpired' | 'passkeyOptions' | 'passkeyVerify' | 'magic' | 'magicConsume' | 'otpVerify' | 'consent' | 'switchIdentifier';
41
+ /**
42
+ * Middleware do AdonisJS aceito por `route.use` — função, import lazy, o nome de um
43
+ * middleware do router, ou a tupla `[middleware, options]`. Tipado frouxo de propósito:
44
+ * a lib não conhece os middlewares do host e não deve inferir a forma deles.
45
+ */
46
+ export type AuthHostMiddleware = unknown;
32
47
  export interface AuthHostOptions {
33
48
  /**
34
49
  * Onde o provider OIDC é montado. Deve casar com o final do issuer. OPCIONAL:
@@ -58,6 +73,26 @@ export interface AuthHostOptions {
58
73
  * NÃO espelhe o config aqui — omitir a chave já herda dele.
59
74
  */
60
75
  rateLimit?: RateLimitConfigInput;
76
+ /**
77
+ * Middleware EXTRA por rota do fluxo de login, aplicado DEPOIS do throttle da lib.
78
+ *
79
+ * É o escape hatch para quem quer política própria sem fork: a rota continua sendo
80
+ * da lib (handler, validação, OIDC), mas o host pendura o que quiser nela —
81
+ * `throttle` do AdonisJS, verificação de origem, auditoria:
82
+ *
83
+ * ```ts
84
+ * registerAuthHost(router, {
85
+ * extraMiddleware: {
86
+ * identifier: [throttle({ limit: 5, window: '1 minute' })],
87
+ * },
88
+ * })
89
+ * ```
90
+ *
91
+ * Não é opção de POLÍTICA (não é travada pelo `defineConfig`): é encanamento.
92
+ * Rodeu antes do throttle da lib? Não: a ordem é lib → host, então o seu middleware
93
+ * vê a requisição depois da barreira padrão.
94
+ */
95
+ extraMiddleware?: Partial<Record<AuthHostRouteKey, readonly AuthHostMiddleware[]>>;
61
96
  /**
62
97
  * Console admin opt-in (B6).
63
98
  *
@@ -272,6 +307,24 @@ export interface AuthHostRouteMap {
272
307
  } | null;
273
308
  /** Nomes das rotas nomeadas (as demais herdam o auto-naming do AdonisJS). */
274
309
  names: Record<string, string>;
310
+ /**
311
+ * Handles das rotas do fluxo de login (os `Route` do AdonisJS), por chave estável.
312
+ *
313
+ * Serve para o host pendurar middleware DEPOIS do registro (`route.use([...])`) ou
314
+ * inspecionar o que já está aplicado. Para o mesmo efeito de forma declarativa, use
315
+ * a opção `extraMiddleware` ao chamar `registerAuthHost`.
316
+ */
317
+ routes: Partial<Record<AuthHostRouteKey, ReturnType<Router['post']>>>;
318
+ /** Pattern de cada rota do fluxo (para montar href sem hardcodar). `:uid` incluído. */
319
+ urls: Record<AuthHostRouteKey, string>;
320
+ /**
321
+ * URL concreta de uma rota do fluxo, com o `:uid` já preenchido.
322
+ *
323
+ * ```ts
324
+ * map.interactionUrl('magic', uid) // '/auth/interaction/<uid>/magic'
325
+ * ```
326
+ */
327
+ interactionUrl: (key: AuthHostRouteKey, uid: string) => string;
275
328
  /** Ids dos métodos de sudo cujas rotas foram montadas, na ordem de montagem. */
276
329
  sudoMethods: string[];
277
330
  /**
@@ -389,6 +389,7 @@ export function registerAuthHost(router, opts = {}) {
389
389
  const withLogin = (route) => {
390
390
  if (throttles)
391
391
  route.use([throttles.login]);
392
+ return route;
392
393
  };
393
394
  const withIntrospection = (route) => {
394
395
  if (throttles)
@@ -406,6 +407,33 @@ export function registerAuthHost(router, opts = {}) {
406
407
  const withOtpLogin = (route) => {
407
408
  if (throttles)
408
409
  route.use([throttles.otpLogin]);
410
+ return route;
411
+ };
412
+ const hostRoutes = {};
413
+ const interactionUrls = {
414
+ interaction: '/auth/interaction/:uid',
415
+ identifier: '/auth/interaction/:uid/identifier',
416
+ login: '/auth/interaction/:uid/login',
417
+ mfa: '/auth/interaction/:uid/mfa',
418
+ mfaCustomBegin: '/auth/interaction/:uid/mfa/custom/:method/begin',
419
+ mfaCustomVerify: '/auth/interaction/:uid/mfa/custom/:method/verify',
420
+ passwordExpired: '/auth/interaction/:uid/password-expired',
421
+ passkeyOptions: '/auth/interaction/:uid/passkey/options',
422
+ passkeyVerify: '/auth/interaction/:uid/passkey/verify',
423
+ magic: '/auth/interaction/:uid/magic',
424
+ magicConsume: '/auth/interaction/:uid/magic',
425
+ otpVerify: '/auth/interaction/:uid/otp-verify',
426
+ consent: '/auth/interaction/:uid/consent',
427
+ switchIdentifier: '/auth/interaction/:uid/switch',
428
+ };
429
+ const keep = (key, route) => {
430
+ hostRoutes[key] = route;
431
+ const extraForRoute = opts.extraMiddleware?.[key];
432
+ if (extraForRoute && extraForRoute.length > 0) {
433
+ // `use` espera o MiddlewareList do AdonisJS; aqui é lista opaca do host.
434
+ route.use([...extraForRoute]);
435
+ }
436
+ return route;
409
437
  };
410
438
  // ─── Assets estáticos do host-kit (públicos, sem autenticação) ─────────────
411
439
  // Bundle do @simplewebauthn/browser servido pelo próprio app, no lugar do
@@ -452,38 +480,46 @@ export function registerAuthHost(router, opts = {}) {
452
480
  router.any(`${mount}/*`, [C.oidc]).as('authkit.oidc.wildcard');
453
481
  router.any(mount, [C.oidc]).as('authkit.oidc.root');
454
482
  // Interaction (login multi-step + consent + signup).
455
- router.get('/auth/interaction/:uid', [C.interaction, 'show']);
456
- router.post('/auth/interaction/:uid/identifier', [C.interaction, 'identifier']);
457
- withLogin(router.post('/auth/interaction/:uid/login', [C.interaction, 'login']));
458
- withLogin(router.post('/auth/interaction/:uid/mfa', [C.interaction, 'mfaVerify']));
459
- const customMfaBeginRoute = router.post('/auth/interaction/:uid/mfa/custom/:method/begin', [
483
+ // Cada rota passa por `keep(key, …)`: entra em `AuthHostRouteMap.routes`/`urls` e
484
+ // recebe o `extraMiddleware` do host (depois do throttle da lib).
485
+ keep('interaction', router.get('/auth/interaction/:uid', [C.interaction, 'show']));
486
+ // `/identifier` é a ENTRADA do fluxo: recebe só o e-mail e abre/avança a interaction.
487
+ // Sem throttle, um anônimo cria estado de interaction sem limite (Redis) e o passo
488
+ // fica de fora do orçamento que o resto do login respeita. Bucket `login` (por IP),
489
+ // o mesmo do `/magic`: um login normal gasta 2 dos 10 pontos por minuto.
490
+ keep('identifier', withLogin(router.post('/auth/interaction/:uid/identifier', [C.interaction, 'identifier'])));
491
+ withLogin(keep('login', router.post('/auth/interaction/:uid/login', [C.interaction, 'login'])));
492
+ withLogin(keep('mfa', router.post('/auth/interaction/:uid/mfa', [C.interaction, 'mfaVerify'])));
493
+ const customMfaBeginRoute = keep('mfaCustomBegin', router.post('/auth/interaction/:uid/mfa/custom/:method/begin', [
460
494
  C.interaction,
461
495
  'customMfaBegin',
462
- ]);
496
+ ]));
463
497
  withLogin(customMfaBeginRoute);
464
498
  customMfaBeginRoute.as('authkit.mfa.custom.begin');
465
- const customMfaVerifyRoute = router.post('/auth/interaction/:uid/mfa/custom/:method/verify', [
499
+ const customMfaVerifyRoute = keep('mfaCustomVerify', router.post('/auth/interaction/:uid/mfa/custom/:method/verify', [
466
500
  C.interaction,
467
501
  'customMfaVerify',
468
- ]);
502
+ ]));
469
503
  withLogin(customMfaVerifyRoute);
470
504
  customMfaVerifyRoute.as('authkit.mfa.custom.verify');
471
505
  // Troca de senha obrigatória quando a senha expirou (password expiration gate).
472
- withLogin(router.post('/auth/interaction/:uid/password-expired', [
506
+ withLogin(keep('passwordExpired', router.post('/auth/interaction/:uid/password-expired', [
473
507
  C.interaction,
474
508
  'changeExpiredPassword',
475
- ]));
509
+ ])));
476
510
  // Passkey como 2º fator alternativo no login (begin/finish; challenge na sessão).
477
- router.post('/auth/interaction/:uid/passkey/options', [C.interaction, 'passkeyOptions']);
478
- withLogin(router.post('/auth/interaction/:uid/passkey/verify', [C.interaction, 'passkeyVerify']));
511
+ // O `options` também cria estado (challenge) e é anônimo: mesmo bucket `login` do
512
+ // `/identifier` e do `/magic`. Um login normal gasta 1 ponto aqui quando usa passkey.
513
+ keep('passkeyOptions', withLogin(router.post('/auth/interaction/:uid/passkey/options', [C.interaction, 'passkeyOptions'])));
514
+ withLogin(keep('passkeyVerify', router.post('/auth/interaction/:uid/passkey/verify', [C.interaction, 'passkeyVerify'])));
479
515
  // Magic link (passwordless): POST emite (throttled), GET consome o token do link.
480
- withLogin(router.post('/auth/interaction/:uid/magic', [C.interaction, 'magicLinkRequest']));
481
- router.get('/auth/interaction/:uid/magic', [C.interaction, 'magicLinkConsume']);
516
+ withLogin(keep('magic', router.post('/auth/interaction/:uid/magic', [C.interaction, 'magicLinkRequest'])));
517
+ keep('magicConsume', router.get('/auth/interaction/:uid/magic', [C.interaction, 'magicLinkConsume']));
482
518
  // Login por OTP (código digitável): verifica o código no bucket dedicado
483
519
  // `authkit_otp_login` (por IP, mais apertado que o login).
484
- withOtpLogin(router.post('/auth/interaction/:uid/otp-verify', [C.interaction, 'otpVerify']));
485
- router.post('/auth/interaction/:uid/consent', [C.interaction, 'consent']);
486
- router.get('/auth/interaction/:uid/switch', [C.interaction, 'switchIdentifier']);
520
+ keep('otpVerify', withOtpLogin(router.post('/auth/interaction/:uid/otp-verify', [C.interaction, 'otpVerify'])));
521
+ keep('consent', router.post('/auth/interaction/:uid/consent', [C.interaction, 'consent']));
522
+ keep('switchIdentifier', router.get('/auth/interaction/:uid/switch', [C.interaction, 'switchIdentifier']));
487
523
  // OTP unlock: link enviado por e-mail quando o fator TOTP/recovery é travado.
488
524
  router.get('/auth/otp-unlock/:token', [C.interaction, 'otpUnlock']);
489
525
  router.get('/auth/interaction/:uid/signup', [C.registration, 'showSignup']);
@@ -867,6 +903,7 @@ export function registerAuthHost(router, opts = {}) {
867
903
  router.get(`${ap}/api/users/:id`, [C.consoleUsers, 'show']);
868
904
  router.post(`${ap}/api/users`, [C.consoleUsers, 'store']);
869
905
  router.patch(`${ap}/api/users/:id/roles`, [C.consoleUsers, 'updateRoles']);
906
+ router.patch(`${ap}/api/users/:id`, [C.consoleUsers, 'update']);
870
907
  router.post(`${ap}/api/users/:id/disable`, [C.consoleUsers, 'disable']);
871
908
  router.post(`${ap}/api/users/:id/enable`, [C.consoleUsers, 'enable']);
872
909
  router.post(`${ap}/api/users/:id/reset-password`, [C.consoleUsers, 'resetPassword']);
@@ -1025,6 +1062,9 @@ export function registerAuthHost(router, opts = {}) {
1025
1062
  // inteiro, e não só metade (certo no servidor, errado na UI).
1026
1063
  return {
1027
1064
  mountPath: mount,
1065
+ routes: hostRoutes,
1066
+ urls: interactionUrls,
1067
+ interactionUrl: (key, uid) => interactionUrls[key].replace(':uid', encodeURIComponent(uid)),
1028
1068
  account: {
1029
1069
  prefix: accountPrefix(),
1030
1070
  api: apiBase,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "0.85.0",
3
+ "version": "0.86.0",
4
4
  "description": "AdonisJS OIDC/OAuth2 provider (Identity Provider) toolkit: ejectable auth server with sessions, rate-limiting, MFA/TOTP, audit log, federated logout and OpenTelemetry metrics.",
5
5
  "license": "MIT",
6
6
  "author": "dudousxd",