@adonis-agora/authkit-server 0.77.0 → 0.79.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 +4 -0
- package/build/host/views/agents/consent.edge +13 -2
- package/build/host/views/agents/done.edge +3 -0
- package/build/index.d.ts +10 -0
- package/build/index.js +8 -0
- package/build/providers/authkit_server_provider.js +15 -1
- package/build/src/agents/config.d.ts +22 -3
- package/build/src/agents/config.js +22 -4
- package/build/src/agents/delegation_service.js +18 -8
- package/build/src/agents/delegation_store.d.ts +16 -0
- package/build/src/agents/delegation_store.js +44 -0
- package/build/src/agents/middleware.js +2 -1
- package/build/src/agents/poppy/client_registry.d.ts +45 -0
- package/build/src/agents/poppy/client_registry.js +226 -0
- package/build/src/agents/poppy/config.d.ts +365 -0
- package/build/src/agents/poppy/config.js +208 -0
- package/build/src/agents/poppy/dpop.d.ts +61 -0
- package/build/src/agents/poppy/dpop.js +158 -0
- package/build/src/agents/poppy/errors.d.ts +20 -0
- package/build/src/agents/poppy/errors.js +22 -0
- package/build/src/agents/poppy/replay.d.ts +25 -0
- package/build/src/agents/poppy/replay.js +106 -0
- package/build/src/agents/poppy/resource.d.ts +95 -0
- package/build/src/agents/poppy/resource.js +125 -0
- package/build/src/agents/poppy/safe_fetch.d.ts +48 -0
- package/build/src/agents/poppy/safe_fetch.js +256 -0
- package/build/src/agents/poppy/service.d.ts +286 -0
- package/build/src/agents/poppy/service.js +1464 -0
- package/build/src/agents/poppy/store.d.ts +111 -0
- package/build/src/agents/poppy/store.js +269 -0
- package/build/src/agents/poppy/web_session.d.ts +32 -0
- package/build/src/agents/poppy/web_session.js +104 -0
- package/build/src/agents/protocol.d.ts +8 -4
- package/build/src/agents/registry_store.d.ts +41 -0
- package/build/src/agents/registry_store.js +138 -0
- package/build/src/agents/runtime.d.ts +30 -1
- package/build/src/agents/runtime.js +221 -14
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/audit/audit_sink.js +6 -0
- package/build/src/define_config.js +6 -0
- package/build/src/host/account_api/account_api_controller.d.ts +12 -1
- package/build/src/host/account_api/account_api_controller.js +10 -5
- package/build/src/host/admin_api/dto.d.ts +1 -1
- package/build/src/host/admin_console/admin_shell_controller.js +7 -0
- package/build/src/host/admin_console/console_personal_agents_controller.d.ts +83 -0
- package/build/src/host/admin_console/console_personal_agents_controller.js +337 -0
- package/build/src/host/admin_sessions_service.js +8 -5
- package/build/src/host/auth_host_config.d.ts +2 -1
- package/build/src/host/controllers/account_apps_controller.js +13 -3
- package/build/src/host/controllers/poppy_consent_controller.d.ts +19 -0
- package/build/src/host/controllers/poppy_consent_controller.js +173 -0
- package/build/src/host/controllers/poppy_controller.d.ts +25 -0
- package/build/src/host/controllers/poppy_controller.js +194 -0
- package/build/src/host/csrf.d.ts +7 -0
- package/build/src/host/csrf.js +16 -1
- package/build/src/host/i18n.d.ts +4 -0
- package/build/src/host/i18n.js +4 -0
- package/build/src/host/poppy_session_key.d.ts +9 -0
- package/build/src/host/poppy_session_key.js +9 -0
- package/build/src/host/register_auth_host.js +76 -0
- package/build/src/host/ui-dist/assets/index-LrScaR9C.js +155 -0
- package/build/src/host/ui-dist/index.html +1 -1
- package/build/src/provider/oidc_service.d.ts +5 -0
- package/build/src/provider/oidc_service.js +9 -2
- package/build/src/schema/ensure.js +152 -0
- package/package.json +2 -2
- package/build/src/host/ui-dist/assets/index-CIqtoNcO.js +0 -155
|
@@ -0,0 +1,1464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authorization server do Poppy (Draft 0.1, §4–§6): Sessions, Session Tokens
|
|
3
|
+
* opacos presos por DPoP, Account Tokens (refresh tokens), Direct/Device/Mediated
|
|
4
|
+
* Sign-In, revogação, verificação nos resource servers e a asserção de navegador.
|
|
5
|
+
*
|
|
6
|
+
* Os Account Tokens são grants de personal agents (`auth_agent_grants`, prefixo
|
|
7
|
+
* `pgrant_`) — a mesma lista/revogação do console de conta do PACT.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash, randomBytes, randomInt, randomUUID } from 'node:crypto';
|
|
10
|
+
import { decodeJwt, decodeProtectedHeader, jwtVerify } from 'jose';
|
|
11
|
+
import { formatScope, parseScope } from '../config.js';
|
|
12
|
+
import { normalizeHtu, verifyDpopProof } from './dpop.js';
|
|
13
|
+
import { PoppyError } from './errors.js';
|
|
14
|
+
import { replayKey } from './replay.js';
|
|
15
|
+
export const JWT_BEARER_GRANT = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
|
|
16
|
+
export const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
17
|
+
export const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
|
|
18
|
+
export const BROWSER_ASSERTION_TYP = 'poppy-browser+jwt';
|
|
19
|
+
/** Algoritmos aceitos nas asserções do agente (client assertion, sessão, navegador). */
|
|
20
|
+
const ASSERTION_ALGS = ['ES256', 'ES384', 'ES512', 'RS256', 'PS256', 'EdDSA'];
|
|
21
|
+
const CLOCK_SKEW_S = 30;
|
|
22
|
+
/** Sem vogais: o código não forma palavras (RFC 8628 §6.1). */
|
|
23
|
+
const USER_CODE_ALPHABET = 'BCDFGHJKLMNPQRSTVWXZ';
|
|
24
|
+
/** Pedido de autorização (Direct) aguardando o consentimento. */
|
|
25
|
+
const AUTHORIZE_REQUEST_TTL_S = 600;
|
|
26
|
+
/** Deriva as URLs da ORIGEM do issuer configurado — nunca do Host da request. */
|
|
27
|
+
export function poppyUrls(oidcIssuer, prefix) {
|
|
28
|
+
const origin = new URL(oidcIssuer).origin;
|
|
29
|
+
const issuer = `${origin}${prefix}`;
|
|
30
|
+
return {
|
|
31
|
+
origin,
|
|
32
|
+
issuer,
|
|
33
|
+
metadata: `${origin}/.well-known/oauth-authorization-server${prefix}`,
|
|
34
|
+
token: `${issuer}/oauth/token`,
|
|
35
|
+
revocation: `${issuer}/oauth/revoke`,
|
|
36
|
+
authorization: `${issuer}/oauth/authorize`,
|
|
37
|
+
deviceAuthorization: `${issuer}/oauth/device`,
|
|
38
|
+
device: `${issuer}/device`,
|
|
39
|
+
mediated: `${issuer}/sign-in`,
|
|
40
|
+
browserSession: `${issuer}/browser-session`,
|
|
41
|
+
discovery: `${origin}/.well-known/poppy.json`,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function sha256(value) {
|
|
45
|
+
return createHash('sha256').update(value).digest('hex');
|
|
46
|
+
}
|
|
47
|
+
function randomId(prefix, bytes = 16) {
|
|
48
|
+
return `${prefix}${randomBytes(bytes).toString('base64url')}`;
|
|
49
|
+
}
|
|
50
|
+
function newUserCode() {
|
|
51
|
+
let code = '';
|
|
52
|
+
for (let i = 0; i < 8; i++)
|
|
53
|
+
code += USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)];
|
|
54
|
+
return `${code.slice(0, 4)}-${code.slice(4)}`;
|
|
55
|
+
}
|
|
56
|
+
/** Aceita o código como o usuário digitar: minúsculas, sem hífen, com espaços. */
|
|
57
|
+
export function normalizePoppyUserCode(input) {
|
|
58
|
+
if (typeof input !== 'string')
|
|
59
|
+
return null;
|
|
60
|
+
const letters = input.toUpperCase().replace(/[^A-Z]/g, '');
|
|
61
|
+
if (letters.length !== 8)
|
|
62
|
+
return null;
|
|
63
|
+
return `${letters.slice(0, 4)}-${letters.slice(4)}`;
|
|
64
|
+
}
|
|
65
|
+
function str(value) {
|
|
66
|
+
if (Array.isArray(value))
|
|
67
|
+
return undefined;
|
|
68
|
+
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
69
|
+
}
|
|
70
|
+
function trimSlash(value) {
|
|
71
|
+
return value.replace(/\/+$/, '');
|
|
72
|
+
}
|
|
73
|
+
function sameUrl(a, b) {
|
|
74
|
+
const na = normalizeHtu(a);
|
|
75
|
+
const nb = normalizeHtu(b);
|
|
76
|
+
return na !== null && nb !== null && trimSlash(na) === trimSlash(nb);
|
|
77
|
+
}
|
|
78
|
+
/** `true` quando `url` está "debaixo" de `base` (mesma origem, path igual ou filho). */
|
|
79
|
+
function underUrl(url, base) {
|
|
80
|
+
const u = normalizeHtu(url);
|
|
81
|
+
const b = normalizeHtu(base);
|
|
82
|
+
if (!u || !b)
|
|
83
|
+
return false;
|
|
84
|
+
const ub = trimSlash(b);
|
|
85
|
+
return trimSlash(u) === ub || u.startsWith(`${ub}/`);
|
|
86
|
+
}
|
|
87
|
+
function header(headers, name) {
|
|
88
|
+
const lower = name.toLowerCase();
|
|
89
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
90
|
+
if (key.toLowerCase() === lower)
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
function quote(value) {
|
|
96
|
+
return value.replace(/["\\]/g, '');
|
|
97
|
+
}
|
|
98
|
+
function hostMatches(host, domains) {
|
|
99
|
+
const h = host.toLowerCase().replace(/\.$/, '');
|
|
100
|
+
return domains.some((d) => h === d || h.endsWith(`.${d}`));
|
|
101
|
+
}
|
|
102
|
+
/** Slot global onde o `@adonis-agora/agent` registra o endpoint de conversas Poppy. */
|
|
103
|
+
export const POPPY_ENDPOINTS_SLOT = Symbol.for('@adonis-agora/poppy:endpoints');
|
|
104
|
+
/**
|
|
105
|
+
* `agent.protocols` vindo do slot `Symbol.for('@adonis-agora/poppy:endpoints')`
|
|
106
|
+
* (`{ conversations: 'https://…' }`), usado quando o config não declara
|
|
107
|
+
* `agent.protocols` — o mesmo padrão do slot de resources do MCP.
|
|
108
|
+
*/
|
|
109
|
+
export function registeredConversationProtocols() {
|
|
110
|
+
const slot = globalThis[POPPY_ENDPOINTS_SLOT];
|
|
111
|
+
const url = slot?.conversations;
|
|
112
|
+
if (typeof url !== 'string')
|
|
113
|
+
return [];
|
|
114
|
+
try {
|
|
115
|
+
const parsed = new URL(url);
|
|
116
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
return [{ type: 'poppy', endpoint: url }];
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* @experimental Poppy (Personal Agent Protocol) Draft 0.1 — pode mudar de forma incompatível enquanto a spec for draft.
|
|
126
|
+
*/
|
|
127
|
+
export class PoppyService {
|
|
128
|
+
cfg;
|
|
129
|
+
urls;
|
|
130
|
+
registry;
|
|
131
|
+
#store;
|
|
132
|
+
#grants;
|
|
133
|
+
#replay;
|
|
134
|
+
#limiter;
|
|
135
|
+
#nonces;
|
|
136
|
+
#isAccountActive;
|
|
137
|
+
#mcp;
|
|
138
|
+
#now;
|
|
139
|
+
#housekeepingAt = 0;
|
|
140
|
+
constructor(deps) {
|
|
141
|
+
this.cfg = deps.cfg;
|
|
142
|
+
this.urls = deps.urls;
|
|
143
|
+
this.registry = deps.registry;
|
|
144
|
+
this.#store = deps.store;
|
|
145
|
+
this.#grants = deps.grants;
|
|
146
|
+
this.#replay = deps.replay;
|
|
147
|
+
this.#limiter = deps.limiter;
|
|
148
|
+
this.#nonces = deps.nonces;
|
|
149
|
+
this.#isAccountActive = deps.isAccountActive ?? (async () => true);
|
|
150
|
+
this.#mcp = deps.mcp;
|
|
151
|
+
this.#now = deps.now ?? (() => new Date());
|
|
152
|
+
}
|
|
153
|
+
/** Um nonce DPoP novo (header `DPoP-Nonce`), quando o app exige nonces. */
|
|
154
|
+
nonce() {
|
|
155
|
+
return this.cfg.dpop.requireNonce ? this.#nonces.issue() : null;
|
|
156
|
+
}
|
|
157
|
+
/** Domínios da empresa (o principal + aliases). */
|
|
158
|
+
get domains() {
|
|
159
|
+
return [this.cfg.organization.domain, ...this.cfg.organization.aliases];
|
|
160
|
+
}
|
|
161
|
+
/** Descrição de um scope para o usuário. */
|
|
162
|
+
describe(ids) {
|
|
163
|
+
return ids.map((id) => ({ id, description: this.cfg.scopes[id] ?? id }));
|
|
164
|
+
}
|
|
165
|
+
// ─── descoberta ───────────────────────────────────────────────────────────
|
|
166
|
+
/** `/.well-known/poppy.json` (§3) para o domínio pedido; `null` = domínio que não é nosso. */
|
|
167
|
+
discoveryDocument(requestHost) {
|
|
168
|
+
const hostWithPort = requestHost
|
|
169
|
+
.toLowerCase()
|
|
170
|
+
.replace(/\.$/, '')
|
|
171
|
+
.replace(/\.(:\d+)$/, '$1');
|
|
172
|
+
const host = hostWithPort.replace(/:\d+$/, '');
|
|
173
|
+
const bare = host.startsWith('www.') ? host.slice(4) : host;
|
|
174
|
+
const domain = this.domains.find((d) => d === bare);
|
|
175
|
+
if (!domain)
|
|
176
|
+
return null;
|
|
177
|
+
const { signIn } = this.cfg;
|
|
178
|
+
const auth = { issuer: this.urls.issuer };
|
|
179
|
+
if (signIn.direct)
|
|
180
|
+
auth.direct = { scopes: signIn.direct.scopes };
|
|
181
|
+
if (signIn.device)
|
|
182
|
+
auth.device = { scopes: signIn.device.scopes };
|
|
183
|
+
if (signIn.mediated) {
|
|
184
|
+
auth.mediated = {
|
|
185
|
+
endpoint: this.urls.mediated,
|
|
186
|
+
fields: signIn.mediated.fields,
|
|
187
|
+
scopes: signIn.mediated.scopes,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
if (Object.keys(this.cfg.customScopes).length > 0)
|
|
191
|
+
auth.custom_scopes = this.cfg.customScopes;
|
|
192
|
+
const doc = {
|
|
193
|
+
protocol_version: '0.1',
|
|
194
|
+
organization: { name: this.cfg.organization.name, domain },
|
|
195
|
+
auth,
|
|
196
|
+
};
|
|
197
|
+
const protocols = this.cfg.agent?.protocols ?? registeredConversationProtocols();
|
|
198
|
+
if (protocols.length > 0) {
|
|
199
|
+
doc.agent = {
|
|
200
|
+
protocols: protocols.map((p) => ({
|
|
201
|
+
type: p.type,
|
|
202
|
+
endpoint: p.endpoint,
|
|
203
|
+
...(p.resource ? { resource: p.resource } : {}),
|
|
204
|
+
})),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
if (this.cfg.web) {
|
|
208
|
+
doc.web = this.cfg.web.browserSession
|
|
209
|
+
? { browser_session_endpoint: this.#browserSessionEndpointFor(hostWithPort) }
|
|
210
|
+
: {};
|
|
211
|
+
}
|
|
212
|
+
if (this.cfg.apis.length > 0) {
|
|
213
|
+
doc.apis = this.cfg.apis.map((a) => ({
|
|
214
|
+
type: a.type,
|
|
215
|
+
url: a.url,
|
|
216
|
+
description: a.description,
|
|
217
|
+
...(a.resource ? { resource: a.resource } : {}),
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
if (Object.keys(this.cfg.extensions).length > 0)
|
|
221
|
+
doc.extensions = this.cfg.extensions;
|
|
222
|
+
return doc;
|
|
223
|
+
}
|
|
224
|
+
/** RFC 8414 com `poppy_domains` (§3.2). */
|
|
225
|
+
authorizationServerMetadata() {
|
|
226
|
+
const { signIn } = this.cfg;
|
|
227
|
+
const grants = [JWT_BEARER_GRANT, 'refresh_token'];
|
|
228
|
+
if (signIn.direct)
|
|
229
|
+
grants.push('authorization_code');
|
|
230
|
+
if (signIn.device)
|
|
231
|
+
grants.push(DEVICE_CODE_GRANT);
|
|
232
|
+
return {
|
|
233
|
+
issuer: this.urls.issuer,
|
|
234
|
+
token_endpoint: this.urls.token,
|
|
235
|
+
revocation_endpoint: this.urls.revocation,
|
|
236
|
+
...(signIn.direct ? { authorization_endpoint: this.urls.authorization } : {}),
|
|
237
|
+
...(signIn.device ? { device_authorization_endpoint: this.urls.deviceAuthorization } : {}),
|
|
238
|
+
poppy_domains: this.domains,
|
|
239
|
+
scopes_supported: Object.keys(this.cfg.scopes),
|
|
240
|
+
response_types_supported: signIn.direct ? ['code'] : [],
|
|
241
|
+
response_modes_supported: signIn.direct ? ['query'] : [],
|
|
242
|
+
grant_types_supported: grants,
|
|
243
|
+
code_challenge_methods_supported: ['S256'],
|
|
244
|
+
token_endpoint_auth_methods_supported: ['private_key_jwt'],
|
|
245
|
+
token_endpoint_auth_signing_alg_values_supported: ASSERTION_ALGS,
|
|
246
|
+
revocation_endpoint_auth_methods_supported: ['private_key_jwt'],
|
|
247
|
+
revocation_endpoint_auth_signing_alg_values_supported: ASSERTION_ALGS,
|
|
248
|
+
dpop_signing_alg_values_supported: this.cfg.dpop.algorithms,
|
|
249
|
+
authorization_response_iss_parameter_supported: true,
|
|
250
|
+
client_id_metadata_document_supported: true,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/** Endpoints de sessão de navegador aceitos (um por domínio, com e sem `www.`). */
|
|
254
|
+
#browserSessionEndpoints() {
|
|
255
|
+
const path = new URL(this.urls.browserSession).pathname;
|
|
256
|
+
const protocol = new URL(this.urls.origin).protocol;
|
|
257
|
+
const list = new Set([this.urls.browserSession]);
|
|
258
|
+
for (const d of this.domains) {
|
|
259
|
+
list.add(`${protocol}//${d}${path}`);
|
|
260
|
+
list.add(`${protocol}//www.${d}${path}`);
|
|
261
|
+
}
|
|
262
|
+
return [...list];
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* O endpoint no host pedido (o cookie do site é do domínio que o agente abriu). No host do issuer
|
|
266
|
+
* é o do issuer — com a porta dele: o `Host` pode vir sem porta (ou o controller só ter o
|
|
267
|
+
* hostname), e `http://localhost/poppy/…` apontaria para outro servidor.
|
|
268
|
+
*/
|
|
269
|
+
#browserSessionEndpointFor(requestHost) {
|
|
270
|
+
const issuer = new URL(this.urls.origin);
|
|
271
|
+
let requested;
|
|
272
|
+
try {
|
|
273
|
+
requested = new URL(`${issuer.protocol}//${requestHost}`);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return this.urls.browserSession;
|
|
277
|
+
}
|
|
278
|
+
if (requested.hostname === issuer.hostname.toLowerCase())
|
|
279
|
+
return this.urls.browserSession;
|
|
280
|
+
const path = new URL(this.urls.browserSession).pathname;
|
|
281
|
+
return `${issuer.protocol}//${requested.host}${path}`;
|
|
282
|
+
}
|
|
283
|
+
// ─── autenticação do agente ───────────────────────────────────────────────
|
|
284
|
+
/**
|
|
285
|
+
* `private_key_jwt` (RFC 7523 §2.2, §3): `client_assertion` assinada por uma
|
|
286
|
+
* chave do `jwks_uri`, `iss` = `sub` = `client_id`, `aud` = o endpoint
|
|
287
|
+
* chamado (ou o token endpoint/issuer), `exp` curto, `jti` único.
|
|
288
|
+
*/
|
|
289
|
+
async authenticateClient(form, endpoint) {
|
|
290
|
+
const clientId = str(form.client_id);
|
|
291
|
+
const type = str(form.client_assertion_type);
|
|
292
|
+
const assertion = str(form.client_assertion);
|
|
293
|
+
if (!clientId || type !== CLIENT_ASSERTION_TYPE || !assertion) {
|
|
294
|
+
throw new PoppyError('invalid_client', 'private_key_jwt client authentication is required');
|
|
295
|
+
}
|
|
296
|
+
const client = await this.registry.resolve(clientId);
|
|
297
|
+
const payload = await this.#verifyAgentJwt(client, assertion, {
|
|
298
|
+
audience: [endpoint, this.urls.token, this.urls.issuer],
|
|
299
|
+
error: 'invalid_client',
|
|
300
|
+
forbidTyp: [BROWSER_ASSERTION_TYP, 'dpop+jwt'],
|
|
301
|
+
});
|
|
302
|
+
if (payload.sub !== clientId) {
|
|
303
|
+
throw new PoppyError('invalid_client', 'client assertion sub must be the client_id');
|
|
304
|
+
}
|
|
305
|
+
await this.#claimJti('client', clientId, payload, 'invalid_client');
|
|
306
|
+
return client;
|
|
307
|
+
}
|
|
308
|
+
async #verifyAgentJwt(client, token, options) {
|
|
309
|
+
const fail = (why) => new PoppyError(options.error, why);
|
|
310
|
+
let typ;
|
|
311
|
+
try {
|
|
312
|
+
typ = decodeProtectedHeader(token).typ;
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
throw fail('malformed JWT');
|
|
316
|
+
}
|
|
317
|
+
if (options.typ !== undefined && typ !== options.typ)
|
|
318
|
+
throw fail(`typ must be ${options.typ}`);
|
|
319
|
+
if (typeof typ === 'string' && options.forbidTyp?.includes(typ.toLowerCase())) {
|
|
320
|
+
throw fail(`a ${typ} is not accepted here`);
|
|
321
|
+
}
|
|
322
|
+
const now = this.#now();
|
|
323
|
+
let payload;
|
|
324
|
+
try {
|
|
325
|
+
({ payload } = await jwtVerify(token, client.keys, {
|
|
326
|
+
algorithms: ASSERTION_ALGS,
|
|
327
|
+
issuer: client.clientId,
|
|
328
|
+
audience: options.audience,
|
|
329
|
+
currentDate: now,
|
|
330
|
+
clockTolerance: CLOCK_SKEW_S,
|
|
331
|
+
requiredClaims: ['exp', 'jti'],
|
|
332
|
+
}));
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
if (error instanceof PoppyError)
|
|
336
|
+
throw error;
|
|
337
|
+
throw fail('JWT verification failed');
|
|
338
|
+
}
|
|
339
|
+
const nowS = Math.floor(now.getTime() / 1000);
|
|
340
|
+
const max = options.maxLifetime ?? this.cfg.assertionMaxLifetime;
|
|
341
|
+
const start = typeof payload.iat === 'number' ? payload.iat : nowS;
|
|
342
|
+
if (typeof payload.iat === 'number' && payload.iat > nowS + CLOCK_SKEW_S) {
|
|
343
|
+
throw fail('iat is in the future');
|
|
344
|
+
}
|
|
345
|
+
if (payload.exp - start > max)
|
|
346
|
+
throw fail('JWT lifetime is too long');
|
|
347
|
+
if (typeof payload.jti !== 'string' || payload.jti.length < 16)
|
|
348
|
+
throw fail('jti is required');
|
|
349
|
+
return payload;
|
|
350
|
+
}
|
|
351
|
+
async #claimJti(kind, clientId, payload, error) {
|
|
352
|
+
const expiresAt = new Date((payload.exp + CLOCK_SKEW_S) * 1000);
|
|
353
|
+
if (!(await this.#replay.claim(replayKey(kind, clientId, String(payload.jti)), expiresAt))) {
|
|
354
|
+
throw new PoppyError(error, 'jti was already used');
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// ─── token endpoint ───────────────────────────────────────────────────────
|
|
358
|
+
/** `POST token_endpoint` (§4.2, §4.3, §4.5, §4.6, §4.8). */
|
|
359
|
+
async token(form, dpopHeader) {
|
|
360
|
+
await this.#housekeeping();
|
|
361
|
+
const client = await this.authenticateClient(form, this.urls.token);
|
|
362
|
+
const grantType = str(form.grant_type);
|
|
363
|
+
if (grantType !== JWT_BEARER_GRANT &&
|
|
364
|
+
grantType !== 'refresh_token' &&
|
|
365
|
+
!(grantType === 'authorization_code' && this.cfg.signIn.direct) &&
|
|
366
|
+
!(grantType === DEVICE_CODE_GRANT && this.cfg.signIn.device)) {
|
|
367
|
+
throw new PoppyError('unsupported_grant_type', 'Unsupported grant_type');
|
|
368
|
+
}
|
|
369
|
+
const binding = await this.#tokenBinding(form, dpopHeader);
|
|
370
|
+
const scopeParam = form.scope;
|
|
371
|
+
const requestedScope = scopeParam === undefined ? null : parseScope(str(scopeParam) ?? '');
|
|
372
|
+
switch (grantType) {
|
|
373
|
+
case JWT_BEARER_GRANT:
|
|
374
|
+
return this.#jwtBearer(client, form, binding, requestedScope);
|
|
375
|
+
case 'refresh_token':
|
|
376
|
+
return this.#refresh(client, form, binding, requestedScope);
|
|
377
|
+
case 'authorization_code':
|
|
378
|
+
return this.#authorizationCode(client, form, binding, requestedScope);
|
|
379
|
+
default:
|
|
380
|
+
return this.#deviceCode(client, form, binding, requestedScope);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* DPoP da request de token (sem `ath`), ou Bearer — que só existe para MCP e
|
|
385
|
+
* exige `resource` = a URL de um servidor MCP (§4.3).
|
|
386
|
+
*/
|
|
387
|
+
async #tokenBinding(form, dpopHeader) {
|
|
388
|
+
const rawResource = form.resource;
|
|
389
|
+
if (Array.isArray(rawResource)) {
|
|
390
|
+
throw new PoppyError('invalid_target', 'Request a token for one resource at a time');
|
|
391
|
+
}
|
|
392
|
+
const resource = str(rawResource) ?? null;
|
|
393
|
+
if (dpopHeader === undefined || dpopHeader === '') {
|
|
394
|
+
if (resource && this.#isMcp(resource))
|
|
395
|
+
return { jkt: null, resource: this.#mcpUrl(resource) };
|
|
396
|
+
throw new PoppyError('invalid_dpop_proof', 'A DPoP proof is required (Bearer tokens are issued only for an MCP resource)');
|
|
397
|
+
}
|
|
398
|
+
const result = await verifyDpopProof({
|
|
399
|
+
proof: dpopHeader,
|
|
400
|
+
method: 'POST',
|
|
401
|
+
url: this.urls.token,
|
|
402
|
+
algorithms: this.cfg.dpop.algorithms,
|
|
403
|
+
maxAge: this.cfg.dpop.maxAge,
|
|
404
|
+
replay: this.#replay,
|
|
405
|
+
nonce: { required: this.cfg.dpop.requireNonce, source: this.#nonces },
|
|
406
|
+
now: () => this.#now().getTime(),
|
|
407
|
+
});
|
|
408
|
+
if (!result.ok) {
|
|
409
|
+
throw new PoppyError(result.error, result.description, {
|
|
410
|
+
headers: result.error === 'use_dpop_nonce' ? { 'DPoP-Nonce': this.#nonces.issue() } : {},
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
if (resource) {
|
|
414
|
+
if (!this.#isConfiguredResource(resource)) {
|
|
415
|
+
throw new PoppyError('invalid_target', 'Unknown resource');
|
|
416
|
+
}
|
|
417
|
+
return { jkt: result.jkt, resource };
|
|
418
|
+
}
|
|
419
|
+
return { jkt: result.jkt, resource: null };
|
|
420
|
+
}
|
|
421
|
+
/** `resource`s que exigem tokens próprios (§4.3): os `resource` de `apis` e `agent.protocols`. */
|
|
422
|
+
#isConfiguredResource(resource) {
|
|
423
|
+
const entries = [
|
|
424
|
+
...this.cfg.apis.filter((a) => a.type !== 'mcp').map((a) => a.resource),
|
|
425
|
+
...(this.cfg.agent?.protocols ?? []).map((p) => p.resource),
|
|
426
|
+
].filter((r) => !!r);
|
|
427
|
+
return entries.some((r) => sameUrl(r, resource));
|
|
428
|
+
}
|
|
429
|
+
#isMcp(url) {
|
|
430
|
+
if (this.cfg.apis.some((a) => a.type === 'mcp' && sameUrl(a.url, url)))
|
|
431
|
+
return true;
|
|
432
|
+
return this.#mcp?.isMcpResource(url) ?? false;
|
|
433
|
+
}
|
|
434
|
+
/** A URL canônica do MCP (a de `apis`, quando listado). */
|
|
435
|
+
#mcpUrl(url) {
|
|
436
|
+
const listed = this.cfg.apis.find((a) => a.type === 'mcp' && sameUrl(a.url, url));
|
|
437
|
+
return listed ? listed.url : url;
|
|
438
|
+
}
|
|
439
|
+
async #jwtBearer(client, form, binding, requestedScope) {
|
|
440
|
+
const assertion = str(form.assertion);
|
|
441
|
+
if (!assertion)
|
|
442
|
+
throw new PoppyError('invalid_grant', 'assertion is required');
|
|
443
|
+
const payload = await this.#verifyAgentJwt(client, assertion, {
|
|
444
|
+
audience: this.urls.token,
|
|
445
|
+
error: 'invalid_grant',
|
|
446
|
+
forbidTyp: [BROWSER_ASSERTION_TYP, 'dpop+jwt'],
|
|
447
|
+
});
|
|
448
|
+
// `aud` é UMA string (§4.2): uma asserção com lista não serve.
|
|
449
|
+
if (typeof payload.aud !== 'string' || payload.aud !== this.urls.token) {
|
|
450
|
+
throw new PoppyError('invalid_grant', 'aud must be the token endpoint, as a single string');
|
|
451
|
+
}
|
|
452
|
+
if (typeof payload.iat !== 'number')
|
|
453
|
+
throw new PoppyError('invalid_grant', 'iat is required');
|
|
454
|
+
if (typeof payload.jti !== 'string' || payload.jti.length < 22) {
|
|
455
|
+
throw new PoppyError('invalid_grant', 'jti needs at least 128 random bits');
|
|
456
|
+
}
|
|
457
|
+
const userId = payload.sub;
|
|
458
|
+
if (typeof userId !== 'string' || !userId || userId.length > 255) {
|
|
459
|
+
throw new PoppyError('invalid_grant', 'sub (the User ID) is required');
|
|
460
|
+
}
|
|
461
|
+
await this.#claimJti('session', client.clientId, payload, 'invalid_grant');
|
|
462
|
+
if (requestedScope && requestedScope.length > 0) {
|
|
463
|
+
throw new PoppyError('invalid_scope', 'A signed-out Session Token has no scopes');
|
|
464
|
+
}
|
|
465
|
+
const sessionId = str(form.session_id);
|
|
466
|
+
const now = this.#now();
|
|
467
|
+
let session;
|
|
468
|
+
if (sessionId) {
|
|
469
|
+
session = await this.#sessionFor(sessionId, client.clientId, userId);
|
|
470
|
+
}
|
|
471
|
+
else {
|
|
472
|
+
await this.#limit('session', client.clientId);
|
|
473
|
+
session = await this.#newSession(client.clientId, userId, now);
|
|
474
|
+
}
|
|
475
|
+
await this.#store.touchSession(session.id, this.#sessionExpiry(now), now);
|
|
476
|
+
return this.#issue(session, { ...binding, signedIn: null, scopes: [] });
|
|
477
|
+
}
|
|
478
|
+
async #refresh(client, form, binding, requestedScope) {
|
|
479
|
+
const token = str(form.refresh_token);
|
|
480
|
+
if (!token)
|
|
481
|
+
throw new PoppyError('invalid_grant', 'refresh_token is required');
|
|
482
|
+
const now = this.#now();
|
|
483
|
+
const invalid = new PoppyError('invalid_grant', 'Unknown, expired or revoked Account Token');
|
|
484
|
+
const row = await this.#grants.findRefreshToken(sha256(token));
|
|
485
|
+
if (!row || row.expiresAt <= now)
|
|
486
|
+
throw invalid;
|
|
487
|
+
const grant = await this.#grants.findGrant(row.grantId);
|
|
488
|
+
// Só o `client_id` a que foi emitido (§4.8).
|
|
489
|
+
if (!grant || !(await this.#grantUsable(grant, client.clientId, now)))
|
|
490
|
+
throw invalid;
|
|
491
|
+
const granted = parseScope(grant.scope);
|
|
492
|
+
const scopes = this.#narrow(granted, requestedScope);
|
|
493
|
+
const sessionId = str(form.session_id);
|
|
494
|
+
let session;
|
|
495
|
+
if (sessionId) {
|
|
496
|
+
session = await this.#sessionFor(sessionId, client.clientId, grant.agentSub);
|
|
497
|
+
if (session.accountId && session.accountId !== grant.accountId) {
|
|
498
|
+
throw new PoppyError('account_mismatch', 'The Session is signed in to a different account');
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
await this.#limit('session', client.clientId);
|
|
503
|
+
session = await this.#newSession(client.clientId, grant.agentSub, now);
|
|
504
|
+
}
|
|
505
|
+
if (!(await this.#store.signInSession(session.id, {
|
|
506
|
+
accountId: grant.accountId,
|
|
507
|
+
grantId: grant.id,
|
|
508
|
+
scope: grant.scope,
|
|
509
|
+
expiresAt: this.#sessionExpiry(now),
|
|
510
|
+
at: now,
|
|
511
|
+
}))) {
|
|
512
|
+
throw new PoppyError('account_mismatch', 'The Session is signed in to a different account');
|
|
513
|
+
}
|
|
514
|
+
const fresh = { ...session, accountId: grant.accountId, grantId: grant.id, scope: grant.scope };
|
|
515
|
+
return this.#issue(fresh, { ...binding, signedIn: grant, scopes });
|
|
516
|
+
}
|
|
517
|
+
async #authorizationCode(client, form, binding, requestedScope) {
|
|
518
|
+
const code = str(form.code);
|
|
519
|
+
if (!code)
|
|
520
|
+
throw new PoppyError('invalid_request', 'code is required');
|
|
521
|
+
const now = this.#now();
|
|
522
|
+
const row = await this.#store.findRequestByCodeHash(sha256(code));
|
|
523
|
+
const invalid = new PoppyError('invalid_grant', 'Unknown, expired or used authorization code');
|
|
524
|
+
if (row?.kind !== 'authorize' || row.clientId !== client.clientId)
|
|
525
|
+
throw invalid;
|
|
526
|
+
if (row.status === 'consumed') {
|
|
527
|
+
// Código reusado: revoga o que ele já entregou (RFC 6749 §4.1.2).
|
|
528
|
+
const grantId = row.data?.grantId;
|
|
529
|
+
if (typeof grantId === 'string')
|
|
530
|
+
await this.revokeGrant(grantId);
|
|
531
|
+
throw invalid;
|
|
532
|
+
}
|
|
533
|
+
if (row.status !== 'approved' || row.expiresAt <= now || !row.accountId)
|
|
534
|
+
throw invalid;
|
|
535
|
+
if (str(form.redirect_uri) !== row.redirectUri) {
|
|
536
|
+
throw new PoppyError('invalid_grant', 'redirect_uri does not match the authorization request');
|
|
537
|
+
}
|
|
538
|
+
const verifier = str(form.code_verifier);
|
|
539
|
+
if (!verifier ||
|
|
540
|
+
!/^[A-Za-z0-9\-._~]{43,128}$/.test(verifier) ||
|
|
541
|
+
createHash('sha256').update(verifier).digest('base64url') !== row.codeChallenge) {
|
|
542
|
+
throw new PoppyError('invalid_grant', 'PKCE verification failed');
|
|
543
|
+
}
|
|
544
|
+
const sessionId = str(form.session_id);
|
|
545
|
+
if (!sessionId)
|
|
546
|
+
throw new PoppyError('invalid_request', 'session_id is required');
|
|
547
|
+
const session = await this.#sessionFor(sessionId, client.clientId, null);
|
|
548
|
+
if (session.accountId && session.accountId !== row.accountId) {
|
|
549
|
+
throw new PoppyError('account_mismatch', 'The Session is signed in to a different account');
|
|
550
|
+
}
|
|
551
|
+
if (!(await this.#store.transitionRequest(row.id, 'approved', { status: 'consumed' }))) {
|
|
552
|
+
throw invalid;
|
|
553
|
+
}
|
|
554
|
+
const granted = parseScope(row.grantedScope);
|
|
555
|
+
const response = await this.#signIn(session, row.accountId, granted, binding, requestedScope);
|
|
556
|
+
await this.#store.setRequestData(row.id, { ...(row.data ?? {}), grantId: response.grantId });
|
|
557
|
+
return response.body;
|
|
558
|
+
}
|
|
559
|
+
async #deviceCode(client, form, binding, requestedScope) {
|
|
560
|
+
const deviceCode = str(form.device_code);
|
|
561
|
+
if (!deviceCode)
|
|
562
|
+
throw new PoppyError('invalid_request', 'device_code is required');
|
|
563
|
+
const row = await this.#store.findRequestByCodeHash(sha256(deviceCode));
|
|
564
|
+
if (row?.kind !== 'device' || row.clientId !== client.clientId) {
|
|
565
|
+
throw new PoppyError('invalid_grant', 'Unknown device_code');
|
|
566
|
+
}
|
|
567
|
+
const now = this.#now();
|
|
568
|
+
if (row.status === 'denied')
|
|
569
|
+
throw new PoppyError('access_denied', 'The user denied access');
|
|
570
|
+
if (row.status === 'consumed')
|
|
571
|
+
throw new PoppyError('invalid_grant', 'device_code was used');
|
|
572
|
+
if (row.expiresAt <= now)
|
|
573
|
+
throw new PoppyError('expired_token', 'The device code expired');
|
|
574
|
+
if (row.status === 'pending') {
|
|
575
|
+
const interval = row.intervalSeconds ?? this.cfg.pollInterval;
|
|
576
|
+
const tooSoon = row.lastPolledAt !== null && now.getTime() - row.lastPolledAt.getTime() < interval * 1000;
|
|
577
|
+
await this.#store.markPolled(row.id, now);
|
|
578
|
+
throw tooSoon
|
|
579
|
+
? new PoppyError('slow_down', 'Poll less often')
|
|
580
|
+
: new PoppyError('authorization_pending', 'Waiting for the user');
|
|
581
|
+
}
|
|
582
|
+
if (row.status !== 'approved' || !row.accountId) {
|
|
583
|
+
throw new PoppyError('invalid_grant', 'Unknown device_code');
|
|
584
|
+
}
|
|
585
|
+
const sessionId = str(form.session_id);
|
|
586
|
+
if (!sessionId)
|
|
587
|
+
throw new PoppyError('invalid_request', 'session_id is required');
|
|
588
|
+
const session = await this.#sessionFor(sessionId, client.clientId, null);
|
|
589
|
+
if (session.accountId && session.accountId !== row.accountId) {
|
|
590
|
+
throw new PoppyError('account_mismatch', 'The Session is signed in to a different account');
|
|
591
|
+
}
|
|
592
|
+
if (!(await this.#store.transitionRequest(row.id, 'approved', { status: 'consumed' }))) {
|
|
593
|
+
throw new PoppyError('invalid_grant', 'device_code was used');
|
|
594
|
+
}
|
|
595
|
+
const result = await this.#signIn(session, row.accountId, parseScope(row.grantedScope), binding, requestedScope);
|
|
596
|
+
return result.body;
|
|
597
|
+
}
|
|
598
|
+
/** `POST device_authorization_endpoint` (§4.6). */
|
|
599
|
+
async deviceAuthorization(form) {
|
|
600
|
+
const device = this.cfg.signIn.device;
|
|
601
|
+
if (!device)
|
|
602
|
+
throw new PoppyError('unauthorized_client', 'Device Sign-In is not offered');
|
|
603
|
+
await this.#housekeeping();
|
|
604
|
+
const client = await this.authenticateClient(form, this.urls.deviceAuthorization);
|
|
605
|
+
const scopes = this.#signInScopes(form.scope, 'device');
|
|
606
|
+
const now = this.#now();
|
|
607
|
+
const deviceCode = randomId('pdc_', 32);
|
|
608
|
+
const expiresAt = new Date(now.getTime() + this.cfg.deviceCodeTtl * 1000);
|
|
609
|
+
let userCode = newUserCode();
|
|
610
|
+
for (let attempt = 0;; attempt++) {
|
|
611
|
+
try {
|
|
612
|
+
await this.#store.insertRequest({
|
|
613
|
+
id: randomId('dev_'),
|
|
614
|
+
kind: 'device',
|
|
615
|
+
codeHash: sha256(deviceCode),
|
|
616
|
+
userCode,
|
|
617
|
+
clientId: client.clientId,
|
|
618
|
+
sessionId: null,
|
|
619
|
+
accountId: null,
|
|
620
|
+
requestedScope: formatScope(scopes),
|
|
621
|
+
redirectUri: null,
|
|
622
|
+
codeChallenge: null,
|
|
623
|
+
status: 'pending',
|
|
624
|
+
intervalSeconds: this.cfg.pollInterval,
|
|
625
|
+
data: null,
|
|
626
|
+
expiresAt,
|
|
627
|
+
createdAt: now,
|
|
628
|
+
});
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
catch (error) {
|
|
632
|
+
if (attempt >= 2)
|
|
633
|
+
throw error;
|
|
634
|
+
userCode = newUserCode();
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
const complete = new URL(this.urls.device);
|
|
638
|
+
complete.searchParams.set('user_code', userCode);
|
|
639
|
+
return {
|
|
640
|
+
device_code: deviceCode,
|
|
641
|
+
user_code: userCode,
|
|
642
|
+
verification_uri: this.urls.device,
|
|
643
|
+
verification_uri_complete: complete.toString(),
|
|
644
|
+
expires_in: this.cfg.deviceCodeTtl,
|
|
645
|
+
interval: this.cfg.pollInterval,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
/** `POST revocation_endpoint` (§4.9, RFC 7009). */
|
|
649
|
+
async revoke(form) {
|
|
650
|
+
const client = await this.authenticateClient(form, this.urls.revocation);
|
|
651
|
+
const token = str(form.token);
|
|
652
|
+
if (!token)
|
|
653
|
+
throw new PoppyError('invalid_request', 'token is required');
|
|
654
|
+
const hint = str(form.token_type_hint);
|
|
655
|
+
if (hint && hint !== 'refresh_token' && hint !== 'access_token') {
|
|
656
|
+
throw new PoppyError('unsupported_token_type', 'Unsupported token_type_hint');
|
|
657
|
+
}
|
|
658
|
+
const hash = sha256(token);
|
|
659
|
+
const refresh = await this.#grants.findRefreshToken(hash);
|
|
660
|
+
if (refresh) {
|
|
661
|
+
const grant = await this.#grants.findGrant(refresh.grantId);
|
|
662
|
+
// De outro agente: RFC 7009 — token inválido para este cliente, nada acontece.
|
|
663
|
+
if (grant && grant.clientId === client.clientId && grant.id.startsWith('pgrant_')) {
|
|
664
|
+
await this.revokeGrant(grant.id);
|
|
665
|
+
}
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
const sessionToken = await this.#store.findToken(hash);
|
|
669
|
+
if (sessionToken && sessionToken.clientId === client.clientId) {
|
|
670
|
+
await this.#store.deleteToken(hash);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Revoga um Account Token (grant): as Sessions logadas com ele continuam
|
|
675
|
+
* deslogadas (§4.9) e os Bearer de MCP no oidc-provider morrem junto.
|
|
676
|
+
*/
|
|
677
|
+
async revokeGrant(grantId) {
|
|
678
|
+
const now = this.#now();
|
|
679
|
+
await this.#grants.revokeGrantById(grantId, now);
|
|
680
|
+
await this.#store.signOutSessionsOfGrant(grantId, now);
|
|
681
|
+
await this.#mcp?.revoke?.(grantId);
|
|
682
|
+
}
|
|
683
|
+
// ─── Direct Sign-In (§4.5) ────────────────────────────────────────────────
|
|
684
|
+
/**
|
|
685
|
+
* `GET authorization_endpoint` com o usuário já logado neste app. Erros no
|
|
686
|
+
* `client_id`/`redirect_uri` não redirecionam (RFC 6749 §4.1.2.1); os demais
|
|
687
|
+
* voltam ao agente com `error`, `state` e `iss`.
|
|
688
|
+
*/
|
|
689
|
+
async startAuthorization(params, accountId) {
|
|
690
|
+
const direct = this.cfg.signIn.direct;
|
|
691
|
+
if (!direct)
|
|
692
|
+
return { kind: 'error', description: 'Direct Sign-In is not offered' };
|
|
693
|
+
let client;
|
|
694
|
+
try {
|
|
695
|
+
client = await this.registry.resolve(params.client_id);
|
|
696
|
+
}
|
|
697
|
+
catch (error) {
|
|
698
|
+
return {
|
|
699
|
+
kind: 'error',
|
|
700
|
+
description: error instanceof PoppyError ? error.description : 'invalid client',
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
const redirectUri = str(params.redirect_uri);
|
|
704
|
+
if (!redirectUri || !client.redirectUris.includes(redirectUri)) {
|
|
705
|
+
return { kind: 'error', description: 'redirect_uri is not registered for this client' };
|
|
706
|
+
}
|
|
707
|
+
const state = str(params.state);
|
|
708
|
+
const back = (error, description) => ({
|
|
709
|
+
kind: 'redirect',
|
|
710
|
+
url: this.#redirect(redirectUri, { error, error_description: description, state }),
|
|
711
|
+
});
|
|
712
|
+
if (str(params.response_type) !== 'code') {
|
|
713
|
+
return back('unsupported_response_type', 'response_type must be code');
|
|
714
|
+
}
|
|
715
|
+
const challenge = str(params.code_challenge);
|
|
716
|
+
if (str(params.code_challenge_method) !== 'S256' ||
|
|
717
|
+
!challenge ||
|
|
718
|
+
!/^[A-Za-z0-9_-]{43}$/.test(challenge)) {
|
|
719
|
+
return back('invalid_request', 'PKCE with code_challenge_method=S256 is required');
|
|
720
|
+
}
|
|
721
|
+
let scopes;
|
|
722
|
+
try {
|
|
723
|
+
scopes = this.#signInScopes(params.scope, 'direct');
|
|
724
|
+
}
|
|
725
|
+
catch (error) {
|
|
726
|
+
return back('invalid_scope', error instanceof PoppyError ? error.description : 'invalid scope');
|
|
727
|
+
}
|
|
728
|
+
await this.#housekeeping();
|
|
729
|
+
const now = this.#now();
|
|
730
|
+
const id = randomId('azr_');
|
|
731
|
+
await this.#store.insertRequest({
|
|
732
|
+
id,
|
|
733
|
+
kind: 'authorize',
|
|
734
|
+
codeHash: null,
|
|
735
|
+
userCode: null,
|
|
736
|
+
clientId: client.clientId,
|
|
737
|
+
sessionId: null,
|
|
738
|
+
accountId,
|
|
739
|
+
requestedScope: formatScope(scopes),
|
|
740
|
+
redirectUri,
|
|
741
|
+
codeChallenge: challenge,
|
|
742
|
+
status: 'pending',
|
|
743
|
+
intervalSeconds: null,
|
|
744
|
+
data: state !== undefined ? { state } : {},
|
|
745
|
+
expiresAt: new Date(now.getTime() + AUTHORIZE_REQUEST_TTL_S * 1000),
|
|
746
|
+
createdAt: now,
|
|
747
|
+
});
|
|
748
|
+
return {
|
|
749
|
+
kind: 'consent',
|
|
750
|
+
request: {
|
|
751
|
+
id,
|
|
752
|
+
clientId: client.clientId,
|
|
753
|
+
agentName: client.name,
|
|
754
|
+
agentOrigin: new URL(client.clientId).host,
|
|
755
|
+
logoUri: client.logoUri,
|
|
756
|
+
scopes: this.describe(scopes),
|
|
757
|
+
},
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* `POST` da tela de consentimento Direct. Devolve para onde mandar o
|
|
762
|
+
* navegador (`code`/`error`, `state`, `iss`), ou `null` quando o pedido não
|
|
763
|
+
* vale mais (expirou, decidido em outra aba, de outra conta).
|
|
764
|
+
*/
|
|
765
|
+
async decideAuthorization(input) {
|
|
766
|
+
const row = await this.#store.findRequest(input.requestId);
|
|
767
|
+
const now = this.#now();
|
|
768
|
+
if (row?.kind !== 'authorize' ||
|
|
769
|
+
row.status !== 'pending' ||
|
|
770
|
+
row.expiresAt <= now ||
|
|
771
|
+
row.accountId !== input.accountId ||
|
|
772
|
+
!row.redirectUri) {
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
const state = typeof row.data?.state === 'string' ? row.data.state : undefined;
|
|
776
|
+
const requested = parseScope(row.requestedScope);
|
|
777
|
+
const granted = input.allow ? requested.filter((s) => input.scopes.includes(s)) : [];
|
|
778
|
+
if (granted.length === 0) {
|
|
779
|
+
if (!(await this.#store.transitionRequest(row.id, 'pending', { status: 'denied' })))
|
|
780
|
+
return null;
|
|
781
|
+
return {
|
|
782
|
+
url: this.#redirect(row.redirectUri, { error: 'access_denied', state }),
|
|
783
|
+
approved: null,
|
|
784
|
+
clientId: row.clientId,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
const code = randomBytes(32).toString('base64url');
|
|
788
|
+
if (!(await this.#store.transitionRequest(row.id, 'pending', {
|
|
789
|
+
status: 'approved',
|
|
790
|
+
codeHash: sha256(code),
|
|
791
|
+
grantedScope: formatScope(granted),
|
|
792
|
+
expiresAt: new Date(now.getTime() + this.cfg.authorizationCodeTtl * 1000),
|
|
793
|
+
}))) {
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
796
|
+
return {
|
|
797
|
+
url: this.#redirect(row.redirectUri, { code, state }),
|
|
798
|
+
approved: granted,
|
|
799
|
+
clientId: row.clientId,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
/** `redirect_uri` + params + `iss` (RFC 9207). */
|
|
803
|
+
#redirect(redirectUri, params) {
|
|
804
|
+
const url = new URL(redirectUri);
|
|
805
|
+
for (const [k, v] of Object.entries(params))
|
|
806
|
+
if (v !== undefined)
|
|
807
|
+
url.searchParams.set(k, v);
|
|
808
|
+
url.searchParams.set('iss', this.urls.issuer);
|
|
809
|
+
return url.toString();
|
|
810
|
+
}
|
|
811
|
+
// ─── Device Sign-In: tela (§4.6) ──────────────────────────────────────────
|
|
812
|
+
async pendingDevice(userCodeInput) {
|
|
813
|
+
const userCode = normalizePoppyUserCode(userCodeInput);
|
|
814
|
+
if (!userCode)
|
|
815
|
+
return null;
|
|
816
|
+
const row = await this.#store.findRequestByUserCode(userCode);
|
|
817
|
+
if (row?.kind !== 'device' || row.status !== 'pending' || row.expiresAt <= this.#now()) {
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
const name = await this.registry.displayName(row.clientId);
|
|
821
|
+
return {
|
|
822
|
+
id: row.id,
|
|
823
|
+
clientId: row.clientId,
|
|
824
|
+
agentName: name,
|
|
825
|
+
agentOrigin: new URL(row.clientId).host,
|
|
826
|
+
logoUri: null,
|
|
827
|
+
scopes: this.describe(parseScope(row.requestedScope)),
|
|
828
|
+
userCode: row.userCode ?? undefined,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
/** Aprova (scopes marcados) ou nega um device code. `null` = não está mais pendente. */
|
|
832
|
+
async decideDevice(input) {
|
|
833
|
+
const userCode = normalizePoppyUserCode(input.userCode);
|
|
834
|
+
const row = userCode ? await this.#store.findRequestByUserCode(userCode) : null;
|
|
835
|
+
if (row?.kind !== 'device' || row.status !== 'pending' || row.expiresAt <= this.#now()) {
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
const granted = input.allow
|
|
839
|
+
? parseScope(row.requestedScope).filter((s) => input.scopes.includes(s))
|
|
840
|
+
: [];
|
|
841
|
+
if (granted.length === 0) {
|
|
842
|
+
const ok = await this.#store.transitionRequest(row.id, 'pending', {
|
|
843
|
+
status: 'denied',
|
|
844
|
+
accountId: input.accountId,
|
|
845
|
+
});
|
|
846
|
+
return ok ? { approved: null, clientId: row.clientId } : null;
|
|
847
|
+
}
|
|
848
|
+
const ok = await this.#store.transitionRequest(row.id, 'pending', {
|
|
849
|
+
status: 'approved',
|
|
850
|
+
accountId: input.accountId,
|
|
851
|
+
grantedScope: formatScope(granted),
|
|
852
|
+
});
|
|
853
|
+
return ok ? { approved: granted, clientId: row.clientId } : null;
|
|
854
|
+
}
|
|
855
|
+
// ─── Mediated Sign-In (§4.7) ──────────────────────────────────────────────
|
|
856
|
+
/** `POST auth.mediated.endpoint` — já autenticado pelo Session Token (DPoP). */
|
|
857
|
+
async mediatedStart(principal, jkt, body, ctx) {
|
|
858
|
+
const mediated = this.cfg.signIn.mediated;
|
|
859
|
+
if (!mediated)
|
|
860
|
+
throw new PoppyError('invalid_request', 'Mediated Sign-In is not offered');
|
|
861
|
+
const b = (body ?? {});
|
|
862
|
+
const scopes = this.#signInScopes(b.scope, 'mediated');
|
|
863
|
+
const raw = b.credentials;
|
|
864
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
865
|
+
throw new PoppyError('invalid_request', 'credentials is required');
|
|
866
|
+
}
|
|
867
|
+
const credentials = {};
|
|
868
|
+
for (const field of mediated.fields) {
|
|
869
|
+
const value = raw[field.name];
|
|
870
|
+
if (typeof value !== 'string' || value === '') {
|
|
871
|
+
throw new PoppyError('invalid_request', `credentials.${field.name} is required`);
|
|
872
|
+
}
|
|
873
|
+
credentials[field.name] = value;
|
|
874
|
+
}
|
|
875
|
+
await this.#limit('mediated', `${principal.clientId}\u0000${principal.userId}`);
|
|
876
|
+
const result = await mediated.verify({
|
|
877
|
+
credentials,
|
|
878
|
+
scopes,
|
|
879
|
+
clientId: principal.clientId,
|
|
880
|
+
userId: principal.userId,
|
|
881
|
+
sessionId: principal.sessionId,
|
|
882
|
+
ctx,
|
|
883
|
+
});
|
|
884
|
+
if (!result || result.status === 'failed')
|
|
885
|
+
return { status: 'failed' };
|
|
886
|
+
const session = await this.#sessionFor(principal.sessionId, principal.clientId, principal.userId);
|
|
887
|
+
if (result.status === 'complete') {
|
|
888
|
+
if (session.accountId && session.accountId !== result.accountId) {
|
|
889
|
+
throw new PoppyError('account_mismatch', 'The Session is signed in to a different account');
|
|
890
|
+
}
|
|
891
|
+
const done = await this.#signIn(session, result.accountId, scopes, { jkt, resource: null }, null);
|
|
892
|
+
return { status: 'complete', ...done.body };
|
|
893
|
+
}
|
|
894
|
+
if (result.status !== 'code_required')
|
|
895
|
+
return { status: 'failed' };
|
|
896
|
+
// `accountId: null` = sem conta elegível (anti-enumeração): a resposta é a mesma de uma conta
|
|
897
|
+
// real e nenhum código vale. A conta da Session só é conferida no código (sem vazar aqui).
|
|
898
|
+
const decoy = result.accountId === null;
|
|
899
|
+
if (!decoy && result.code === undefined && !mediated.verifyCode) {
|
|
900
|
+
throw new Error('authkit: personalAgents.poppy.signIn.mediated — `verify` devolveu code_required sem `code` e não há `verifyCode`.');
|
|
901
|
+
}
|
|
902
|
+
const now = this.#now();
|
|
903
|
+
const expiresAt = new Date(now.getTime() + mediated.codeTtl * 1000);
|
|
904
|
+
const id = randomId('sgn_');
|
|
905
|
+
await this.#store.insertRequest({
|
|
906
|
+
id,
|
|
907
|
+
kind: 'mediated',
|
|
908
|
+
codeHash: null,
|
|
909
|
+
userCode: null,
|
|
910
|
+
clientId: principal.clientId,
|
|
911
|
+
sessionId: principal.sessionId,
|
|
912
|
+
accountId: result.accountId,
|
|
913
|
+
requestedScope: formatScope(scopes),
|
|
914
|
+
redirectUri: null,
|
|
915
|
+
codeChallenge: null,
|
|
916
|
+
status: 'pending',
|
|
917
|
+
intervalSeconds: null,
|
|
918
|
+
data: {
|
|
919
|
+
sentTo: result.sentTo,
|
|
920
|
+
...(!decoy && result.code !== undefined
|
|
921
|
+
? { codeHash: sha256(`${id}:${result.code}`) }
|
|
922
|
+
: {}),
|
|
923
|
+
...(result.state !== undefined ? { state: result.state } : {}),
|
|
924
|
+
},
|
|
925
|
+
expiresAt,
|
|
926
|
+
createdAt: now,
|
|
927
|
+
});
|
|
928
|
+
return {
|
|
929
|
+
status: 'code_required',
|
|
930
|
+
sign_in_id: id,
|
|
931
|
+
code: { sent_to: result.sentTo },
|
|
932
|
+
expires_at: expiresAt.toISOString(),
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
/** `POST {mediated}/{sign_in_id}` com o código de uso único. */
|
|
936
|
+
async mediatedCode(principal, jkt, signInId, body) {
|
|
937
|
+
const mediated = this.cfg.signIn.mediated;
|
|
938
|
+
if (!mediated)
|
|
939
|
+
throw new PoppyError('invalid_request', 'Mediated Sign-In is not offered');
|
|
940
|
+
const row = await this.#store.findRequest(signInId);
|
|
941
|
+
// Só a Session que começou o sign-in termina (§4.7). De outra = não existe.
|
|
942
|
+
if (row?.kind !== 'mediated' || row.sessionId !== principal.sessionId)
|
|
943
|
+
return null;
|
|
944
|
+
const now = this.#now();
|
|
945
|
+
if (row.status === 'failed' || row.status === 'denied')
|
|
946
|
+
return { status: 'failed' };
|
|
947
|
+
if (row.status === 'consumed')
|
|
948
|
+
return { status: 'failed' };
|
|
949
|
+
if (row.expiresAt <= now)
|
|
950
|
+
return { status: 'expired' };
|
|
951
|
+
const code = body?.code;
|
|
952
|
+
if (typeof code !== 'string' || code === '') {
|
|
953
|
+
throw new PoppyError('invalid_request', 'code is required');
|
|
954
|
+
}
|
|
955
|
+
const data = row.data ?? {};
|
|
956
|
+
let correct;
|
|
957
|
+
if (row.accountId === null) {
|
|
958
|
+
// Identificador sem conta (anti-enumeração): todo código é um código errado.
|
|
959
|
+
correct = false;
|
|
960
|
+
}
|
|
961
|
+
else if (typeof data.codeHash === 'string') {
|
|
962
|
+
correct = sha256(`${row.id}:${code}`) === data.codeHash;
|
|
963
|
+
}
|
|
964
|
+
else {
|
|
965
|
+
correct =
|
|
966
|
+
(await mediated.verifyCode?.({
|
|
967
|
+
code,
|
|
968
|
+
accountId: row.accountId,
|
|
969
|
+
state: data.state,
|
|
970
|
+
clientId: row.clientId,
|
|
971
|
+
userId: principal.userId,
|
|
972
|
+
})) === true;
|
|
973
|
+
}
|
|
974
|
+
if (!correct) {
|
|
975
|
+
const attempts = row.attempts + 1;
|
|
976
|
+
if (attempts >= mediated.maxCodeAttempts) {
|
|
977
|
+
await this.#store.transitionRequest(row.id, 'pending', { status: 'failed', attempts });
|
|
978
|
+
return { status: 'failed' };
|
|
979
|
+
}
|
|
980
|
+
if (!(await this.#store.bumpAttempts(row.id, row.attempts)))
|
|
981
|
+
return { status: 'failed' };
|
|
982
|
+
return {
|
|
983
|
+
status: 'code_required',
|
|
984
|
+
sign_in_id: row.id,
|
|
985
|
+
code: { sent_to: String(data.sentTo ?? '') },
|
|
986
|
+
expires_at: row.expiresAt.toISOString(),
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
if (!(await this.#store.transitionRequest(row.id, 'pending', { status: 'consumed' }))) {
|
|
990
|
+
return { status: 'failed' };
|
|
991
|
+
}
|
|
992
|
+
const session = await this.#sessionFor(principal.sessionId, principal.clientId, principal.userId);
|
|
993
|
+
if (session.accountId && session.accountId !== row.accountId) {
|
|
994
|
+
throw new PoppyError('account_mismatch', 'The Session is signed in to a different account');
|
|
995
|
+
}
|
|
996
|
+
const done = await this.#signIn(session, row.accountId, parseScope(row.requestedScope), { jkt, resource: null }, null);
|
|
997
|
+
return { status: 'complete', ...done.body };
|
|
998
|
+
}
|
|
999
|
+
// ─── resource server (§4.3, §6) ───────────────────────────────────────────
|
|
1000
|
+
/**
|
|
1001
|
+
* Verifica `Authorization: DPoP|Bearer` (+ prova DPoP) de uma request a uma API
|
|
1002
|
+
* ou conversa do app. Nunca lê token da URL (§4.3).
|
|
1003
|
+
*/
|
|
1004
|
+
async verifyAccess(input, options = {}) {
|
|
1005
|
+
const target = this.#endpointFor(input.url, options.resource);
|
|
1006
|
+
const scheme = target.mcp ? 'Bearer' : 'DPoP';
|
|
1007
|
+
const deny = (status, error, description, extra = {}) => {
|
|
1008
|
+
const s = extra.scheme ?? scheme;
|
|
1009
|
+
const parts = [`error="${error}"`, `error_description="${quote(description)}"`];
|
|
1010
|
+
if (extra.scope)
|
|
1011
|
+
parts.push(`scope="${quote(extra.scope)}"`);
|
|
1012
|
+
if (s === 'DPoP')
|
|
1013
|
+
parts.unshift(`algs="${this.cfg.dpop.algorithms.join(' ')}"`);
|
|
1014
|
+
return {
|
|
1015
|
+
ok: false,
|
|
1016
|
+
status,
|
|
1017
|
+
error,
|
|
1018
|
+
description,
|
|
1019
|
+
...(extra.scope ? { scope: extra.scope } : {}),
|
|
1020
|
+
wwwAuthenticate: `${s} ${parts.join(', ')}`,
|
|
1021
|
+
headers: extra.headers ?? {},
|
|
1022
|
+
};
|
|
1023
|
+
};
|
|
1024
|
+
const auth = header(input.headers, 'authorization');
|
|
1025
|
+
const authValue = Array.isArray(auth) ? undefined : auth;
|
|
1026
|
+
const match = authValue
|
|
1027
|
+
? /^(DPoP|Bearer)\s+([A-Za-z0-9\-._~+/]+=*)\s*$/i.exec(authValue)
|
|
1028
|
+
: null;
|
|
1029
|
+
if (!match)
|
|
1030
|
+
return deny(401, 'invalid_token', 'A Session Token is required in the Authorization header');
|
|
1031
|
+
const usedScheme = match[1].toLowerCase() === 'dpop' ? 'DPoP' : 'Bearer';
|
|
1032
|
+
const token = match[2];
|
|
1033
|
+
const now = this.#now();
|
|
1034
|
+
const row = await this.#store.findToken(sha256(token));
|
|
1035
|
+
if (!row || row.expiresAt <= now)
|
|
1036
|
+
return deny(401, 'invalid_token', 'The token is unknown or expired');
|
|
1037
|
+
let jkt = null;
|
|
1038
|
+
if (row.jkt && target.mcp) {
|
|
1039
|
+
// O MCP aceita só o Bearer emitido para a URL dele (§6).
|
|
1040
|
+
return deny(401, 'invalid_token', 'The MCP server accepts only Bearer tokens issued for it', {
|
|
1041
|
+
scheme: 'Bearer',
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
if (row.jkt) {
|
|
1045
|
+
if (usedScheme !== 'DPoP') {
|
|
1046
|
+
return deny(401, 'invalid_token', 'This token is DPoP-bound', { scheme: 'DPoP' });
|
|
1047
|
+
}
|
|
1048
|
+
const proof = await verifyDpopProof({
|
|
1049
|
+
proof: header(input.headers, 'dpop'),
|
|
1050
|
+
method: input.method,
|
|
1051
|
+
url: input.url,
|
|
1052
|
+
accessToken: token,
|
|
1053
|
+
expectedJkt: row.jkt,
|
|
1054
|
+
algorithms: this.cfg.dpop.algorithms,
|
|
1055
|
+
maxAge: this.cfg.dpop.maxAge,
|
|
1056
|
+
replay: this.#replay,
|
|
1057
|
+
nonce: { required: this.cfg.dpop.requireNonce, source: this.#nonces },
|
|
1058
|
+
now: () => now.getTime(),
|
|
1059
|
+
});
|
|
1060
|
+
if (!proof.ok) {
|
|
1061
|
+
return deny(401, proof.error, proof.description, {
|
|
1062
|
+
scheme: 'DPoP',
|
|
1063
|
+
headers: proof.error === 'use_dpop_nonce' ? { 'DPoP-Nonce': this.#nonces.issue() } : {},
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
jkt = proof.jkt;
|
|
1067
|
+
// Um token de um `resource` só vale nele (§4.3); um sem `resource`, onde não se exige.
|
|
1068
|
+
if ((row.resource ?? null) !== null || target.resource !== null) {
|
|
1069
|
+
if (!row.resource || !target.resource || !sameUrl(row.resource, target.resource)) {
|
|
1070
|
+
return deny(401, 'invalid_token', 'The token was not issued for this resource');
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
else {
|
|
1075
|
+
// Bearer: só no servidor MCP para o qual foi emitido (§4.3, §6).
|
|
1076
|
+
if (usedScheme !== 'Bearer' ||
|
|
1077
|
+
!target.mcp ||
|
|
1078
|
+
!row.resource ||
|
|
1079
|
+
!sameUrl(row.resource, target.mcp)) {
|
|
1080
|
+
return deny(401, 'invalid_token', 'Bearer Session Tokens are accepted only at their MCP server', {
|
|
1081
|
+
scheme: 'Bearer',
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
const session = await this.#store.findSession(row.sessionId);
|
|
1086
|
+
if (!session ||
|
|
1087
|
+
session.endedAt ||
|
|
1088
|
+
session.expiresAt <= now ||
|
|
1089
|
+
session.clientId !== row.clientId) {
|
|
1090
|
+
return deny(401, 'invalid_token', 'The Session has ended');
|
|
1091
|
+
}
|
|
1092
|
+
// Logada só se o token foi emitido logado E a Session ainda está logada com
|
|
1093
|
+
// o mesmo Account Token, vivo (§4.4, §4.9 — sign-out vale na hora).
|
|
1094
|
+
let signedIn = false;
|
|
1095
|
+
if (row.grantId && session.grantId === row.grantId) {
|
|
1096
|
+
const grant = await this.#grants.findGrant(row.grantId);
|
|
1097
|
+
signedIn = !!grant && (await this.#grantUsable(grant, row.clientId, now));
|
|
1098
|
+
}
|
|
1099
|
+
const scopes = signedIn ? parseScope(row.scope) : [];
|
|
1100
|
+
const principal = {
|
|
1101
|
+
userId: row.userId,
|
|
1102
|
+
accountId: signedIn ? row.accountId : null,
|
|
1103
|
+
clientId: row.clientId,
|
|
1104
|
+
scopes,
|
|
1105
|
+
sessionId: row.sessionId,
|
|
1106
|
+
signedIn,
|
|
1107
|
+
resource: row.resource,
|
|
1108
|
+
tokenType: row.jkt ? 'DPoP' : 'Bearer',
|
|
1109
|
+
};
|
|
1110
|
+
if ((options.signedIn || (options.scopes?.length ?? 0) > 0) && !signedIn) {
|
|
1111
|
+
return deny(403, 'sign_in_required', 'This request needs a signed-in Session', {
|
|
1112
|
+
scheme: principal.tokenType,
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
const required = options.scopes ?? [];
|
|
1116
|
+
if (required.some((s) => !scopes.includes(s))) {
|
|
1117
|
+
return deny(403, 'insufficient_scope', 'The token lacks required account scopes', {
|
|
1118
|
+
scope: formatScope(required),
|
|
1119
|
+
scheme: principal.tokenType,
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
return { ok: true, principal, jkt };
|
|
1123
|
+
}
|
|
1124
|
+
/** O endpoint pedido: MCP? exige `resource`? */
|
|
1125
|
+
#endpointFor(url, explicit) {
|
|
1126
|
+
if (this.#isMcp(url))
|
|
1127
|
+
return { mcp: this.#mcpUrl(url), resource: null };
|
|
1128
|
+
if (explicit !== undefined) {
|
|
1129
|
+
if (explicit && this.#isMcp(explicit))
|
|
1130
|
+
return { mcp: this.#mcpUrl(explicit), resource: null };
|
|
1131
|
+
return { mcp: null, resource: explicit };
|
|
1132
|
+
}
|
|
1133
|
+
const entries = [
|
|
1134
|
+
...this.cfg.apis
|
|
1135
|
+
.filter((a) => a.type !== 'mcp')
|
|
1136
|
+
.map((a) => ({ base: a.url, resource: a.resource })),
|
|
1137
|
+
...(this.cfg.agent?.protocols ?? []).map((p) => ({ base: p.endpoint, resource: p.resource })),
|
|
1138
|
+
];
|
|
1139
|
+
for (const entry of entries) {
|
|
1140
|
+
if (entry.resource && (underUrl(url, entry.base) || underUrl(url, entry.resource))) {
|
|
1141
|
+
return { mcp: null, resource: entry.resource };
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
return { mcp: null, resource: null };
|
|
1145
|
+
}
|
|
1146
|
+
// ─── navegação (§5) ───────────────────────────────────────────────────────
|
|
1147
|
+
/**
|
|
1148
|
+
* Confere a asserção de navegador (`typ` `poppy-browser+jwt`). `requestOrigin`
|
|
1149
|
+
* é a origem em que o POST chegou — o cookie precisa nascer nela.
|
|
1150
|
+
*/
|
|
1151
|
+
async verifyBrowserAssertion(assertion, requestOrigin) {
|
|
1152
|
+
const fail = (why) => new PoppyError('invalid_request', why);
|
|
1153
|
+
if (typeof assertion !== 'string' || !assertion)
|
|
1154
|
+
throw fail('assertion is required');
|
|
1155
|
+
let iss;
|
|
1156
|
+
try {
|
|
1157
|
+
if (decodeProtectedHeader(assertion).typ !== BROWSER_ASSERTION_TYP) {
|
|
1158
|
+
throw fail(`typ must be ${BROWSER_ASSERTION_TYP}`);
|
|
1159
|
+
}
|
|
1160
|
+
iss = decodeJwt(assertion).iss;
|
|
1161
|
+
}
|
|
1162
|
+
catch (error) {
|
|
1163
|
+
if (error instanceof PoppyError)
|
|
1164
|
+
throw error;
|
|
1165
|
+
throw fail('malformed assertion');
|
|
1166
|
+
}
|
|
1167
|
+
const client = await this.registry.resolve(iss);
|
|
1168
|
+
const accepted = this.#browserSessionEndpoints().filter((e) => new URL(e).origin === new URL(requestOrigin).origin);
|
|
1169
|
+
if (accepted.length === 0)
|
|
1170
|
+
throw fail('this host has no browser session endpoint');
|
|
1171
|
+
const payload = await this.#verifyAgentJwt(client, assertion, {
|
|
1172
|
+
audience: accepted,
|
|
1173
|
+
error: 'invalid_request',
|
|
1174
|
+
typ: BROWSER_ASSERTION_TYP,
|
|
1175
|
+
maxLifetime: 60,
|
|
1176
|
+
});
|
|
1177
|
+
if (typeof payload.aud !== 'string')
|
|
1178
|
+
throw fail('aud must be a single string');
|
|
1179
|
+
if (typeof payload.iat !== 'number' || payload.exp - payload.iat > 60) {
|
|
1180
|
+
throw fail('exp must be at most 60 seconds after iat');
|
|
1181
|
+
}
|
|
1182
|
+
const sessionId = payload.session_id;
|
|
1183
|
+
const userId = payload.sub;
|
|
1184
|
+
if (typeof sessionId !== 'string' || typeof userId !== 'string') {
|
|
1185
|
+
throw fail('session_id and sub are required');
|
|
1186
|
+
}
|
|
1187
|
+
const returnTo = payload.return_to;
|
|
1188
|
+
if (typeof returnTo !== 'string' || !this.#onCompanyDomain(returnTo)) {
|
|
1189
|
+
throw fail('return_to must be an HTTPS URL on the company domain');
|
|
1190
|
+
}
|
|
1191
|
+
const session = await this.#store.findSession(sessionId);
|
|
1192
|
+
const now = this.#now();
|
|
1193
|
+
if (!session ||
|
|
1194
|
+
session.endedAt ||
|
|
1195
|
+
session.expiresAt <= now ||
|
|
1196
|
+
session.clientId !== client.clientId ||
|
|
1197
|
+
session.userId !== userId) {
|
|
1198
|
+
throw fail('the Session is not active for this agent and user');
|
|
1199
|
+
}
|
|
1200
|
+
await this.#claimJti('browser', client.clientId, payload, 'invalid_request');
|
|
1201
|
+
const state = await this.sessionState(session.id);
|
|
1202
|
+
return {
|
|
1203
|
+
sessionId: session.id,
|
|
1204
|
+
clientId: client.clientId,
|
|
1205
|
+
userId,
|
|
1206
|
+
accountId: state?.signedIn ? state.accountId : null,
|
|
1207
|
+
scopes: state?.scopes ?? [],
|
|
1208
|
+
returnTo,
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
#onCompanyDomain(value) {
|
|
1212
|
+
let url;
|
|
1213
|
+
try {
|
|
1214
|
+
url = new URL(value);
|
|
1215
|
+
}
|
|
1216
|
+
catch {
|
|
1217
|
+
return false;
|
|
1218
|
+
}
|
|
1219
|
+
// HTTPS (§5). Em dev, com o próprio issuer em http, aceita http.
|
|
1220
|
+
const insecureOk = new URL(this.urls.origin).protocol === 'http:';
|
|
1221
|
+
if (url.protocol !== 'https:' && !(insecureOk && url.protocol === 'http:'))
|
|
1222
|
+
return false;
|
|
1223
|
+
if (url.username || url.password)
|
|
1224
|
+
return false;
|
|
1225
|
+
return hostMatches(url.hostname, this.domains);
|
|
1226
|
+
}
|
|
1227
|
+
/** Estado atual da Session (ativa? logada? scopes) — o cookie do site segue isto. */
|
|
1228
|
+
async sessionState(sessionId) {
|
|
1229
|
+
const session = await this.#store.findSession(sessionId);
|
|
1230
|
+
if (!session)
|
|
1231
|
+
return null;
|
|
1232
|
+
const now = this.#now();
|
|
1233
|
+
const active = !session.endedAt && session.expiresAt > now;
|
|
1234
|
+
let signedIn = false;
|
|
1235
|
+
if (active && session.grantId) {
|
|
1236
|
+
const grant = await this.#grants.findGrant(session.grantId);
|
|
1237
|
+
signedIn = !!grant && (await this.#grantUsable(grant, session.clientId, now));
|
|
1238
|
+
}
|
|
1239
|
+
return {
|
|
1240
|
+
active,
|
|
1241
|
+
sessionId: session.id,
|
|
1242
|
+
clientId: session.clientId,
|
|
1243
|
+
userId: session.userId,
|
|
1244
|
+
accountId: signedIn ? session.accountId : null,
|
|
1245
|
+
signedIn,
|
|
1246
|
+
scopes: signedIn ? parseScope(session.scope) : [],
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
// ─── console de conta ─────────────────────────────────────────────────────
|
|
1250
|
+
async listGrants(accountId) {
|
|
1251
|
+
const now = this.#now();
|
|
1252
|
+
return (await this.#grants.listGrants(accountId))
|
|
1253
|
+
.filter((g) => g.id.startsWith('pgrant_') && g.expiresAt > now)
|
|
1254
|
+
.map((g) => ({
|
|
1255
|
+
id: g.id,
|
|
1256
|
+
clientId: g.clientId,
|
|
1257
|
+
scopes: this.describe(parseScope(g.scope)),
|
|
1258
|
+
createdAt: g.createdAt,
|
|
1259
|
+
updatedAt: g.updatedAt,
|
|
1260
|
+
expiresAt: g.expiresAt,
|
|
1261
|
+
}));
|
|
1262
|
+
}
|
|
1263
|
+
/** Desconectar o agente pela conta (§4.9): revoga um Account Token DA CONTA. */
|
|
1264
|
+
async revokeAccountGrant(accountId, grantId) {
|
|
1265
|
+
if (!grantId.startsWith('pgrant_'))
|
|
1266
|
+
return false;
|
|
1267
|
+
const grant = await this.#grants.findGrant(grantId);
|
|
1268
|
+
if (!grant || grant.accountId !== accountId || grant.revokedAt)
|
|
1269
|
+
return false;
|
|
1270
|
+
await this.revokeGrant(grantId);
|
|
1271
|
+
return true;
|
|
1272
|
+
}
|
|
1273
|
+
/** Revoga todos os Account Tokens da conta (sair de tudo, conta suspensa…). */
|
|
1274
|
+
async revokeAllAccountGrants(accountId) {
|
|
1275
|
+
const live = (await this.#grants.listGrants(accountId)).filter((g) => g.id.startsWith('pgrant_'));
|
|
1276
|
+
for (const g of live)
|
|
1277
|
+
await this.revokeGrant(g.id);
|
|
1278
|
+
return live.length;
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* Bloqueio pelo console admin: revoga todos os Account Tokens do agente (todas as
|
|
1282
|
+
* contas) e encerra as Sessions dele — os Session Tokens param na próxima request.
|
|
1283
|
+
* Devolve quantos Account Tokens estavam vivos.
|
|
1284
|
+
*/
|
|
1285
|
+
async disconnectClient(clientId) {
|
|
1286
|
+
const ids = (await this.#grants.unrevokedGrantIdsOfClient(clientId)).filter((id) => id.startsWith('pgrant_'));
|
|
1287
|
+
for (const id of ids)
|
|
1288
|
+
await this.revokeGrant(id);
|
|
1289
|
+
await this.#store.endSessionsOfClient(clientId, this.#now());
|
|
1290
|
+
return ids.length;
|
|
1291
|
+
}
|
|
1292
|
+
// ─── internos ─────────────────────────────────────────────────────────────
|
|
1293
|
+
/** Scopes de um pedido de sign-in: obrigatórios, conhecidos e do tipo (§4.4). */
|
|
1294
|
+
#signInScopes(raw, type) {
|
|
1295
|
+
const allowed = type === 'mediated' ? this.cfg.signIn.mediated?.scopes : this.cfg.signIn[type]?.scopes;
|
|
1296
|
+
const scopes = typeof raw === 'string' ? parseScope(raw) : [];
|
|
1297
|
+
if (scopes.length === 0)
|
|
1298
|
+
throw new PoppyError('invalid_scope', 'scope is required');
|
|
1299
|
+
if (!allowed || scopes.some((s) => !allowed.includes(s))) {
|
|
1300
|
+
throw new PoppyError('invalid_scope', `Unknown or unavailable scope for ${type} sign-in`);
|
|
1301
|
+
}
|
|
1302
|
+
return scopes;
|
|
1303
|
+
}
|
|
1304
|
+
#narrow(available, requested) {
|
|
1305
|
+
if (requested === null)
|
|
1306
|
+
return available;
|
|
1307
|
+
if (requested.some((s) => !available.includes(s))) {
|
|
1308
|
+
throw new PoppyError('invalid_scope', 'scope can only narrow the granted scopes');
|
|
1309
|
+
}
|
|
1310
|
+
return requested;
|
|
1311
|
+
}
|
|
1312
|
+
/** A Session existe, está viva e é deste agente (e deste User ID, quando dado). */
|
|
1313
|
+
async #sessionFor(sessionId, clientId, userId) {
|
|
1314
|
+
const session = await this.#store.findSession(sessionId);
|
|
1315
|
+
const now = this.#now();
|
|
1316
|
+
if (!session ||
|
|
1317
|
+
session.endedAt ||
|
|
1318
|
+
session.expiresAt <= now ||
|
|
1319
|
+
session.clientId !== clientId ||
|
|
1320
|
+
(userId !== null && session.userId !== userId)) {
|
|
1321
|
+
throw new PoppyError('invalid_session', 'The Session has ended or belongs to another agent or user');
|
|
1322
|
+
}
|
|
1323
|
+
return session;
|
|
1324
|
+
}
|
|
1325
|
+
async #newSession(clientId, userId, now) {
|
|
1326
|
+
const row = {
|
|
1327
|
+
id: randomId('ses_'),
|
|
1328
|
+
clientId,
|
|
1329
|
+
userId,
|
|
1330
|
+
accountId: null,
|
|
1331
|
+
grantId: null,
|
|
1332
|
+
scope: '',
|
|
1333
|
+
expiresAt: this.#sessionExpiry(now),
|
|
1334
|
+
createdAt: now,
|
|
1335
|
+
updatedAt: now,
|
|
1336
|
+
};
|
|
1337
|
+
await this.#store.insertSession(row);
|
|
1338
|
+
return { ...row, endedAt: null };
|
|
1339
|
+
}
|
|
1340
|
+
#sessionExpiry(now) {
|
|
1341
|
+
return new Date(now.getTime() + this.cfg.sessionTtl * 1000);
|
|
1342
|
+
}
|
|
1343
|
+
async #grantUsable(grant, clientId, now) {
|
|
1344
|
+
return (grant.id.startsWith('pgrant_') &&
|
|
1345
|
+
grant.revokedAt === null &&
|
|
1346
|
+
grant.expiresAt > now &&
|
|
1347
|
+
grant.clientId === clientId &&
|
|
1348
|
+
(await this.#isAccountActive(grant.accountId)));
|
|
1349
|
+
}
|
|
1350
|
+
/**
|
|
1351
|
+
* Fim comum dos três sign-ins (§4.4): Account Token novo (grant + refresh
|
|
1352
|
+
* token) e a Session logada com ele. A Session presa a outra conta →
|
|
1353
|
+
* `account_mismatch`.
|
|
1354
|
+
*/
|
|
1355
|
+
async #signIn(session, accountId, granted, binding, requestedScope) {
|
|
1356
|
+
if (!(await this.#isAccountActive(accountId))) {
|
|
1357
|
+
throw new PoppyError('invalid_grant', 'The account cannot sign in');
|
|
1358
|
+
}
|
|
1359
|
+
const scopes = this.#narrow(granted, requestedScope);
|
|
1360
|
+
const now = this.#now();
|
|
1361
|
+
const grantId = `pgrant_${randomUUID()}`;
|
|
1362
|
+
const scope = formatScope(granted);
|
|
1363
|
+
if (!(await this.#store.signInSession(session.id, {
|
|
1364
|
+
accountId,
|
|
1365
|
+
grantId,
|
|
1366
|
+
scope,
|
|
1367
|
+
expiresAt: this.#sessionExpiry(now),
|
|
1368
|
+
at: now,
|
|
1369
|
+
}))) {
|
|
1370
|
+
throw new PoppyError('account_mismatch', 'The Session is signed in to a different account');
|
|
1371
|
+
}
|
|
1372
|
+
const expiresAt = new Date(now.getTime() + this.cfg.accountTokenTtl * 1000);
|
|
1373
|
+
const grant = {
|
|
1374
|
+
id: grantId,
|
|
1375
|
+
accountId,
|
|
1376
|
+
clientId: session.clientId,
|
|
1377
|
+
agentSub: session.userId,
|
|
1378
|
+
scope,
|
|
1379
|
+
expiresAt,
|
|
1380
|
+
revokedAt: null,
|
|
1381
|
+
createdAt: now,
|
|
1382
|
+
updatedAt: now,
|
|
1383
|
+
};
|
|
1384
|
+
await this.#grants.insertGrant(grant);
|
|
1385
|
+
const accountToken = randomId('poa_', 32);
|
|
1386
|
+
await this.#grants.insertRefreshToken({
|
|
1387
|
+
tokenHash: sha256(accountToken),
|
|
1388
|
+
grantId,
|
|
1389
|
+
expiresAt,
|
|
1390
|
+
createdAt: now,
|
|
1391
|
+
});
|
|
1392
|
+
const fresh = { ...session, accountId, grantId, scope };
|
|
1393
|
+
const body = await this.#issue(fresh, { ...binding, signedIn: grant, scopes });
|
|
1394
|
+
return {
|
|
1395
|
+
body: {
|
|
1396
|
+
...body,
|
|
1397
|
+
refresh_token: accountToken,
|
|
1398
|
+
refresh_token_expires_in: this.cfg.accountTokenTtl,
|
|
1399
|
+
},
|
|
1400
|
+
grantId,
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
/** Emite um Session Token para o estado da Session (logado com `signedIn`, ou deslogado). */
|
|
1404
|
+
async #issue(session, input) {
|
|
1405
|
+
const now = this.#now();
|
|
1406
|
+
const ttl = this.cfg.sessionTokenTtl;
|
|
1407
|
+
const scope = input.signedIn ? formatScope(input.scopes) : '';
|
|
1408
|
+
let token = null;
|
|
1409
|
+
// Bearer de MCP de Session logada: vira access token do oidc-provider, para
|
|
1410
|
+
// a integração MCP existente aceitá-lo sem código no app (§6).
|
|
1411
|
+
if (input.jkt === null && input.resource && input.signedIn && this.#mcp?.mint) {
|
|
1412
|
+
token = await this.#mcp.mint({
|
|
1413
|
+
grantId: input.signedIn.id,
|
|
1414
|
+
accountId: input.signedIn.accountId,
|
|
1415
|
+
clientId: session.clientId,
|
|
1416
|
+
scope,
|
|
1417
|
+
resource: input.resource,
|
|
1418
|
+
expiresIn: ttl,
|
|
1419
|
+
grantExpiresAt: input.signedIn.expiresAt,
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
token ??= randomId('pst_', 32);
|
|
1423
|
+
await this.#store.insertToken({
|
|
1424
|
+
tokenHash: sha256(token),
|
|
1425
|
+
sessionId: session.id,
|
|
1426
|
+
clientId: session.clientId,
|
|
1427
|
+
userId: session.userId,
|
|
1428
|
+
accountId: input.signedIn ? input.signedIn.accountId : null,
|
|
1429
|
+
grantId: input.signedIn ? input.signedIn.id : null,
|
|
1430
|
+
scope,
|
|
1431
|
+
resource: input.resource,
|
|
1432
|
+
jkt: input.jkt,
|
|
1433
|
+
expiresAt: new Date(now.getTime() + ttl * 1000),
|
|
1434
|
+
createdAt: now,
|
|
1435
|
+
});
|
|
1436
|
+
return {
|
|
1437
|
+
access_token: token,
|
|
1438
|
+
token_type: input.jkt ? 'DPoP' : 'Bearer',
|
|
1439
|
+
expires_in: ttl,
|
|
1440
|
+
scope,
|
|
1441
|
+
session_id: session.id,
|
|
1442
|
+
signed_in: input.signedIn !== null,
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
async #limit(action, key) {
|
|
1446
|
+
if (!this.#limiter)
|
|
1447
|
+
return;
|
|
1448
|
+
const retryAfter = await this.#limiter({ action, key });
|
|
1449
|
+
if (retryAfter !== null) {
|
|
1450
|
+
throw new PoppyError('rate_limited', 'Too many requests for this client_id', {
|
|
1451
|
+
headers: { 'Retry-After': String(Math.max(1, retryAfter)) },
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
/** Limpeza preguiçosa: no máximo uma vez por minuto. */
|
|
1456
|
+
async #housekeeping() {
|
|
1457
|
+
const now = this.#now();
|
|
1458
|
+
if (now.getTime() - this.#housekeepingAt < 60_000)
|
|
1459
|
+
return;
|
|
1460
|
+
this.#housekeepingAt = now.getTime();
|
|
1461
|
+
await this.#store.deleteRequestsExpiredBefore(new Date(now.getTime() - 3600_000));
|
|
1462
|
+
await this.#store.deleteTokensExpiredBefore(now);
|
|
1463
|
+
}
|
|
1464
|
+
}
|