@adonis-agora/authkit-server 0.72.0 → 0.74.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 (49) hide show
  1. package/README.md +4 -0
  2. package/build/commands/clients_create.d.ts +2 -0
  3. package/build/commands/clients_create.js +41 -13
  4. package/build/commands/commands.json +7 -0
  5. package/build/index.d.ts +6 -0
  6. package/build/index.js +7 -0
  7. package/build/src/commands/import_clients.js +2 -0
  8. package/build/src/host/access_token_verifier.d.ts +100 -0
  9. package/build/src/host/access_token_verifier.js +240 -0
  10. package/build/src/host/account_api/account_orgs_api_controller.js +6 -1
  11. package/build/src/host/active_org_cookie.d.ts +21 -1
  12. package/build/src/host/active_org_cookie.js +38 -0
  13. package/build/src/host/admin_api/admin_orgs_service.d.ts +7 -1
  14. package/build/src/host/admin_api/admin_orgs_service.js +11 -1
  15. package/build/src/host/admin_api/api_clients_controller.d.ts +4 -1
  16. package/build/src/host/admin_api/api_clients_controller.js +25 -2
  17. package/build/src/host/admin_api/api_orgs_controller.js +20 -20
  18. package/build/src/host/admin_api/dto.d.ts +1 -0
  19. package/build/src/host/admin_api/dto.js +1 -0
  20. package/build/src/host/admin_clients_service.d.ts +9 -0
  21. package/build/src/host/admin_clients_service.js +21 -0
  22. package/build/src/host/admin_console/console_clients_controller.d.ts +3 -1
  23. package/build/src/host/admin_console/console_clients_controller.js +25 -2
  24. package/build/src/host/admin_console/console_orgs_controller.js +1 -1
  25. package/build/src/host/admin_sessions_service.d.ts +21 -0
  26. package/build/src/host/admin_sessions_service.js +49 -0
  27. package/build/src/host/admin_validators.d.ts +11 -1
  28. package/build/src/host/admin_validators.js +36 -8
  29. package/build/src/host/bearer_account.d.ts +9 -0
  30. package/build/src/host/bearer_account.js +28 -0
  31. package/build/src/host/client_metadata.d.ts +51 -0
  32. package/build/src/host/client_metadata.js +98 -0
  33. package/build/src/host/console_session.d.ts +14 -3
  34. package/build/src/host/console_session.js +21 -9
  35. package/build/src/host/controllers/account_orgs_controller.js +3 -0
  36. package/build/src/host/controllers/interaction_controller.d.ts +18 -1
  37. package/build/src/host/controllers/interaction_controller.js +55 -14
  38. package/build/src/host/oidc_bearer_guard.d.ts +113 -0
  39. package/build/src/host/oidc_bearer_guard.js +223 -0
  40. package/build/src/host/oidc_rp_guard.d.ts +3 -1
  41. package/build/src/host/oidc_rp_guard.js +6 -2
  42. package/build/src/host/org_access_revocation.d.ts +20 -0
  43. package/build/src/host/org_access_revocation.js +41 -0
  44. package/build/src/host/ui-dist/assets/{index-Dct63ai-.js → index-CIqtoNcO.js} +1 -1
  45. package/build/src/host/ui-dist/index.html +1 -1
  46. package/build/src/provider/build_provider.js +42 -17
  47. package/build/src/provider/oidc_service.d.ts +8 -0
  48. package/build/src/provider/oidc_service.js +23 -4
  49. package/package.json +3 -3
@@ -1,4 +1,5 @@
1
1
  import vine from '@vinejs/vine';
2
+ import { APPLICATION_TYPES, redirectUriProblem } from './client_metadata.js';
2
3
  import { normalizeEmailIdentifier } from './email_identifier.js';
3
4
  /**
4
5
  * Validators VineJS dos recursos administrativos (Admin REST API + console admin).
@@ -42,8 +43,25 @@ const authMethod = vine.enum(['client_secret_basic', 'client_secret_post', 'none
42
43
  * (`require_tld:false`) p/ não rejeitar `localhost` ou hostnames internos legítimos.
43
44
  */
44
45
  const URL_OPTS = { require_protocol: true, require_tld: false, protocols: ['http', 'https'] };
45
- /** Array de URIs ABSOLUTAS http/https. Rejeita strings que não são URL válida. */
46
- const urlArray = vine.array(vine.string().trim().url(URL_OPTS));
46
+ /**
47
+ * Regra de redirect URI ciente do `applicationType` do MESMO payload (RFC 8252):
48
+ * client web continua restrito a http/https (L10); client nativo aceita esquema
49
+ * privado (`com.example.app:/cb`), https claimed e loopback http. Sem o tipo no
50
+ * payload (PATCH parcial) só as regras universais valem aqui — URI absoluta, sem
51
+ * fragmento, sem `javascript:`/`data:`/… — e o `AdminClientsService` confere o
52
+ * resto contra o tipo EFETIVO do client (ver `client_metadata.ts`).
53
+ */
54
+ const redirectUriRule = vine.createRule((value, _options, field) => {
55
+ if (typeof value !== 'string')
56
+ return;
57
+ const declared = field.data?.applicationType;
58
+ const type = declared === 'web' || declared === 'native' ? declared : undefined;
59
+ const problem = redirectUriProblem(value, type);
60
+ if (problem)
61
+ field.report(`The {{ field }} field ${problem}`, 'redirectUri', field);
62
+ });
63
+ /** Array de redirect URIs (ver {@link redirectUriRule}). Rejeita strings que não são URI válida. */
64
+ const redirectUriArray = vine.array(vine.string().trim().use(redirectUriRule()));
47
65
  /**
48
66
  * Allowlist de grant_types aceitos no registro/edição de client (M10). Bloqueia
49
67
  * `implicit` (tokens no fragment da URL — sem PKCE/refresh) e qualquer valor fora
@@ -70,10 +88,12 @@ const grantTypeArray = vine.array(vine.enum(ALLOWED_GRANT_TYPES));
70
88
  */
71
89
  export const clientInputValidator = vine.compile(vine.object({
72
90
  clientId: vine.string().trim().optional(),
73
- // redirect/postLogout: URIs ABSOLUTAS (L10). `vine.string().url()` rejeita
74
- // valores que não são URL (ex.: 'javascript:...', paths relativos).
75
- redirectUris: urlArray.optional(),
76
- postLogoutRedirectUris: urlArray.optional(),
91
+ // `web` (default) ou `native` (RFC 8252 — app mobile/desktop, sempre público).
92
+ applicationType: vine.enum(APPLICATION_TYPES).optional(),
93
+ // redirect/postLogout: URIs ABSOLUTAS (L10), http/https para client web;
94
+ // esquema privado/loopback só para `native` (ver `redirectUriRule`).
95
+ redirectUris: redirectUriArray.optional(),
96
+ postLogoutRedirectUris: redirectUriArray.optional(),
77
97
  // grant_types restrito à allowlist; `implicit` e desconhecidos viram 422 (M10).
78
98
  grantTypes: grantTypeArray.optional(),
79
99
  grants: grantTypeArray.optional(),
@@ -82,14 +102,20 @@ export const clientInputValidator = vine.compile(vine.object({
82
102
  backchannelLogoutUri: vine.string().trim().url(URL_OPTS).optional(),
83
103
  backchannelLogoutSessionRequired: vine.boolean().optional(),
84
104
  }));
85
- /** Mapeia o input validado para um {@link ClientInput} COMPLETO (create), com defaults. */
105
+ /**
106
+ * Mapeia o input validado para um {@link ClientInput} COMPLETO (create), com defaults.
107
+ * Client nativo sem `tokenEndpointAuthMethod` explícito vira público (`none`); um
108
+ * nativo confidencial explícito é recusado pelo service (422).
109
+ */
86
110
  export function clientCreateInput(v) {
111
+ const applicationType = v.applicationType ?? 'web';
87
112
  return {
88
113
  clientId: v.clientId?.trim() || undefined,
114
+ applicationType,
89
115
  redirectUris: v.redirectUris ?? [],
90
116
  postLogoutRedirectUris: v.postLogoutRedirectUris ?? [],
91
117
  grantTypes: v.grantTypes ?? v.grants ?? [],
92
- tokenEndpointAuthMethod: v.tokenEndpointAuthMethod ?? 'client_secret_basic',
118
+ tokenEndpointAuthMethod: v.tokenEndpointAuthMethod ?? (applicationType === 'native' ? 'none' : 'client_secret_basic'),
93
119
  backchannelLogoutUri: v.backchannelLogoutUri || undefined,
94
120
  backchannelLogoutSessionRequired: v.backchannelLogoutSessionRequired,
95
121
  };
@@ -100,6 +126,8 @@ export function clientCreateInput(v) {
100
126
  */
101
127
  export function clientPartialInput(v) {
102
128
  const out = {};
129
+ if (v.applicationType !== undefined)
130
+ out.applicationType = v.applicationType;
103
131
  if (v.redirectUris !== undefined)
104
132
  out.redirectUris = v.redirectUris;
105
133
  if (v.postLogoutRedirectUris !== undefined)
@@ -0,0 +1,9 @@
1
+ /** Registra a conta autenticada via bearer nesta request. */
2
+ export declare function setBearerAccountId(ctx: object, accountId: string): void;
3
+ /** Esquece a conta bearer desta request (falha de autenticação). */
4
+ export declare function clearBearerAccountId(ctx: object): void;
5
+ /**
6
+ * Id da conta autenticada via bearer nesta request, ou `null` quando nenhum
7
+ * `oidcBearerGuard` autenticou (ainda) a request.
8
+ */
9
+ export declare function bearerAccountId(ctx: object): string | null;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Conta autenticada por `Authorization: Bearer` NESTA request — gravada pelo
3
+ * `oidcBearerGuard` quando ele autentica e lida pelo `getAccountId` como
4
+ * fallback da sessão.
5
+ *
6
+ * Um `WeakMap` keyed pelo próprio `HttpContext` (e não uma prop em `ctx`): nada
7
+ * de augmentation de `HttpContext` vazando pelo barrel (ver o porquê em
8
+ * `account_session_key.ts`), e o valor morre junto com a request.
9
+ *
10
+ * Mantenha este arquivo sem imports: `console_session.ts` (reexportado pelo
11
+ * barrel) depende dele.
12
+ */
13
+ const bearerAccounts = new WeakMap();
14
+ /** Registra a conta autenticada via bearer nesta request. */
15
+ export function setBearerAccountId(ctx, accountId) {
16
+ bearerAccounts.set(ctx, accountId);
17
+ }
18
+ /** Esquece a conta bearer desta request (falha de autenticação). */
19
+ export function clearBearerAccountId(ctx) {
20
+ bearerAccounts.delete(ctx);
21
+ }
22
+ /**
23
+ * Id da conta autenticada via bearer nesta request, ou `null` quando nenhum
24
+ * `oidcBearerGuard` autenticou (ainda) a request.
25
+ */
26
+ export function bearerAccountId(ctx) {
27
+ return bearerAccounts.get(ctx) ?? null;
28
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Regras de metadata de client que dependem do TIPO de aplicação (OIDC
3
+ * Registration §2, `application_type`) — compartilhadas pelo
4
+ * {@link AdminClientsService} (console admin, Admin REST API, CLI, import) e pelo
5
+ * validator Vine de `admin_validators.ts`.
6
+ *
7
+ * Espelham as regras que o próprio oidc-provider aplica ao montar o `Client`
8
+ * (`helpers/client_schema.js#redirectUris`). Checamos ANTES de persistir porque o
9
+ * provider só valida no `Client.find` — um client inválido gravado no adapter
10
+ * vira "client não encontrado" em runtime, sem mensagem útil para quem o criou.
11
+ *
12
+ * `native` segue a RFC 8252 (OAuth 2.0 for Native Apps):
13
+ * - esquema privado (`com.example.app:/callback`, `myapp://auth`) — §7.1;
14
+ * - https "claimed" (universal links / app links), nunca em loopback — §7.2;
15
+ * - loopback `http://127.0.0.1:<qualquer porta>/…` (e `localhost`/`[::1]`) — §7.3.
16
+ * O provider ignora a porta do loopback na comparação do redirect (§7.3), então
17
+ * basta registrar `http://127.0.0.1/callback`.
18
+ * - client PÚBLICO: um segredo embarcado num app instalado não é segredo (§8.5).
19
+ * PKCE (S256) já é obrigatório para todo client neste issuer.
20
+ */
21
+ /** Tipo de aplicação do client. `web` é o default histórico. */
22
+ export type ApplicationType = 'web' | 'native';
23
+ export declare const APPLICATION_TYPES: readonly ['web', 'native'];
24
+ /**
25
+ * Metadata inválida para o tipo de client (ex.: client nativo com secret, redirect
26
+ * `myapp://` num client web). Os controllers traduzem em 422.
27
+ */
28
+ export declare class ClientMetadataError extends Error {
29
+ constructor(message: string);
30
+ }
31
+ /**
32
+ * Problema de UMA redirect URI para o tipo de client, ou `null` quando ok.
33
+ *
34
+ * `applicationType` ausente = "ainda não sei" (PATCH que não mandou o tipo): só as
35
+ * regras universais (URI absoluta, sem fragmento, esquema não proibido). A regra
36
+ * por tipo roda depois, no service, com o tipo efetivo do client.
37
+ */
38
+ export declare function redirectUriProblem(uri: string, applicationType?: ApplicationType): string | null;
39
+ /** Entrada mínima para {@link assertClientMetadata}. */
40
+ export interface ClientMetadataInput {
41
+ applicationType: ApplicationType;
42
+ tokenEndpointAuthMethod: string;
43
+ grantTypes: string[];
44
+ redirectUris: string[];
45
+ postLogoutRedirectUris: string[];
46
+ }
47
+ /**
48
+ * Valida a combinação tipo × autenticação × grants × redirects. Lança
49
+ * {@link ClientMetadataError} na primeira violação.
50
+ */
51
+ export declare function assertClientMetadata(input: ClientMetadataInput): void;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Regras de metadata de client que dependem do TIPO de aplicação (OIDC
3
+ * Registration §2, `application_type`) — compartilhadas pelo
4
+ * {@link AdminClientsService} (console admin, Admin REST API, CLI, import) e pelo
5
+ * validator Vine de `admin_validators.ts`.
6
+ *
7
+ * Espelham as regras que o próprio oidc-provider aplica ao montar o `Client`
8
+ * (`helpers/client_schema.js#redirectUris`). Checamos ANTES de persistir porque o
9
+ * provider só valida no `Client.find` — um client inválido gravado no adapter
10
+ * vira "client não encontrado" em runtime, sem mensagem útil para quem o criou.
11
+ *
12
+ * `native` segue a RFC 8252 (OAuth 2.0 for Native Apps):
13
+ * - esquema privado (`com.example.app:/callback`, `myapp://auth`) — §7.1;
14
+ * - https "claimed" (universal links / app links), nunca em loopback — §7.2;
15
+ * - loopback `http://127.0.0.1:<qualquer porta>/…` (e `localhost`/`[::1]`) — §7.3.
16
+ * O provider ignora a porta do loopback na comparação do redirect (§7.3), então
17
+ * basta registrar `http://127.0.0.1/callback`.
18
+ * - client PÚBLICO: um segredo embarcado num app instalado não é segredo (§8.5).
19
+ * PKCE (S256) já é obrigatório para todo client neste issuer.
20
+ */
21
+ export const APPLICATION_TYPES = ['web', 'native'];
22
+ /** Hosts loopback reconhecidos (mesma lista do oidc-provider). */
23
+ const LOOPBACKS = new Set(['localhost', '127.0.0.1', '[::1]']);
24
+ /** Esquemas NUNCA aceitos como redirect, qualquer que seja o tipo de client. */
25
+ const FORBIDDEN_SCHEMES = new Set([
26
+ 'javascript:',
27
+ 'vbscript:',
28
+ 'data:',
29
+ 'blob:',
30
+ 'file:',
31
+ 'about:',
32
+ ]);
33
+ /**
34
+ * Metadata inválida para o tipo de client (ex.: client nativo com secret, redirect
35
+ * `myapp://` num client web). Os controllers traduzem em 422.
36
+ */
37
+ export class ClientMetadataError extends Error {
38
+ constructor(message) {
39
+ super(message);
40
+ this.name = 'ClientMetadataError';
41
+ }
42
+ }
43
+ /**
44
+ * Problema de UMA redirect URI para o tipo de client, ou `null` quando ok.
45
+ *
46
+ * `applicationType` ausente = "ainda não sei" (PATCH que não mandou o tipo): só as
47
+ * regras universais (URI absoluta, sem fragmento, esquema não proibido). A regra
48
+ * por tipo roda depois, no service, com o tipo efetivo do client.
49
+ */
50
+ export function redirectUriProblem(uri, applicationType) {
51
+ const parsed = URL.parse(uri);
52
+ if (!parsed)
53
+ return 'deve ser uma URI absoluta';
54
+ if (uri.includes('#'))
55
+ return 'não pode conter fragmento (#)';
56
+ const { protocol, hostname } = parsed;
57
+ if (FORBIDDEN_SCHEMES.has(protocol))
58
+ return `não pode usar o esquema ${protocol.slice(0, -1)}`;
59
+ if (applicationType === 'web') {
60
+ if (protocol !== 'http:' && protocol !== 'https:') {
61
+ return 'client web só aceita URIs http/https (use applicationType "native" para apps mobile)';
62
+ }
63
+ }
64
+ else if (applicationType === 'native') {
65
+ if (protocol === 'http:' && !LOOPBACKS.has(hostname)) {
66
+ return 'client nativo com http só aceita loopback (127.0.0.1, [::1] ou localhost)';
67
+ }
68
+ if (protocol === 'https:' && LOOPBACKS.has(hostname)) {
69
+ return 'client nativo com https "claimed" não pode usar host loopback';
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+ /**
75
+ * Valida a combinação tipo × autenticação × grants × redirects. Lança
76
+ * {@link ClientMetadataError} na primeira violação.
77
+ */
78
+ export function assertClientMetadata(input) {
79
+ const type = input.applicationType;
80
+ if (type === 'native') {
81
+ if (input.tokenEndpointAuthMethod !== 'none') {
82
+ throw new ClientMetadataError('client nativo precisa ser público (tokenEndpointAuthMethod "none", sem secret) — RFC 8252 §8.5');
83
+ }
84
+ if (input.grantTypes.includes('client_credentials')) {
85
+ throw new ClientMetadataError('client nativo não pode usar client_credentials (não há secret para autenticar)');
86
+ }
87
+ }
88
+ for (const [label, uris] of [
89
+ ['redirectUris', input.redirectUris],
90
+ ['postLogoutRedirectUris', input.postLogoutRedirectUris],
91
+ ]) {
92
+ for (const uri of uris) {
93
+ const problem = redirectUriProblem(uri, type);
94
+ if (problem)
95
+ throw new ClientMetadataError(`${label}: "${uri}" ${problem}`);
96
+ }
97
+ }
98
+ }
@@ -9,8 +9,18 @@ import type { HttpContext } from '@adonisjs/core/http';
9
9
  * sessão mudar.
10
10
  */
11
11
  /**
12
- * Retorna o id da conta que o request está representando no console (ou `null`
13
- * quando não há sessão).
12
+ * Retorna o id da conta que o request está representando (ou `null` quando não
13
+ * há identidade).
14
+ *
15
+ * Fonte, nesta ordem:
16
+ * 1. a sessão do console (`ACCOUNT_SESSION_KEY`) — o comportamento de sempre;
17
+ * 2. SEM sessão logada: a conta que um `oidcBearerGuard` autenticou NESTA
18
+ * request via `Authorization: Bearer` (app nativo, SPA, serviço).
19
+ *
20
+ * O fallback (2) só existe depois que o guard bearer RODOU na request (ex.:
21
+ * `middleware.auth({ guards: ['web', 'api'] })` ou `auth.authenticateUsing`);
22
+ * um app que não usa o guard bearer continua exatamente como antes. A sessão
23
+ * sempre ganha: request com cookie de sessão E bearer responde pela sessão.
14
24
  *
15
25
  * ATENÇÃO: com impersonation ativa isto é a conta PERSONIFICADA, não o admin
16
26
  * que a personificou. É o comportamento certo para "como qual conta este
@@ -50,7 +60,8 @@ export declare function getAccountId(ctx: HttpContext): string | null;
50
60
  */
51
61
  export declare function realAccountId(ctx: HttpContext): string | null;
52
62
  /**
53
- * `true` quando o request carrega uma sessão de conta do console.
63
+ * `true` quando o request carrega uma sessão de conta do console. Só a SESSÃO:
64
+ * uma identidade bearer (`oidcBearerGuard`) não conta — use `getAccountId`.
54
65
  */
55
66
  export declare function hasAccountSession(ctx: HttpContext): boolean;
56
67
  /**
@@ -1,5 +1,6 @@
1
1
  import { getAccountLoginUrl } from './account_login_url.js';
2
2
  import { ACCOUNT_SESSION_KEY } from './account_session_key.js';
3
+ import { bearerAccountId } from './bearer_account.js';
3
4
  import { impersonationState } from './impersonation_session.js';
4
5
  /**
5
6
  * Helpers públicos para integrar a sessão do console do AuthKit com
@@ -11,8 +12,18 @@ import { impersonationState } from './impersonation_session.js';
11
12
  * sessão mudar.
12
13
  */
13
14
  /**
14
- * Retorna o id da conta que o request está representando no console (ou `null`
15
- * quando não há sessão).
15
+ * Retorna o id da conta que o request está representando (ou `null` quando não
16
+ * há identidade).
17
+ *
18
+ * Fonte, nesta ordem:
19
+ * 1. a sessão do console (`ACCOUNT_SESSION_KEY`) — o comportamento de sempre;
20
+ * 2. SEM sessão logada: a conta que um `oidcBearerGuard` autenticou NESTA
21
+ * request via `Authorization: Bearer` (app nativo, SPA, serviço).
22
+ *
23
+ * O fallback (2) só existe depois que o guard bearer RODOU na request (ex.:
24
+ * `middleware.auth({ guards: ['web', 'api'] })` ou `auth.authenticateUsing`);
25
+ * um app que não usa o guard bearer continua exatamente como antes. A sessão
26
+ * sempre ganha: request com cookie de sessão E bearer responde pela sessão.
16
27
  *
17
28
  * ATENÇÃO: com impersonation ativa isto é a conta PERSONIFICADA, não o admin
18
29
  * que a personificou. É o comportamento certo para "como qual conta este
@@ -21,7 +32,7 @@ import { impersonationState } from './impersonation_session.js';
21
32
  */
22
33
  export function getAccountId(ctx) {
23
34
  const accountId = ctx.session?.get(ACCOUNT_SESSION_KEY);
24
- return accountId ?? null;
35
+ return accountId ?? bearerAccountId(ctx);
25
36
  }
26
37
  /**
27
38
  * Retorna o id do HUMANO real por trás do request — o id que uma decisão de
@@ -54,18 +65,19 @@ export function getAccountId(ctx) {
54
65
  * if (!id || !(await authz.hasRole(id, 'admin'))) throw new Error('forbidden')
55
66
  */
56
67
  export function realAccountId(ctx) {
57
- // Sem sessão não há nem impersonation nem conta: mesma tolerância do
58
- // `getAccountId` (que usa `ctx.session?.`), pois `impersonationState` assume
59
- // uma sessão presente.
68
+ // Sem sessão não há impersonation: mesma tolerância do `getAccountId` (que usa
69
+ // `ctx.session?.`), pois `impersonationState` assume uma sessão presente. Resta
70
+ // a identidade bearer (se o `oidcBearerGuard` autenticou a request).
60
71
  if (!ctx.session)
61
- return null;
72
+ return bearerAccountId(ctx);
62
73
  return impersonationState(ctx).impersonatorId ?? getAccountId(ctx);
63
74
  }
64
75
  /**
65
- * `true` quando o request carrega uma sessão de conta do console.
76
+ * `true` quando o request carrega uma sessão de conta do console. Só a SESSÃO:
77
+ * uma identidade bearer (`oidcBearerGuard`) não conta — use `getAccountId`.
66
78
  */
67
79
  export function hasAccountSession(ctx) {
68
- return getAccountId(ctx) !== null;
80
+ return ctx.session?.get(ACCOUNT_SESSION_KEY) != null;
69
81
  }
70
82
  /**
71
83
  * URL do login do console, com `return_to` opcional de volta ao destino
@@ -6,6 +6,7 @@ import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
6
6
  import { ACTIVE_ORG_COOKIE, ACTIVE_ORG_COOKIE_TTL, encodeActiveOrgCookie, } from '../active_org_cookie.js';
7
7
  import { sendOrgInvitationEmail } from '../default_mailer.js';
8
8
  import { ensureConsoleSession } from '../idp_session_bridge.js';
9
+ import { revokeOrgAccess } from '../org_access_revocation.js';
9
10
  // Política efetiva compartilhada com o espelho JSON (`account_orgs_api_controller`):
10
11
  // ponto de verdade único, para as duas superfícies nunca divergirem.
11
12
  import { effectiveOrgPolicy, orgPolicyDefaults } from '../org_policy.js';
@@ -154,6 +155,7 @@ export default class AccountOrgsController {
154
155
  return response.forbidden();
155
156
  const result = await store.removeOrgMember(params.id, accountId);
156
157
  if (result.ok) {
158
+ await revokeOrgAccess(service, params.id, accountId);
157
159
  await cfg.audit?.record({
158
160
  type: 'organization.member_removed',
159
161
  accountId,
@@ -314,6 +316,7 @@ export default class AccountOrgsController {
314
316
  }
315
317
  const result = await store.removeOrgMember(params.id, params.accountId);
316
318
  if (result.ok) {
319
+ await revokeOrgAccess(service, params.id, params.accountId);
317
320
  await cfg.audit?.record({
318
321
  type: 'organization.member_removed',
319
322
  actorId,
@@ -41,6 +41,22 @@ export default class AuthInteractionController {
41
41
  * Retorna `undefined` quando não há step-up — completeLogin usa o default.
42
42
  */
43
43
  private stepUpExtra;
44
+ /**
45
+ * acr/amr de um login que PASSOU pelo desafio do 2º fator: `amr` =
46
+ * `[primário, 'mfa', método]` (RFC 8176), SEMPRE — não só no step-up. Antes o
47
+ * `completeLogin` só recebia amr no step-up; fora dele o id_token saía sem `amr`
48
+ * nenhum, e o fator primário (senha ou e-mail) se perdia. O step-up continua
49
+ * carimbando o `acr`.
50
+ *
51
+ * `primary` ausente (sessão gravada antes desta versão, ou valor adulterado):
52
+ * o amr sai sem o primário, em vez de inventar um.
53
+ */
54
+ private mfaCompletion;
55
+ /**
56
+ * Lê E esquece o desafio pendente do 2º fator (accountId + fator primário). Os
57
+ * dois saem juntos: um primário sobrando na sessão não pode colar no próximo login.
58
+ */
59
+ private takeMfaPending;
44
60
  /**
45
61
  * Gate do segundo fator, compartilhado pelos caminhos de login: senha, link
46
62
  * mágico, código por e-mail e troca forçada de senha. Antes disto só o login
@@ -60,7 +76,8 @@ export default class AuthInteractionController {
60
76
  * instrução de enrolar (não há o que desafiar);
61
77
  * - fator presente, trusted-device válido e sem step-up → `trusted`; quem
62
78
  * chamou decide como finalizar, porque o amr muda conforme o caminho;
63
- * - caso geral → `challenge`, com o accountId em `MFA_PENDING_KEY`.
79
+ * - caso geral → `challenge`, com o accountId em `MFA_PENDING_KEY` e o fator
80
+ * primário (`primary`) em `MFA_PRIMARY_KEY`.
64
81
  */
65
82
  private secondFactorGate;
66
83
  /**
@@ -45,6 +45,12 @@ function accountStatusErrorKey(reason) {
45
45
  const SESSION_KEY = 'authkit_login_email';
46
46
  /** accountId aguardando o 2º fator depois da senha verificada. */
47
47
  const MFA_PENDING_KEY = 'authkit_mfa_pending';
48
+ /**
49
+ * Fator PRIMÁRIO que levou ao desafio do 2º fator (`pwd` ou `email`), guardado JUNTO
50
+ * do {@link MFA_PENDING_KEY}. Sem ele, quem completa o 2º fator não sabe como o login
51
+ * começou e o `amr` do id_token perdia o primeiro fator (ver `mfaCompletion`).
52
+ */
53
+ const MFA_PRIMARY_KEY = 'authkit_mfa_primary';
48
54
  /** Desafio WebAuthn pendente (autenticação) guardado entre begin/finish no login. */
49
55
  const PASSKEY_AUTH_CHALLENGE_KEY = 'authkit_passkey_auth_challenge';
50
56
  /**
@@ -408,7 +414,7 @@ export default class AuthInteractionController {
408
414
  // Gate do 2º fator — a MESMA regra dos três caminhos de login (ver
409
415
  // `secondFactorGate`). Quando desafia, não finaliza a interaction: guarda o
410
416
  // accountId pendente e devolve a tela.
411
- const gate = await this.secondFactorGate(ctx, cfg, acc.id, details);
417
+ const gate = await this.secondFactorGate(ctx, cfg, acc.id, details, 'pwd');
412
418
  if (gate.kind === 'challenge')
413
419
  return gate.response;
414
420
  if (gate.kind === 'trusted') {
@@ -556,7 +562,7 @@ export default class AuthInteractionController {
556
562
  // Sucesso no 2º fator: opcionalmente confia neste dispositivo (checkbox).
557
563
  await this.maybeTrustDevice(ctx, cfg, accountId);
558
564
  // Finaliza a interaction para o accountId pendente.
559
- ctx.session.forget(MFA_PENDING_KEY);
565
+ const { primary } = this.takeMfaPending(ctx);
560
566
  forgetLoginEmail(ctx);
561
567
  await notifyLoginSuccess(ctx, cfg, {
562
568
  accountId,
@@ -564,9 +570,9 @@ export default class AuthInteractionController {
564
570
  clientId,
565
571
  metadata: { mfa: usedRecovery ? 'recovery' : 'totp' },
566
572
  });
567
- // Step-up: um 2º fator foi de fato verificado — carimba acr/amr no id_token
568
- // se o client solicitou o mfaAcr nesta requisição.
569
- await service.interactions.completeLogin(ctx, accountId, this.stepUpExtra(cfg, details, usedRecovery ? 'recovery' : 'totp'));
573
+ // Um 2º fator foi de fato verificado: amr = [primário, 'mfa', método]. Com
574
+ // step-up (mfaAcr solicitado nesta requisição), carimba também o acr.
575
+ await service.interactions.completeLogin(ctx, accountId, this.mfaCompletion(cfg, details, primary, usedRecovery ? 'recovery' : 'totp'));
570
576
  }
571
577
  /**
572
578
  * true se o authorize request exige MFA via acr_values (contém o mfaAcr da
@@ -591,6 +597,31 @@ export default class AuthInteractionController {
591
597
  return undefined;
592
598
  return { acr: cfg.stepUp.mfaAcr, amr: ['mfa', method] };
593
599
  }
600
+ /**
601
+ * acr/amr de um login que PASSOU pelo desafio do 2º fator: `amr` =
602
+ * `[primário, 'mfa', método]` (RFC 8176), SEMPRE — não só no step-up. Antes o
603
+ * `completeLogin` só recebia amr no step-up; fora dele o id_token saía sem `amr`
604
+ * nenhum, e o fator primário (senha ou e-mail) se perdia. O step-up continua
605
+ * carimbando o `acr`.
606
+ *
607
+ * `primary` ausente (sessão gravada antes desta versão, ou valor adulterado):
608
+ * o amr sai sem o primário, em vez de inventar um.
609
+ */
610
+ mfaCompletion(cfg, details, primary, method) {
611
+ const amr = [...(primary ? [primary] : []), 'mfa', method];
612
+ const stepUp = this.stepUpExtra(cfg, details, method);
613
+ return stepUp ? { acr: stepUp.acr, amr } : { amr };
614
+ }
615
+ /**
616
+ * Lê E esquece o desafio pendente do 2º fator (accountId + fator primário). Os
617
+ * dois saem juntos: um primário sobrando na sessão não pode colar no próximo login.
618
+ */
619
+ takeMfaPending(ctx) {
620
+ const raw = ctx.session.get(MFA_PRIMARY_KEY);
621
+ ctx.session.forget(MFA_PENDING_KEY);
622
+ ctx.session.forget(MFA_PRIMARY_KEY);
623
+ return { primary: raw === 'pwd' || raw === 'email' ? raw : undefined };
624
+ }
594
625
  /**
595
626
  * Gate do segundo fator, compartilhado pelos caminhos de login: senha, link
596
627
  * mágico, código por e-mail e troca forçada de senha. Antes disto só o login
@@ -610,9 +641,10 @@ export default class AuthInteractionController {
610
641
  * instrução de enrolar (não há o que desafiar);
611
642
  * - fator presente, trusted-device válido e sem step-up → `trusted`; quem
612
643
  * chamou decide como finalizar, porque o amr muda conforme o caminho;
613
- * - caso geral → `challenge`, com o accountId em `MFA_PENDING_KEY`.
644
+ * - caso geral → `challenge`, com o accountId em `MFA_PENDING_KEY` e o fator
645
+ * primário (`primary`) em `MFA_PRIMARY_KEY`.
614
646
  */
615
- async secondFactorGate(ctx, cfg, accountId, details) {
647
+ async secondFactorGate(ctx, cfg, accountId, details, primary) {
616
648
  const mfaRequired = this.acrRequiresMfa(cfg, details);
617
649
  const mfa = (await cfg.accountStore.getMfaState?.(accountId)) ?? { enabled: false };
618
650
  // Passkey disponível como alternativa ao TOTP se o store suporta E a conta
@@ -647,6 +679,9 @@ export default class AuthInteractionController {
647
679
  return { kind: 'trusted' };
648
680
  }
649
681
  ctx.session.put(MFA_PENDING_KEY, accountId);
682
+ // O fator primário viaja junto: quem completa o desafio (TOTP, recovery ou
683
+ // passkey) precisa dele para montar o `amr` inteiro.
684
+ ctx.session.put(MFA_PRIMARY_KEY, primary);
650
685
  return {
651
686
  kind: 'challenge',
652
687
  response: await render(ctx, 'mfa-challenge', {
@@ -971,7 +1006,7 @@ export default class AuthInteractionController {
971
1006
  });
972
1007
  }
973
1008
  // Conta com segundo fator não termina o login só com o e-mail.
974
- const magicGate = await this.secondFactorGate(ctx, cfg, acc.id, await service.interactions.details(ctx));
1009
+ const magicGate = await this.secondFactorGate(ctx, cfg, acc.id, await service.interactions.details(ctx), 'email');
975
1010
  if (magicGate.kind === 'challenge')
976
1011
  return magicGate.response;
977
1012
  await notifyLoginSuccess(ctx, cfg, {
@@ -1093,7 +1128,7 @@ export default class AuthInteractionController {
1093
1128
  clientId,
1094
1129
  });
1095
1130
  // Conta com segundo fator não termina o login só com o código do e-mail.
1096
- const otpGate = await this.secondFactorGate(ctx, cfg, result.account.id, await service.interactions.details(ctx));
1131
+ const otpGate = await this.secondFactorGate(ctx, cfg, result.account.id, await service.interactions.details(ctx), 'email');
1097
1132
  if (otpGate.kind === 'challenge')
1098
1133
  return otpGate.response;
1099
1134
  await notifyLoginSuccess(ctx, cfg, {
@@ -1350,7 +1385,10 @@ export default class AuthInteractionController {
1350
1385
  }
1351
1386
  // Passkey OK: opcionalmente confia neste dispositivo (checkbox no challenge).
1352
1387
  await this.maybeTrustDevice(ctx, cfg, accountId);
1353
- ctx.session.forget(MFA_PENDING_KEY);
1388
+ // Passkey como 2º fator (havia desafio pendente) ou passkey-first (não havia)?
1389
+ // Lido ANTES de esquecer: é o que decide o amr abaixo.
1390
+ const wasSecondFactor = ctx.session.get(MFA_PENDING_KEY) === accountId;
1391
+ const { primary } = this.takeMfaPending(ctx);
1354
1392
  forgetLoginEmail(ctx);
1355
1393
  await notifyLoginSuccess(ctx, cfg, {
1356
1394
  accountId,
@@ -1358,9 +1396,12 @@ export default class AuthInteractionController {
1358
1396
  clientId,
1359
1397
  metadata: { mfa: 'webauthn' },
1360
1398
  });
1361
- // Step-up carimba acr/amr quando solicitado; senão a passkey conta como o fator
1362
- // forte do login (amr `['webauthn']`) — vale tanto p/ MFA quanto passkey-first.
1363
- await service.interactions.completeLogin(ctx, accountId, this.stepUpExtra(cfg, details, 'webauthn') ?? { amr: ['webauthn'] });
1399
+ // 2º fator: amr = [primário, 'mfa', 'webauthn'] (+ acr no step-up), igual ao
1400
+ // TOTP. Passkey-first: a passkey É o login (amr `['webauthn']`); o step-up
1401
+ // carimba acr/amr quando solicitado.
1402
+ await service.interactions.completeLogin(ctx, accountId, wasSecondFactor
1403
+ ? this.mfaCompletion(cfg, details, primary, 'webauthn')
1404
+ : (this.stepUpExtra(cfg, details, 'webauthn') ?? { amr: ['webauthn'] }));
1364
1405
  }
1365
1406
  /**
1366
1407
  * GET /auth/interaction/:uid/switch
@@ -1441,7 +1482,7 @@ export default class AuthInteractionController {
1441
1482
  // Senha trocada: limpa o step e finaliza o login.
1442
1483
  ctx.session.forget(PASSWORD_EXPIRED_KEY);
1443
1484
  // Verifica se precisa de MFA mesmo após a troca — mesma regra do resto.
1444
- const expiredGate = await this.secondFactorGate(ctx, cfg, accountId, details);
1485
+ const expiredGate = await this.secondFactorGate(ctx, cfg, accountId, details, 'pwd');
1445
1486
  if (expiredGate.kind === 'challenge')
1446
1487
  return expiredGate.response;
1447
1488
  await service.interactions.completeLogin(ctx, accountId);