@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
@@ -3,6 +3,7 @@ import { supportsOrganizations } from '../../accounts/account_store.js';
3
3
  import { getAccountLoginUrl } from '../account_login_url.js';
4
4
  import { accountPath } from '../account_paths.js';
5
5
  import { ACTIVE_ORG_COOKIE, ACTIVE_ORG_COOKIE_TTL, encodeActiveOrgCookie, } from '../active_org_cookie.js';
6
+ import { sendOrgInvitationEmail } from '../default_mailer.js';
6
7
  import { ACCOUNT_SESSION_KEY } from '../middleware/account_auth.js';
7
8
  import { authkitOrigin } from '../origin.js';
8
9
  import { resolveRuntimeSettings } from '../runtime_settings.js';
@@ -188,25 +189,31 @@ export default class AccountOrgsController {
188
189
  invitedBy: accountId,
189
190
  ttlHours: cfg.organizations.invitationTtlHours,
190
191
  });
191
- // Dispara e-mail via mail hook (best-effort)
192
- if (cfg.mail?.onOrgInvitation) {
192
+ // Sends the invitation email (best-effort). The host hook wins when present;
193
+ // otherwise the host-kit sends its own branded/translated email, like every
194
+ // other email of the library. Delivery NEVER breaks invitation creation.
195
+ try {
193
196
  const org = await store.findOrgById(params.id);
194
197
  const acceptUrl = `${authkitOrigin(cfg)}${accountPath('orgs')}/invitations/${token}/accept`;
195
- try {
196
- await cfg.mail.onOrgInvitation({
197
- email,
198
- invitationId: invitation.id,
199
- orgName: org?.name ?? params.id,
200
- orgSlug: org?.slug ?? params.id,
201
- role,
202
- acceptUrl,
203
- token,
204
- });
198
+ const payload = {
199
+ email,
200
+ invitationId: invitation.id,
201
+ orgName: org?.name ?? params.id,
202
+ orgSlug: org?.slug ?? params.id,
203
+ role,
204
+ acceptUrl,
205
+ token,
206
+ };
207
+ if (cfg.mail?.onOrgInvitation) {
208
+ await cfg.mail.onOrgInvitation(payload);
205
209
  }
206
- catch {
207
- // best-effort
210
+ else {
211
+ await sendOrgInvitationEmail(ctx, payload);
208
212
  }
209
213
  }
214
+ catch {
215
+ // best-effort
216
+ }
210
217
  await cfg.audit?.record({
211
218
  type: 'organization.invitation_sent',
212
219
  accountId,
@@ -134,6 +134,27 @@ export declare function sendOtpUnlockEmail(ctx: HttpContext, data: {
134
134
  email: string;
135
135
  unlockUrl: string;
136
136
  }): Promise<void>;
137
+ /**
138
+ * Sends the organization invitation email through the host default mailer.
139
+ *
140
+ * WHY IT EXISTS. `mail.onOrgInvitation` used to be the ONLY hook without a
141
+ * default-mailer fallback: without it the invitation row was created and NO
142
+ * email was ever sent — a silent failure where the invited person never learned
143
+ * about the invitation. This restores the same posture as every other email of
144
+ * the library: the host hook wins when present, otherwise the host-kit itself
145
+ * sends a branded/translated email; with no mailer at all, the link is logged (dev).
146
+ *
147
+ * Best-effort: never throws — invitation creation must not depend on delivery.
148
+ */
149
+ export declare function sendOrgInvitationEmail(ctx: HttpContext, data: {
150
+ email: string;
151
+ invitationId: string;
152
+ orgName: string;
153
+ orgSlug: string;
154
+ role: string;
155
+ acceptUrl: string;
156
+ token: string;
157
+ }): Promise<void>;
137
158
  /**
138
159
  * Envia o e-mail de verificação pelo mailer default do host.
139
160
  * Best-effort: no fallback (sem mail) loga o link; nunca lança.
@@ -475,6 +475,47 @@ export async function sendOtpUnlockEmail(ctx, data) {
475
475
  ctx.logger.error({ err: error, email: data.email }, 'authkit: falha ao enviar e-mail de desbloqueio OTP');
476
476
  }
477
477
  }
478
+ /**
479
+ * Sends the organization invitation email through the host default mailer.
480
+ *
481
+ * WHY IT EXISTS. `mail.onOrgInvitation` used to be the ONLY hook without a
482
+ * default-mailer fallback: without it the invitation row was created and NO
483
+ * email was ever sent — a silent failure where the invited person never learned
484
+ * about the invitation. This restores the same posture as every other email of
485
+ * the library: the host hook wins when present, otherwise the host-kit itself
486
+ * sends a branded/translated email; with no mailer at all, the link is logged (dev).
487
+ *
488
+ * Best-effort: never throws — invitation creation must not depend on delivery.
489
+ */
490
+ export async function sendOrgInvitationEmail(ctx, data) {
491
+ try {
492
+ const brand = resolveBrand(ctx);
493
+ const { messages: t, locale } = resolveMailMessages(ctx);
494
+ const content = renderTransactionalEmail({
495
+ brand,
496
+ locale,
497
+ linkFallback: translate(t, 'mail.common.link_fallback'),
498
+ subject: translate(t, 'mail.org_invitation.subject', { org: data.orgName }),
499
+ heading: translate(t, 'mail.org_invitation.heading', { org: data.orgName }),
500
+ intro: translate(t, 'mail.org_invitation.intro', { org: data.orgName, role: data.role }),
501
+ ctaLabel: translate(t, 'mail.org_invitation.cta'),
502
+ ctaUrl: data.acceptUrl,
503
+ footnote: translate(t, 'mail.org_invitation.fallback'),
504
+ });
505
+ const sent = await sendEmail(ctx, data.email, content);
506
+ if (!sent) {
507
+ ctx.logger?.info({
508
+ acceptUrl: data.acceptUrl,
509
+ email: data.email,
510
+ orgSlug: data.orgSlug,
511
+ invitationId: data.invitationId,
512
+ }, 'authkit: convite de organização (dev — @adonisjs/mail ausente)');
513
+ }
514
+ }
515
+ catch (error) {
516
+ ctx.logger?.error({ err: error, email: data.email, orgSlug: data.orgSlug }, 'authkit: falha ao enviar convite de organização');
517
+ }
518
+ }
478
519
  /**
479
520
  * Envia o e-mail de verificação pelo mailer default do host.
480
521
  * Best-effort: no fallback (sem mail) loga o link; nunca lança.
@@ -570,6 +570,11 @@ export declare const DEFAULT_MESSAGES: {
570
570
  'mail.security_notice.kind_passkey_added': string;
571
571
  'mail.security_notice.kind_passkey_removed': string;
572
572
  'mail.security_notice.kind_email_changed': string;
573
+ 'mail.org_invitation.subject': string;
574
+ 'mail.org_invitation.heading': string;
575
+ 'mail.org_invitation.intro': string;
576
+ 'mail.org_invitation.cta': string;
577
+ 'mail.org_invitation.fallback': string;
573
578
  'mail.otp_unlock.subject': string;
574
579
  'mail.otp_unlock.heading': string;
575
580
  'mail.otp_unlock.intro': string;
@@ -1309,6 +1314,11 @@ export declare const PT_BR_MESSAGES: {
1309
1314
  'admin.settings.sudo_mode_from_config': string;
1310
1315
  'admin.settings.sudo_mode_from_setting': string;
1311
1316
  'admin.settings.sudo_mode_grace_label': string;
1317
+ 'mail.org_invitation.subject': string;
1318
+ 'mail.org_invitation.heading': string;
1319
+ 'mail.org_invitation.intro': string;
1320
+ 'mail.org_invitation.cta': string;
1321
+ 'mail.org_invitation.fallback': string;
1312
1322
  'mail.otp_unlock.subject': string;
1313
1323
  'mail.otp_unlock.heading': string;
1314
1324
  'mail.otp_unlock.intro': string;
@@ -323,7 +323,7 @@ export const DEFAULT_MESSAGES = {
323
323
  'admin.impersonation.title': 'Impersonate this user',
324
324
  'admin.impersonation.help': 'Token Exchange (RFC 8693) lets an admin act as this user. There is no auth bypass: you exchange YOUR OWN admin access token for one scoped to the target.',
325
325
  'admin.impersonation.curl_label': 'Ready-to-run request',
326
- 'admin.impersonation.note': 'Replace <ADMIN_ACCESS_TOKEN> with a current admin access token. The resulting id_token carries act={sub: admin}; the event is audited as impersonation.started.',
326
+ 'admin.impersonation.note': 'Replace <ADMIN_ACCESS_TOKEN> with a current admin access token. The resulting id_token carries act={sub: admin}; the exchange is audited as impersonation.',
327
327
  'admin.impersonation.no_client': 'No client has the token-exchange grant enabled. Add "urn:ietf:params:oauth:grant-type:token-exchange" to a client to enable impersonation.',
328
328
  // Console admin — clients.
329
329
  'admin.clients.page_title': 'OAuth clients',
@@ -621,6 +621,13 @@ export const DEFAULT_MESSAGES = {
621
621
  'mail.security_notice.kind_passkey_added': 'passkey added',
622
622
  'mail.security_notice.kind_passkey_removed': 'passkey removed',
623
623
  'mail.security_notice.kind_email_changed': 'email address changed',
624
+ // E-mail de convite para organização (fallback quando o host não define o
625
+ // hook `mail.onOrgInvitation`).
626
+ 'mail.org_invitation.subject': 'You have been invited to join {org}',
627
+ 'mail.org_invitation.heading': 'You have been invited to join {org}',
628
+ 'mail.org_invitation.intro': 'You have been invited to join the organization {org} as {role}. Accept the invitation to get access.',
629
+ 'mail.org_invitation.cta': 'Accept invitation',
630
+ 'mail.org_invitation.fallback': 'If you were not expecting this invitation, you can ignore this email.',
624
631
  // E-mail de desbloqueio do fator OTP.
625
632
  'mail.otp_unlock.subject': 'Two-factor authentication unlock',
626
633
  'mail.otp_unlock.heading': 'Unlock your two-factor authentication',
@@ -1161,7 +1168,7 @@ export const PT_BR_MESSAGES = {
1161
1168
  'admin.impersonation.title': 'Personificar este usuário',
1162
1169
  'admin.impersonation.help': 'O Token Exchange (RFC 8693) permite que um admin aja como este usuário. Não há bypass de auth: você troca o SEU PRÓPRIO access token de admin por um escopado ao alvo.',
1163
1170
  'admin.impersonation.curl_label': 'Requisição pronta para rodar',
1164
- 'admin.impersonation.note': 'Troque <ADMIN_ACCESS_TOKEN> por um access token de admin válido. O id_token resultante carrega act={sub: admin}; o evento é auditado como impersonation.started.',
1171
+ 'admin.impersonation.note': 'Troque <ADMIN_ACCESS_TOKEN> por um access token de admin válido. O id_token resultante carrega act={sub: admin}; o exchange é auditado como impersonation.',
1165
1172
  'admin.impersonation.no_client': 'Nenhum client tem o grant token-exchange habilitado. Adicione "urn:ietf:params:oauth:grant-type:token-exchange" a um client para habilitar a personificação.',
1166
1173
  // Console admin — clients.
1167
1174
  'admin.clients.page_title': 'Clients OAuth',
@@ -1443,6 +1450,12 @@ export const PT_BR_MESSAGES = {
1443
1450
  'admin.settings.sudo_mode_from_config': 'Fonte: padrão',
1444
1451
  'admin.settings.sudo_mode_from_setting': 'Fonte: setting em runtime',
1445
1452
  'admin.settings.sudo_mode_grace_label': 'Período de graça (minutos)',
1453
+ // E-mail de convite para organização (pt-BR).
1454
+ 'mail.org_invitation.subject': 'Você foi convidado para {org}',
1455
+ 'mail.org_invitation.heading': 'Você foi convidado para {org}',
1456
+ 'mail.org_invitation.intro': 'Você foi convidado para participar da organização {org} como {role}. Aceite o convite para ter acesso.',
1457
+ 'mail.org_invitation.cta': 'Aceitar convite',
1458
+ 'mail.org_invitation.fallback': 'Se você não esperava este convite, pode ignorar este e-mail.',
1446
1459
  // E-mail de desbloqueio do fator OTP (pt-BR).
1447
1460
  'mail.otp_unlock.subject': 'Desbloqueio da verificação em duas etapas',
1448
1461
  'mail.otp_unlock.heading': 'Desbloqueie sua verificação em duas etapas',
@@ -17,9 +17,30 @@ export interface ImpersonationPanel {
17
17
  /** Comando curl pronto (subject_token como placeholder a preencher). */
18
18
  curl: string;
19
19
  }
20
+ /**
21
+ * Um client candidato a hospedar o token-exchange. Modelado estruturalmente para
22
+ * aceitar tanto os clients ESTÁTICOS do config (`ResolvedServerConfig['clients']`,
23
+ * que trazem o secret) quanto os clients de RUNTIME lidos do adapter pelo
24
+ * `AdminClientsService` (que NÃO trazem o secret — ele não é recuperável).
25
+ */
26
+ export interface ImpersonationClientLike {
27
+ clientId: string;
28
+ clientSecret?: string;
29
+ grants?: string[];
30
+ /** Runtime clients reportam confidencialidade sem expor o secret. */
31
+ confidential?: boolean;
32
+ }
20
33
  /**
21
34
  * Monta o painel de impersonation para o `targetId`. Retorna `null` quando NENHUM
22
- * client da config tem o grant token-exchange habilitado (sem ele o fluxo não
35
+ * client candidato tem o grant token-exchange habilitado (sem ele o fluxo não
23
36
  * funciona) — a UI então mostra a instrução de habilitar o grant.
37
+ *
38
+ * ⚠️ `clients` é OBRIGATÓRIO na prática. `cfg.clients` é sempre `[]` num host
39
+ * real: clients são 100% de runtime (console admin / Admin API / `authkit:clients:create`),
40
+ * e o config resolvido nunca os carrega — é o que o `checkClients` do doctor
41
+ * afirma. Passar só o config faria o painel devolver `null` SEMPRE, que foi
42
+ * exatamente o bug: o botão do console nunca teve um client para oferecer.
43
+ * O fallback para `cfg.clients` existe só para hosts que ainda declaram clients
44
+ * estáticos e para os testes que os declaram.
24
45
  */
25
- export declare function buildImpersonationPanel(cfg: Pick<ResolvedServerConfig, 'issuer' | 'clients'>, targetId: string): ImpersonationPanel | null;
46
+ export declare function buildImpersonationPanel(cfg: Pick<ResolvedServerConfig, 'issuer' | 'clients'>, targetId: string, clients?: readonly ImpersonationClientLike[]): ImpersonationPanel | null;
@@ -1,17 +1,33 @@
1
1
  const TOKEN_EXCHANGE = 'urn:ietf:params:oauth:grant-type:token-exchange';
2
2
  const ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token';
3
+ /** Placeholder do secret quando o client é confidencial mas o secret não é legível. */
4
+ const SECRET_PLACEHOLDER = '<CLIENT_SECRET>';
3
5
  /**
4
6
  * Monta o painel de impersonation para o `targetId`. Retorna `null` quando NENHUM
5
- * client da config tem o grant token-exchange habilitado (sem ele o fluxo não
7
+ * client candidato tem o grant token-exchange habilitado (sem ele o fluxo não
6
8
  * funciona) — a UI então mostra a instrução de habilitar o grant.
9
+ *
10
+ * ⚠️ `clients` é OBRIGATÓRIO na prática. `cfg.clients` é sempre `[]` num host
11
+ * real: clients são 100% de runtime (console admin / Admin API / `authkit:clients:create`),
12
+ * e o config resolvido nunca os carrega — é o que o `checkClients` do doctor
13
+ * afirma. Passar só o config faria o painel devolver `null` SEMPRE, que foi
14
+ * exatamente o bug: o botão do console nunca teve um client para oferecer.
15
+ * O fallback para `cfg.clients` existe só para hosts que ainda declaram clients
16
+ * estáticos e para os testes que os declaram.
7
17
  */
8
- export function buildImpersonationPanel(cfg, targetId) {
9
- const client = cfg.clients.find((c) => (c.grants ?? []).includes(TOKEN_EXCHANGE));
18
+ export function buildImpersonationPanel(cfg, targetId, clients) {
19
+ const candidates = clients && clients.length > 0 ? clients : (cfg.clients ?? []);
20
+ const client = candidates.find((c) => (c.grants ?? []).includes(TOKEN_EXCHANGE));
10
21
  if (!client)
11
22
  return null;
12
23
  const tokenEndpoint = `${cfg.issuer.replace(/\/+$/, '')}/token`;
13
- const auth = client.clientSecret
14
- ? ` -u '${client.clientId}:${client.clientSecret}' \\\n`
24
+ // Confidencial = tem secret conhecido, ou o adapter marcou como confidencial.
25
+ // No segundo caso o secret NÃO é recuperável (o adapter guarda o hash/valor
26
+ // opaco e a lib mostra o secret uma única vez na criação), então o curl sai
27
+ // com um placeholder em vez de mentir com uma string vazia.
28
+ const confidential = client.confidential ?? client.clientSecret !== undefined;
29
+ const auth = confidential
30
+ ? ` -u '${client.clientId}:${client.clientSecret ?? SECRET_PLACEHOLDER}' \\\n`
15
31
  : ` -d 'client_id=${client.clientId}' \\\n`;
16
32
  const curl = `curl -X POST '${tokenEndpoint}' \\\n${auth} -d 'grant_type=${TOKEN_EXCHANGE}' \\\n -d 'subject_token=<ADMIN_ACCESS_TOKEN>' \\\n -d 'subject_token_type=${ACCESS_TOKEN_TYPE}' \\\n -d 'requested_subject=${targetId}' \\\n -d 'scope=openid profile email'`;
17
33
  return {