@adonis-agora/authkit-server 0.68.4 → 0.70.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/commands/import_users.js +6 -1
- package/build/index.d.ts +5 -1
- package/build/index.js +7 -1
- package/build/src/accounts/account_store.d.ts +40 -0
- package/build/src/accounts/account_store.js +20 -0
- package/build/src/accounts/lucid_store/mfa.js +22 -0
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/audit/audit_sink.js +4 -0
- package/build/src/commands/import_users.d.ts +7 -0
- package/build/src/commands/import_users.js +15 -3
- package/build/src/define_config.d.ts +81 -0
- package/build/src/define_config.js +18 -3
- package/build/src/host/account_api/account_api_controller.d.ts +2 -0
- package/build/src/host/account_api/account_api_controller.js +22 -3
- package/build/src/host/account_api/account_mfa_api_controller.d.ts +101 -0
- package/build/src/host/account_api/account_mfa_api_controller.js +286 -0
- package/build/src/host/account_api/account_orgs_api_controller.d.ts +126 -0
- package/build/src/host/account_api/account_orgs_api_controller.js +468 -0
- package/build/src/host/account_lockout.js +7 -2
- package/build/src/host/admin_api/admin_orgs_service.js +3 -2
- package/build/src/host/admin_api/admin_users_service.js +13 -3
- package/build/src/host/admin_api/dto.d.ts +1 -1
- package/build/src/host/admin_validators.d.ts +2 -2
- package/build/src/host/admin_validators.js +3 -2
- package/build/src/host/controllers/account_mfa_controller.js +1 -2
- package/build/src/host/controllers/account_orgs_controller.js +19 -11
- package/build/src/host/controllers/account_security_controller.js +3 -1
- package/build/src/host/controllers/account_session_controller.js +17 -6
- package/build/src/host/controllers/interaction_controller.d.ts +10 -0
- package/build/src/host/controllers/interaction_controller.js +93 -34
- package/build/src/host/controllers/registration_controller.js +35 -9
- package/build/src/host/controllers/social_controller.js +11 -2
- package/build/src/host/email_identifier.d.ts +92 -0
- package/build/src/host/email_identifier.js +234 -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/org_policy.d.ts +26 -0
- package/build/src/host/org_policy.js +35 -0
- package/build/src/host/passkey_registration_challenge.d.ts +12 -0
- package/build/src/host/passkey_registration_challenge.js +12 -0
- package/build/src/host/register_auth_host.js +56 -1
- package/build/src/host/runtime_toggles.d.ts +2 -2
- package/build/src/host/runtime_toggles.js +3 -1
- package/build/src/host/sudo_mode.d.ts +17 -0
- package/build/src/host/sudo_mode.js +31 -12
- package/build/src/host/ui-dist/assets/{index-D9CYQnZR.js → index-Dct63ai-.js} +2 -2
- package/build/src/host/ui-dist/index.html +1 -1
- package/build/src/host/validators.d.ts +5 -5
- package/build/src/host/validators.js +17 -5
- 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 +2 -2
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
9
9
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
10
10
|
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap" rel="stylesheet">
|
|
11
|
-
<script type="module" crossorigin src="/__AUTHKIT_BASE__/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/__AUTHKIT_BASE__/assets/index-Dct63ai-.js"></script>
|
|
12
12
|
<link rel="stylesheet" crossorigin href="/__AUTHKIT_BASE__/assets/index-DTSmD4RU.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Cadastro passwordless: só e-mail + nome (sem senha). O login vem por magic link. */
|
|
2
2
|
export declare const passwordlessSignupValidator: import("@vinejs/vine").VineValidator<import("@vinejs/vine").VineObject<{
|
|
3
|
-
email: import("@vinejs/vine").VineString
|
|
3
|
+
email: import("@vinejs/vine/schema/base/literal").TransformModifier<import("@vinejs/vine").VineString, string>;
|
|
4
4
|
fullName: import("@vinejs/vine").VineString;
|
|
5
5
|
}, {
|
|
6
6
|
email: string;
|
|
@@ -13,7 +13,7 @@ export declare const passwordlessSignupValidator: import("@vinejs/vine").VineVal
|
|
|
13
13
|
fullName: string;
|
|
14
14
|
}>, Record<string, any> | undefined>;
|
|
15
15
|
export declare const signupValidator: import("@vinejs/vine").VineValidator<import("@vinejs/vine").VineObject<{
|
|
16
|
-
email: import("@vinejs/vine").VineString
|
|
16
|
+
email: import("@vinejs/vine/schema/base/literal").TransformModifier<import("@vinejs/vine").VineString, string>;
|
|
17
17
|
fullName: import("@vinejs/vine").VineString;
|
|
18
18
|
password: import("@vinejs/vine").VineString;
|
|
19
19
|
}, {
|
|
@@ -30,7 +30,7 @@ export declare const signupValidator: import("@vinejs/vine").VineValidator<impor
|
|
|
30
30
|
password: string;
|
|
31
31
|
}>, Record<string, any> | undefined>;
|
|
32
32
|
export declare const forgotPasswordValidator: import("@vinejs/vine").VineValidator<import("@vinejs/vine").VineObject<{
|
|
33
|
-
email: import("@vinejs/vine").VineString
|
|
33
|
+
email: import("@vinejs/vine/schema/base/literal").TransformModifier<import("@vinejs/vine").VineString, string>;
|
|
34
34
|
}, {
|
|
35
35
|
email: string;
|
|
36
36
|
}, {
|
|
@@ -75,7 +75,7 @@ export declare const changePasswordValidator: import("@vinejs/vine").VineValidat
|
|
|
75
75
|
*/
|
|
76
76
|
export declare const changeEmailValidator: import("@vinejs/vine").VineValidator<import("@vinejs/vine").VineObject<{
|
|
77
77
|
currentPassword: import("@vinejs/vine/schema/base/literal").OptionalModifier<import("@vinejs/vine").VineString>;
|
|
78
|
-
newEmail: import("@vinejs/vine").VineString
|
|
78
|
+
newEmail: import("@vinejs/vine/schema/base/literal").TransformModifier<import("@vinejs/vine").VineString, string>;
|
|
79
79
|
}, {
|
|
80
80
|
currentPassword?: string | null | undefined;
|
|
81
81
|
newEmail: string;
|
|
@@ -123,7 +123,7 @@ export declare const deleteAccountValidator: import("@vinejs/vine").VineValidato
|
|
|
123
123
|
}>, Record<string, any> | undefined>;
|
|
124
124
|
/** Criação de usuário no console admin (email obrigatório; nome/senha opcionais). */
|
|
125
125
|
export declare const adminCreateUserValidator: import("@vinejs/vine").VineValidator<import("@vinejs/vine").VineObject<{
|
|
126
|
-
email: import("@vinejs/vine").VineString
|
|
126
|
+
email: import("@vinejs/vine/schema/base/literal").TransformModifier<import("@vinejs/vine").VineString, string>;
|
|
127
127
|
name: import("@vinejs/vine/schema/base/literal").OptionalModifier<import("@vinejs/vine").VineString>;
|
|
128
128
|
password: import("@vinejs/vine/schema/base/literal").OptionalModifier<import("@vinejs/vine").VineString>;
|
|
129
129
|
}, {
|
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
import vine from '@vinejs/vine';
|
|
2
|
+
import { normalizeEmailIdentifier } from './email_identifier.js';
|
|
3
|
+
/**
|
|
4
|
+
* E-mail usado como IDENTIDADE da conta. Normalização ÚNICA e conservadora
|
|
5
|
+
* ({@link normalizeEmailIdentifier}: `trim` + `toLowerCase`) — a MESMA aplicada
|
|
6
|
+
* no passo de identificador do login, para que os dois lados concordem.
|
|
7
|
+
*
|
|
8
|
+
* NÃO use `.normalizeEmail()` do VineJS aqui: ele aplica os defaults do
|
|
9
|
+
* validator.js, que no gmail REMOVEM os pontos e o sub-endereço `+tag` — a
|
|
10
|
+
* conta nascia com um endereço diferente do digitado e a pessoa ficava trancada
|
|
11
|
+
* do lado de fora (ver `host/email_identifier.ts`).
|
|
12
|
+
*/
|
|
13
|
+
const emailIdentifier = () => vine.string().trim().email().transform(normalizeEmailIdentifier);
|
|
2
14
|
/** Cadastro passwordless: só e-mail + nome (sem senha). O login vem por magic link. */
|
|
3
15
|
export const passwordlessSignupValidator = vine.compile(vine.object({
|
|
4
|
-
email:
|
|
16
|
+
email: emailIdentifier(),
|
|
5
17
|
fullName: vine.string().trim().minLength(2).maxLength(255),
|
|
6
18
|
}));
|
|
7
19
|
export const signupValidator = vine.compile(vine.object({
|
|
8
|
-
email:
|
|
20
|
+
email: emailIdentifier(),
|
|
9
21
|
fullName: vine.string().trim().minLength(2).maxLength(255),
|
|
10
22
|
password: vine.string().minLength(8).maxLength(255),
|
|
11
23
|
}));
|
|
12
24
|
export const forgotPasswordValidator = vine.compile(vine.object({
|
|
13
|
-
email:
|
|
25
|
+
email: emailIdentifier(),
|
|
14
26
|
}));
|
|
15
27
|
export const resetPasswordValidator = vine.compile(vine.object({
|
|
16
28
|
token: vine.string().trim().minLength(1),
|
|
@@ -31,7 +43,7 @@ export const changePasswordValidator = vine.compile(vine.object({
|
|
|
31
43
|
*/
|
|
32
44
|
export const changeEmailValidator = vine.compile(vine.object({
|
|
33
45
|
currentPassword: vine.string().minLength(1).optional(),
|
|
34
|
-
newEmail:
|
|
46
|
+
newEmail: emailIdentifier(),
|
|
35
47
|
}));
|
|
36
48
|
/**
|
|
37
49
|
* Edição de perfil no console de conta: nome e avatarUrl, ambos opcionais.
|
|
@@ -52,7 +64,7 @@ export const deleteAccountValidator = vine.compile(vine.object({
|
|
|
52
64
|
}));
|
|
53
65
|
/** Criação de usuário no console admin (email obrigatório; nome/senha opcionais). */
|
|
54
66
|
export const adminCreateUserValidator = vine.compile(vine.object({
|
|
55
|
-
email:
|
|
67
|
+
email: emailIdentifier(),
|
|
56
68
|
name: vine.string().trim().maxLength(255).optional(),
|
|
57
69
|
password: vine.string().minLength(8).maxLength(255).optional(),
|
|
58
70
|
}));
|
|
@@ -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.70.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",
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
"qrcode": "1.5.4"
|
|
114
114
|
},
|
|
115
115
|
"devDependencies": {
|
|
116
|
-
"@adonis-agora/authkit-react": "0.
|
|
116
|
+
"@adonis-agora/authkit-react": "0.22.0",
|
|
117
117
|
"@adonis-agora/durable": "0.40.0",
|
|
118
118
|
"@adonis-agora/telescope": "0.21.1",
|
|
119
119
|
"@adonisjs/ally": "6.3.0",
|