@adonis-agora/authkit-server 0.82.3 → 0.83.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 (26) hide show
  1. package/build/src/define_config.d.ts +36 -4
  2. package/build/src/host/account_roles.d.ts +14 -3
  3. package/build/src/host/account_roles.js +15 -4
  4. package/build/src/host/admin_api/api_users_controller.d.ts +3 -0
  5. package/build/src/host/admin_api/api_users_controller.js +9 -5
  6. package/build/src/host/admin_api/dto.d.ts +24 -0
  7. package/build/src/host/admin_api/dto.js +14 -0
  8. package/build/src/host/admin_api/token_verify_service.js +7 -2
  9. package/build/src/host/admin_console/admin_shell_controller.js +3 -1
  10. package/build/src/host/admin_console/console_users_controller.d.ts +4 -0
  11. package/build/src/host/admin_console/console_users_controller.js +12 -6
  12. package/build/src/host/controllers/interaction_controller.d.ts +8 -0
  13. package/build/src/host/controllers/interaction_controller.js +46 -12
  14. package/build/src/host/controllers/pat_introspection_controller.d.ts +1 -1
  15. package/build/src/host/controllers/pat_introspection_controller.js +2 -1
  16. package/build/src/host/controllers/registration_controller.d.ts +10 -0
  17. package/build/src/host/controllers/registration_controller.js +94 -30
  18. package/build/src/host/controllers/social_controller.js +62 -1
  19. package/build/src/host/register_auth_host.js +4 -0
  20. package/build/src/host/signup_token.d.ts +9 -0
  21. package/build/src/host/signup_token.js +31 -0
  22. package/build/src/host/social_pending.d.ts +17 -0
  23. package/build/src/host/social_pending.js +11 -0
  24. package/build/src/host/ui-dist/assets/{index-C1eSfG8B.js → index-DhQzXw2i.js} +1 -1
  25. package/build/src/host/ui-dist/index.html +1 -1
  26. package/package.json +2 -2
@@ -23,8 +23,31 @@ import { type RedirectUriPolicy, type ResolvedRedirectUriPolicy, type ValidateRe
23
23
  export type { AuthAccount };
24
24
  export { adapters };
25
25
  export type AuthHostRenderer = (ctx: HttpContext, view: string, props: Record<string, unknown>) => unknown;
26
+ /**
27
+ * Quando o login social pede o 2º fator da conta:
28
+ * - `'enrolled'` (default): pede se a conta tem um fator cadastrado (TOTP,
29
+ * passkey ou método custom de `mfa`). Quem ativou o fator espera que ele
30
+ * valha em qualquer porta de entrada, inclusive "Continuar com Google".
31
+ * - `'required'`: exige sempre; conta sem fator vê a tela de "configure o MFA"
32
+ * (não há o que desafiar). Cuidado: tranca quem ainda não cadastrou fator.
33
+ * - `'never'`: confia só no provider (o comportamento antigo).
34
+ */
35
+ export type SocialSecondFactorPolicy = 'enrolled' | 'required' | 'never';
26
36
  export interface AuthSocialConfig {
27
37
  providers: string[];
38
+ /**
39
+ * 2º fator no login social. Aceita uma política fixa ou uma função por conta
40
+ * (ex.: `'required'` só para papéis administrativos). Default: `'enrolled'`.
41
+ *
42
+ * `roles` vem da MESMA fonte do claim de papéis: `resolveTokenRoles` quando o
43
+ * host o configura (ex.: `@adonis-agora/authz`), senão `account.globalRoles`.
44
+ * Só é resolvido quando a política é uma função.
45
+ */
46
+ secondFactor?: SocialSecondFactorPolicy | ((account: {
47
+ id: string;
48
+ email: string;
49
+ roles: string[];
50
+ }) => SocialSecondFactorPolicy | Promise<SocialSecondFactorPolicy>);
28
51
  }
29
52
  /**
30
53
  * Hooks de e-mail plugáveis (best-effort). Quando ausentes, o host-kit cai no
@@ -62,6 +85,12 @@ export interface MailHooks {
62
85
  * hosts existentes simplesmente ignoram este campo (back-compat).
63
86
  */
64
87
  channel?: 'code' | 'link';
88
+ /**
89
+ * `'signup'`: link de confirmação do cadastro passwordless (a conta é criada
90
+ * ao abri-lo). Ausente = login por magic link. O host pode trocar o texto do
91
+ * e-mail ("confirme seu cadastro"); ignorar o campo continua funcionando.
92
+ */
93
+ kind?: 'signup';
65
94
  }) => Promise<void>;
66
95
  /**
67
96
  * Envia o link de CONFIRMAÇÃO DE IDENTIDADE (sudo). Distinto de
@@ -488,10 +517,13 @@ export interface PasswordlessConfigInput {
488
517
  passkeyFirst?: boolean;
489
518
  /**
490
519
  * Liga o cadastro público passwordless: o signup pede só e-mail + nome (sem
491
- * senha), cria a conta com uma senha aleatória inutilizável e envia um magic
492
- * link que finaliza o login (o mesmo fluxo do login por magic link). Default:
493
- * false. Exige que o accountStore implemente {@link MagicLinkCapability}; sem
494
- * ela o cadastro passwordless fica indisponível e o fluxo de senha segue valendo.
520
+ * senha) e envia um link de confirmação (`onMagicLink` com `kind: 'signup'`).
521
+ * A conta só é criada quando o dono do e-mail abre o link: nasce com o e-mail
522
+ * verificado e uma senha aleatória inutilizável, e o login segue pelo magic
523
+ * link. E-mail que já tem conta recebe um magic link de login. Default: false.
524
+ * Exige que o accountStore implemente {@link MagicLinkCapability} e o serviço de
525
+ * encryption do app (APP_KEY), que assina o link; sem a capacidade o cadastro
526
+ * passwordless fica indisponível e o fluxo de senha segue valendo.
495
527
  */
496
528
  signup?: boolean;
497
529
  }
@@ -19,8 +19,19 @@ type RoleResolverConfig = {
19
19
  * account's stored `globalRoles`. Falls back to `account.globalRoles ?? []` when no hook is set, so
20
20
  * default behavior is unchanged.
21
21
  *
22
- * The console is not OIDC-client- or org-scoped for role purposes, so the hook is called with an
23
- * empty context (`clientId: undefined`, `activeOrg: null`).
22
+ * Gates that are not tied to an OIDC client (console, maintenance mode, PAT introspection) call the
23
+ * hook with an empty context; callers that know the client (opaque access-token introspection) pass
24
+ * its `clientId`, matching what the token was minted with. `activeOrg` is always `null` here.
24
25
  */
25
- export declare function resolveAccountRoles(cfg: RoleResolverConfig, account: AuthAccount): Promise<string[]>;
26
+ export declare function resolveAccountRoles(cfg: RoleResolverConfig, account: AuthAccount, context?: {
27
+ clientId?: string;
28
+ }): Promise<string[]>;
29
+ /**
30
+ * true quando os papéis da conta vêm de fora do authkit: o host configurou
31
+ * `resolveTokenRoles` (ex.: `@adonis-agora/authz` ou uma tabela própria). Nesse
32
+ * caso `globalRoles` não decide nada, então o console e a Admin API mostram os
33
+ * papéis do hook e recusam editá-los — gravar na coluna diria "salvo" sem mudar
34
+ * acesso nenhum. Quem edita é o próprio app, na fonte dele.
35
+ */
36
+ export declare function rolesManagedExternally(cfg: RoleResolverConfig): boolean;
26
37
  export {};
@@ -7,12 +7,23 @@
7
7
  * account's stored `globalRoles`. Falls back to `account.globalRoles ?? []` when no hook is set, so
8
8
  * default behavior is unchanged.
9
9
  *
10
- * The console is not OIDC-client- or org-scoped for role purposes, so the hook is called with an
11
- * empty context (`clientId: undefined`, `activeOrg: null`).
10
+ * Gates that are not tied to an OIDC client (console, maintenance mode, PAT introspection) call the
11
+ * hook with an empty context; callers that know the client (opaque access-token introspection) pass
12
+ * its `clientId`, matching what the token was minted with. `activeOrg` is always `null` here.
12
13
  */
13
- export async function resolveAccountRoles(cfg, account) {
14
+ export async function resolveAccountRoles(cfg, account, context = {}) {
14
15
  if (cfg.resolveTokenRoles) {
15
- return cfg.resolveTokenRoles(account, { clientId: undefined, activeOrg: null });
16
+ return cfg.resolveTokenRoles(account, { clientId: context.clientId, activeOrg: null });
16
17
  }
17
18
  return account.globalRoles ?? [];
18
19
  }
20
+ /**
21
+ * true quando os papéis da conta vêm de fora do authkit: o host configurou
22
+ * `resolveTokenRoles` (ex.: `@adonis-agora/authz` ou uma tabela própria). Nesse
23
+ * caso `globalRoles` não decide nada, então o console e a Admin API mostram os
24
+ * papéis do hook e recusam editá-los — gravar na coluna diria "salvo" sem mudar
25
+ * acesso nenhum. Quem edita é o próprio app, na fonte dele.
26
+ */
27
+ export function rolesManagedExternally(cfg) {
28
+ return typeof cfg.resolveTokenRoles === 'function';
29
+ }
@@ -23,6 +23,7 @@ export default class ApiUsersController {
23
23
  avatarUrl: string | null;
24
24
  globalRoles: string[];
25
25
  disabled: boolean;
26
+ roles: string[];
26
27
  }>;
27
28
  /** POST /users — { email, name?, password? | invite?:true }. */
28
29
  store(ctx: HttpContext): Promise<void | {
@@ -32,6 +33,7 @@ export default class ApiUsersController {
32
33
  avatarUrl: string | null;
33
34
  globalRoles: string[];
34
35
  disabled: boolean;
36
+ roles: string[];
35
37
  invited: boolean;
36
38
  }>;
37
39
  /** PATCH /users/:id — { globalRoles?, name?, avatarUrl? }. */
@@ -42,6 +44,7 @@ export default class ApiUsersController {
42
44
  avatarUrl: string | null;
43
45
  globalRoles: string[];
44
46
  disabled: boolean;
47
+ roles: string[];
45
48
  }>;
46
49
  /** DELETE /users/:id — deleção completa (cascade) da conta. */
47
50
  destroy(ctx: HttpContext): Promise<void | {
@@ -1,11 +1,12 @@
1
1
  import '../augmentations.js';
2
2
  import { ADMIN_LIST_HTTP_DEFAULT_SIZE, parseListPage, parseListSize } from '../../pagination.js';
3
+ import { rolesManagedExternally } from '../account_roles.js';
3
4
  import { AdminSessionsService } from '../admin_sessions_service.js';
4
5
  import { adminUserCreateValidator, adminUserUpdateValidator } from '../admin_validators.js';
5
6
  import { resolveRuntimeSettings } from '../runtime_settings.js';
6
7
  import { enrichSessionsWithContext } from '../session_context.js';
7
8
  import { AdminUsersService } from './admin_users_service.js';
8
- import { apiError, grantDto, sessionDto, userDto } from './dto.js';
9
+ import { apiError, grantDto, rolesManagedExternallyError, sessionDto, userDtoWithRoles, } from './dto.js';
9
10
  /**
10
11
  * Lê a config + monta o actor `admin-api` para auditoria. O `actorId` recebe o
11
12
  * id NÃO-SENSÍVEL da API key que autenticou (anexado pelo `adminApiGuard` em
@@ -35,7 +36,7 @@ export default class ApiUsersController {
35
36
  const size = parseListSize(ctx.request.input('size'), ADMIN_LIST_HTTP_DEFAULT_SIZE);
36
37
  const result = await cfg.accountStore.listAccounts({ search, page, size });
37
38
  const users = new AdminUsersService(cfg);
38
- const data = await Promise.all(result.data.map(async (u) => userDto(u, await users.isDisabled(u.id))));
39
+ const data = await Promise.all(result.data.map(async (u) => userDtoWithRoles(cfg, u, await users.isDisabled(u.id))));
39
40
  return { meta: { page, size, total: result.total }, data };
40
41
  }
41
42
  /** GET /users/:id */
@@ -46,7 +47,7 @@ export default class ApiUsersController {
46
47
  if (!account)
47
48
  return ctx.response.notFound(apiError('not_found', 'Usuário não encontrado.'));
48
49
  const disabled = await new AdminUsersService(cfg).isDisabled(id);
49
- return userDto(account, disabled);
50
+ return userDtoWithRoles(cfg, account, disabled);
50
51
  }
51
52
  /** POST /users — { email, name?, password? | invite?:true }. */
52
53
  async store(ctx) {
@@ -66,7 +67,7 @@ export default class ApiUsersController {
66
67
  return ctx.response.conflict(apiError('email_taken', 'Já existe uma conta com este e-mail.'));
67
68
  }
68
69
  ctx.response.status(201);
69
- return { ...userDto(result.account), invited: result.invited };
70
+ return { ...(await userDtoWithRoles(cfg, result.account)), invited: result.invited };
70
71
  }
71
72
  /** PATCH /users/:id — { globalRoles?, name?, avatarUrl? }. */
72
73
  async update(ctx) {
@@ -77,6 +78,9 @@ export default class ApiUsersController {
77
78
  return ctx.response.notFound(apiError('not_found', 'Usuário não encontrado.'));
78
79
  const users = new AdminUsersService(cfg);
79
80
  const { globalRoles: roles, name, avatarUrl, } = await ctx.request.validateUsing(adminUserUpdateValidator);
81
+ if (Array.isArray(roles) && rolesManagedExternally(cfg)) {
82
+ return ctx.response.conflict(rolesManagedExternallyError());
83
+ }
80
84
  if (Array.isArray(roles)) {
81
85
  const normalized = Array.from(new Set(roles.map((r) => r.trim()).filter(Boolean)));
82
86
  // Proteções de escalonamento/lockout: último admin + auto-rebaixamento.
@@ -107,7 +111,7 @@ export default class ApiUsersController {
107
111
  await users.updateProfile(id, patch);
108
112
  }
109
113
  const updated = await cfg.accountStore.findById(id);
110
- return userDto(updated, await users.isDisabled(id));
114
+ return userDtoWithRoles(cfg, updated, await users.isDisabled(id));
111
115
  }
112
116
  /** DELETE /users/:id — deleção completa (cascade) da conta. */
113
117
  async destroy(ctx) {
@@ -1,5 +1,6 @@
1
1
  import type { AuthAccount, OrgInvitation, OrgMember, OrgSummary } from '../../accounts/account_store.js';
2
2
  import type { StoredAuditEvent } from '../../audit/audit_sink.js';
3
+ import { resolveAccountRoles } from '../account_roles.js';
3
4
  import type { AdminClient, CreatedClient } from '../admin_clients_service.js';
4
5
  import type { AdminGrant, AdminSession } from '../admin_sessions_service.js';
5
6
  import type { OrgDetail } from './admin_orgs_service.js';
@@ -12,6 +13,21 @@ export declare function userDto(account: AuthAccount, disabled?: boolean): {
12
13
  globalRoles: string[];
13
14
  disabled: boolean;
14
15
  };
16
+ /**
17
+ * {@link userDto} + `roles`: os papéis efetivos da conta, pela mesma fonte do
18
+ * claim (`resolveAccountRoles`). Sem `resolveTokenRoles` é igual a
19
+ * `globalRoles`; com ele, é o que o app diz (ex.: authz) e `globalRoles` segue
20
+ * sendo só a coluna guardada.
21
+ */
22
+ export declare function userDtoWithRoles(cfg: Parameters<typeof resolveAccountRoles>[0], account: AuthAccount, disabled?: boolean): Promise<{
23
+ id: string;
24
+ email: string;
25
+ name: string | null;
26
+ avatarUrl: string | null;
27
+ globalRoles: string[];
28
+ disabled: boolean;
29
+ roles: string[];
30
+ }>;
15
31
  export declare function clientDto(client: AdminClient): {
16
32
  clientId: string;
17
33
  confidential: boolean;
@@ -129,3 +145,11 @@ export declare function settingDto(row: SettingRow): {
129
145
  locked: boolean;
130
146
  lockedBy?: "config" | undefined;
131
147
  };
148
+ /** 409 de quem tenta editar papéis geridos fora do authkit (`resolveTokenRoles`). */
149
+ export declare function rolesManagedExternallyError(): {
150
+ error: {
151
+ code: string;
152
+ message: string;
153
+ details?: Record<string, unknown> | undefined;
154
+ };
155
+ };
@@ -1,3 +1,4 @@
1
+ import { resolveAccountRoles } from '../account_roles.js';
1
2
  /** Projeta uma conta para a forma JSON (camelCase) da Admin REST API. */
2
3
  export function userDto(account, disabled = false) {
3
4
  return {
@@ -9,6 +10,15 @@ export function userDto(account, disabled = false) {
9
10
  disabled,
10
11
  };
11
12
  }
13
+ /**
14
+ * {@link userDto} + `roles`: os papéis efetivos da conta, pela mesma fonte do
15
+ * claim (`resolveAccountRoles`). Sem `resolveTokenRoles` é igual a
16
+ * `globalRoles`; com ele, é o que o app diz (ex.: authz) e `globalRoles` segue
17
+ * sendo só a coluna guardada.
18
+ */
19
+ export async function userDtoWithRoles(cfg, account, disabled = false) {
20
+ return { ...userDto(account, disabled), roles: await resolveAccountRoles(cfg, account) };
21
+ }
12
22
  export function clientDto(client) {
13
23
  return {
14
24
  clientId: client.clientId,
@@ -121,3 +131,7 @@ export function settingDto(row) {
121
131
  ...(locked ? { lockedBy: 'config' } : {}),
122
132
  };
123
133
  }
134
+ /** 409 de quem tenta editar papéis geridos fora do authkit (`resolveTokenRoles`). */
135
+ export function rolesManagedExternallyError() {
136
+ return apiError('roles_managed_externally', 'Os papéis desta instalação vêm do app (resolveTokenRoles) e só podem ser alterados lá.');
137
+ }
@@ -1,3 +1,4 @@
1
+ import { resolveAccountRoles } from '../account_roles.js';
1
2
  /**
2
3
  * Introspecção genérica de token usada pela Admin REST API (`POST /tokens/verify`).
3
4
  * Roteia por prefixo: tokens `pat_...` vão pelo {@link PatStore} (mesma rota do
@@ -36,7 +37,7 @@ export class TokenVerifyService {
36
37
  sub: account.id,
37
38
  email: account.email,
38
39
  name: account.name ?? null,
39
- roles: account.globalRoles ?? [],
40
+ roles: await resolveAccountRoles(this.cfg, account),
40
41
  scopes: meta.scopes,
41
42
  audience: meta.audience,
42
43
  exp: meta.exp,
@@ -64,7 +65,11 @@ export class TokenVerifyService {
64
65
  sub,
65
66
  email: account?.email ?? null,
66
67
  name: account?.name ?? null,
67
- roles: account?.globalRoles ?? [],
68
+ roles: account
69
+ ? await resolveAccountRoles(this.cfg, account, {
70
+ clientId: at.clientId ?? undefined,
71
+ })
72
+ : [],
68
73
  scopes,
69
74
  audience: at.aud ?? null,
70
75
  clientId: at.clientId ?? null,
@@ -1,7 +1,7 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { extname } from 'node:path';
3
3
  import { accountPath } from '../account_paths.js';
4
- import { resolveAccountRoles } from '../account_roles.js';
4
+ import { resolveAccountRoles, rolesManagedExternally } from '../account_roles.js';
5
5
  import { getAdminPrefix } from '../admin_prefix.js';
6
6
  // ─── Shell HTML cache ─────────────────────────────────────────────────────────
7
7
  /**
@@ -141,6 +141,8 @@ export default class AdminShellController {
141
141
  features: {
142
142
  // A página de personal agents só aparece com `personalAgents` no config.
143
143
  personalAgents: !!cfg.personalAgents,
144
+ // Papéis vindos de fora (resolveTokenRoles): a tela só mostra, não edita.
145
+ rolesManagedExternally: rolesManagedExternally(cfg),
144
146
  },
145
147
  };
146
148
  const { html: rawHtml } = await loadShell();
@@ -35,6 +35,7 @@ export default class ConsoleUsersController {
35
35
  avatarUrl: string | null;
36
36
  globalRoles: string[];
37
37
  disabled: boolean;
38
+ roles: string[];
38
39
  sessionsSupported: boolean;
39
40
  sessions: {
40
41
  id: string;
@@ -70,6 +71,7 @@ export default class ConsoleUsersController {
70
71
  avatarUrl: string | null;
71
72
  globalRoles: string[];
72
73
  disabled: boolean;
74
+ roles: string[];
73
75
  invited: boolean;
74
76
  }>;
75
77
  /** PATCH {prefix}/api/users/:id/roles */
@@ -80,6 +82,7 @@ export default class ConsoleUsersController {
80
82
  avatarUrl: string | null;
81
83
  globalRoles: string[];
82
84
  disabled: boolean;
85
+ roles: string[];
83
86
  }>;
84
87
  /** POST {prefix}/api/users/:id/disable */
85
88
  disable(ctx: HttpContext): Promise<void | {}>;
@@ -91,6 +94,7 @@ export default class ConsoleUsersController {
91
94
  avatarUrl: string | null;
92
95
  globalRoles: string[];
93
96
  disabled: boolean;
97
+ roles: string[];
94
98
  }>;
95
99
  /** POST {prefix}/api/users/:id/reset-password */
96
100
  resetPassword(ctx: HttpContext): Promise<void | {}>;
@@ -1,9 +1,10 @@
1
1
  import '../augmentations.js';
2
2
  import { supportsAccountDeletion, supportsAccountStatus } from '../../accounts/account_store.js';
3
3
  import { ADMIN_LIST_HTTP_DEFAULT_SIZE, parseListPage, parseListSize } from '../../pagination.js';
4
+ import { rolesManagedExternally } from '../account_roles.js';
4
5
  import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
5
6
  import { AdminUsersService } from '../admin_api/admin_users_service.js';
6
- import { apiError, grantDto, sessionDto, userDto } from '../admin_api/dto.js';
7
+ import { apiError, grantDto, rolesManagedExternallyError, sessionDto, userDtoWithRoles, } from '../admin_api/dto.js';
7
8
  import { AdminSessionsService } from '../admin_sessions_service.js';
8
9
  import { adminUserCreateValidator, adminUserRolesValidator } from '../admin_validators.js';
9
10
  import { resolveRuntimeSettings } from '../runtime_settings.js';
@@ -61,7 +62,7 @@ export default class ConsoleUsersController {
61
62
  size,
62
63
  });
63
64
  const users = new AdminUsersService(cfg);
64
- const data = await Promise.all(result.data.map(async (u) => userDto(u, await users.isDisabled(u.id))));
65
+ const data = await Promise.all(result.data.map(async (u) => userDtoWithRoles(cfg, u, await users.isDisabled(u.id))));
65
66
  return { meta: { page, size, total: result.total }, data };
66
67
  }
67
68
  /** GET {prefix}/api/users/:id */
@@ -92,7 +93,7 @@ export default class ConsoleUsersController {
92
93
  // fail-safe
93
94
  }
94
95
  return {
95
- ...userDto(account, disabled),
96
+ ...(await userDtoWithRoles(cfg, account, disabled)),
96
97
  sessionsSupported: sessions.canList,
97
98
  sessions: enriched.map(sessionDto),
98
99
  grants: grants.map(grantDto),
@@ -122,7 +123,7 @@ export default class ConsoleUsersController {
122
123
  return ctx.response.conflict(apiError('email_taken', 'Já existe uma conta com este e-mail.'));
123
124
  }
124
125
  ctx.response.status(201);
125
- return { ...userDto(result.account), invited: result.invited };
126
+ return { ...(await userDtoWithRoles(cfg, result.account)), invited: result.invited };
126
127
  }
127
128
  /** PATCH {prefix}/api/users/:id/roles */
128
129
  async updateRoles(ctx) {
@@ -132,6 +133,11 @@ export default class ConsoleUsersController {
132
133
  const account = await cfg.accountStore.findById(id);
133
134
  if (!account)
134
135
  return ctx.response.notFound(apiError('not_found', 'Usuário não encontrado.'));
136
+ // Papéis de fora do authkit (resolveTokenRoles): a coluna não decide acesso,
137
+ // então gravar nela seria um "salvo" mentiroso. O app edita na fonte dele.
138
+ if (rolesManagedExternally(cfg)) {
139
+ return ctx.response.status(409).send(rolesManagedExternallyError());
140
+ }
135
141
  const actorId = ctx.session?.get(ACCOUNT_SESSION_KEY) ?? null;
136
142
  const { roles: rolesInput } = await ctx.request.validateUsing(adminUserRolesValidator);
137
143
  const roles = Array.from(new Set((rolesInput ?? []).map((r) => r.trim()).filter(Boolean)));
@@ -156,7 +162,7 @@ export default class ConsoleUsersController {
156
162
  }
157
163
  const updated = await cfg.accountStore.findById(id);
158
164
  const disabled = await users.isDisabled(id);
159
- return userDto(updated, disabled);
165
+ return userDtoWithRoles(cfg, updated, disabled);
160
166
  }
161
167
  /** POST {prefix}/api/users/:id/disable */
162
168
  async disable(ctx) {
@@ -193,7 +199,7 @@ export default class ConsoleUsersController {
193
199
  }
194
200
  const updated = await cfg.accountStore.findById(id);
195
201
  const disabled = await users.isDisabled(id);
196
- return userDto(updated, disabled);
202
+ return userDtoWithRoles(cfg, updated, disabled);
197
203
  }
198
204
  /** POST {prefix}/api/users/:id/reset-password */
199
205
  async resetPassword(ctx) {
@@ -136,6 +136,14 @@ export default class AuthInteractionController {
136
136
  * Consome o magic link. Em sucesso finaliza o login (amr `['email']`). Token
137
137
  * inválido/expirado volta ao início do login.
138
138
  */
139
+ /**
140
+ * GET /auth/interaction/:uid/social/continue — segunda metade do login social
141
+ * quando a conta deve o 2º fator (ver `AuthSocialController#callback`). Lê o
142
+ * login pendente da sessão UMA vez, confere que é desta interaction e passa
143
+ * pelo mesmo `secondFactorGate` dos outros fluxos, com `fed` como fator
144
+ * primário no `amr`.
145
+ */
146
+ socialContinue(ctx: HttpContext): Promise<any>;
139
147
  magicLinkConsume(ctx: HttpContext): Promise<any>;
140
148
  /**
141
149
  * POST /auth/interaction/:uid/otp-verify
@@ -1,5 +1,6 @@
1
1
  import '../augmentations.js';
2
2
  import { supportsLoginMethodsPreference, supportsMagicLink, supportsOtpLogin, supportsPasskeys, } from '../../accounts/account_store.js';
3
+ import { resolveAccountRoles } from '../account_roles.js';
3
4
  import { AdminSessionsService } from '../admin_sessions_service.js';
4
5
  import { guardBotProtection, resolveEffectiveBotProtection } from '../bot_protection.js';
5
6
  import { brandFor, isFirstParty } from '../branding.js';
@@ -16,8 +17,19 @@ import { authkitOrigin } from '../origin.js';
16
17
  import { createOtpLockout, generateOtpUnlockToken, rawToDbOtpUnlockToken, resolveEffectiveOtpLockout, } from '../otp_lockout.js';
17
18
  import { resolveRuntimeSettingsOrNoop } from '../runtime_settings.js';
18
19
  import { resolveEffectiveAuthMethods, resolveEffectiveMaintenanceMode, resolveEffectiveRegistration, resolveEffectiveSessionPolicy, } from '../runtime_toggles.js';
20
+ import { SOCIAL_PENDING_KEY, SOCIAL_PENDING_TTL_MS, } from '../social_pending.js';
19
21
  import { buildTrustedDevicePayload, isTrustedDeviceValid, TRUSTED_DEVICE_COOKIE, } from '../trusted_device.js';
20
22
  import { normalizeUserLoginMethods, resolveEffectiveUserLoginMethods, } from '../user_login_methods.js';
23
+ /**
24
+ * Conta com papel administrativo (`cfg.admin.roles`, default `['ADMIN']`) — o
25
+ * bypass do modo manutenção. Os papéis vêm de `resolveAccountRoles`, a mesma
26
+ * fonte do claim (`resolveTokenRoles`, ex.: authz), não só do `globalRoles`.
27
+ */
28
+ async function isAdminAccount(cfg, account) {
29
+ const adminRoles = cfg.admin?.roles ?? ['ADMIN'];
30
+ const roles = await resolveAccountRoles(cfg, account);
31
+ return adminRoles.some((role) => roles.includes(role));
32
+ }
21
33
  /**
22
34
  * Chave i18n do erro de status de conta (disabled/expired) compartilhada pelos
23
35
  * fluxos passwordless (magic link, OTP, passkey).
@@ -105,8 +117,7 @@ export default class AuthInteractionController {
105
117
  if (!status.allowed)
106
118
  return fail(accountStatusErrorKey(status.reason));
107
119
  const maintenance = await resolveEffectiveMaintenanceMode(settings);
108
- if (maintenance.enabled &&
109
- !(cfg.admin?.roles ?? ['ADMIN']).some((role) => (account.globalRoles ?? []).includes(role))) {
120
+ if (maintenance.enabled && !(await isAdminAccount(cfg, account))) {
110
121
  return render(ctx, 'maintenance', {
111
122
  uid: details.uid,
112
123
  csrfToken: ctx.request.csrfToken,
@@ -468,9 +479,7 @@ export default class AuthInteractionController {
468
479
  // Contas admin (role em cfg.admin.roles) CONTINUAM podendo logar.
469
480
  // Contas comuns são bloqueadas com a tela de manutenção.
470
481
  if (maintenance.enabled) {
471
- const adminRoles = cfg.admin?.roles ?? ['ADMIN'];
472
- const accountRoles = acc.globalRoles ?? [];
473
- const isAdmin = adminRoles.some((r) => accountRoles.includes(r));
482
+ const isAdmin = await isAdminAccount(cfg, acc);
474
483
  if (!isAdmin) {
475
484
  return render(ctx, 'maintenance', {
476
485
  uid: details.uid,
@@ -563,9 +572,9 @@ export default class AuthInteractionController {
563
572
  ...(used.has('webauthn') ? { passkeyAvailable: false } : {}),
564
573
  });
565
574
  }
566
- async configuredMfaGate(ctx, cfg, accountId, details, primary, primaryFactorId, rememberRequested, passwordless = primary !== 'pwd') {
575
+ async configuredMfaGate(ctx, cfg, accountId, details, primary, primaryFactorId, rememberRequested, passwordless = primary !== 'pwd', forceRequired = false) {
567
576
  const requiredFactors = cfg.mfa?.requiredFactors ?? 2;
568
- const mfaRequired = this.acrRequiresMfa(cfg, details) || cfg.mfa?.requiredFactors !== undefined;
577
+ const mfaRequired = forceRequired || this.acrRequiresMfa(cfg, details) || cfg.mfa?.requiredFactors !== undefined;
569
578
  const mfa = (await cfg.accountStore.getMfaState?.(accountId)) ?? { enabled: false };
570
579
  const enrolledTotp = mfa.totp ?? mfa.enabled;
571
580
  const enrolledPasskey = await this.hasPasskeys(cfg, accountId);
@@ -736,8 +745,7 @@ export default class AuthInteractionController {
736
745
  });
737
746
  }
738
747
  const maintenance = await resolveEffectiveMaintenanceMode(settings);
739
- if (maintenance.enabled &&
740
- !(cfg.admin?.roles ?? ['ADMIN']).some((role) => (account.globalRoles ?? []).includes(role))) {
748
+ if (maintenance.enabled && !(await isAdminAccount(cfg, account))) {
741
749
  return cfg.render(ctx, 'maintenance', {
742
750
  uid: flow.uid,
743
751
  csrfToken: ctx.request.csrfToken,
@@ -950,12 +958,12 @@ export default class AuthInteractionController {
950
958
  * - caso geral → `challenge`, com o accountId em `MFA_PENDING_KEY` e o fator
951
959
  * primário (`primary`) em `MFA_PRIMARY_KEY`.
952
960
  */
953
- async secondFactorGate(ctx, cfg, accountId, details, primary, primaryFactorId = primary, rememberRequested, passwordless = primary !== 'pwd') {
961
+ async secondFactorGate(ctx, cfg, accountId, details, primary, primaryFactorId = primary, rememberRequested, passwordless = primary !== 'pwd', forceRequired = false) {
954
962
  ctx.session.forget(MFA_CUSTOM_PRIMARY_KEY);
955
963
  clearMfaFlow(ctx);
956
964
  if (cfg.mfa)
957
- return this.configuredMfaGate(ctx, cfg, accountId, details, primary, primaryFactorId, rememberRequested, passwordless);
958
- const mfaRequired = this.acrRequiresMfa(cfg, details);
965
+ return this.configuredMfaGate(ctx, cfg, accountId, details, primary, primaryFactorId, rememberRequested, passwordless, forceRequired);
966
+ const mfaRequired = forceRequired || this.acrRequiresMfa(cfg, details);
959
967
  const mfa = (await cfg.accountStore.getMfaState?.(accountId)) ?? { enabled: false };
960
968
  // Passkey disponível como alternativa ao TOTP se o store suporta E a conta
961
969
  // tem ao menos uma credencial registrada.
@@ -1212,6 +1220,32 @@ export default class AuthInteractionController {
1212
1220
  * Consome o magic link. Em sucesso finaliza o login (amr `['email']`). Token
1213
1221
  * inválido/expirado volta ao início do login.
1214
1222
  */
1223
+ /**
1224
+ * GET /auth/interaction/:uid/social/continue — segunda metade do login social
1225
+ * quando a conta deve o 2º fator (ver `AuthSocialController#callback`). Lê o
1226
+ * login pendente da sessão UMA vez, confere que é desta interaction e passa
1227
+ * pelo mesmo `secondFactorGate` dos outros fluxos, com `fed` como fator
1228
+ * primário no `amr`.
1229
+ */
1230
+ async socialContinue(ctx) {
1231
+ const service = await ctx.containerResolver.make('authkit.server');
1232
+ const cfg = service.config;
1233
+ const uid = ctx.request.param('uid');
1234
+ const pending = ctx.session.get(SOCIAL_PENDING_KEY);
1235
+ ctx.session.forget(SOCIAL_PENDING_KEY);
1236
+ const details = await service.interactions.details(ctx);
1237
+ if (!pending ||
1238
+ pending.uid !== uid ||
1239
+ details.uid !== uid ||
1240
+ details.prompt.name !== 'login' ||
1241
+ Date.now() - pending.at > SOCIAL_PENDING_TTL_MS) {
1242
+ return ctx.response.redirect(`/auth/interaction/${uid}`);
1243
+ }
1244
+ const gate = await this.secondFactorGate(ctx, cfg, pending.accountId, details, 'fed', 'fed', undefined, true, pending.required);
1245
+ if (gate.kind === 'challenge')
1246
+ return gate.response;
1247
+ return service.interactions.completeLogin(ctx, pending.accountId, { amr: ['fed'] });
1248
+ }
1215
1249
  async magicLinkConsume(ctx) {
1216
1250
  const service = await ctx.containerResolver.make('authkit.server');
1217
1251
  const cfg = service.config;
@@ -14,7 +14,7 @@ export default class PatIntrospectionController {
14
14
  sub: any;
15
15
  email: any;
16
16
  name: any;
17
- roles: any;
17
+ roles: string[];
18
18
  scopes: any;
19
19
  audience: any;
20
20
  exp: any;
@@ -1,4 +1,5 @@
1
1
  import { timingSafeEqual } from 'node:crypto';
2
+ import { resolveAccountRoles } from '../account_roles.js';
2
3
  function bearerMatches(header, expected) {
3
4
  if (!header || !header.startsWith('Bearer '))
4
5
  return false;
@@ -47,7 +48,7 @@ export default class PatIntrospectionController {
47
48
  sub: account.id,
48
49
  email: account.email,
49
50
  name: account.name ?? null,
50
- roles: account.globalRoles ?? [],
51
+ roles: await resolveAccountRoles(cfg, account),
51
52
  scopes: meta.scopes,
52
53
  audience: meta.audience,
53
54
  exp: meta.exp,
@@ -6,6 +6,16 @@ export default class AuthRegistrationController {
6
6
  showSignup(ctx: HttpContext): Promise<any>;
7
7
  /** POST /auth/interaction/:uid/signup — cria o usuário e finaliza o login. */
8
8
  signup(ctx: HttpContext): Promise<any>;
9
+ /**
10
+ * GET /auth/interaction/:uid/signup/confirm?token= — o dono do e-mail abriu o
11
+ * link do cadastro passwordless. Cria a conta (e-mail já verificado: quem
12
+ * clicou provou a caixa de entrada) e conclui pelo fluxo de magic link, que
13
+ * aplica os mesmos gates do login (status, manutenção, 2º fator).
14
+ *
15
+ * Conta que já existe → o link é recusado e a pessoa volta ao login: é o que
16
+ * faz o link valer uma vez só, sem estado no banco.
17
+ */
18
+ signupConfirm(ctx: HttpContext): Promise<void>;
9
19
  /** GET /auth/forgot-password — tela standalone. */
10
20
  showForgot(ctx: HttpContext): Promise<any>;
11
21
  /** POST /auth/forgot-password — gera token e (dev) loga o link. Sempre responde sucesso (não vaza emails). */