@adonis-agora/authkit-server 0.74.0 → 0.76.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/README.md +3 -0
- package/build/host/views/account/apps.edge +30 -0
- package/build/host/views/agents/consent.edge +60 -0
- package/build/host/views/agents/done.edge +24 -0
- package/build/host/views/consent.edge +9 -1
- package/build/host/views/partials/styles.edge +1 -1
- package/build/index.d.ts +9 -0
- package/build/index.js +4 -0
- package/build/providers/authkit_server_provider.js +75 -50
- package/build/src/agents/agent_identity.d.ts +33 -0
- package/build/src/agents/agent_identity.js +156 -0
- package/build/src/agents/config.d.ts +99 -0
- package/build/src/agents/config.js +101 -0
- package/build/src/agents/delegation_service.d.ts +154 -0
- package/build/src/agents/delegation_service.js +394 -0
- package/build/src/agents/delegation_store.d.ts +93 -0
- package/build/src/agents/delegation_store.js +222 -0
- package/build/src/agents/middleware.d.ts +73 -0
- package/build/src/agents/middleware.js +113 -0
- package/build/src/agents/protocol.d.ts +62 -0
- package/build/src/agents/protocol.js +51 -0
- package/build/src/agents/runtime.d.ts +36 -0
- package/build/src/agents/runtime.js +65 -0
- package/build/src/agents/signer.d.ts +34 -0
- package/build/src/agents/signer.js +70 -0
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/audit/audit_sink.js +5 -0
- package/build/src/controllers/authorization_server_metadata_controller.d.ts +10 -0
- package/build/src/controllers/authorization_server_metadata_controller.js +20 -0
- package/build/src/define_config.d.ts +34 -3
- package/build/src/define_config.js +35 -1
- package/build/src/host/access_token_verifier.d.ts +7 -0
- package/build/src/host/access_token_verifier.js +8 -0
- package/build/src/host/account_api/account_api_controller.d.ts +12 -0
- package/build/src/host/account_api/account_api_controller.js +40 -0
- package/build/src/host/admin_api/dto.d.ts +1 -1
- package/build/src/host/admin_sessions_service.d.ts +2 -0
- package/build/src/host/admin_sessions_service.js +18 -0
- package/build/src/host/auth_host_config.d.ts +4 -0
- package/build/src/host/bearer_account.d.ts +17 -0
- package/build/src/host/bearer_account.js +10 -0
- package/build/src/host/client_names.d.ts +11 -0
- package/build/src/host/client_names.js +12 -0
- package/build/src/host/console_session.js +3 -5
- package/build/src/host/controllers/account_apps_controller.d.ts +2 -0
- package/build/src/host/controllers/account_apps_controller.js +39 -3
- package/build/src/host/controllers/account_orgs_controller.js +2 -1
- package/build/src/host/controllers/account_session_controller.js +2 -1
- package/build/src/host/controllers/agent_consent_controller.d.ts +17 -0
- package/build/src/host/controllers/agent_consent_controller.js +138 -0
- package/build/src/host/controllers/agent_oauth_controller.d.ts +23 -0
- package/build/src/host/controllers/agent_oauth_controller.js +110 -0
- package/build/src/host/controllers/interaction_controller.js +28 -0
- package/build/src/host/csrf.d.ts +8 -18
- package/build/src/host/csrf.js +28 -2
- package/build/src/host/i18n.d.ts +54 -0
- package/build/src/host/i18n.js +54 -0
- package/build/src/host/impersonation_session.d.ts +5 -0
- package/build/src/host/impersonation_session.js +37 -4
- package/build/src/host/oidc_bearer_guard.js +10 -1
- package/build/src/host/redirect_exact.d.ts +12 -0
- package/build/src/host/redirect_exact.js +17 -0
- package/build/src/host/register_auth_host.js +48 -7
- package/build/src/host/request_url.d.ts +10 -0
- package/build/src/host/request_url.js +17 -0
- package/build/src/host/sudo/methods/magic_link.js +2 -1
- package/build/src/host/sudo/runtime.js +3 -2
- package/build/src/host/sudo_mode.js +4 -4
- package/build/src/mcp/mcp_oauth.d.ts +85 -0
- package/build/src/mcp/mcp_oauth.js +154 -0
- package/build/src/provider/build_provider.js +20 -1
- package/build/src/provider/oidc_service.d.ts +8 -0
- package/build/src/provider/oidc_service.js +11 -0
- package/build/src/provider/token_exchange.d.ts +26 -0
- package/build/src/provider/token_exchange.js +44 -3
- package/build/src/schema/ensure.js +92 -0
- package/build/stubs/ui/react/pages/consent.tsx +17 -1
- package/package.json +1 -1
- package/stubs/ui/react/pages/consent.tsx +17 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { resolveRedirectUriPolicy } from '../provider/registration_policy.js';
|
|
2
|
+
/** Callbacks dos clientes MCP conhecidos. Loopback (Claude Code, CLIs) já entra pela política. */
|
|
3
|
+
export const MCP_CLIENT_REDIRECTS = {
|
|
4
|
+
loopback: true,
|
|
5
|
+
exact: [
|
|
6
|
+
'https://claude.ai/api/mcp/auth_callback',
|
|
7
|
+
'https://claude.com/api/mcp/auth_callback',
|
|
8
|
+
'https://chatgpt.com/connector_platform_oauth_redirect',
|
|
9
|
+
'https://vscode.dev/redirect',
|
|
10
|
+
'https://insiders.vscode.dev/redirect',
|
|
11
|
+
],
|
|
12
|
+
appSchemes: ['cursor', 'vscode', 'vscode-insiders'],
|
|
13
|
+
anyHttps: false,
|
|
14
|
+
};
|
|
15
|
+
/** Escopos de um token de servidor MCP: identidade + refresh. */
|
|
16
|
+
export const MCP_RESOURCE_SCOPES = ['openid', 'profile', 'email', 'offline_access'];
|
|
17
|
+
export function resolveMcpOAuth(input) {
|
|
18
|
+
const options = typeof input === 'object' ? input : {};
|
|
19
|
+
const extra = options.redirectUris ?? {};
|
|
20
|
+
return {
|
|
21
|
+
enabled: input === true || typeof input === 'object',
|
|
22
|
+
redirectUriPolicy: resolveRedirectUriPolicy({
|
|
23
|
+
loopback: extra.loopback ?? MCP_CLIENT_REDIRECTS.loopback,
|
|
24
|
+
exact: [...new Set([...MCP_CLIENT_REDIRECTS.exact, ...(extra.exact ?? [])])],
|
|
25
|
+
appSchemes: [...new Set([...MCP_CLIENT_REDIRECTS.appSchemes, ...(extra.appSchemes ?? [])])],
|
|
26
|
+
anyHttps: extra.anyHttps ?? MCP_CLIENT_REDIRECTS.anyHttps,
|
|
27
|
+
}),
|
|
28
|
+
resources: [...(options.resources ?? [])],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Registro em runtime — um slot global, para que outra lib (o MCP do `@adonis-agora/agent`) se
|
|
33
|
+
* registre sem importar esta: o contrato é o símbolo, não o módulo.
|
|
34
|
+
*/
|
|
35
|
+
const REGISTRY = Symbol.for('@adonis-agora/oauth:resources');
|
|
36
|
+
function registry() {
|
|
37
|
+
const slot = globalThis;
|
|
38
|
+
if (!Array.isArray(slot[REGISTRY]))
|
|
39
|
+
slot[REGISTRY] = [];
|
|
40
|
+
return slot[REGISTRY];
|
|
41
|
+
}
|
|
42
|
+
export function registerOAuthResource(resource) {
|
|
43
|
+
if (!resource.url && !resource.path) {
|
|
44
|
+
throw new Error('authkit: registerOAuthResource precisa de `url` ou `path`.');
|
|
45
|
+
}
|
|
46
|
+
registry().push({ ...resource });
|
|
47
|
+
}
|
|
48
|
+
export function registeredOAuthResources() {
|
|
49
|
+
return registry();
|
|
50
|
+
}
|
|
51
|
+
const trim = (value) => value.replace(/\/+$/, '');
|
|
52
|
+
function normalizePath(path) {
|
|
53
|
+
return `/${path.replace(/^\/+|\/+$/g, '')}`;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* O resource MCP que `indicator` nomeia, ou `null`. Casa com `mcp.resources`, com uma URL
|
|
57
|
+
* registrada, ou com um `path` registrado na origem do issuer. Barra final tolerada.
|
|
58
|
+
*/
|
|
59
|
+
export function findMcpResource(indicator, issuer, config) {
|
|
60
|
+
if (!config.enabled)
|
|
61
|
+
return null;
|
|
62
|
+
let url;
|
|
63
|
+
try {
|
|
64
|
+
url = new URL(indicator);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const wanted = trim(url.href);
|
|
70
|
+
for (const declared of config.resources) {
|
|
71
|
+
if (trim(declared) === wanted)
|
|
72
|
+
return { audience: trim(declared), scopes: MCP_RESOURCE_SCOPES };
|
|
73
|
+
}
|
|
74
|
+
const issuerOrigin = new URL(issuer).origin;
|
|
75
|
+
for (const resource of registeredOAuthResources()) {
|
|
76
|
+
const scopes = resource.scopes ?? MCP_RESOURCE_SCOPES;
|
|
77
|
+
if (resource.url && trim(resource.url) === wanted)
|
|
78
|
+
return { audience: trim(resource.url), scopes };
|
|
79
|
+
if (resource.path &&
|
|
80
|
+
url.origin === issuerOrigin &&
|
|
81
|
+
trim(url.pathname) === normalizePath(resource.path) &&
|
|
82
|
+
!url.search) {
|
|
83
|
+
return { audience: wanted, scopes };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Registro de um cliente MCP: quem pede `refresh_token` ganha `openid offline_access` no escopo
|
|
90
|
+
* registrado, senão o provider recusaria pedi-los no authorize.
|
|
91
|
+
*/
|
|
92
|
+
export function mcpClientRegistration(metadata) {
|
|
93
|
+
const grants = Array.isArray(metadata.grant_types)
|
|
94
|
+
? metadata.grant_types
|
|
95
|
+
: ['authorization_code'];
|
|
96
|
+
if (!grants.includes('refresh_token') || typeof metadata.scope !== 'string')
|
|
97
|
+
return metadata;
|
|
98
|
+
const scopes = metadata.scope.split(' ').filter(Boolean);
|
|
99
|
+
for (const needed of ['openid', 'offline_access']) {
|
|
100
|
+
if (!scopes.includes(needed))
|
|
101
|
+
scopes.push(needed);
|
|
102
|
+
}
|
|
103
|
+
return { ...metadata, scope: scopes.join(' ') };
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* `scope`/`prompt` do authorize de um cliente MCP para que saia um refresh token: `offline_access`
|
|
107
|
+
* no escopo e `consent` no prompt (sem ele o provider descarta o `offline_access`). `null` quando
|
|
108
|
+
* já estão lá. `prompt=none` pede "sem interação", o contrário de consentir: sai.
|
|
109
|
+
*/
|
|
110
|
+
export function withOfflineAccess(params) {
|
|
111
|
+
const scopes = String(params.scope ?? '')
|
|
112
|
+
.split(' ')
|
|
113
|
+
.filter(Boolean);
|
|
114
|
+
const prompts = String(params.prompt ?? '')
|
|
115
|
+
.split(' ')
|
|
116
|
+
.filter(Boolean);
|
|
117
|
+
const needsScope = !scopes.includes('offline_access');
|
|
118
|
+
const needsPrompt = !prompts.includes('consent');
|
|
119
|
+
if (!needsScope && !needsPrompt)
|
|
120
|
+
return null;
|
|
121
|
+
if (!scopes.includes('openid'))
|
|
122
|
+
scopes.unshift('openid');
|
|
123
|
+
if (needsScope)
|
|
124
|
+
scopes.push('offline_access');
|
|
125
|
+
const prompt = needsPrompt ? [...prompts.filter((p) => p !== 'none'), 'consent'] : prompts;
|
|
126
|
+
return { scope: scopes.join(' '), prompt: prompt.join(' ') };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Middleware do provider (Koa) que aplica {@link withOfflineAccess} ao `GET /auth` de um client
|
|
130
|
+
* registrado dinamicamente que tem o grant `refresh_token`.
|
|
131
|
+
*/
|
|
132
|
+
export function mcpAuthorizeMiddleware(provider) {
|
|
133
|
+
return async (ctx, next) => {
|
|
134
|
+
if (ctx.method !== 'GET' || ctx.path !== '/auth')
|
|
135
|
+
return next();
|
|
136
|
+
const query = ctx.query;
|
|
137
|
+
const clientId = typeof query.client_id === 'string' ? query.client_id : '';
|
|
138
|
+
if (!clientId || (query.response_type !== undefined && query.response_type !== 'code')) {
|
|
139
|
+
return next();
|
|
140
|
+
}
|
|
141
|
+
const client = await provider.Client.find(clientId).catch(() => undefined);
|
|
142
|
+
const metadata = client?.metadata?.() ?? {};
|
|
143
|
+
const grants = metadata.grant_types;
|
|
144
|
+
if (metadata.client_id_issued_at === undefined ||
|
|
145
|
+
!Array.isArray(grants) ||
|
|
146
|
+
!grants.includes('refresh_token')) {
|
|
147
|
+
return next();
|
|
148
|
+
}
|
|
149
|
+
const widened = withOfflineAccess(query);
|
|
150
|
+
if (widened)
|
|
151
|
+
ctx.query = { ...query, ...widened };
|
|
152
|
+
return next();
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -2,9 +2,11 @@ import * as oidc from 'oidc-provider';
|
|
|
2
2
|
import { pickModelAdapterClass } from '../adapters/factory.js';
|
|
3
3
|
import { normalizeActiveOrg, readActiveOrgFromKoaCtx } from '../host/active_org_cookie.js';
|
|
4
4
|
import { assertClientMetadata } from '../host/client_metadata.js';
|
|
5
|
+
import { findMcpResource, mcpAuthorizeMiddleware } from '../mcp/mcp_oauth.js';
|
|
5
6
|
import { createDeviceSources } from './device_sources.js';
|
|
6
7
|
import { createLogoutSources } from './logout_sources.js';
|
|
7
8
|
import { registrationPolicyMiddleware } from './registration_policy.js';
|
|
9
|
+
import { impersonationExtraClaims } from './token_exchange.js';
|
|
8
10
|
/** Atualiza o holder mutável do TTL de sessão com os valores da setting. */
|
|
9
11
|
export function updateSessionTtlHolder(holder, policy) {
|
|
10
12
|
holder.rememberSec = Math.max(1, Math.floor(policy.rememberDays * 86400));
|
|
@@ -76,7 +78,8 @@ export function buildProvider(config, options, sessionTtlHolder, tokenTtlHolder)
|
|
|
76
78
|
const key = declaredResources.find((k) => k.replace(/\/+$/, '') === trimmed);
|
|
77
79
|
return key ? { key, rc: at.resources[key] } : null;
|
|
78
80
|
};
|
|
79
|
-
const
|
|
81
|
+
const mcp = config.mcp;
|
|
82
|
+
const resourceIndicatorFeatures = at.anyJwt || declaredResources.length > 0 || mcp.enabled
|
|
80
83
|
? {
|
|
81
84
|
resourceIndicators: {
|
|
82
85
|
enabled: true,
|
|
@@ -93,6 +96,15 @@ export function buildProvider(config, options, sessionTtlHolder, tokenTtlHolder)
|
|
|
93
96
|
getResourceServerInfo: (_ctx, resourceIndicator, _client) => {
|
|
94
97
|
const found = findResource(resourceIndicator);
|
|
95
98
|
const isDefault = at.anyJwt && resourceIndicator === at.audience;
|
|
99
|
+
// Servidores MCP (`mcp`): os declarados e os registrados em runtime.
|
|
100
|
+
const mcpResource = found || isDefault ? null : findMcpResource(resourceIndicator, config.issuer, mcp);
|
|
101
|
+
if (mcpResource) {
|
|
102
|
+
return {
|
|
103
|
+
scope: mcpResource.scopes.join(' '),
|
|
104
|
+
audience: mcpResource.audience,
|
|
105
|
+
accessTokenFormat: 'opaque',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
96
108
|
if (!found && !isDefault) {
|
|
97
109
|
throw new oidc.errors.InvalidTarget(`resource indicator not allowed: ${resourceIndicator}`);
|
|
98
110
|
}
|
|
@@ -118,6 +130,10 @@ export function buildProvider(config, options, sessionTtlHolder, tokenTtlHolder)
|
|
|
118
130
|
}
|
|
119
131
|
: {};
|
|
120
132
|
const provider = new oidc.Provider(config.issuer, {
|
|
133
|
+
// O ator (`act`, RFC 8693) dos access tokens de impersonation — ver
|
|
134
|
+
// `impersonationExtraClaims` em `token_exchange.ts`. Sem isto o token trocado
|
|
135
|
+
// não se distingue de um token do próprio alvo no resource server.
|
|
136
|
+
extraTokenClaims: async (_ctx, token) => impersonationExtraClaims(token),
|
|
121
137
|
// Dispatcher por modelo (suportado pelo oidc-provider: `Adapter` aceita
|
|
122
138
|
// função `(name) => adapter` além de classe). Session-scoped vai pro
|
|
123
139
|
// `SessionAdapterClass`, o resto pro `AdapterClass` — mesma regra de
|
|
@@ -336,6 +352,9 @@ export function buildProvider(config, options, sessionTtlHolder, tokenTtlHolder)
|
|
|
336
352
|
validate: dynReg.validateRegistration,
|
|
337
353
|
}));
|
|
338
354
|
}
|
|
355
|
+
// Refresh token dos clientes MCP registrados dinamicamente (ver mcp/mcp_oauth.ts).
|
|
356
|
+
if (mcp.enabled)
|
|
357
|
+
provider.use(mcpAuthorizeMiddleware(provider));
|
|
339
358
|
provider.proxy = true;
|
|
340
359
|
return provider;
|
|
341
360
|
}
|
|
@@ -17,6 +17,14 @@ export declare class OidcService {
|
|
|
17
17
|
get publicJwks(): {
|
|
18
18
|
keys: Record<string, any>[];
|
|
19
19
|
};
|
|
20
|
+
/**
|
|
21
|
+
* @internal JWKS PRIVADO em uso (mesmas chaves do `publicJwks`). Só para quem
|
|
22
|
+
* assina in-process com o keystore do IdP — tokens de delegação e recibos de
|
|
23
|
+
* personal agents. Troca junto com o provider numa rotação.
|
|
24
|
+
*/
|
|
25
|
+
get signingJwks(): {
|
|
26
|
+
keys: Record<string, any>[];
|
|
27
|
+
};
|
|
20
28
|
/** Pathname do issuer sem barra final (ex.: `/oidc`). Vazio quando montado na raiz. */
|
|
21
29
|
readonly mountPath: string;
|
|
22
30
|
readonly recorder: MetricsRecorder;
|
|
@@ -14,6 +14,7 @@ export class OidcService {
|
|
|
14
14
|
#callback;
|
|
15
15
|
#interactions;
|
|
16
16
|
#publicJwks;
|
|
17
|
+
#signingJwks;
|
|
17
18
|
#appKey;
|
|
18
19
|
get provider() {
|
|
19
20
|
return this.#provider;
|
|
@@ -32,6 +33,14 @@ export class OidcService {
|
|
|
32
33
|
get publicJwks() {
|
|
33
34
|
return this.#publicJwks;
|
|
34
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* @internal JWKS PRIVADO em uso (mesmas chaves do `publicJwks`). Só para quem
|
|
38
|
+
* assina in-process com o keystore do IdP — tokens de delegação e recibos de
|
|
39
|
+
* personal agents. Troca junto com o provider numa rotação.
|
|
40
|
+
*/
|
|
41
|
+
get signingJwks() {
|
|
42
|
+
return this.#signingJwks;
|
|
43
|
+
}
|
|
35
44
|
/** Pathname do issuer sem barra final (ex.: `/oidc`). Vazio quando montado na raiz. */
|
|
36
45
|
mountPath;
|
|
37
46
|
recorder;
|
|
@@ -181,6 +190,7 @@ export class OidcService {
|
|
|
181
190
|
// conta que acabou de desabilitar, recebendo tokens plenamente funcionais.
|
|
182
191
|
accountStore: config.accountStore,
|
|
183
192
|
audit: config.audit,
|
|
193
|
+
impersonateAdmins: config.admin.impersonateAdmins,
|
|
184
194
|
});
|
|
185
195
|
}
|
|
186
196
|
// Quando o issuer tem um path (ex.: http://host/oidc), o provider precisa ser
|
|
@@ -215,6 +225,7 @@ export class OidcService {
|
|
|
215
225
|
this.#callback = callback;
|
|
216
226
|
this.#interactions = interactions;
|
|
217
227
|
this.#publicJwks = toPublicJwks(jwks);
|
|
228
|
+
this.#signingJwks = jwks;
|
|
218
229
|
}
|
|
219
230
|
/**
|
|
220
231
|
* Recarrega as chaves de assinatura AO VIVO: relê o keystore do cofre e reconstrói
|
|
@@ -56,5 +56,31 @@ export interface TokenExchangeDeps {
|
|
|
56
56
|
* regra de todo o resto da lib — nunca quebra hosts com um store mínimo).
|
|
57
57
|
*/
|
|
58
58
|
accountStore?: AccountStore;
|
|
59
|
+
/**
|
|
60
|
+
* Permite impersonar uma conta que TAMBÉM tem um dos `adminRoles`. Default
|
|
61
|
+
* `true` (back-compat). Com `false`, alvo admin vira `invalid_grant`: um admin
|
|
62
|
+
* não assume a identidade (e os tokens) de outro admin.
|
|
63
|
+
*/
|
|
64
|
+
impersonateAdmins?: boolean;
|
|
59
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Propriedade (não persistida) onde a troca marca o ator ANTES do `save()`. O
|
|
68
|
+
* `extraTokenClaims` do provider (`build_provider.ts`) a transforma em
|
|
69
|
+
* `extra.act` — é o único jeito de pôr claims no `extra` de um token opaco: o
|
|
70
|
+
* oidc-provider SOBRESCREVE `extra` com o retorno desse hook ao salvar.
|
|
71
|
+
*/
|
|
72
|
+
export declare const IMPERSONATION_ACTOR_PROP = "authkitImpersonationActor";
|
|
73
|
+
/** `extraTokenClaims` do provider: `{ act }` para token de impersonation, nada para o resto. */
|
|
74
|
+
export declare function impersonationExtraClaims(token: unknown): {
|
|
75
|
+
act: {
|
|
76
|
+
sub: string;
|
|
77
|
+
};
|
|
78
|
+
} | undefined;
|
|
79
|
+
/**
|
|
80
|
+
* O ator de um access token de impersonation: o `act` (RFC 8693 §4.1) gravado
|
|
81
|
+
* no `extra` do token trocado. `null` quando o token não é de impersonation.
|
|
82
|
+
*/
|
|
83
|
+
export declare function impersonationActorOf(token: {
|
|
84
|
+
extra?: unknown;
|
|
85
|
+
} | null | undefined): string | null;
|
|
60
86
|
export declare function registerTokenExchange(provider: any, deps: TokenExchangeDeps): void;
|
|
@@ -2,6 +2,26 @@ import { errors } from 'oidc-provider';
|
|
|
2
2
|
import { assertAccountEnabled } from '../host/login_attempt.js';
|
|
3
3
|
const TOKEN_EXCHANGE = 'urn:ietf:params:oauth:grant-type:token-exchange';
|
|
4
4
|
const ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token';
|
|
5
|
+
/**
|
|
6
|
+
* Propriedade (não persistida) onde a troca marca o ator ANTES do `save()`. O
|
|
7
|
+
* `extraTokenClaims` do provider (`build_provider.ts`) a transforma em
|
|
8
|
+
* `extra.act` — é o único jeito de pôr claims no `extra` de um token opaco: o
|
|
9
|
+
* oidc-provider SOBRESCREVE `extra` com o retorno desse hook ao salvar.
|
|
10
|
+
*/
|
|
11
|
+
export const IMPERSONATION_ACTOR_PROP = 'authkitImpersonationActor';
|
|
12
|
+
/** `extraTokenClaims` do provider: `{ act }` para token de impersonation, nada para o resto. */
|
|
13
|
+
export function impersonationExtraClaims(token) {
|
|
14
|
+
const actor = token?.[IMPERSONATION_ACTOR_PROP];
|
|
15
|
+
return typeof actor === 'string' && actor ? { act: { sub: actor } } : undefined;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* O ator de um access token de impersonation: o `act` (RFC 8693 §4.1) gravado
|
|
19
|
+
* no `extra` do token trocado. `null` quando o token não é de impersonation.
|
|
20
|
+
*/
|
|
21
|
+
export function impersonationActorOf(token) {
|
|
22
|
+
const act = token?.extra?.act;
|
|
23
|
+
return typeof act?.sub === 'string' && act.sub ? act.sub : null;
|
|
24
|
+
}
|
|
5
25
|
/**
|
|
6
26
|
* Interseção entre os scopes pedidos e os scopes permitidos do client (allowlist).
|
|
7
27
|
* Preserva a ordem do pedido. Nunca excede a allowlist do client.
|
|
@@ -30,6 +50,12 @@ export function registerTokenExchange(provider, deps) {
|
|
|
30
50
|
if (!subjectAt || subjectAt.isExpired) {
|
|
31
51
|
throw new errors.InvalidGrant('subject_token invalid or expired');
|
|
32
52
|
}
|
|
53
|
+
// Sem impersonation encadeada: um token que JÁ é de impersonation não vira
|
|
54
|
+
// subject de outra troca (o "ator" seria a conta personificada, e a trilha
|
|
55
|
+
// perderia quem de fato está agindo).
|
|
56
|
+
if (impersonationActorOf(subjectAt)) {
|
|
57
|
+
throw new errors.InvalidGrant('subject_token is already an impersonation token');
|
|
58
|
+
}
|
|
33
59
|
// O subject_token DEVE ter sido emitido para o MESMO client autenticado: senão
|
|
34
60
|
// um client B poderia trocar um AT emitido para o client A (cross-client).
|
|
35
61
|
if (subjectAt.clientId !== client?.clientId) {
|
|
@@ -62,6 +88,9 @@ export function registerTokenExchange(provider, deps) {
|
|
|
62
88
|
if (!target) {
|
|
63
89
|
throw new errors.InvalidGrant('requested_subject not found');
|
|
64
90
|
}
|
|
91
|
+
if (target.id === actor.id) {
|
|
92
|
+
throw new errors.InvalidGrant('requested_subject must be another account');
|
|
93
|
+
}
|
|
65
94
|
// Status do alvo (disabled/expirado): mesmo gate de `attemptPasswordLogin`.
|
|
66
95
|
// Sem isso, impersonar um alvo desabilitado mintava tokens funcionais para
|
|
67
96
|
// uma identidade que o admin acreditava ter revogado. `accountStore` é
|
|
@@ -107,8 +136,6 @@ export function registerTokenExchange(provider, deps) {
|
|
|
107
136
|
// Client sem allowlist declarada: comportamento atual preservado.
|
|
108
137
|
scope = params.scope || DEFAULT_SCOPE;
|
|
109
138
|
}
|
|
110
|
-
const at = new provider.AccessToken({ accountId: target.id, client, scope });
|
|
111
|
-
const accessToken = await at.save();
|
|
112
139
|
// Token exchange is not tied to a browser session, so there is no active org
|
|
113
140
|
// context here — roles are resolved for the impersonated target with clientId only.
|
|
114
141
|
const roles = deps.resolveTokenRoles
|
|
@@ -117,6 +144,17 @@ export function registerTokenExchange(provider, deps) {
|
|
|
117
144
|
activeOrg: null,
|
|
118
145
|
})
|
|
119
146
|
: (target.globalRoles ?? []);
|
|
147
|
+
if (deps.impersonateAdmins === false && roles.some((r) => adminRoles.includes(r))) {
|
|
148
|
+
throw new errors.InvalidGrant('requested_subject is not impersonable');
|
|
149
|
+
}
|
|
150
|
+
// O ACCESS token (não só o id_token) carrega o ator: é o que o resource server
|
|
151
|
+
// vê a cada request (`oidcBearerGuard` → `impersonationState`/`realAccountId`).
|
|
152
|
+
// Sem isso, o token trocado era indistinguível de um token do próprio alvo, e
|
|
153
|
+
// toda regra "negado durante impersonation" deixava passar o app nativo.
|
|
154
|
+
// `extra` sai no JWT (formato jwt) e na introspecção (RFC 7662).
|
|
155
|
+
const at = new provider.AccessToken({ accountId: target.id, client, scope });
|
|
156
|
+
at[IMPERSONATION_ACTOR_PROP] = actor.id;
|
|
157
|
+
const accessToken = await at.save();
|
|
120
158
|
const idToken = new provider.IdToken({
|
|
121
159
|
sub: target.id,
|
|
122
160
|
email: target.email,
|
|
@@ -134,7 +172,7 @@ export function registerTokenExchange(provider, deps) {
|
|
|
134
172
|
email: target.email ?? null,
|
|
135
173
|
clientId: client?.clientId ?? null,
|
|
136
174
|
ip: ctx.req?.socket?.remoteAddress ?? null,
|
|
137
|
-
metadata: { scope },
|
|
175
|
+
metadata: { scope, jti: at.jti ?? null },
|
|
138
176
|
});
|
|
139
177
|
ctx.body = {
|
|
140
178
|
access_token: accessToken,
|
|
@@ -143,6 +181,9 @@ export function registerTokenExchange(provider, deps) {
|
|
|
143
181
|
expires_in: at.expiration ?? 3600,
|
|
144
182
|
id_token: idTokenJwt,
|
|
145
183
|
scope,
|
|
184
|
+
// RFC 8693 §4.1: o ator também no corpo da resposta (o `actSub` que o
|
|
185
|
+
// `requestTokenExchange` do host lê — antes ficava sempre vazio).
|
|
186
|
+
act: { sub: actor.id },
|
|
146
187
|
};
|
|
147
188
|
};
|
|
148
189
|
provider.registerGrantType(TOKEN_EXCHANGE, handler, [
|
|
@@ -203,6 +203,98 @@ const TABLES = [
|
|
|
203
203
|
updated_at: (t) => t.timestamp('updated_at', { useTz: true }).nullable(),
|
|
204
204
|
},
|
|
205
205
|
},
|
|
206
|
+
/*
|
|
207
|
+
* As três tabelas de personal agents usam `dateTime(…, precision 3)` e não
|
|
208
|
+
* `timestamp`: no MySQL o TIMESTAMP sem fração arredonda o `last_polled_at`
|
|
209
|
+
* (um agente que respeita o `interval` levaria `slow_down`) e, com
|
|
210
|
+
* `explicit_defaults_for_timestamp=OFF`, o primeiro TIMESTAMP NOT NULL da
|
|
211
|
+
* tabela ganha `ON UPDATE CURRENT_TIMESTAMP` — marcar um refresh como usado
|
|
212
|
+
* reescreveria a validade dele. DATETIME não tem nenhum dos dois; no Postgres
|
|
213
|
+
* vira o mesmo `timestamptz`.
|
|
214
|
+
*/
|
|
215
|
+
{
|
|
216
|
+
name: 'auth_agent_device_codes',
|
|
217
|
+
/**
|
|
218
|
+
* Pedidos de delegação de personal agents (device flow, RFC 8628). O
|
|
219
|
+
* `device_code` só existe como hash; o `user_code` é o que o usuário vê.
|
|
220
|
+
* Linhas expiradas são apagadas no próximo pedido.
|
|
221
|
+
*/
|
|
222
|
+
create: (t) => {
|
|
223
|
+
t.string('id').primary();
|
|
224
|
+
t.string('device_code_hash', 64).notNullable().unique();
|
|
225
|
+
t.string('user_code', 16).notNullable().unique();
|
|
226
|
+
t.string('client_id', 2048).notNullable();
|
|
227
|
+
t.string('agent_sub').notNullable();
|
|
228
|
+
t.text('requested_scope').notNullable();
|
|
229
|
+
t.string('status', 16).notNullable();
|
|
230
|
+
t.string('account_id').nullable();
|
|
231
|
+
t.string('grant_id').nullable();
|
|
232
|
+
t.integer('interval_seconds').notNullable();
|
|
233
|
+
t.dateTime('last_polled_at', { useTz: true, precision: 3 }).nullable();
|
|
234
|
+
t.dateTime('expires_at', { useTz: true, precision: 3 }).notNullable().index();
|
|
235
|
+
t.dateTime('created_at', { useTz: true, precision: 3 }).notNullable();
|
|
236
|
+
},
|
|
237
|
+
columns: {
|
|
238
|
+
device_code_hash: (t) => t.string('device_code_hash', 64),
|
|
239
|
+
user_code: (t) => t.string('user_code', 16),
|
|
240
|
+
client_id: (t) => t.string('client_id', 2048),
|
|
241
|
+
agent_sub: (t) => t.string('agent_sub'),
|
|
242
|
+
requested_scope: (t) => t.text('requested_scope'),
|
|
243
|
+
status: (t) => t.string('status', 16),
|
|
244
|
+
account_id: (t) => t.string('account_id').nullable(),
|
|
245
|
+
grant_id: (t) => t.string('grant_id').nullable(),
|
|
246
|
+
interval_seconds: (t) => t.integer('interval_seconds'),
|
|
247
|
+
last_polled_at: (t) => t.dateTime('last_polled_at', { useTz: true, precision: 3 }).nullable(),
|
|
248
|
+
expires_at: (t) => t.dateTime('expires_at', { useTz: true, precision: 3 }).nullable(),
|
|
249
|
+
created_at: (t) => t.dateTime('created_at', { useTz: true, precision: 3 }).nullable(),
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
name: 'auth_agent_grants',
|
|
254
|
+
/**
|
|
255
|
+
* O que um usuário autorizou um personal agent a fazer na conta dele: um
|
|
256
|
+
* grant por (conta, agente, usuário do agente); aprovar mais scopes soma.
|
|
257
|
+
* `revoked_at` preenchido = revogado (os tokens param na próxima request).
|
|
258
|
+
*/
|
|
259
|
+
create: (t) => {
|
|
260
|
+
t.string('id').primary();
|
|
261
|
+
t.string('account_id').notNullable().index();
|
|
262
|
+
t.string('client_id', 2048).notNullable();
|
|
263
|
+
t.string('agent_sub').notNullable();
|
|
264
|
+
t.text('scope').notNullable();
|
|
265
|
+
t.dateTime('expires_at', { useTz: true, precision: 3 }).notNullable();
|
|
266
|
+
t.dateTime('revoked_at', { useTz: true, precision: 3 }).nullable();
|
|
267
|
+
t.dateTime('created_at', { useTz: true, precision: 3 }).notNullable();
|
|
268
|
+
t.dateTime('updated_at', { useTz: true, precision: 3 }).notNullable();
|
|
269
|
+
},
|
|
270
|
+
columns: {
|
|
271
|
+
account_id: (t) => t.string('account_id'),
|
|
272
|
+
client_id: (t) => t.string('client_id', 2048),
|
|
273
|
+
agent_sub: (t) => t.string('agent_sub'),
|
|
274
|
+
scope: (t) => t.text('scope'),
|
|
275
|
+
expires_at: (t) => t.dateTime('expires_at', { useTz: true, precision: 3 }).nullable(),
|
|
276
|
+
revoked_at: (t) => t.dateTime('revoked_at', { useTz: true, precision: 3 }).nullable(),
|
|
277
|
+
created_at: (t) => t.dateTime('created_at', { useTz: true, precision: 3 }).nullable(),
|
|
278
|
+
updated_at: (t) => t.dateTime('updated_at', { useTz: true, precision: 3 }).nullable(),
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
name: 'auth_agent_refresh_tokens',
|
|
283
|
+
/** Refresh tokens dos grants de personal agents — só o hash, uso único. */
|
|
284
|
+
create: (t) => {
|
|
285
|
+
t.string('token_hash', 64).primary();
|
|
286
|
+
t.string('grant_id').notNullable().index();
|
|
287
|
+
t.dateTime('expires_at', { useTz: true, precision: 3 }).notNullable();
|
|
288
|
+
t.dateTime('used_at', { useTz: true, precision: 3 }).nullable();
|
|
289
|
+
t.dateTime('created_at', { useTz: true, precision: 3 }).notNullable();
|
|
290
|
+
},
|
|
291
|
+
columns: {
|
|
292
|
+
grant_id: (t) => t.string('grant_id').index(),
|
|
293
|
+
expires_at: (t) => t.dateTime('expires_at', { useTz: true, precision: 3 }).nullable(),
|
|
294
|
+
used_at: (t) => t.dateTime('used_at', { useTz: true, precision: 3 }).nullable(),
|
|
295
|
+
created_at: (t) => t.dateTime('created_at', { useTz: true, precision: 3 }).nullable(),
|
|
296
|
+
},
|
|
297
|
+
},
|
|
206
298
|
];
|
|
207
299
|
/**
|
|
208
300
|
* Probe searchPath-aware: `schema.hasTable` no Postgres ignora o
|
|
@@ -9,14 +9,20 @@ export default function AuthkitConsent({
|
|
|
9
9
|
params,
|
|
10
10
|
csrfToken,
|
|
11
11
|
brand,
|
|
12
|
+
clientName,
|
|
13
|
+
scopes = [],
|
|
12
14
|
}: {
|
|
13
15
|
uid: string;
|
|
14
16
|
params: { client_id: string };
|
|
15
17
|
csrfToken: string;
|
|
16
18
|
brand?: AuthBrand;
|
|
19
|
+
/** O nome que o client registrou (ex.: "Claude Code"); sem ele, o `client_id`. */
|
|
20
|
+
clientName?: string;
|
|
21
|
+
/** Os escopos pedidos, com o rótulo de cada um. */
|
|
22
|
+
scopes?: { id: string; label: string }[];
|
|
17
23
|
}) {
|
|
18
24
|
const accent = brand?.accent ?? '#111827';
|
|
19
|
-
const appName =
|
|
25
|
+
const appName = clientName ?? params.client_id;
|
|
20
26
|
|
|
21
27
|
return (
|
|
22
28
|
<AuthShell brand={brand}>
|
|
@@ -26,6 +32,16 @@ export default function AuthkitConsent({
|
|
|
26
32
|
<p className="mt-2 text-sm text-gray-600">
|
|
27
33
|
O app <strong>{appName}</strong> quer acessar sua conta.
|
|
28
34
|
</p>
|
|
35
|
+
{scopes.length > 0 && (
|
|
36
|
+
<>
|
|
37
|
+
<p className="mt-4 text-sm font-medium text-gray-900">Ele vai poder:</p>
|
|
38
|
+
<ul className="mt-2 list-inside list-disc space-y-1 text-sm text-gray-600">
|
|
39
|
+
{scopes.map((scope) => (
|
|
40
|
+
<li key={scope.id}>{scope.label}</li>
|
|
41
|
+
))}
|
|
42
|
+
</ul>
|
|
43
|
+
</>
|
|
44
|
+
)}
|
|
29
45
|
|
|
30
46
|
<button
|
|
31
47
|
type="submit"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adonis-agora/authkit-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.76.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",
|
|
@@ -9,14 +9,20 @@ export default function AuthkitConsent({
|
|
|
9
9
|
params,
|
|
10
10
|
csrfToken,
|
|
11
11
|
brand,
|
|
12
|
+
clientName,
|
|
13
|
+
scopes = [],
|
|
12
14
|
}: {
|
|
13
15
|
uid: string;
|
|
14
16
|
params: { client_id: string };
|
|
15
17
|
csrfToken: string;
|
|
16
18
|
brand?: AuthBrand;
|
|
19
|
+
/** O nome que o client registrou (ex.: "Claude Code"); sem ele, o `client_id`. */
|
|
20
|
+
clientName?: string;
|
|
21
|
+
/** Os escopos pedidos, com o rótulo de cada um. */
|
|
22
|
+
scopes?: { id: string; label: string }[];
|
|
17
23
|
}) {
|
|
18
24
|
const accent = brand?.accent ?? '#111827';
|
|
19
|
-
const appName =
|
|
25
|
+
const appName = clientName ?? params.client_id;
|
|
20
26
|
|
|
21
27
|
return (
|
|
22
28
|
<AuthShell brand={brand}>
|
|
@@ -26,6 +32,16 @@ export default function AuthkitConsent({
|
|
|
26
32
|
<p className="mt-2 text-sm text-gray-600">
|
|
27
33
|
O app <strong>{appName}</strong> quer acessar sua conta.
|
|
28
34
|
</p>
|
|
35
|
+
{scopes.length > 0 && (
|
|
36
|
+
<>
|
|
37
|
+
<p className="mt-4 text-sm font-medium text-gray-900">Ele vai poder:</p>
|
|
38
|
+
<ul className="mt-2 list-inside list-disc space-y-1 text-sm text-gray-600">
|
|
39
|
+
{scopes.map((scope) => (
|
|
40
|
+
<li key={scope.id}>{scope.label}</li>
|
|
41
|
+
))}
|
|
42
|
+
</ul>
|
|
43
|
+
</>
|
|
44
|
+
)}
|
|
29
45
|
|
|
30
46
|
<button
|
|
31
47
|
type="submit"
|