@adonis-agora/authkit-server 0.57.1 → 0.58.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/build/index.d.ts +2 -2
  2. package/build/index.js +1 -1
  3. package/build/providers/authkit_server_provider.d.ts +1 -12
  4. package/build/providers/authkit_server_provider.js +13 -0
  5. package/build/src/audit/audit_sink.d.ts +11 -2
  6. package/build/src/audit/audit_sink.js +104 -1
  7. package/build/src/define_config.d.ts +36 -1
  8. package/build/src/define_config.js +3 -0
  9. package/build/src/doctor/checks.d.ts +10 -0
  10. package/build/src/doctor/checks.js +38 -0
  11. package/build/src/host/admin_api/admin_orgs_service.d.ts +9 -2
  12. package/build/src/host/admin_api/admin_orgs_service.js +29 -16
  13. package/build/src/host/admin_api/api_orgs_controller.js +1 -1
  14. package/build/src/host/admin_api/dto.d.ts +1 -1
  15. package/build/src/host/admin_console/console_impersonation_controller.js +37 -3
  16. package/build/src/host/admin_console/console_orgs_controller.js +1 -1
  17. package/build/src/host/branding.d.ts +19 -0
  18. package/build/src/host/branding.js +25 -0
  19. package/build/src/host/controllers/account_orgs_controller.js +21 -14
  20. package/build/src/host/default_mailer.d.ts +21 -0
  21. package/build/src/host/default_mailer.js +41 -0
  22. package/build/src/host/i18n.d.ts +10 -0
  23. package/build/src/host/i18n.js +15 -2
  24. package/build/src/host/impersonation.d.ts +23 -2
  25. package/build/src/host/impersonation.js +21 -5
  26. package/build/src/host/ui-dist/assets/{index-pAbBjdHC.js → index-6cE5JMyP.js} +1 -1
  27. package/build/src/host/ui-dist/index.html +1 -1
  28. package/build/src/observability/telescope/data_providers.js +4 -1
  29. package/build/src/provider/oidc_service.js +18 -6
  30. package/build/types.d.ts +21 -1
  31. package/package.json +6 -6
package/build/index.d.ts CHANGED
@@ -49,7 +49,7 @@ export type { AuthkitScreen } from './src/host/renderers/inertia_renderer.js';
49
49
  export type { InertiaRendererOptions } from './src/host/renderers/inertia_renderer.js';
50
50
  export type { AccountLoginProps, AccountSecurityProps, AccountMfaProps, AccountConfirmProps, AccountConfirmMethod, AccountEmailConfirmedProps, } from './src/host/account_screen_props.js';
51
51
  export { edgeRenderer } from './src/host/renderers/edge_renderer.js';
52
- export { brandFor, isFirstParty } from './src/host/branding.js';
52
+ export { brandFor, isFirstParty, isFirstPartyClient } from './src/host/branding.js';
53
53
  export type { BrandingConfig, ClientBrand } from './src/host/branding.js';
54
54
  export { resolveMessages, translate, DEFAULT_MESSAGES, PT_BR_MESSAGES, BUILTIN_MESSAGES, DEFAULT_LOCALE, } from './src/host/i18n.js';
55
55
  export type { I18nConfig, AuthMessages } from './src/host/i18n.js';
@@ -105,7 +105,7 @@ export { computeAdminStats } from './src/host/admin_stats_service.js';
105
105
  export type { AdminStats, DailyPoint } from './src/host/admin_stats_service.js';
106
106
  export { barChartSvg } from './src/host/svg_chart.js';
107
107
  export { buildImpersonationPanel } from './src/host/impersonation.js';
108
- export type { ImpersonationPanel } from './src/host/impersonation.js';
108
+ export type { ImpersonationPanel, ImpersonationClientLike } from './src/host/impersonation.js';
109
109
  export { ACTIVE_ORG_COOKIE, ACTIVE_ORG_COOKIE_TTL, encodeActiveOrgCookie, decodeActiveOrgCookie, readActiveOrgFromKoaCtx, } from './src/host/active_org_cookie.js';
110
110
  export { resolveOrganizations } from './src/define_config.js';
111
111
  export type { OrganizationsConfigInput, ResolvedOrganizationsConfig, } from './src/define_config.js';
package/build/index.js CHANGED
@@ -30,7 +30,7 @@ export { withAuditLog } from './src/mixins/with_audit_log.js';
30
30
  export { composeAuditSink, resolveEvents, buildWebhookBody, signWebhookBody, } from './src/events/dispatcher.js';
31
31
  export { inertiaRenderer } from './src/host/renderers/inertia_renderer.js';
32
32
  export { edgeRenderer } from './src/host/renderers/edge_renderer.js';
33
- export { brandFor, isFirstParty } from './src/host/branding.js';
33
+ export { brandFor, isFirstParty, isFirstPartyClient } from './src/host/branding.js';
34
34
  export { resolveMessages, translate, DEFAULT_MESSAGES, PT_BR_MESSAGES, BUILTIN_MESSAGES, DEFAULT_LOCALE, } from './src/host/i18n.js';
35
35
  export { registerAuthHost } from './src/host/register_auth_host.js';
36
36
  export { POLICY_ROUTE_OPTIONS } from './src/host/config_locks.js';
@@ -1,16 +1,5 @@
1
- import type { MetricsRecorder } from '@adonis-agora/authkit-core';
1
+ import '../types.js';
2
2
  import type { ApplicationService } from '@adonisjs/core/types';
3
- import type { AccountStore } from '../src/accounts/account_store.js';
4
- import type { PatStore } from '../src/pat/pat_store.js';
5
- import { OidcService } from '../src/provider/oidc_service.js';
6
- declare module '@adonisjs/core/types' {
7
- interface ContainerBindings {
8
- 'authkit.server': OidcService;
9
- 'authkit.metrics': MetricsRecorder;
10
- 'authkit.accountStore': AccountStore;
11
- 'authkit.patStore': PatStore;
12
- }
13
- }
14
3
  export default class AuthkitServerProvider {
15
4
  #private;
16
5
  protected app: ApplicationService;
@@ -1,3 +1,16 @@
1
+ // Side-effect import, and it is load-bearing: the `ContainerBindings`
2
+ // augmentation for this package's four bindings lives in the root `types.ts`,
3
+ // and this is the module that carries it into a host app's declaration graph —
4
+ // every host registers this provider in `adonisrc.ts`.
5
+ //
6
+ // The declaration used to be duplicated here as well. One copy typed the hosts
7
+ // and the other backed the `./types` subpath, and the two had drifted: this one
8
+ // declared four bindings, `types.ts` declared one. There is a single table now.
9
+ //
10
+ // It must be a bare side-effect import — the `import type {} from` form is erased
11
+ // from the emitted `.d.ts` and would drop the augmentation. `build/types.js`
12
+ // compiles down to `export {}`, so the runtime cost is nil.
13
+ import '../types.js';
1
14
  import { existsSync } from 'node:fs';
2
15
  import { fileURLToPath } from 'node:url';
3
16
  import { configProvider } from '@adonisjs/core';
@@ -1,7 +1,16 @@
1
1
  /**
2
- * Tipos de eventos de auditoria relevantes para segurança emitidos pelo IdP.
2
+ * Tipos de eventos de auditoria emitidos pelo IdP, como VALOR em runtime.
3
+ *
4
+ * A união {@link AuditEventType} é DERIVADA desta lista, e não escrita à mão,
5
+ * porque uma união escrita à mão não tem como ser conferida contra o que o
6
+ * código de fato emite — foi assim que cinco eventos passaram a existir sem
7
+ * estar no tipo. Com a lista em runtime, `tests/audit/audit_event_drift.spec.ts`
8
+ * varre os `audit.record({ type: ... })` do `src/` e falha quando um evento
9
+ * emitido não está aqui.
3
10
  */
4
- export type AuditEventType = 'login.success' | 'login.failure' | 'signup' | 'password_reset.issued' | 'password_reset.consumed' | 'pat.issued' | 'pat.revoked' | 'pat.used' | 'impersonation' | 'impersonation.started' | 'mfa.enabled' | 'mfa.disabled' | 'account.locked' | 'passkey.registered' | 'passkey.removed' | 'email_verification.issued' | 'email_verification.consumed' | 'client.created' | 'client.updated' | 'client.deleted' | 'session.revoked_all' | 'password.changed' | 'password.rehashed' | 'email.change_requested' | 'email.changed' | 'login.new_ip_notified' | 'login.new_device' | 'login.otp_sent' | 'login.otp_verified' | 'login.otp_failed' | 'login.otp_invalidated' | 'bot_protection.rejected' | 'grant.revoked_by_user' | 'user.created' | 'user.password_reset_sent' | 'user.disabled' | 'user.enabled' | 'user.deleted' | 'profile.updated' | 'account.deleted' | 'account.exported' | 'keys.rotated' | 'organization.created' | 'organization.updated' | 'organization.deleted' | 'organization.member_added' | 'organization.member_removed' | 'organization.member_role_changed' | 'organization.member_role_updated' | 'organization.switched' | 'organization.deactivated' | 'organization.invitation_sent' | 'organization.invitation_accepted' | 'organization.invitation_revoked' | 'email_change.requested' | 'email_change.confirmed' | 'email_change.cancelled' | 'security_notice.sent' | 'settings.updated' | 'maintenance.enabled' | 'maintenance.disabled' | 'trusted_device.revoked' | 'password.expired_change_forced' | 'otp.locked' | 'otp.unlocked' | 'otp.unlock_failed' | 'sudo.confirmed' | 'session.single_enforced' | 'account.expired_login_blocked' | 'account.expiration_warned';
11
+ export declare const AUDIT_EVENT_TYPES: readonly ["login.success", "login.failure", "signup", "password_reset.issued", "password_reset.consumed", "pat.issued", "pat.revoked", "pat.used", "impersonation", "impersonation.panel_viewed", "mfa.enabled", "mfa.disabled", "account.locked", "passkey.registered", "passkey.removed", "email_verification.issued", "email_verification.consumed", "client.created", "client.updated", "client.deleted", "session.revoked_all", "password.changed", "password.rehashed", "email.change_requested", "email.changed", "login.new_ip_notified", "login.new_device", "login.otp_sent", "login.otp_verified", "login.otp_failed", "login.otp_invalidated", "bot_protection.rejected", "grant.revoked_by_user", "user.created", "user.password_reset_sent", "user.disabled", "user.enabled", "user.deleted", "profile.updated", "account.deleted", "account.exported", "keys.rotated", "organization.created", "organization.updated", "organization.deleted", "organization.member_added", "organization.member_removed", "organization.member_role_changed", "organization.member_role_updated", "organization.switched", "organization.deactivated", "organization.invitation_sent", "organization.invitation_accepted", "organization.invitation_revoked", "email_change.requested", "email_change.confirmed", "email_change.cancelled", "security_notice.sent", "settings.updated", "maintenance.enabled", "maintenance.disabled", "trusted_device.revoked", "password.expired_change_forced", "otp.locked", "otp.unlocked", "otp.unlock_failed", "sudo.confirmed", "session.single_enforced", "account.expired_login_blocked", "account.expiration_warned", "login.magic_link_sent", "session.revoked", "account.signed_out_all", "client.secret_regenerated", "roles_catalog.updated"];
12
+ /** Tipos de eventos de auditoria relevantes para segurança emitidos pelo IdP. */
13
+ export type AuditEventType = (typeof AUDIT_EVENT_TYPES)[number];
5
14
  /**
6
15
  * Evento de auditoria a registrar. O timestamp é definido pelo sink (não aqui).
7
16
  */
@@ -1 +1,104 @@
1
- export {};
1
+ /**
2
+ * Tipos de eventos de auditoria emitidos pelo IdP, como VALOR em runtime.
3
+ *
4
+ * A união {@link AuditEventType} é DERIVADA desta lista, e não escrita à mão,
5
+ * porque uma união escrita à mão não tem como ser conferida contra o que o
6
+ * código de fato emite — foi assim que cinco eventos passaram a existir sem
7
+ * estar no tipo. Com a lista em runtime, `tests/audit/audit_event_drift.spec.ts`
8
+ * varre os `audit.record({ type: ... })` do `src/` e falha quando um evento
9
+ * emitido não está aqui.
10
+ */
11
+ export const AUDIT_EVENT_TYPES = [
12
+ 'login.success',
13
+ 'login.failure',
14
+ 'signup',
15
+ 'password_reset.issued',
16
+ 'password_reset.consumed',
17
+ 'pat.issued',
18
+ 'pat.revoked',
19
+ 'pat.used',
20
+ // O exchange RFC 8693 de fato aconteceu: uma identidade foi assumida.
21
+ 'impersonation',
22
+ // O admin abriu o painel do console e revelou os parâmetros do exchange.
23
+ // NÃO é uma impersonação: nada foi assumido ainda, e o exchange pode nunca
24
+ // acontecer. Manter os dois separados é o que impede a trilha de auditoria de
25
+ // afirmar uma impersonação que só foi consultada.
26
+ 'impersonation.panel_viewed',
27
+ 'mfa.enabled',
28
+ 'mfa.disabled',
29
+ 'account.locked',
30
+ 'passkey.registered',
31
+ 'passkey.removed',
32
+ 'email_verification.issued',
33
+ 'email_verification.consumed',
34
+ 'client.created',
35
+ 'client.updated',
36
+ 'client.deleted',
37
+ 'session.revoked_all',
38
+ 'password.changed',
39
+ 'password.rehashed',
40
+ 'email.change_requested',
41
+ 'email.changed',
42
+ 'login.new_ip_notified',
43
+ 'login.new_device',
44
+ // Login por OTP (código digitável)
45
+ 'login.otp_sent',
46
+ 'login.otp_verified',
47
+ 'login.otp_failed',
48
+ 'login.otp_invalidated',
49
+ 'bot_protection.rejected',
50
+ 'grant.revoked_by_user',
51
+ 'user.created',
52
+ 'user.password_reset_sent',
53
+ 'user.disabled',
54
+ 'user.enabled',
55
+ 'user.deleted',
56
+ 'profile.updated',
57
+ 'account.deleted',
58
+ 'account.exported',
59
+ 'keys.rotated',
60
+ 'organization.created',
61
+ 'organization.updated',
62
+ 'organization.deleted',
63
+ 'organization.member_added',
64
+ 'organization.member_removed',
65
+ 'organization.member_role_changed',
66
+ 'organization.member_role_updated',
67
+ 'organization.switched',
68
+ 'organization.deactivated',
69
+ 'organization.invitation_sent',
70
+ 'organization.invitation_accepted',
71
+ 'organization.invitation_revoked',
72
+ // Email change (verified flow)
73
+ 'email_change.requested',
74
+ 'email_change.confirmed',
75
+ 'email_change.cancelled',
76
+ // Security notices
77
+ 'security_notice.sent',
78
+ // Settings
79
+ 'settings.updated',
80
+ 'maintenance.enabled',
81
+ 'maintenance.disabled',
82
+ 'trusted_device.revoked',
83
+ // Password hygiene
84
+ 'password.expired_change_forced',
85
+ // OTP lockout
86
+ 'otp.locked',
87
+ 'otp.unlocked',
88
+ 'otp.unlock_failed',
89
+ // Sudo mode
90
+ 'sudo.confirmed',
91
+ // Session
92
+ 'session.single_enforced',
93
+ // Account expiration
94
+ 'account.expired_login_blocked',
95
+ 'account.expiration_warned',
96
+ // Emitidos pelo host-kit e antes AUSENTES desta lista — o switch exaustivo
97
+ // de um consumidor os perdia silenciosamente. Ver `audit_event_drift.spec.ts`,
98
+ // que varre os call sites e falha se a divergência voltar.
99
+ 'login.magic_link_sent',
100
+ 'session.revoked',
101
+ 'account.signed_out_all',
102
+ 'client.secret_regenerated',
103
+ 'roles_catalog.updated',
104
+ ];
@@ -87,7 +87,14 @@ export interface MailHooks {
87
87
  userAgent?: string | null;
88
88
  timestamp: string;
89
89
  }) => Promise<void>;
90
- /** Disparado ao criar um convite de organização. */
90
+ /**
91
+ * Disparado ao criar um convite de organização.
92
+ *
93
+ * OPTIONAL, like every other mail hook here: without it the host-kit itself
94
+ * sends the invitation email through the default mailer (`@adonisjs/mail`),
95
+ * branded and translated. Best-effort — a delivery failure never breaks the
96
+ * invitation.
97
+ */
91
98
  onOrgInvitation?: (data: {
92
99
  email: string;
93
100
  invitationId: string;
@@ -789,6 +796,28 @@ export interface AuthServerConfigInput {
789
796
  interactionRecovery?: InteractionRecoveryConfigInput;
790
797
  /** Configuração de branding por cliente. */
791
798
  branding?: BrandingConfig;
799
+ /**
800
+ * Allowlist dos clients FIRST-PARTY: quem recebe as claims de AUTORIZAÇÃO
801
+ * (`<globalRolesClaim>`, `org_id`, `org_slug`, `org_role`) nos tokens.
802
+ *
803
+ * Ausente (default) → TODO client registrado é first-party. Isso NÃO é
804
+ * "qualquer um vê as roles": a claim está amarrada ao escopo `roles` (ver
805
+ * `claims` em `provider/build_provider.ts`), então o client precisa (a) ter
806
+ * sido registrado por um admin e (b) pedir `scope=roles`. A allowlist é uma
807
+ * terceira barreira, opcional, para quem hospeda clients de terceiros.
808
+ *
809
+ * Declarada → só os clients listados recebem; os demais nunca, nem pedindo
810
+ * `scope=roles`.
811
+ *
812
+ * Por que existe separado de `branding.firstParty`: aquele campo vive num
813
+ * bloco de TEMA. Quem não personalizava a aparência não declarava `branding`,
814
+ * e o gate lia isso como "ninguém é first-party" — todo RP recebia
815
+ * `globalRoles: []` enquanto o console admin seguia funcionando (ele lê roles
816
+ * da sessão, não do token). Uma decisão de autorização não pode depender de o
817
+ * host ter ou não escolhido uma cor. `branding.firstParty` continua sendo lido
818
+ * como fallback para quem já o declarou.
819
+ */
820
+ firstPartyClients?: string[];
792
821
  /** Internacionalização das telas. Default: pt-BR embutido (zero config). */
793
822
  i18n?: I18nConfig;
794
823
  /** Configuração de providers sociais. */
@@ -1038,6 +1067,12 @@ export interface ResolvedServerConfig {
1038
1067
  /** Recuperação de sessão de interaction perdida resolvida (default `{ mode: 'screen' }`). */
1039
1068
  interactionRecovery: ResolvedInteractionRecoveryConfig;
1040
1069
  branding?: BrandingConfig;
1070
+ /**
1071
+ * Allowlist resolvida de clients first-party, ou `undefined` quando o host não
1072
+ * declarou nenhuma — caso em que todo client registrado é first-party. Ver
1073
+ * {@link AuthServerConfigInput.firstPartyClients}.
1074
+ */
1075
+ firstPartyClients?: string[];
1041
1076
  social?: AuthSocialConfig;
1042
1077
  patIntrospectionSecret?: string;
1043
1078
  rateLimit: ResolvedRateLimitConfig;
@@ -360,6 +360,9 @@ export function defineConfig(config) {
360
360
  redirectTo: config.interactionRecovery?.redirectTo,
361
361
  },
362
362
  branding: config.branding,
363
+ // `firstPartyClients` manda; `branding.firstParty` é o fallback de
364
+ // back-compat. Nenhum dos dois declarado → `undefined` = sem allowlist.
365
+ firstPartyClients: config.firstPartyClients ?? config.branding?.firstParty,
363
366
  social: config.social,
364
367
  patIntrospectionSecret: config.patIntrospectionSecret,
365
368
  rateLimit: resolveRateLimit(config.rateLimit),
@@ -188,6 +188,16 @@ export declare function checkAccountExpiration(input: DoctorInput): Finding | nu
188
188
  * Informativo: se webauthn não estiver configurado, o autofill nunca será exibido.
189
189
  */
190
190
  export declare function checkPasskeyAutofill(input: DoctorInput): Finding | null;
191
+ /**
192
+ * Allowlist de clients first-party — quem recebe as claims de autorização
193
+ * (`<globalRolesClaim>`, `org_id`, `org_slug`, `org_role`).
194
+ *
195
+ * Informativa quando declarada. `warn` quando NÃO declarada, porque aí todo
196
+ * client registrado recebe as claims ao pedir `scope=roles` — o que é o default
197
+ * certo (o oposto apagava as roles de todo RP), mas um host que registra clients
198
+ * de TERCEIROS precisa saber que existe uma lista para fechar.
199
+ */
200
+ export declare function checkFirstPartyClients(input: DoctorInput): Finding | null;
191
201
  /**
192
202
  * Finding da idade da chave de assinatura managed. `ageDays === null` (sem
193
203
  * keystore em arquivo/cofre) → no-op `ok`. Acima de `maxAgeDays` → `warn`.
@@ -782,6 +782,41 @@ export function checkPasskeyAutofill(input) {
782
782
  'Requires browsers with PublicKeyCredential.isConditionalMediationAvailable() support.',
783
783
  };
784
784
  }
785
+ /**
786
+ * Allowlist de clients first-party — quem recebe as claims de autorização
787
+ * (`<globalRolesClaim>`, `org_id`, `org_slug`, `org_role`).
788
+ *
789
+ * Informativa quando declarada. `warn` quando NÃO declarada, porque aí todo
790
+ * client registrado recebe as claims ao pedir `scope=roles` — o que é o default
791
+ * certo (o oposto apagava as roles de todo RP), mas um host que registra clients
792
+ * de TERCEIROS precisa saber que existe uma lista para fechar.
793
+ */
794
+ export function checkFirstPartyClients(input) {
795
+ const cfg = input.authkitConfig;
796
+ if (!cfg)
797
+ return null;
798
+ const allowlist = cfg.firstPartyClients;
799
+ if (allowlist === undefined) {
800
+ return {
801
+ level: 'warn',
802
+ message: 'firstPartyClients is not declared: every registered client that requests `scope=roles` ' +
803
+ 'receives the roles and organization claims. That is the default on purpose — the claims ' +
804
+ 'are already gated by client registration and by the requested scope. Declare ' +
805
+ '`firstPartyClients: [...]` in config/authkit.ts to restrict them to your own apps.',
806
+ };
807
+ }
808
+ if (allowlist.length === 0) {
809
+ return {
810
+ level: 'ok',
811
+ message: 'firstPartyClients is an empty list: NO client receives the roles/organization claims. ' +
812
+ 'Remove the key entirely if you meant "no restriction".',
813
+ };
814
+ }
815
+ return {
816
+ level: 'ok',
817
+ message: `firstPartyClients: ${allowlist.length} client(s) receive the roles/organization claims (${allowlist.join(', ')}).`,
818
+ };
819
+ }
785
820
  /**
786
821
  * Finding da idade da chave de assinatura managed. `ageDays === null` (sem
787
822
  * keystore em arquivo/cofre) → no-op `ok`. Acima de `maxAgeDays` → `warn`.
@@ -877,6 +912,9 @@ export function runAllChecks(input) {
877
912
  const passkeyAutofill = checkPasskeyAutofill(input);
878
913
  if (passkeyAutofill)
879
914
  findings.push(passkeyAutofill);
915
+ const firstPartyClients = checkFirstPartyClients(input);
916
+ if (firstPartyClients)
917
+ findings.push(firstPartyClients);
880
918
  return findings;
881
919
  }
882
920
  /** Há algum finding de nível 'error'? (define o exit code). */
@@ -1,3 +1,4 @@
1
+ import type { HttpContext } from '@adonisjs/core/http';
1
2
  import type { OrgInvitation, OrgMember, OrgSummary } from '../../accounts/account_store.js';
2
3
  import type { ResolvedServerConfig } from '../../define_config.js';
3
4
  import type { SettingsCapability } from '../runtime_settings.js';
@@ -109,8 +110,14 @@ export declare class AdminOrgsService {
109
110
  ok: false;
110
111
  reason: 'member_not_found';
111
112
  }>;
112
- /** Cria um convite por e-mail. Dispara o mail hook quando configurado. */
113
- createInvitation(orgId: string, input: CreateInvitationInput, actor: AdminActor, origin: string, settings?: SettingsCapability | null): Promise<{
113
+ /**
114
+ * Cria um convite por e-mail. Dispara o mail hook quando configurado.
115
+ *
116
+ * `ctx` is optional only for back-compat with callers that have no request in
117
+ * hand; when given (both HTTP call sites do), the library default invitation
118
+ * email is sent whenever the host defines no `mail.onOrgInvitation` hook.
119
+ */
120
+ createInvitation(orgId: string, input: CreateInvitationInput, actor: AdminActor, origin: string, settings?: SettingsCapability | null, ctx?: HttpContext): Promise<{
114
121
  ok: true;
115
122
  invitation: OrgInvitation;
116
123
  token: string;
@@ -1,5 +1,6 @@
1
1
  import { supportsOrganizations } from '../../accounts/account_store.js';
2
2
  import { accountPath } from '../account_paths.js';
3
+ import { sendOrgInvitationEmail } from '../default_mailer.js';
3
4
  import { isRoleInCatalog, resolveRoleCatalogList } from '../runtime_toggles.js';
4
5
  /**
5
6
  * Lógica de gestão de organizações compartilhada entre o console admin (HTML)
@@ -247,8 +248,14 @@ export class AdminOrgsService {
247
248
  });
248
249
  return { ok: true };
249
250
  }
250
- /** Cria um convite por e-mail. Dispara o mail hook quando configurado. */
251
- async createInvitation(orgId, input, actor, origin, settings = null) {
251
+ /**
252
+ * Cria um convite por e-mail. Dispara o mail hook quando configurado.
253
+ *
254
+ * `ctx` is optional only for back-compat with callers that have no request in
255
+ * hand; when given (both HTTP call sites do), the library default invitation
256
+ * email is sent whenever the host defines no `mail.onOrgInvitation` hook.
257
+ */
258
+ async createInvitation(orgId, input, actor, origin, settings = null, ctx) {
252
259
  const store = this.cfg.accountStore;
253
260
  if (!supportsOrganizations(store))
254
261
  return { ok: false, reason: 'not_supported' };
@@ -266,24 +273,30 @@ export class AdminOrgsService {
266
273
  invitedBy: actor.actorId ?? 'admin',
267
274
  ttlHours: this.cfg.organizations.invitationTtlHours,
268
275
  });
269
- // Dispara mail hook (best-effort)
270
- if (this.cfg.mail?.onOrgInvitation) {
276
+ // Sends the invitation email (best-effort). The host hook wins when present;
277
+ // otherwise the host-kit sends its own branded/translated email, like every
278
+ // other email of the library. Delivery NEVER breaks invitation creation.
279
+ try {
271
280
  const acceptUrl = `${origin}${accountPath('orgs')}/invitations/${token}/accept`;
272
- try {
273
- await this.cfg.mail.onOrgInvitation({
274
- email: input.email,
275
- invitationId: invitation.id,
276
- orgName: org.name,
277
- orgSlug: org.slug,
278
- role: input.role,
279
- acceptUrl,
280
- token,
281
- });
281
+ const payload = {
282
+ email: input.email,
283
+ invitationId: invitation.id,
284
+ orgName: org.name,
285
+ orgSlug: org.slug,
286
+ role: input.role,
287
+ acceptUrl,
288
+ token,
289
+ };
290
+ if (this.cfg.mail?.onOrgInvitation) {
291
+ await this.cfg.mail.onOrgInvitation(payload);
282
292
  }
283
- catch {
284
- // best-effort
293
+ else if (ctx) {
294
+ await sendOrgInvitationEmail(ctx, payload);
285
295
  }
286
296
  }
297
+ catch {
298
+ // best-effort
299
+ }
287
300
  await this.cfg.audit?.record({
288
301
  type: 'organization.invitation_sent',
289
302
  actorId: actor.actorId,
@@ -160,7 +160,7 @@ export default class ApiOrgsController {
160
160
  const orgId = ctx.request.param('id');
161
161
  const { email, role } = await ctx.request.validateUsing(orgInvitationValidator);
162
162
  const origin = authkitOrigin(cfg);
163
- const result = await svc.createInvitation(orgId, { email, role: role ?? 'member' }, actor, origin, await resolveRuntimeSettings(ctx));
163
+ const result = await svc.createInvitation(orgId, { email, role: role ?? 'member' }, actor, origin, await resolveRuntimeSettings(ctx), ctx);
164
164
  if (!result.ok) {
165
165
  if (result.reason === 'not_supported')
166
166
  return notSupported(ctx);
@@ -49,7 +49,7 @@ export declare function grantDto(grant: AdminGrant): {
49
49
  };
50
50
  export declare function auditDto(event: StoredAuditEvent): {
51
51
  id: string;
52
- type: import("../../audit/audit_sink.js").AuditEventType;
52
+ type: "login.success" | "login.failure" | "signup" | "password_reset.issued" | "password_reset.consumed" | "pat.issued" | "pat.revoked" | "pat.used" | "impersonation" | "impersonation.panel_viewed" | "mfa.enabled" | "mfa.disabled" | "account.locked" | "passkey.registered" | "passkey.removed" | "email_verification.issued" | "email_verification.consumed" | "client.created" | "client.updated" | "client.deleted" | "session.revoked_all" | "password.changed" | "password.rehashed" | "email.change_requested" | "email.changed" | "login.new_ip_notified" | "login.new_device" | "login.otp_sent" | "login.otp_verified" | "login.otp_failed" | "login.otp_invalidated" | "bot_protection.rejected" | "grant.revoked_by_user" | "user.created" | "user.password_reset_sent" | "user.disabled" | "user.enabled" | "user.deleted" | "profile.updated" | "account.deleted" | "account.exported" | "keys.rotated" | "organization.created" | "organization.updated" | "organization.deleted" | "organization.member_added" | "organization.member_removed" | "organization.member_role_changed" | "organization.member_role_updated" | "organization.switched" | "organization.deactivated" | "organization.invitation_sent" | "organization.invitation_accepted" | "organization.invitation_revoked" | "email_change.requested" | "email_change.confirmed" | "email_change.cancelled" | "security_notice.sent" | "settings.updated" | "maintenance.enabled" | "maintenance.disabled" | "trusted_device.revoked" | "password.expired_change_forced" | "otp.locked" | "otp.unlocked" | "otp.unlock_failed" | "sudo.confirmed" | "session.single_enforced" | "account.expired_login_blocked" | "account.expiration_warned" | "login.magic_link_sent" | "session.revoked" | "account.signed_out_all" | "client.secret_regenerated" | "roles_catalog.updated";
53
53
  accountId: string | null;
54
54
  email: string | null;
55
55
  clientId: string | null;
@@ -1,5 +1,6 @@
1
1
  import '../augmentations.js';
2
2
  import { apiError } from '../admin_api/dto.js';
3
+ import { AdminClientsService } from '../admin_clients_service.js';
3
4
  import { buildImpersonationPanel } from '../impersonation.js';
4
5
  import { ACCOUNT_SESSION_KEY } from '../middleware/account_auth.js';
5
6
  import { resolveRuntimeSettingsOrNoop } from '../runtime_settings.js';
@@ -48,13 +49,26 @@ export default class ConsoleImpersonationController {
48
49
  if (!account) {
49
50
  return ctx.response.notFound(apiError('not_found', 'Usuário não encontrado.'));
50
51
  }
51
- const panel = buildImpersonationPanel(cfg, targetId);
52
+ // Clients de RUNTIME. `cfg.clients` é sempre `[]` num host real — clients
53
+ // vivem no adapter, criados pelo console/Admin API/`authkit:clients:create`.
54
+ // Ler só o config fazia o painel devolver 404 SEMPRE, que é por que o botão
55
+ // do console nunca funcionou. O fallback para o config cobre hosts legados
56
+ // com clients estáticos e adapters que não enumeram.
57
+ const panel = buildImpersonationPanel(cfg, targetId, await listRuntimeClients(service));
52
58
  if (!panel) {
53
59
  return ctx.response.notFound(apiError('no_token_exchange_client', 'Nenhum client habilitado ao grant token-exchange encontrado.'));
54
60
  }
55
- // Auditoria: acessar o painel é uma intenção de impersonation.
61
+ // Auditoria: o admin REVELOU os parâmetros de impersonation do alvo. Não é
62
+ // `impersonation` — nenhuma identidade foi assumida aqui; quem registra isso
63
+ // é o handler do token-exchange, quando o exchange de fato acontece
64
+ // (`provider/token_exchange.ts`). Registrar "impersonation.started" neste
65
+ // ponto fazia a trilha de auditoria afirmar uma impersonação que podia
66
+ // nunca ocorrer — e, com o painel quebrado, NUNCA ocorria.
67
+ //
68
+ // Emitido DEPOIS de o painel existir: um evento antes do sucesso audita uma
69
+ // intenção que a própria lib então recusa.
56
70
  await cfg.audit?.record({
57
- type: 'impersonation.started',
71
+ type: 'impersonation.panel_viewed',
58
72
  accountId: targetId,
59
73
  actorId: ctx.session?.get(ACCOUNT_SESSION_KEY) ?? null,
60
74
  ip: ctx.request.ip?.() ?? null,
@@ -67,3 +81,23 @@ export default class ConsoleImpersonationController {
67
81
  };
68
82
  }
69
83
  }
84
+ /**
85
+ * Clients persistidos que podem hospedar o token-exchange. Best-effort: um
86
+ * adapter sem `list` (ou que falhe) devolve lista vazia e o
87
+ * `buildImpersonationPanel` cai no `cfg.clients`.
88
+ */
89
+ async function listRuntimeClients(service) {
90
+ try {
91
+ const clients = new AdminClientsService(service);
92
+ if (!clients.canList)
93
+ return [];
94
+ return (await clients.list()).map((c) => ({
95
+ clientId: c.clientId,
96
+ grants: c.grants,
97
+ confidential: c.confidential,
98
+ }));
99
+ }
100
+ catch {
101
+ return [];
102
+ }
103
+ }
@@ -204,7 +204,7 @@ export default class ConsoleOrgsController {
204
204
  const { email, role } = await ctx.request.validateUsing(orgInvitationValidator);
205
205
  const service = await ctx.containerResolver.make('authkit.server');
206
206
  const origin = authkitOrigin(service.config);
207
- const result = await svc.createInvitation(orgId, { email, role: role ?? 'member' }, this.actor(ctx), origin, await this.settings(ctx));
207
+ const result = await svc.createInvitation(orgId, { email, role: role ?? 'member' }, this.actor(ctx), origin, await this.settings(ctx), ctx);
208
208
  if ('ok' in result && result.ok === false) {
209
209
  if (result.reason === 'not_found') {
210
210
  return ctx.response.notFound(apiError('not_found', 'Organização não encontrada.'));
@@ -16,4 +16,23 @@ export interface BrandingConfig {
16
16
  audienceLabels?: Record<string, string>;
17
17
  }
18
18
  export declare function isFirstParty(cfg: BrandingConfig, clientId: string | undefined): boolean;
19
+ /**
20
+ * O client é FIRST-PARTY, ou seja, recebe as claims de autorização
21
+ * (`<globalRolesClaim>`, `org_id`, `org_slug`, `org_role`)?
22
+ *
23
+ * `allowlist` ausente (o host não declarou `firstPartyClients` nem
24
+ * `branding.firstParty`) → TODO client registrado é first-party. É o default
25
+ * de propósito: a claim já está amarrada ao escopo `roles`, então o client
26
+ * precisa ter sido registrado por um admin e pedir `scope=roles`; tratar
27
+ * "sem allowlist" como "ninguém" apagava as roles de todo RP num host que
28
+ * simplesmente não personalizou o tema.
29
+ *
30
+ * `allowlist` declarada (inclusive VAZIA) → vale ao pé da letra. Uma lista
31
+ * vazia é uma declaração deliberada de "nenhum client recebe roles", e não a
32
+ * mesma coisa que não declarar nada.
33
+ *
34
+ * Sem `clientId` (fluxos sem client no contexto) → nunca first-party: não há a
35
+ * quem atribuir a decisão, e fail-closed é a resposta certa.
36
+ */
37
+ export declare function isFirstPartyClient(allowlist: readonly string[] | undefined, clientId: string | undefined): boolean;
19
38
  export declare function brandFor(cfg: BrandingConfig, clientId: string | undefined, audience?: string): ClientBrand;
@@ -1,6 +1,31 @@
1
1
  export function isFirstParty(cfg, clientId) {
2
2
  return !!clientId && cfg.firstParty.includes(clientId);
3
3
  }
4
+ /**
5
+ * O client é FIRST-PARTY, ou seja, recebe as claims de autorização
6
+ * (`<globalRolesClaim>`, `org_id`, `org_slug`, `org_role`)?
7
+ *
8
+ * `allowlist` ausente (o host não declarou `firstPartyClients` nem
9
+ * `branding.firstParty`) → TODO client registrado é first-party. É o default
10
+ * de propósito: a claim já está amarrada ao escopo `roles`, então o client
11
+ * precisa ter sido registrado por um admin e pedir `scope=roles`; tratar
12
+ * "sem allowlist" como "ninguém" apagava as roles de todo RP num host que
13
+ * simplesmente não personalizou o tema.
14
+ *
15
+ * `allowlist` declarada (inclusive VAZIA) → vale ao pé da letra. Uma lista
16
+ * vazia é uma declaração deliberada de "nenhum client recebe roles", e não a
17
+ * mesma coisa que não declarar nada.
18
+ *
19
+ * Sem `clientId` (fluxos sem client no contexto) → nunca first-party: não há a
20
+ * quem atribuir a decisão, e fail-closed é a resposta certa.
21
+ */
22
+ export function isFirstPartyClient(allowlist, clientId) {
23
+ if (!clientId)
24
+ return false;
25
+ if (allowlist === undefined)
26
+ return true;
27
+ return allowlist.includes(clientId);
28
+ }
4
29
  export function brandFor(cfg, clientId, audience) {
5
30
  const base = (clientId && cfg.clients[clientId]) || cfg.default;
6
31
  const label = audience ? cfg.audienceLabels?.[audience] : undefined;