@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.
- package/build/index.d.ts +2 -2
- package/build/index.js +1 -1
- package/build/providers/authkit_server_provider.d.ts +1 -12
- package/build/providers/authkit_server_provider.js +13 -0
- package/build/src/audit/audit_sink.d.ts +11 -2
- package/build/src/audit/audit_sink.js +104 -1
- package/build/src/define_config.d.ts +36 -1
- package/build/src/define_config.js +3 -0
- package/build/src/doctor/checks.d.ts +10 -0
- package/build/src/doctor/checks.js +38 -0
- package/build/src/host/admin_api/admin_orgs_service.d.ts +9 -2
- package/build/src/host/admin_api/admin_orgs_service.js +29 -16
- package/build/src/host/admin_api/api_orgs_controller.js +1 -1
- package/build/src/host/admin_api/dto.d.ts +1 -1
- package/build/src/host/admin_console/console_impersonation_controller.js +37 -3
- package/build/src/host/admin_console/console_orgs_controller.js +1 -1
- package/build/src/host/branding.d.ts +19 -0
- package/build/src/host/branding.js +25 -0
- package/build/src/host/controllers/account_orgs_controller.js +21 -14
- package/build/src/host/default_mailer.d.ts +21 -0
- package/build/src/host/default_mailer.js +41 -0
- package/build/src/host/i18n.d.ts +10 -0
- package/build/src/host/i18n.js +15 -2
- package/build/src/host/impersonation.d.ts +23 -2
- package/build/src/host/impersonation.js +21 -5
- package/build/src/host/ui-dist/assets/{index-pAbBjdHC.js → index-6cE5JMyP.js} +1 -1
- package/build/src/host/ui-dist/index.html +1 -1
- package/build/src/observability/telescope/data_providers.js +4 -1
- package/build/src/provider/oidc_service.js +18 -6
- package/build/types.d.ts +21 -1
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
113
|
-
|
|
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
|
-
/**
|
|
251
|
-
|
|
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
|
-
//
|
|
270
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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;
|