@adonis-agora/authkit-server 0.68.4 → 0.69.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.
package/build/index.d.ts CHANGED
@@ -68,6 +68,7 @@ export type { ResolveGeo } from './src/host/geo.js';
68
68
  export { GEO_RESOLVE_TIMEOUT_MS, resolveGeoSafe } from './src/host/geo.js';
69
69
  export type { AuthMessages, I18nConfig } from './src/host/i18n.js';
70
70
  export { BUILTIN_MESSAGES, DEFAULT_LOCALE, DEFAULT_MESSAGES, PT_BR_MESSAGES, resolveMessages, translate, } from './src/host/i18n.js';
71
+ export { ensureConsoleSession } from './src/host/idp_session_bridge.js';
71
72
  export type { ImpersonationClientLike, ImpersonationPanel } from './src/host/impersonation.js';
72
73
  export { buildImpersonationPanel } from './src/host/impersonation.js';
73
74
  export type { ImpersonationStartErrorCode, ImpersonationState, StartImpersonationParams, StopImpersonationOptions, TokenExchangeResult, } from './src/host/impersonation_session.js';
@@ -122,6 +123,7 @@ export { lucidPatStore } from './src/pat/lucid_pat_store.js';
122
123
  export type { IssuePatInput, PatRecord, PatStore } from './src/pat/pat_store.js';
123
124
  export { generatePatToken, hashPatToken } from './src/pat/pat_tokens.js';
124
125
  export { OidcService } from './src/provider/oidc_service.js';
126
+ export { checkClientRegistration, classifyRedirect, type RedirectUriPolicy, type RegistrationOperation, RegistrationPolicyError, type ResolvedRedirectUriPolicy, type ValidateRegistrationHook, } from './src/provider/registration_policy.js';
125
127
  export { registerOidcRoutes } from './src/register_routes.js';
126
128
  export type { EnsureSchemaOptions, EnsureSchemaReport } from './src/schema/ensure.js';
127
129
  export { ensureAuthkitSchema } from './src/schema/ensure.js';
package/build/index.js CHANGED
@@ -51,6 +51,7 @@ export { consoleLoginUrl, getAccountId, hasAccountSession, realAccountId, } from
51
51
  export { authkitCsrfExceptions } from './src/host/csrf.js';
52
52
  export { GEO_RESOLVE_TIMEOUT_MS, resolveGeoSafe } from './src/host/geo.js';
53
53
  export { BUILTIN_MESSAGES, DEFAULT_LOCALE, DEFAULT_MESSAGES, PT_BR_MESSAGES, resolveMessages, translate, } from './src/host/i18n.js';
54
+ export { ensureConsoleSession } from './src/host/idp_session_bridge.js';
54
55
  export { buildImpersonationPanel } from './src/host/impersonation.js';
55
56
  // Session impersonation — RP-side glue that routes through the IdP's RFC 8693
56
57
  // token-exchange (the IdP validates the admin role + audits). See
@@ -110,6 +111,7 @@ export { __setFetchForTests as __setPwnedFetchForTests, isPasswordPwned, } from
110
111
  export { lucidPatStore } from './src/pat/lucid_pat_store.js';
111
112
  export { generatePatToken, hashPatToken } from './src/pat/pat_tokens.js';
112
113
  export { OidcService } from './src/provider/oidc_service.js';
114
+ export { checkClientRegistration, classifyRedirect, RegistrationPolicyError, } from './src/provider/registration_policy.js';
113
115
  export { registerOidcRoutes } from './src/register_routes.js';
114
116
  export { ensureAuthkitSchema } from './src/schema/ensure.js';
115
117
  export { stubsRoot } from './stubs/main.js';
@@ -14,6 +14,7 @@ import type { AuthHostOptions } from './host/register_auth_host.js';
14
14
  import type { SudoMethod } from './host/sudo/types.js';
15
15
  import { type ResolvedTrustedDevicesConfig, type TrustedDevicesConfigInput } from './host/trusted_device.js';
16
16
  import type { PatStore } from './pat/pat_store.js';
17
+ import { type RedirectUriPolicy, type ResolvedRedirectUriPolicy, type ValidateRegistrationHook } from './provider/registration_policy.js';
17
18
  export type { AuthAccount };
18
19
  export { adapters };
19
20
  export type AuthHostRenderer = (ctx: HttpContext, view: string, props: Record<string, unknown>) => unknown;
@@ -305,11 +306,37 @@ export interface DynamicRegistrationConfigInput {
305
306
  * registrado via o `registration_access_token` devolvido no registro. Default: false.
306
307
  */
307
308
  management?: boolean;
309
+ /**
310
+ * Política de redirect URIs aplicada a TODO registro (`POST /reg`) e update
311
+ * (`PUT /reg/:id`) ANTES do oidc-provider. Com a política ativa, o client
312
+ * também fica restrito ao fluxo de código (`authorization_code` +
313
+ * `refresh_token`, `response_type=code`; PKCE já é obrigatório no IdP), e
314
+ * um client só-loopback/app instalado é registrado como `application_type: native`.
315
+ *
316
+ * Default:
317
+ * - registro ABERTO (sem `initialAccessToken`): `{ loopback: true }` — só
318
+ * `http://localhost|127.0.0.1|[::1]` em qualquer porta. Callbacks web de
319
+ * fornecedores (ex.: `https://claude.ai/api/mcp/auth_callback`) e esquemas
320
+ * de app (`cursor`, `vscode`) precisam ser listados em `exact`/`appSchemes`.
321
+ * - registro com `initialAccessToken`: sem política (quem tem o IAT é confiável).
322
+ *
323
+ * `false` desliga a política explicitamente (comportamento puro do oidc-provider).
324
+ */
325
+ redirectUriPolicy?: RedirectUriPolicy | false;
326
+ /**
327
+ * Gancho do host rodado depois da política de redirect: valida/ajusta o
328
+ * metadata do registro. Lance {@link RegistrationPolicyError} para recusar com
329
+ * `400`; retorne um objeto para substituir o metadata.
330
+ */
331
+ validateRegistration?: ValidateRegistrationHook;
308
332
  }
309
333
  export interface ResolvedDynamicRegistrationConfig {
310
334
  enabled: boolean;
311
335
  initialAccessToken?: string;
312
336
  management: boolean;
337
+ /** `null` = sem política de redirect (oidc-provider puro). */
338
+ redirectUriPolicy: ResolvedRedirectUriPolicy | null;
339
+ validateRegistration?: ValidateRegistrationHook;
313
340
  }
314
341
  /**
315
342
  * Resolve a config de registro dinâmico e VALIDA invariantes em tempo de resolução.
@@ -635,6 +662,9 @@ export declare function resolveAdminApi(input?: AdminApiConfigInput): ResolvedAd
635
662
  * A role `'owner'` é reservada: uma org SEMPRE precisa de pelo menos um owner.
636
663
  * `allowSelfCreate`: se um usuário autenticado pode criar sua própria org (default false).
637
664
  * `invitationTtlHours`: TTL dos convites em horas (default 168 = 7 dias).
665
+ * Os três são o default estático da política; a setting `organizations_policy`
666
+ * os sobrescreve em runtime — exceto quando `organizations` está declarado no
667
+ * config, o que trava a setting e faz destes campos a política efetiva.
638
668
  * `claimStrategy: 'active'`: emite claims da org ATIVA da sessão (única estratégia implementada).
639
669
  */
640
670
  export interface OrganizationsConfigInput {
@@ -646,6 +676,21 @@ export interface OrganizationsConfigInput {
646
676
  * Default: 'active'.
647
677
  */
648
678
  claimStrategy?: 'active';
679
+ /**
680
+ * Catálogo de roles de org aceitas em convites/membros. `owner` é sempre
681
+ * garantido. Default: `['owner', 'admin', 'member']`.
682
+ *
683
+ * Declarar `organizations` TRAVA a setting `organizations_policy` (ver
684
+ * `config-locks`): com a chave travada, os campos de política daqui SÃO a
685
+ * política efetiva. Sem eles, a política trava no default da lib — era o
686
+ * que acontecia antes destes campos existirem (e.g. `allowSelfCreate` preso
687
+ * em `false`, sem jeito de ligar).
688
+ */
689
+ roles?: string[];
690
+ /** Usuário autenticado pode criar a própria org em `/account/orgs`. Default: false. */
691
+ allowSelfCreate?: boolean;
692
+ /** TTL dos convites em horas. Default: 168 (7 dias). */
693
+ invitationTtlHours?: number;
649
694
  }
650
695
  export interface ResolvedOrganizationsConfig {
651
696
  /** `undefined` = auto (decide em runtime pelo capability-probing do store). */
@@ -1061,6 +1106,22 @@ export interface AuthServerConfigInput {
1061
1106
  adonisAuth?: {
1062
1107
  guard: string;
1063
1108
  };
1109
+ /**
1110
+ * Sessão do console de conta (`/account/*`, e o `/admin/*`).
1111
+ *
1112
+ * `acceptIdpSession: true` — SSO: o console aceita a sessão ATIVA do IdP (o
1113
+ * login feito na interaction OIDC — senha, magic link, OTP, passkey, social)
1114
+ * em vez de pedir um segundo login. A sessão de console aberta assim fica
1115
+ * amarrada à sessão do IdP: termina quando ela termina (logout OIDC,
1116
+ * expiração), e o "Sair" do console encerra também a sessão do IdP. Contas
1117
+ * desabilitadas não entram. Operações sensíveis continuam pedindo sudo.
1118
+ *
1119
+ * Default `false`: o console só aceita a própria sessão (`POST /account/login`),
1120
+ * como sempre.
1121
+ */
1122
+ accountSession?: {
1123
+ acceptIdpSession?: boolean;
1124
+ };
1064
1125
  }
1065
1126
  export interface ResolvedServerConfig {
1066
1127
  issuer: string;
@@ -1204,6 +1265,10 @@ export interface ResolvedServerConfig {
1204
1265
  adonisAuth?: {
1205
1266
  guard: string;
1206
1267
  };
1268
+ /** Sessão do console. Ver {@link AuthServerConfigInput.accountSession}. */
1269
+ accountSession: {
1270
+ acceptIdpSession: boolean;
1271
+ };
1207
1272
  }
1208
1273
  export declare function toSeconds(value: string | number | undefined, fallback: number): number;
1209
1274
  /**
@@ -13,6 +13,7 @@ import { generateJwks } from './keys/jwks_manager.js';
13
13
  import { KeystoreCodec } from './keys/keystore_codec.js';
14
14
  import { loadEncryptionService } from './keys/keystore_crypto.js';
15
15
  import { KeystoreManager, resolveKeystoreVault } from './keys/keystore_manager.js';
16
+ import { OPEN_REGISTRATION_REDIRECT_POLICY, resolveRedirectUriPolicy, } from './provider/registration_policy.js';
16
17
  export { adapters };
17
18
  const RATE_LIMIT_DEFAULTS = {
18
19
  login: { points: 10, duration: '1 min' },
@@ -64,10 +65,20 @@ export function resolveDynamicRegistration(input) {
64
65
  'dynamicRegistration.enabled: true (RFC 7591). Habilite o registro dinâmico ' +
65
66
  'ou desligue o management.');
66
67
  }
68
+ const declared = input?.redirectUriPolicy;
69
+ const redirectUriPolicy = declared === false
70
+ ? null
71
+ : declared
72
+ ? resolveRedirectUriPolicy(declared)
73
+ : input?.initialAccessToken
74
+ ? null
75
+ : { ...OPEN_REGISTRATION_REDIRECT_POLICY };
67
76
  return {
68
77
  enabled,
69
78
  initialAccessToken: input?.initialAccessToken,
70
79
  management,
80
+ redirectUriPolicy,
81
+ validateRegistration: input?.validateRegistration,
71
82
  };
72
83
  }
73
84
  export function resolveDeviceFlow(input) {
@@ -183,9 +194,11 @@ export function resolveAdminApi(input) {
183
194
  export function resolveOrganizations(input) {
184
195
  return {
185
196
  enabled: input?.enabled,
186
- roles: ['owner', 'admin', 'member'],
187
- allowSelfCreate: false,
188
- invitationTtlHours: 168,
197
+ roles: input?.roles && input.roles.length > 0 ? input.roles : ['owner', 'admin', 'member'],
198
+ allowSelfCreate: input?.allowSelfCreate ?? false,
199
+ invitationTtlHours: typeof input?.invitationTtlHours === 'number' && input.invitationTtlHours >= 1
200
+ ? Math.floor(input.invitationTtlHours)
201
+ : 168,
189
202
  claimStrategy: input?.claimStrategy ?? 'active',
190
203
  };
191
204
  }
@@ -433,6 +446,7 @@ export function defineConfig(config) {
433
446
  lockedRouteOptions: deriveLockedRouteOptions(config),
434
447
  // Opt-in: ausente = authkit nunca toca `ctx.auth` (comportamento de sempre).
435
448
  adonisAuth: config.adonisAuth,
449
+ accountSession: { acceptIdpSession: config.accountSession?.acceptIdpSession === true },
436
450
  };
437
451
  });
438
452
  }
@@ -2,7 +2,7 @@ import { supportsOrganizations } from '../../accounts/account_store.js';
2
2
  import { ADMIN_LIST_DEFAULT_SIZE, LIST_FIRST_PAGE } from '../../pagination.js';
3
3
  import { accountPath } from '../account_paths.js';
4
4
  import { sendOrgInvitationEmail } from '../default_mailer.js';
5
- import { isRoleInCatalog, resolveRoleCatalogList } from '../runtime_toggles.js';
5
+ import { isRoleInCatalog, resolveEffectiveOrganizationsPolicy, resolveRoleCatalogList, } from '../runtime_toggles.js';
6
6
  /**
7
7
  * Lógica de gestão de organizações compartilhada entre o console admin (HTML)
8
8
  * e a Admin REST API (JSON). Usada pelos `AdminOrgsController` e `ApiOrgsController`.
@@ -278,7 +278,8 @@ export class AdminOrgsService {
278
278
  email: input.email,
279
279
  role: input.role,
280
280
  invitedBy: actor.actorId ?? 'admin',
281
- ttlHours: this.cfg.organizations.invitationTtlHours,
281
+ // TTL da política efetiva da org (setting org → global → config).
282
+ ttlHours: (await resolveEffectiveOrganizationsPolicy(settings, this.orgPolicyConfigDefaults(), orgId)).invitationTtlHours,
282
283
  });
283
284
  // Sends the invitation email (best-effort). The host hook wins when present;
284
285
  // otherwise the host-kit sends its own branded/translated email, like every
@@ -5,9 +5,27 @@ import { accountPath } from '../account_paths.js';
5
5
  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
+ import { ensureConsoleSession } from '../idp_session_bridge.js';
8
9
  import { authkitOrigin } from '../origin.js';
9
10
  import { resolveRuntimeSettings } from '../runtime_settings.js';
10
- import { isRoleInCatalog } from '../runtime_toggles.js';
11
+ import { isRoleInCatalog, resolveEffectiveOrganizationsPolicy, } from '../runtime_toggles.js';
12
+ /** Defaults estáticos da política de org (config do host) — o fallback da setting. */
13
+ function orgPolicyDefaults(cfg) {
14
+ return {
15
+ roles: cfg.organizations.roles,
16
+ allowSelfCreate: cfg.organizations.allowSelfCreate,
17
+ invitationTtlHours: cfg.organizations.invitationTtlHours,
18
+ };
19
+ }
20
+ /**
21
+ * Política EFETIVA de organizações: setting `organizations_policy` (org → global)
22
+ * → `config.organizations` → default da lib. É o que a doc promete; antes este
23
+ * controller lia só o config estático, e a setting não tinha efeito aqui.
24
+ */
25
+ async function effectiveOrgPolicy(ctx, cfg, orgId) {
26
+ const settings = await resolveRuntimeSettings(ctx);
27
+ return resolveEffectiveOrganizationsPolicy(settings, orgPolicyDefaults(cfg), orgId);
28
+ }
11
29
  /**
12
30
  * Console de conta — Organizations. Server-rendered, padrão dos outros controllers
13
31
  * de conta (account_tokens_controller, account_security_controller, etc.).
@@ -51,12 +69,13 @@ export default class AccountOrgsController {
51
69
  const members = canManage ? await store.listOrgMembers(org.id) : [];
52
70
  return { ...org, members, canManage, isActive: org.id === activeOrgId };
53
71
  }));
72
+ const policy = await effectiveOrgPolicy(ctx, cfg);
54
73
  const props = {
55
74
  supported: true,
56
75
  orgs: orgsWithMembers,
57
76
  pendingInvitations: invitationsWithOrg,
58
- allowSelfCreate: cfg.organizations.allowSelfCreate,
59
- availableRoles: cfg.organizations.roles,
77
+ allowSelfCreate: policy.allowSelfCreate,
78
+ availableRoles: policy.roles,
60
79
  messages,
61
80
  csrfToken: ctx.request.csrfToken,
62
81
  };
@@ -69,9 +88,10 @@ export default class AccountOrgsController {
69
88
  const cfg = service.config;
70
89
  const store = cfg.accountStore;
71
90
  const accountId = session.get(ACCOUNT_SESSION_KEY);
72
- if (!supportsOrganizations(store) || !cfg.organizations.allowSelfCreate) {
91
+ if (!supportsOrganizations(store))
92
+ return response.forbidden();
93
+ if (!(await effectiveOrgPolicy(ctx, cfg)).allowSelfCreate)
73
94
  return response.forbidden();
74
- }
75
95
  const name = request.input('name', '').trim();
76
96
  const slug = request.input('slug', '').trim();
77
97
  if (!name || !slug)
@@ -179,11 +199,7 @@ export default class AccountOrgsController {
179
199
  // config → defaults). Role fora do catálogo é rejeitada (não cria convite).
180
200
  // Usa o helper PURO `isRoleInCatalog` (mesmo ponto de verdade do caminho admin).
181
201
  const settings = await resolveRuntimeSettings(ctx);
182
- const roleValid = await isRoleInCatalog(role, settings, {
183
- roles: cfg.organizations.roles,
184
- allowSelfCreate: cfg.organizations.allowSelfCreate,
185
- invitationTtlHours: cfg.organizations.invitationTtlHours,
186
- }, params.id);
202
+ const roleValid = await isRoleInCatalog(role, settings, orgPolicyDefaults(cfg), params.id);
187
203
  if (!roleValid) {
188
204
  return response.unprocessableEntity({
189
205
  error: { code: 'invalid_role', message: 'Role inválida.' },
@@ -194,12 +210,14 @@ export default class AccountOrgsController {
194
210
  if (role === 'owner' && membership.role !== 'owner') {
195
211
  return response.forbidden();
196
212
  }
213
+ // TTL da política efetiva da org (setting org → global → config), não só do config.
214
+ const policy = await resolveEffectiveOrganizationsPolicy(settings, orgPolicyDefaults(cfg), params.id);
197
215
  const { invitation, token } = await store.createOrgInvitation({
198
216
  organizationId: params.id,
199
217
  email,
200
218
  role,
201
219
  invitedBy: accountId,
202
- ttlHours: cfg.organizations.invitationTtlHours,
220
+ ttlHours: policy.invitationTtlHours,
203
221
  });
204
222
  // Sends the invitation email (best-effort). The host hook wins when present;
205
223
  // otherwise the host-kit sends its own branded/translated email, like every
@@ -243,6 +261,8 @@ export default class AccountOrgsController {
243
261
  const { createHash } = await import('node:crypto');
244
262
  if (!supportsOrganizations(store))
245
263
  return response.notFound();
264
+ // Rota fora do guard: a sessão do IdP (SSO) também vale, se configurado.
265
+ await ensureConsoleSession(ctx);
246
266
  const accountId = session.get(ACCOUNT_SESSION_KEY);
247
267
  if (!accountId) {
248
268
  // Não logado: redireciona para login (configurável) com return URL
@@ -272,6 +292,8 @@ export default class AccountOrgsController {
272
292
  const { createHash } = await import('node:crypto');
273
293
  if (!supportsOrganizations(store))
274
294
  return response.notFound();
295
+ // Rota fora do guard: a sessão do IdP (SSO) também vale, se configurado.
296
+ await ensureConsoleSession(ctx);
275
297
  const accountId = session.get(ACCOUNT_SESSION_KEY);
276
298
  if (!accountId)
277
299
  return response.redirect(getAccountLoginUrl());
@@ -4,6 +4,7 @@ import { getAccountLoginUrl } from '../account_login_url.js';
4
4
  import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
5
5
  import { syncAdonisAuthLogin, syncAdonisAuthLogout } from '../adonis_auth_sync.js';
6
6
  import { translate } from '../i18n.js';
7
+ import { endBridgedIdpSession } from '../idp_session_bridge.js';
7
8
  import { attemptPasswordLogin } from '../login_attempt.js';
8
9
  import { notifyLoginSuccess } from '../login_notify.js';
9
10
  import { resolveRuntimeSettings } from '../runtime_settings.js';
@@ -134,6 +135,9 @@ export default class AccountSessionController {
134
135
  // timestamp, sem dono. Passou a ser vinculada — por isso este `forget`
135
136
  // continua sendo só do `ACCOUNT_SESSION_KEY`: trocada a conta, a marca de
136
137
  // sudo remanescente já não vale para ninguém.
138
+ // Console aberto pela sessão do IdP (SSO): encerra também essa sessão, senão
139
+ // o próximo request reabriria o console pela ponte e o "Sair" não valeria.
140
+ await endBridgedIdpSession(ctx);
137
141
  ctx.session.forget(ACCOUNT_SESSION_KEY);
138
142
  await ctx.session.regenerate();
139
143
  // Opt-in: espelha o logout no guard de @adonisjs/auth (ver adonisAuth em define_config.ts).
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Ponte SSO: sessão do IdP → sessão do console de conta (`/account/*`, `/admin/*`).
3
+ *
4
+ * POR QUE EXISTE. São duas sessões diferentes no mesmo host:
5
+ *
6
+ * - a sessão do IdP (oidc-provider): cookie `_session`, criada quando o
7
+ * usuário completa a interaction de login do `/oidc/auth` (senha, magic
8
+ * link, OTP, passkey, social). É ela que dá o SSO entre os clients OIDC;
9
+ * - a sessão do console: a chave `ACCOUNT_SESSION_KEY` na sessão do Adonis,
10
+ * criada SÓ pelo login do próprio console (`POST /account/login`).
11
+ *
12
+ * O login do IdP nunca escrevia a segunda, então um usuário que acabou de
13
+ * entrar num app OIDC (inclusive o próprio host, quando ele é IdP e RP ao mesmo
14
+ * tempo) e abria `/account/*` levava um SEGUNDO pedido de login.
15
+ *
16
+ * Com `accountSession.acceptIdpSession: true`, os guards do console aceitam a
17
+ * sessão ATIVA do IdP: sem sessão de console, mas com uma sessão do IdP válida
18
+ * (cookie assinado + registro no adapter, não expirado) de uma conta existente
19
+ * e habilitada, o console é aberto para essa conta. A ponte fica AMARRADA à
20
+ * sessão do IdP que a originou (o `uid` dela vai na sessão do Adonis): quando a
21
+ * sessão do IdP acaba (logout OIDC, expiração, troca de conta), o console
22
+ * derivado dela acaba junto no próximo request.
23
+ *
24
+ * Default `false`: quem não liga continua exatamente como antes.
25
+ */
26
+ import type { HttpContext } from '@adonisjs/core/http';
27
+ /** Chave da sessão Adonis com o `uid` da sessão do IdP que originou o console. */
28
+ export declare const ACCOUNT_IDP_SESSION_KEY = "authkit_idp_session_uid";
29
+ /** O que interessa de uma sessão do oidc-provider (instância do model `Session`). */
30
+ interface IdpSession {
31
+ uid: string;
32
+ accountId?: string;
33
+ destroy(): Promise<void>;
34
+ }
35
+ /**
36
+ * Lê a sessão do IdP a partir do cookie do request (mesma leitura que o
37
+ * provider faz: cookie `_session` assinado com as keys do provider, e o registro
38
+ * no adapter, que já descarta expirados). `null` sem sessão logada.
39
+ */
40
+ export declare function readIdpSession(ctx: HttpContext, service: any): Promise<IdpSession | null>;
41
+ /**
42
+ * Garante a sessão do console, aceitando a sessão do IdP quando configurado.
43
+ * Devolve `true` quando o request tem (ou passou a ter) sessão de console.
44
+ *
45
+ * Sem `accountSession.acceptIdpSession`, é só "existe `ACCOUNT_SESSION_KEY`?" —
46
+ * o comportamento de sempre, sem tocar no provider.
47
+ */
48
+ export declare function ensureConsoleSession(ctx: HttpContext): Promise<boolean>;
49
+ /**
50
+ * Logout do console de uma sessão que veio da ponte: encerra também a sessão
51
+ * do IdP que a originou — senão o próximo request reabriria o console pela
52
+ * própria ponte e o "Sair" não teria efeito. No-op fora da ponte.
53
+ */
54
+ export declare function endBridgedIdpSession(ctx: HttpContext): Promise<void>;
55
+ export {};
@@ -0,0 +1,108 @@
1
+ import { ACCOUNT_SESSION_KEY } from './account_session_key.js';
2
+ import { syncAdonisAuthLogin } from './adonis_auth_sync.js';
3
+ import { impersonationState } from './impersonation_session.js';
4
+ import { assertAccountEnabled } from './login_attempt.js';
5
+ /** Chave da sessão Adonis com o `uid` da sessão do IdP que originou o console. */
6
+ export const ACCOUNT_IDP_SESSION_KEY = 'authkit_idp_session_uid';
7
+ /**
8
+ * Lê a sessão do IdP a partir do cookie do request (mesma leitura que o
9
+ * provider faz: cookie `_session` assinado com as keys do provider, e o registro
10
+ * no adapter, que já descarta expirados). `null` sem sessão logada.
11
+ */
12
+ export async function readIdpSession(ctx, service) {
13
+ const provider = service?.provider;
14
+ if (!provider?.Session || typeof provider.createContext !== 'function')
15
+ return null;
16
+ try {
17
+ const kctx = provider.createContext(ctx.request.request, ctx.response.response);
18
+ const id = kctx.cookies.get(provider.cookieName('session'));
19
+ if (!id)
20
+ return null;
21
+ const session = (await provider.Session.find(id));
22
+ return session?.accountId ? session : null;
23
+ }
24
+ catch {
25
+ // Fail-safe: sem ponte (o guard segue para o login normal).
26
+ return null;
27
+ }
28
+ }
29
+ function acceptsIdpSession(service) {
30
+ return service?.config?.accountSession?.acceptIdpSession === true;
31
+ }
32
+ /** Id do humano por trás da sessão do console (impersonation → o admin real). */
33
+ function realConsoleAccount(ctx, current) {
34
+ try {
35
+ return impersonationState(ctx).impersonatorId ?? current;
36
+ }
37
+ catch {
38
+ return current;
39
+ }
40
+ }
41
+ /**
42
+ * Garante a sessão do console, aceitando a sessão do IdP quando configurado.
43
+ * Devolve `true` quando o request tem (ou passou a ter) sessão de console.
44
+ *
45
+ * Sem `accountSession.acceptIdpSession`, é só "existe `ACCOUNT_SESSION_KEY`?" —
46
+ * o comportamento de sempre, sem tocar no provider.
47
+ */
48
+ export async function ensureConsoleSession(ctx) {
49
+ const current = ctx.session?.get(ACCOUNT_SESSION_KEY);
50
+ const service = await ctx.containerResolver?.make('authkit.server').catch(() => null);
51
+ if (!acceptsIdpSession(service))
52
+ return Boolean(current);
53
+ const bridgedUid = ctx.session?.get(ACCOUNT_IDP_SESSION_KEY);
54
+ // Login próprio do console (não veio da ponte): intocado.
55
+ if (current && !bridgedUid)
56
+ return true;
57
+ const idp = await readIdpSession(ctx, service);
58
+ if (current && bridgedUid) {
59
+ if (idp && idp.uid === bridgedUid && idp.accountId === realConsoleAccount(ctx, current)) {
60
+ return true;
61
+ }
62
+ // A sessão do IdP que originou o console acabou (ou virou outra conta):
63
+ // encerra o console derivado dela. Se houver outra sessão do IdP viva, a
64
+ // ponte abaixo reabre para a conta dela.
65
+ ctx.session.forget(ACCOUNT_SESSION_KEY);
66
+ ctx.session.forget(ACCOUNT_IDP_SESSION_KEY);
67
+ }
68
+ if (!idp?.accountId)
69
+ return false;
70
+ const cfg = service.config;
71
+ const account = await cfg.accountStore.findById(idp.accountId);
72
+ if (!account)
73
+ return false;
74
+ const gate = await assertAccountEnabled(cfg, account.id, {
75
+ email: account.email ?? '',
76
+ ip: ctx.request.ip?.() ?? null,
77
+ });
78
+ if (!gate.allowed)
79
+ return false;
80
+ // Elevação anônimo → autenticado: troca o id da sessão (anti-fixation), como
81
+ // o login do console faz.
82
+ await ctx.session.regenerate();
83
+ ctx.session.put(ACCOUNT_SESSION_KEY, account.id);
84
+ ctx.session.put(ACCOUNT_IDP_SESSION_KEY, idp.uid);
85
+ await syncAdonisAuthLogin(ctx, cfg, account);
86
+ return true;
87
+ }
88
+ /**
89
+ * Logout do console de uma sessão que veio da ponte: encerra também a sessão
90
+ * do IdP que a originou — senão o próximo request reabriria o console pela
91
+ * própria ponte e o "Sair" não teria efeito. No-op fora da ponte.
92
+ */
93
+ export async function endBridgedIdpSession(ctx) {
94
+ const bridgedUid = ctx.session?.get(ACCOUNT_IDP_SESSION_KEY);
95
+ if (!bridgedUid)
96
+ return;
97
+ ctx.session.forget(ACCOUNT_IDP_SESSION_KEY);
98
+ const service = await ctx.containerResolver?.make('authkit.server').catch(() => null);
99
+ const idp = await readIdpSession(ctx, service);
100
+ if (idp && idp.uid === bridgedUid) {
101
+ try {
102
+ await idp.destroy();
103
+ }
104
+ catch {
105
+ // best-effort
106
+ }
107
+ }
108
+ }
@@ -1,11 +1,12 @@
1
1
  import '../augmentations.js';
2
2
  import { getAccountLoginUrl } from '../account_login_url.js';
3
3
  import { ACCOUNT_SESSION_KEY } from '../account_session_key.js';
4
+ import { ensureConsoleSession } from '../idp_session_bridge.js';
4
5
  export { ACCOUNT_SESSION_KEY };
5
6
  export default class AccountAuthMiddleware {
6
7
  async handle(ctx, next) {
7
- const userId = ctx.session.get(ACCOUNT_SESSION_KEY);
8
- if (!userId) {
8
+ // Sessão do console — ou, com `accountSession.acceptIdpSession`, a do IdP (SSO).
9
+ if (!(await ensureConsoleSession(ctx))) {
9
10
  // Destino configurável (`accountLoginUrl`): default `/account/login`.
10
11
  return ctx.response.redirect(getAccountLoginUrl());
11
12
  }
@@ -7,6 +7,7 @@ import { ACCOUNT_SESSION_KEY } from './account_session_key.js';
7
7
  import { adminApiGuard } from './admin_api/admin_api_guard.js';
8
8
  import { normalizeAdminApiPrefix, normalizeAdminPrefix, setAdminApiPrefix, setAdminPrefix, } from './admin_prefix.js';
9
9
  import { getAuthHostConfig, markAuthHostAutoMounted, wasAuthHostAutoMounted, } from './auth_host_config.js';
10
+ import { ensureConsoleSession } from './idp_session_bridge.js';
10
11
  import { createAuthThrottles } from './rate_limit.js';
11
12
  import { resolveRuntimeSettings } from './runtime_settings.js';
12
13
  import { resolveEffectiveSessionPolicy } from './runtime_toggles.js';
@@ -113,7 +114,8 @@ function buildLoginRedirect(ctx, extra) {
113
114
  * /account/mfa acessíveis sem sessão.
114
115
  */
115
116
  const accountGuard = async (ctx, next) => {
116
- if (!ctx.session?.get(ACCOUNT_SESSION_KEY)) {
117
+ // Sessão do console — ou, com `accountSession.acceptIdpSession`, a do IdP (SSO).
118
+ if (!(await ensureConsoleSession(ctx))) {
117
119
  return ctx.response.redirect(buildLoginRedirect(ctx));
118
120
  }
119
121
  // Idle timeout: encerra e redireciona com query param de motivo.
@@ -143,6 +145,8 @@ export const adminGuard = async (ctx, next) => {
143
145
  if (!cfg.admin.enabled) {
144
146
  return ctx.response.notFound();
145
147
  }
148
+ // Com `accountSession.acceptIdpSession`, a sessão do IdP também abre o console.
149
+ await ensureConsoleSession(ctx);
146
150
  const accountId = ctx.session?.get(ACCOUNT_SESSION_KEY);
147
151
  if (!accountId) {
148
152
  // `/account/login` é sempre o login da conta — NÃO muda com o prefixo admin.
@@ -762,11 +762,11 @@ export declare function resolveEffectiveRolesCatalog(settings: SettingsCapabilit
762
762
  *
763
763
  * Invariante mantida: 'owner' é sempre incluído na lista de roles (governance).
764
764
  *
765
- * @param settings - SettingsCapability
765
+ * @param settings - SettingsCapability, ou null (runtime settings indisponível → config/defaults)
766
766
  * @param configDefault - defaults estáticos do config do host
767
767
  * @param orgId - opcional; quando fornecido, tenta settings da org antes do global
768
768
  */
769
- export declare function resolveEffectiveOrganizationsPolicy(settings: SettingsCapability, configDefault?: OrganizationsPolicyConfigDefaults, orgId?: string | null): Promise<ResolvedOrganizationsPolicySetting>;
769
+ export declare function resolveEffectiveOrganizationsPolicy(settings: SettingsCapability | null, configDefault?: OrganizationsPolicyConfigDefaults, orgId?: string | null): Promise<ResolvedOrganizationsPolicySetting>;
770
770
  /**
771
771
  * Resolve o CATÁLOGO efetivo de roles de org (lista de roles permitidas),
772
772
  * garantindo a invariante de governance: `owner` está sempre presente.
@@ -737,7 +737,7 @@ export async function resolveEffectiveRolesCatalog(settings, orgId) {
737
737
  *
738
738
  * Invariante mantida: 'owner' é sempre incluído na lista de roles (governance).
739
739
  *
740
- * @param settings - SettingsCapability
740
+ * @param settings - SettingsCapability, ou null (runtime settings indisponível → config/defaults)
741
741
  * @param configDefault - defaults estáticos do config do host
742
742
  * @param orgId - opcional; quando fornecido, tenta settings da org antes do global
743
743
  */
@@ -769,6 +769,8 @@ export async function resolveEffectiveOrganizationsPolicy(settings, configDefaul
769
769
  roles,
770
770
  };
771
771
  }
772
+ if (!settings)
773
+ return defaults;
772
774
  try {
773
775
  // Resolução org → global → defaults
774
776
  if (orgId) {
@@ -3,6 +3,7 @@ import { pickModelAdapterClass } from '../adapters/factory.js';
3
3
  import { normalizeActiveOrg, readActiveOrgFromKoaCtx } from '../host/active_org_cookie.js';
4
4
  import { createDeviceSources } from './device_sources.js';
5
5
  import { createLogoutSources } from './logout_sources.js';
6
+ import { registrationPolicyMiddleware } from './registration_policy.js';
6
7
  /** Atualiza o holder mutável do TTL de sessão com os valores da setting. */
7
8
  export function updateSessionTtlHolder(holder, policy) {
8
9
  holder.rememberSec = Math.max(1, Math.floor(policy.rememberDays * 86400));
@@ -47,18 +48,34 @@ export function buildProvider(config, options, sessionTtlHolder, tokenTtlHolder)
47
48
  },
48
49
  }
49
50
  : {};
50
- // Access Tokens RFC 9068 (JWT) via Resource Indicators (RFC 8707). Só montamos a
51
- // feature quando ALGUM AT deve ser JWT — caso contrário (default opaque) o
52
- // oidc-provider mantém o comportamento atual (AT opaco introspecionável) intocado.
51
+ // Resource Indicators (RFC 8707). A feature do oidc-provider é montada quando:
53
52
  //
54
- // Um JWT AT no oidc-provider SEMPRE exige um resource indicator com `aud`: o
55
- // `defaultResource` injeta a resource default (o `audience`, default issuer) quando
56
- // o client não pede `resource` explicitamente, e o `getResourceServerInfo` descreve
57
- // a API (scope/audience/formato/TTL) — onde `accessTokenFormat: 'jwt'` faz o token
58
- // sair como JWS `typ: at+jwt` assinado com a chave corrente do JWKS.
53
+ // - ALGUM AT deve ser JWT (RFC 9068): um JWT AT SEMPRE exige um resource com
54
+ // `aud`, então o `defaultResource` injeta o `audience` (default issuer)
55
+ // quando o client não pede `resource` — comportamento histórico; ou
56
+ // - há `accessTokens.resources` declarados, mesmo todos OPACOS: clientes que
57
+ // mandam `resource` (ex.: clientes MCP, cuja spec exige o parâmetro) passam
58
+ // a ser aceitos. Sem `resource` no pedido, NADA muda: o `defaultResource`
59
+ // devolve `undefined` e o AT continua o opaco de sempre (userinfo, sessão
60
+ // web), sem `aud`.
61
+ //
62
+ // Nos dois casos o `resource` pedido é validado contra a lista declarada
63
+ // (chaves de `resources` + o `audience` no modo JWT): fora dela → `invalid_target`
64
+ // (RFC 8707 §2). O resource concedido fica registrado no Grant (consent) e no
65
+ // AT (`aud` = `audience` da resource), inclusive no token opaco, que continua
66
+ // encontrável por `AccessToken.find` e introspecionável.
59
67
  const at = config.accessTokens;
60
68
  const allScopes = ['openid', 'profile', 'email', 'offline_access', 'roles'];
61
- const resourceIndicatorFeatures = at.anyJwt
69
+ const declaredResources = Object.keys(at.resources);
70
+ const findResource = (indicator) => {
71
+ if (at.resources[indicator])
72
+ return { key: indicator, rc: at.resources[indicator] };
73
+ // Tolerância à barra final (`https://app/mcp` ≡ `https://app/mcp/`).
74
+ const trimmed = indicator.replace(/\/+$/, '');
75
+ const key = declaredResources.find((k) => k.replace(/\/+$/, '') === trimmed);
76
+ return key ? { key, rc: at.resources[key] } : null;
77
+ };
78
+ const resourceIndicatorFeatures = at.anyJwt || declaredResources.length > 0
62
79
  ? {
63
80
  resourceIndicators: {
64
81
  enabled: true,
@@ -67,14 +84,20 @@ export function buildProvider(config, options, sessionTtlHolder, tokenTtlHolder)
67
84
  // resources já concedidas — devolvemos para não falhar a request.
68
85
  if (oneOf)
69
86
  return oneOf;
70
- // Authorize/sem resource explícito: liga ao resource default (modo simples).
71
- return at.audience;
87
+ // Authorize/sem resource explícito: no modo JWT, liga ao resource
88
+ // default (modo simples). Só opaco: sem resource (AT de sempre).
89
+ return at.anyJwt ? at.audience : undefined;
72
90
  },
73
91
  useGrantedResource: async () => true,
74
92
  getResourceServerInfo: (_ctx, resourceIndicator, _client) => {
75
- const rc = at.resources[resourceIndicator];
76
- const format = rc?.format ?? (resourceIndicator === at.audience ? at.format : 'opaque');
77
- const audience = rc?.audience ?? resourceIndicator;
93
+ const found = findResource(resourceIndicator);
94
+ const isDefault = at.anyJwt && resourceIndicator === at.audience;
95
+ if (!found && !isDefault) {
96
+ throw new oidc.errors.InvalidTarget(`resource indicator not allowed: ${resourceIndicator}`);
97
+ }
98
+ const rc = found?.rc;
99
+ const format = rc?.format ?? at.format;
100
+ const audience = rc?.audience ?? found?.key ?? resourceIndicator;
78
101
  const scope = (rc?.scopes ?? allScopes).join(' ');
79
102
  const info = {
80
103
  scope,
@@ -279,6 +302,15 @@ export function buildProvider(config, options, sessionTtlHolder, tokenTtlHolder)
279
302
  writable: true,
280
303
  configurable: true,
281
304
  });
305
+ // Política do registro dinâmico (redirect URIs + só fluxo de código + gancho do
306
+ // host). Middleware PRÉ-rota: roda antes do `/reg` do provider, que sozinho
307
+ // aceitaria qualquer redirect sintaticamente válido. Ver registration_policy.ts.
308
+ if (dynReg.enabled && (dynReg.redirectUriPolicy || dynReg.validateRegistration)) {
309
+ provider.use(registrationPolicyMiddleware({
310
+ policy: dynReg.redirectUriPolicy,
311
+ validate: dynReg.validateRegistration,
312
+ }));
313
+ }
282
314
  provider.proxy = true;
283
315
  return provider;
284
316
  }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Política do registro dinâmico de clients (RFC 7591 / RFC 7592).
3
+ *
4
+ * O oidc-provider, sozinho, aceita qualquer `redirect_uri` que passe na
5
+ * validação sintática de metadata (qualquer `https://…`, por exemplo). Com o
6
+ * registro ABERTO (sem Initial Access Token — o caso dos clientes MCP, que não
7
+ * têm como obter um IAT), isso deixa qualquer um registrar um client cujo
8
+ * callback é um domínio do atacante e usar a tela de consent do IdP como isca.
9
+ * O que protege o usuário é PARA ONDE o código de autorização pode ir.
10
+ *
11
+ * Esta política roda ANTES do provider (middleware Koa em `provider.use`), no
12
+ * `POST /reg` (criação) e no `PUT /reg/:clientId` (update do RFC 7592), e:
13
+ *
14
+ * 1. confere cada `redirect_uris` / `post_logout_redirect_uris` contra a
15
+ * política (loopback, URLs exatas, esquemas de app instalado, https);
16
+ * 2. restringe o client ao fluxo de código (`authorization_code` +
17
+ * `refresh_token`, `response_type=code`) — PKCE já é obrigatório no IdP;
18
+ * 3. normaliza `application_type: 'native'` quando todos os redirects são
19
+ * loopback/app instalado (é o que o client é, e sem isso o oidc-provider
20
+ * recusa esquema próprio);
21
+ * 4. chama o gancho `validateRegistration` do host, se houver.
22
+ *
23
+ * Funções puras + um middleware fino, para serem testadas isoladamente.
24
+ */
25
+ /** Política de redirect URIs aceita no registro dinâmico. */
26
+ export interface RedirectUriPolicy {
27
+ /**
28
+ * Aceita `http://localhost`, `http://127.0.0.1` e `http://[::1]` em QUALQUER
29
+ * porta e path (RFC 8252 §7.3 — apps nativos/CLIs escutam numa porta efêmera).
30
+ * Default: `true`.
31
+ */
32
+ loopback?: boolean;
33
+ /** URLs de callback aceitas por igualdade EXATA (ex.: callbacks de fornecedores). Default: `[]`. */
34
+ exact?: string[];
35
+ /**
36
+ * Esquemas privados de app instalado (RFC 8252 §7.1), sem o `:`, ex.:
37
+ * `['cursor', 'vscode']`. Default: `[]`.
38
+ */
39
+ appSchemes?: string[];
40
+ /**
41
+ * Aceita QUALQUER redirect `https://`. É o comportamento do oidc-provider sem
42
+ * política — só faz sentido com registro protegido por Initial Access Token.
43
+ * Default: `false`.
44
+ */
45
+ anyHttps?: boolean;
46
+ }
47
+ export interface ResolvedRedirectUriPolicy {
48
+ loopback: boolean;
49
+ exact: string[];
50
+ appSchemes: string[];
51
+ anyHttps: boolean;
52
+ }
53
+ /** Operação de registro que está sendo validada. */
54
+ export type RegistrationOperation = 'create' | 'update';
55
+ /**
56
+ * Gancho do host para validar/ajustar o metadata de um registro dinâmico,
57
+ * depois da política de redirect. Pode:
58
+ * - retornar `void` → segue com o metadata como está;
59
+ * - retornar um objeto → ele SUBSTITUI o metadata enviado ao provider;
60
+ * - lançar {@link RegistrationPolicyError} → o registro é recusado com
61
+ * `400 { error, error_description }`.
62
+ * Qualquer outro erro sobe (500).
63
+ */
64
+ export type ValidateRegistrationHook = (metadata: Record<string, unknown>, info: {
65
+ operation: RegistrationOperation;
66
+ ctx: unknown;
67
+ }) => void | Record<string, unknown> | Promise<void | Record<string, unknown>>;
68
+ /** Erro de política: vira `400 { error: code, error_description: message }`. */
69
+ export declare class RegistrationPolicyError extends Error {
70
+ readonly code: 'invalid_redirect_uri' | 'invalid_client_metadata';
71
+ constructor(code: 'invalid_redirect_uri' | 'invalid_client_metadata', description: string);
72
+ }
73
+ /** Default seguro do registro ABERTO: só loopback, nada de web arbitrário. */
74
+ export declare const OPEN_REGISTRATION_REDIRECT_POLICY: ResolvedRedirectUriPolicy;
75
+ export declare function resolveRedirectUriPolicy(input: RedirectUriPolicy): ResolvedRedirectUriPolicy;
76
+ export type RedirectKind = 'loopback' | 'app' | 'web';
77
+ /** Classifica um redirect pela política; `null` = fora dela. */
78
+ export declare function classifyRedirect(uri: string, policy: ResolvedRedirectUriPolicy): RedirectKind | null;
79
+ /**
80
+ * Confere o metadata de um registro contra a política. Devolve o metadata
81
+ * normalizado ou lança {@link RegistrationPolicyError}.
82
+ */
83
+ export declare function checkClientRegistration(metadata: Record<string, unknown>, policy: ResolvedRedirectUriPolicy): Record<string, unknown>;
84
+ /**
85
+ * Qual operação de registro o request atinge, com a MESMA regra de casamento do
86
+ * router do provider: case-insensitive e, se o path exato não casar, uma nova
87
+ * tentativa sem UMA barra final (`/reg/` ≡ `/reg`). `null` = não é registro.
88
+ */
89
+ export declare function registrationOperation(method: string, path: string, foldedBase: string): RegistrationOperation | null;
90
+ /**
91
+ * Middleware Koa (para `provider.use`) que aplica a política no registro
92
+ * dinâmico. `registrationPath` é o path da rota DENTRO do provider (default
93
+ * `/reg`; sob koa-mount o prefixo do issuer já foi removido).
94
+ */
95
+ export declare function registrationPolicyMiddleware(options: {
96
+ policy: ResolvedRedirectUriPolicy | null;
97
+ validate?: ValidateRegistrationHook;
98
+ registrationPath?: string;
99
+ }): (ctx: any, next: () => Promise<void>) => Promise<void>;
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Política do registro dinâmico de clients (RFC 7591 / RFC 7592).
3
+ *
4
+ * O oidc-provider, sozinho, aceita qualquer `redirect_uri` que passe na
5
+ * validação sintática de metadata (qualquer `https://…`, por exemplo). Com o
6
+ * registro ABERTO (sem Initial Access Token — o caso dos clientes MCP, que não
7
+ * têm como obter um IAT), isso deixa qualquer um registrar um client cujo
8
+ * callback é um domínio do atacante e usar a tela de consent do IdP como isca.
9
+ * O que protege o usuário é PARA ONDE o código de autorização pode ir.
10
+ *
11
+ * Esta política roda ANTES do provider (middleware Koa em `provider.use`), no
12
+ * `POST /reg` (criação) e no `PUT /reg/:clientId` (update do RFC 7592), e:
13
+ *
14
+ * 1. confere cada `redirect_uris` / `post_logout_redirect_uris` contra a
15
+ * política (loopback, URLs exatas, esquemas de app instalado, https);
16
+ * 2. restringe o client ao fluxo de código (`authorization_code` +
17
+ * `refresh_token`, `response_type=code`) — PKCE já é obrigatório no IdP;
18
+ * 3. normaliza `application_type: 'native'` quando todos os redirects são
19
+ * loopback/app instalado (é o que o client é, e sem isso o oidc-provider
20
+ * recusa esquema próprio);
21
+ * 4. chama o gancho `validateRegistration` do host, se houver.
22
+ *
23
+ * Funções puras + um middleware fino, para serem testadas isoladamente.
24
+ */
25
+ /** Erro de política: vira `400 { error: code, error_description: message }`. */
26
+ export class RegistrationPolicyError extends Error {
27
+ code;
28
+ constructor(code, description) {
29
+ super(description);
30
+ this.name = 'RegistrationPolicyError';
31
+ this.code = code;
32
+ }
33
+ }
34
+ /** Default seguro do registro ABERTO: só loopback, nada de web arbitrário. */
35
+ export const OPEN_REGISTRATION_REDIRECT_POLICY = Object.freeze({
36
+ loopback: true,
37
+ exact: [],
38
+ appSchemes: [],
39
+ anyHttps: false,
40
+ });
41
+ export function resolveRedirectUriPolicy(input) {
42
+ return {
43
+ loopback: input.loopback ?? true,
44
+ exact: [...(input.exact ?? [])],
45
+ appSchemes: (input.appSchemes ?? []).map((s) => s.replace(/:$/, '').toLowerCase()),
46
+ anyHttps: input.anyHttps ?? false,
47
+ };
48
+ }
49
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
50
+ /** Classifica um redirect pela política; `null` = fora dela. */
51
+ export function classifyRedirect(uri, policy) {
52
+ let url;
53
+ try {
54
+ url = new URL(uri);
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ // Credencial embutida e fragmento nunca são redirect legítimo (RFC 6749 §3.1.2).
60
+ if (url.username || url.password || url.hash)
61
+ return null;
62
+ if (policy.loopback && url.protocol === 'http:' && LOOPBACK_HOSTS.has(url.hostname)) {
63
+ return 'loopback';
64
+ }
65
+ if (policy.exact.includes(uri))
66
+ return 'web';
67
+ const scheme = url.protocol.slice(0, -1).toLowerCase();
68
+ if (policy.appSchemes.includes(scheme))
69
+ return 'app';
70
+ if (policy.anyHttps && url.protocol === 'https:')
71
+ return 'web';
72
+ return null;
73
+ }
74
+ function stringList(value) {
75
+ if (value === undefined)
76
+ return null;
77
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string'))
78
+ return null;
79
+ return value;
80
+ }
81
+ const ALLOWED_GRANTS = new Set(['authorization_code', 'refresh_token']);
82
+ /**
83
+ * Confere o metadata de um registro contra a política. Devolve o metadata
84
+ * normalizado ou lança {@link RegistrationPolicyError}.
85
+ */
86
+ export function checkClientRegistration(metadata, policy) {
87
+ const redirects = stringList(metadata.redirect_uris);
88
+ if (!redirects || redirects.length === 0) {
89
+ throw new RegistrationPolicyError('invalid_redirect_uri', 'redirect_uris is required');
90
+ }
91
+ const kinds = [];
92
+ for (const uri of redirects) {
93
+ const kind = classifyRedirect(uri, policy);
94
+ if (!kind) {
95
+ throw new RegistrationPolicyError('invalid_redirect_uri', `redirect_uri not allowed by the registration policy: ${uri}`);
96
+ }
97
+ kinds.push(kind);
98
+ }
99
+ if (metadata.post_logout_redirect_uris !== undefined) {
100
+ const logout = stringList(metadata.post_logout_redirect_uris);
101
+ if (!logout || logout.some((uri) => !classifyRedirect(uri, policy))) {
102
+ throw new RegistrationPolicyError('invalid_client_metadata', 'post_logout_redirect_uris not allowed by the registration policy');
103
+ }
104
+ }
105
+ const grants = metadata.grant_types === undefined ? ['authorization_code'] : stringList(metadata.grant_types);
106
+ if (!grants?.includes('authorization_code') || grants.some((g) => !ALLOWED_GRANTS.has(g))) {
107
+ throw new RegistrationPolicyError('invalid_client_metadata', 'only the authorization_code and refresh_token grant types are allowed');
108
+ }
109
+ const responses = metadata.response_types === undefined ? ['code'] : stringList(metadata.response_types);
110
+ if (responses?.length !== 1 || responses[0] !== 'code') {
111
+ throw new RegistrationPolicyError('invalid_client_metadata', 'only response_type=code is allowed');
112
+ }
113
+ const native = kinds.every((kind) => kind !== 'web');
114
+ return native && metadata.application_type === undefined
115
+ ? { ...metadata, application_type: 'native' }
116
+ : metadata;
117
+ }
118
+ /** Mesmo teto do `selective_body` do oidc-provider. */
119
+ const BODY_LIMIT = 56 * 1024;
120
+ /**
121
+ * Lê o corpo JSON do request. Quando o Adonis já parseou (a ponte do
122
+ * `OidcCallbackController` põe o objeto em `req.body`), usa-o; senão consome o
123
+ * stream. Devolve `undefined` quando não dá para interpretar como objeto — o
124
+ * provider segue e responde o erro de parse ele mesmo.
125
+ */
126
+ async function readJsonBody(ctx) {
127
+ const req = ctx.req;
128
+ let raw;
129
+ if (req.readable && !req.readableEnded) {
130
+ const chunks = [];
131
+ let size = 0;
132
+ for await (const chunk of req) {
133
+ size += chunk.length;
134
+ if (size > BODY_LIMIT)
135
+ return undefined;
136
+ chunks.push(chunk);
137
+ }
138
+ raw = Buffer.concat(chunks).toString('utf8');
139
+ // O stream foi consumido: o provider cai no fallback `req.body`.
140
+ req.body = raw;
141
+ }
142
+ else {
143
+ raw = req.body ?? ctx.request?.body;
144
+ }
145
+ if (typeof raw === 'string' || Buffer.isBuffer(raw)) {
146
+ try {
147
+ raw = JSON.parse(raw.toString());
148
+ }
149
+ catch {
150
+ return undefined;
151
+ }
152
+ }
153
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
154
+ return undefined;
155
+ return raw;
156
+ }
157
+ /**
158
+ * Case-folding IDÊNTICO ao do router do oidc-provider (`lib/helpers/router.js`,
159
+ * `fold`): o router casa rotas sem diferenciar maiúsculas. Comparar o path cru
160
+ * deixaria `POST /REG` chegar ao handler de registro sem passar pela política.
161
+ */
162
+ function foldPath(value) {
163
+ for (let i = 0; i < value.length; i += 1) {
164
+ if (value.charCodeAt(i) > 127) {
165
+ let out = '';
166
+ for (const char of value) {
167
+ const upper = char.toUpperCase();
168
+ out +=
169
+ upper.length === 1 &&
170
+ !((char.codePointAt(0) ?? 0) > 127 && (upper.codePointAt(0) ?? 0) < 128)
171
+ ? upper
172
+ : char;
173
+ }
174
+ return out;
175
+ }
176
+ }
177
+ return value.toUpperCase();
178
+ }
179
+ /**
180
+ * Qual operação de registro o request atinge, com a MESMA regra de casamento do
181
+ * router do provider: case-insensitive e, se o path exato não casar, uma nova
182
+ * tentativa sem UMA barra final (`/reg/` ≡ `/reg`). `null` = não é registro.
183
+ */
184
+ export function registrationOperation(method, path, foldedBase) {
185
+ const folded = foldPath(path);
186
+ if (method === 'POST') {
187
+ const trimmed = folded.length > 1 && folded.endsWith('/') ? folded.slice(0, -1) : folded;
188
+ return folded === foldedBase || trimmed === foldedBase ? 'create' : null;
189
+ }
190
+ // `PUT <base>/:clientId` (RFC 7592). Mais largo que o router de propósito:
191
+ // tudo sob `<base>/` passa pela política.
192
+ if (method === 'PUT' && folded.startsWith(`${foldedBase}/`))
193
+ return 'update';
194
+ return null;
195
+ }
196
+ /**
197
+ * Middleware Koa (para `provider.use`) que aplica a política no registro
198
+ * dinâmico. `registrationPath` é o path da rota DENTRO do provider (default
199
+ * `/reg`; sob koa-mount o prefixo do issuer já foi removido).
200
+ */
201
+ export function registrationPolicyMiddleware(options) {
202
+ const base = foldPath(options.registrationPath ?? '/reg');
203
+ return async (ctx, next) => {
204
+ const operation = registrationOperation(ctx.method, ctx.path, base);
205
+ if (!operation || !ctx.is('application/json'))
206
+ return next();
207
+ const metadata = await readJsonBody(ctx);
208
+ if (!metadata)
209
+ return next();
210
+ try {
211
+ let checked = options.policy ? checkClientRegistration(metadata, options.policy) : metadata;
212
+ if (options.validate) {
213
+ const replaced = await options.validate(checked, { operation, ctx });
214
+ if (replaced && typeof replaced === 'object')
215
+ checked = replaced;
216
+ }
217
+ ctx.req.body = checked;
218
+ }
219
+ catch (error) {
220
+ if (!(error instanceof RegistrationPolicyError))
221
+ throw error;
222
+ ctx.status = 400;
223
+ ctx.set('cache-control', 'no-store');
224
+ ctx.body = { error: error.code, error_description: error.message };
225
+ return;
226
+ }
227
+ return next();
228
+ };
229
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adonis-agora/authkit-server",
3
- "version": "0.68.4",
3
+ "version": "0.69.0",
4
4
  "description": "AdonisJS OIDC/OAuth2 provider (Identity Provider) toolkit: ejectable auth server with sessions, rate-limiting, MFA/TOTP, audit log, federated logout and OpenTelemetry metrics.",
5
5
  "license": "MIT",
6
6
  "author": "dudousxd",