@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 +2 -0
- package/build/index.js +2 -0
- package/build/src/define_config.d.ts +65 -0
- package/build/src/define_config.js +17 -3
- package/build/src/host/admin_api/admin_orgs_service.js +3 -2
- package/build/src/host/controllers/account_orgs_controller.js +33 -11
- package/build/src/host/controllers/account_session_controller.js +4 -0
- package/build/src/host/idp_session_bridge.d.ts +55 -0
- package/build/src/host/idp_session_bridge.js +108 -0
- package/build/src/host/middleware/account_auth.js +3 -2
- package/build/src/host/register_auth_host.js +5 -1
- package/build/src/host/runtime_toggles.d.ts +2 -2
- package/build/src/host/runtime_toggles.js +3 -1
- package/build/src/provider/build_provider.js +46 -14
- package/build/src/provider/registration_policy.d.ts +99 -0
- package/build/src/provider/registration_policy.js +229 -0
- package/package.json +1 -1
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:
|
|
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
|
-
|
|
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:
|
|
59
|
-
availableRoles:
|
|
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)
|
|
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:
|
|
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
|
-
|
|
8
|
-
if (!
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
55
|
-
// `defaultResource` injeta
|
|
56
|
-
// o client não pede `resource`
|
|
57
|
-
//
|
|
58
|
-
//
|
|
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
|
|
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
|
|
71
|
-
|
|
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
|
|
76
|
-
const
|
|
77
|
-
|
|
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.
|
|
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",
|