@adonis-agora/authkit-server 0.86.0 → 0.87.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 +2 -2
- package/build/index.d.ts +1 -2
- package/build/index.js +0 -1
- package/build/src/agents/config.d.ts +1 -1
- package/build/src/agents/delegation_service.js +1 -1
- package/build/src/agents/poppy/client_registry.d.ts +5 -0
- package/build/src/agents/poppy/client_registry.js +35 -1
- package/build/src/agents/poppy/config.d.ts +111 -80
- package/build/src/agents/poppy/config.js +55 -35
- package/build/src/agents/poppy/dpop.d.ts +2 -2
- package/build/src/agents/poppy/dpop.js +3 -3
- package/build/src/agents/poppy/email_code_sign_in.d.ts +4 -4
- package/build/src/agents/poppy/email_code_sign_in.js +5 -5
- package/build/src/agents/poppy/errors.d.ts +7 -2
- package/build/src/agents/poppy/errors.js +5 -2
- package/build/src/agents/poppy/resource.d.ts +30 -25
- package/build/src/agents/poppy/resource.js +24 -20
- package/build/src/agents/poppy/safe_fetch.js +8 -2
- package/build/src/agents/poppy/service.d.ts +69 -62
- package/build/src/agents/poppy/service.js +540 -546
- package/build/src/agents/poppy/session_jwt.d.ts +30 -0
- package/build/src/agents/poppy/session_jwt.js +133 -0
- package/build/src/agents/poppy/store.d.ts +17 -22
- package/build/src/agents/poppy/store.js +22 -30
- package/build/src/agents/poppy/web_session.d.ts +23 -16
- package/build/src/agents/poppy/web_session.js +42 -24
- package/build/src/host/admin_sessions_service.js +3 -3
- package/build/src/host/controllers/account_apps_controller.js +1 -1
- package/build/src/host/controllers/poppy_consent_controller.d.ts +2 -2
- package/build/src/host/controllers/poppy_consent_controller.js +2 -2
- package/build/src/host/controllers/poppy_controller.d.ts +19 -11
- package/build/src/host/controllers/poppy_controller.js +93 -68
- package/build/src/host/csrf.d.ts +2 -4
- package/build/src/host/csrf.js +2 -3
- package/build/src/host/register_auth_host.js +10 -5
- package/build/src/schema/ensure.js +14 -9
- package/package.json +3 -3
|
@@ -1,28 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Lado da empresa no Poppy (Draft 0.3): o authorization server OAuth (§4 — Direct, Device, refresh,
|
|
3
|
+
* revogação, extensão Mediated Sign-In), a verificação do header `Poppy-Session` e das Sessions
|
|
4
|
+
* (§5), os access tokens nos resource servers (§4.5, §7) e a entrada do navegador na Session (§6).
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* `pgrant_`) — a mesma
|
|
6
|
+
* Sessions e acesso à conta são independentes: o sign-in não conhece a Session. Os refresh tokens
|
|
7
|
+
* são grants de personal agents (`auth_agent_grants`, prefixo `pgrant_`) — a mesma
|
|
8
|
+
* lista/revogação do console de conta do PACT.
|
|
8
9
|
*/
|
|
9
10
|
import { createHash, randomBytes, randomInt, randomUUID } from 'node:crypto';
|
|
10
|
-
import {
|
|
11
|
+
import { jwtVerify } from 'jose';
|
|
11
12
|
import { formatScope, parseScope } from '../config.js';
|
|
13
|
+
import { redirectUriAllowed } from './client_registry.js';
|
|
12
14
|
import { normalizeHtu, verifyDpopProof } from './dpop.js';
|
|
13
15
|
import { PoppyError } from './errors.js';
|
|
14
16
|
import { replayKey } from './replay.js';
|
|
15
|
-
|
|
17
|
+
import { SessionJwtError, verifySessionJwt } from './session_jwt.js';
|
|
16
18
|
export const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
19
|
+
export const MEDIATED_SIGN_IN_GRANT = 'https://personalagentprotocol.org/grant/mediated-sign-in';
|
|
20
|
+
export const MEDIATED_SIGN_IN_EXTENSION = 'mediated-sign-in';
|
|
17
21
|
export const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
|
|
18
|
-
export const
|
|
19
|
-
/** Algoritmos
|
|
20
|
-
const ASSERTION_ALGS = ['ES256', 'ES384', 'ES512', 'RS256', 'PS256', 'EdDSA'];
|
|
22
|
+
export const POPPY_PROTOCOL_VERSION = '0.3';
|
|
23
|
+
/** Algoritmos das client assertions: `ES256` obrigatório, `PS384` para assinatura cega (§4.1). */
|
|
24
|
+
const ASSERTION_ALGS = ['ES256', 'PS384', 'ES384', 'ES512', 'RS256', 'PS256', 'EdDSA'];
|
|
21
25
|
const CLOCK_SKEW_S = 30;
|
|
22
26
|
/** Sem vogais: o código não forma palavras (RFC 8628 §6.1). */
|
|
23
27
|
const USER_CODE_ALPHABET = 'BCDFGHJKLMNPQRSTVWXZ';
|
|
24
28
|
/** Pedido de autorização (Direct) aguardando o consentimento. */
|
|
25
29
|
const AUTHORIZE_REQUEST_TTL_S = 600;
|
|
30
|
+
/** Código de entrada do navegador: 128 bits, 60 s (guia `company-browsing`). */
|
|
31
|
+
const BROWSER_CODE_TTL_S = 60;
|
|
32
|
+
/** Só regrava a validade da Session quando o último uso já passou disso. */
|
|
33
|
+
const SESSION_TOUCH_INTERVAL_MS = 60_000;
|
|
26
34
|
/** Deriva as URLs da ORIGEM do issuer configurado — nunca do Host da request. */
|
|
27
35
|
export function poppyUrls(oidcIssuer, prefix) {
|
|
28
36
|
const origin = new URL(oidcIssuer).origin;
|
|
@@ -36,7 +44,6 @@ export function poppyUrls(oidcIssuer, prefix) {
|
|
|
36
44
|
authorization: `${issuer}/oauth/authorize`,
|
|
37
45
|
deviceAuthorization: `${issuer}/oauth/device`,
|
|
38
46
|
device: `${issuer}/device`,
|
|
39
|
-
mediated: `${issuer}/sign-in`,
|
|
40
47
|
browserSession: `${issuer}/browser-session`,
|
|
41
48
|
discovery: `${origin}/.well-known/poppy.json`,
|
|
42
49
|
};
|
|
@@ -95,9 +102,11 @@ function header(headers, name) {
|
|
|
95
102
|
function quote(value) {
|
|
96
103
|
return value.replace(/["\\]/g, '');
|
|
97
104
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
105
|
+
/** A URL da protected resource metadata de um resource (RFC 9728 §3.1). */
|
|
106
|
+
function resourceMetadataUrl(resource) {
|
|
107
|
+
const url = new URL(resource);
|
|
108
|
+
const path = url.pathname === '/' ? '' : url.pathname;
|
|
109
|
+
return `${url.origin}/.well-known/oauth-protected-resource${path}`;
|
|
101
110
|
}
|
|
102
111
|
/** Slot global onde o `@adonis-agora/agent` registra o endpoint de conversas Poppy. */
|
|
103
112
|
export const POPPY_ENDPOINTS_SLOT = Symbol.for('@adonis-agora/poppy:endpoints');
|
|
@@ -122,7 +131,7 @@ export function registeredConversationProtocols() {
|
|
|
122
131
|
return [{ type: 'poppy', endpoint: url }];
|
|
123
132
|
}
|
|
124
133
|
/**
|
|
125
|
-
* @experimental Poppy (Personal Agent Protocol) Draft 0.
|
|
134
|
+
* @experimental Poppy (Personal Agent Protocol) Draft 0.3 — pode mudar de forma incompatível enquanto a spec for draft.
|
|
126
135
|
*/
|
|
127
136
|
export class PoppyService {
|
|
128
137
|
cfg;
|
|
@@ -163,34 +172,46 @@ export class PoppyService {
|
|
|
163
172
|
return ids.map((id) => ({ id, description: this.cfg.scopes[id] ?? id }));
|
|
164
173
|
}
|
|
165
174
|
// ─── descoberta ───────────────────────────────────────────────────────────
|
|
166
|
-
/**
|
|
167
|
-
|
|
175
|
+
/** A URL `https://{domain}` de um domínio (no host do issuer, a origem dele — dev com porta). */
|
|
176
|
+
#domainUrl(domain) {
|
|
177
|
+
const issuer = new URL(this.urls.origin);
|
|
178
|
+
return domain === issuer.hostname.toLowerCase()
|
|
179
|
+
? this.urls.origin
|
|
180
|
+
: `${issuer.protocol}//${domain}`;
|
|
181
|
+
}
|
|
182
|
+
/** O domínio da empresa que o host serve, sem normalizar nada além de caixa e porta (§3.1). */
|
|
183
|
+
#domainOf(requestHost) {
|
|
168
184
|
const hostWithPort = requestHost
|
|
169
185
|
.toLowerCase()
|
|
170
186
|
.replace(/\.$/, '')
|
|
171
187
|
.replace(/\.(:\d+)$/, '$1');
|
|
172
188
|
const host = hostWithPort.replace(/:\d+$/, '');
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
189
|
+
const domain = this.domains.find((d) => d === host);
|
|
190
|
+
return domain ? { domain, hostWithPort } : null;
|
|
191
|
+
}
|
|
192
|
+
/** `www.{domínio}` não é a empresa (§3.1): a descoberta redireciona para o domínio (§3). */
|
|
193
|
+
discoveryRedirect(requestHost) {
|
|
194
|
+
const host = requestHost.toLowerCase().replace(/\.$/, '').replace(/:\d+$/, '');
|
|
195
|
+
if (!host.startsWith('www.') || this.domains.includes(host))
|
|
176
196
|
return null;
|
|
177
|
-
const
|
|
197
|
+
const bare = host.slice(4);
|
|
198
|
+
if (!this.domains.includes(bare))
|
|
199
|
+
return null;
|
|
200
|
+
return `${this.#domainUrl(bare)}/.well-known/poppy.json`;
|
|
201
|
+
}
|
|
202
|
+
/** `/.well-known/poppy.json` (§3) para o domínio pedido; `null` = domínio que não é nosso. */
|
|
203
|
+
discoveryDocument(requestHost) {
|
|
204
|
+
const match = this.#domainOf(requestHost);
|
|
205
|
+
if (!match)
|
|
206
|
+
return null;
|
|
207
|
+
const { domain, hostWithPort } = match;
|
|
178
208
|
const auth = { issuer: this.urls.issuer };
|
|
179
|
-
if (
|
|
180
|
-
auth.
|
|
181
|
-
if (
|
|
182
|
-
auth.
|
|
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;
|
|
209
|
+
if (Object.keys(this.cfg.scopes).length > 0)
|
|
210
|
+
auth.scopes = this.cfg.scopes;
|
|
211
|
+
if (Object.keys(this.cfg.scopeHints).length > 0)
|
|
212
|
+
auth.scope_hints = this.cfg.scopeHints;
|
|
192
213
|
const doc = {
|
|
193
|
-
protocol_version:
|
|
214
|
+
protocol_version: POPPY_PROTOCOL_VERSION,
|
|
194
215
|
organization: { name: this.cfg.organization.name, domain },
|
|
195
216
|
auth,
|
|
196
217
|
};
|
|
@@ -200,7 +221,7 @@ export class PoppyService {
|
|
|
200
221
|
protocols: protocols.map((p) => ({
|
|
201
222
|
type: p.type,
|
|
202
223
|
endpoint: p.endpoint,
|
|
203
|
-
...(p.
|
|
224
|
+
...(p.access ? { access: p.access } : {}),
|
|
204
225
|
})),
|
|
205
226
|
};
|
|
206
227
|
}
|
|
@@ -209,33 +230,60 @@ export class PoppyService {
|
|
|
209
230
|
? { browser_session_endpoint: this.#browserSessionEndpointFor(hostWithPort) }
|
|
210
231
|
: {};
|
|
211
232
|
}
|
|
233
|
+
if (this.cfg.feedback)
|
|
234
|
+
doc.feedback = { endpoint: this.cfg.feedback.endpoint };
|
|
235
|
+
if (this.cfg.excludedTasks)
|
|
236
|
+
doc.excluded_tasks = { url: this.cfg.excludedTasks.url };
|
|
212
237
|
if (this.cfg.apis.length > 0) {
|
|
213
238
|
doc.apis = this.cfg.apis.map((a) => ({
|
|
214
239
|
type: a.type,
|
|
215
240
|
url: a.url,
|
|
216
241
|
description: a.description,
|
|
217
|
-
...(a.resource ? { resource: a.resource } : {}),
|
|
218
242
|
}));
|
|
219
243
|
}
|
|
220
|
-
|
|
221
|
-
|
|
244
|
+
const extensions = { ...this.cfg.extensions };
|
|
245
|
+
const mediated = this.cfg.signIn.mediated;
|
|
246
|
+
if (mediated) {
|
|
247
|
+
extensions[MEDIATED_SIGN_IN_EXTENSION] = {
|
|
248
|
+
version: '1',
|
|
249
|
+
fields: mediated.fields,
|
|
250
|
+
scopes: mediated.scopes,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
if (Object.keys(extensions).length > 0)
|
|
254
|
+
doc.extensions = extensions;
|
|
222
255
|
return doc;
|
|
223
256
|
}
|
|
224
|
-
/**
|
|
257
|
+
/** `/.well-known/oauth-protected-resource` do domínio (§3.2, RFC 9728): aponta o issuer. */
|
|
258
|
+
protectedResourceMetadata(requestHost) {
|
|
259
|
+
const match = this.#domainOf(requestHost);
|
|
260
|
+
if (!match)
|
|
261
|
+
return null;
|
|
262
|
+
return {
|
|
263
|
+
resource: this.#domainUrl(match.domain),
|
|
264
|
+
authorization_servers: [this.urls.issuer],
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
/** RFC 8414 com `protected_resources` (§3.2). */
|
|
225
268
|
authorizationServerMetadata() {
|
|
226
269
|
const { signIn } = this.cfg;
|
|
227
|
-
const grants = [
|
|
270
|
+
const grants = ['refresh_token'];
|
|
228
271
|
if (signIn.direct)
|
|
229
272
|
grants.push('authorization_code');
|
|
230
273
|
if (signIn.device)
|
|
231
274
|
grants.push(DEVICE_CODE_GRANT);
|
|
275
|
+
if (signIn.mediated)
|
|
276
|
+
grants.push(MEDIATED_SIGN_IN_GRANT);
|
|
232
277
|
return {
|
|
233
278
|
issuer: this.urls.issuer,
|
|
234
279
|
token_endpoint: this.urls.token,
|
|
235
280
|
revocation_endpoint: this.urls.revocation,
|
|
236
281
|
...(signIn.direct ? { authorization_endpoint: this.urls.authorization } : {}),
|
|
237
282
|
...(signIn.device ? { device_authorization_endpoint: this.urls.deviceAuthorization } : {}),
|
|
238
|
-
|
|
283
|
+
protected_resources: [
|
|
284
|
+
...this.domains.map((d) => this.#domainUrl(d)),
|
|
285
|
+
...this.cfg.apis.filter((a) => a.type === 'mcp').map((a) => a.url),
|
|
286
|
+
],
|
|
239
287
|
scopes_supported: Object.keys(this.cfg.scopes),
|
|
240
288
|
response_types_supported: signIn.direct ? ['code'] : [],
|
|
241
289
|
response_modes_supported: signIn.direct ? ['query'] : [],
|
|
@@ -250,17 +298,6 @@ export class PoppyService {
|
|
|
250
298
|
client_id_metadata_document_supported: true,
|
|
251
299
|
};
|
|
252
300
|
}
|
|
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
301
|
/**
|
|
265
302
|
* O endpoint no host pedido (o cookie do site é do domínio que o agente abriu). No host do issuer
|
|
266
303
|
* é o do issuer — com a porta dele: o `Host` pode vir sem porta (ou o controller só ter o
|
|
@@ -282,107 +319,80 @@ export class PoppyService {
|
|
|
282
319
|
}
|
|
283
320
|
// ─── autenticação do agente ───────────────────────────────────────────────
|
|
284
321
|
/**
|
|
285
|
-
* `private_key_jwt` (
|
|
286
|
-
*
|
|
287
|
-
* chamado (ou o token endpoint/issuer), `exp` curto, `jti` único.
|
|
322
|
+
* `private_key_jwt` (§4.1, RFC 7523): `client_assertion` assinada por uma chave do `jwks_uri`,
|
|
323
|
+
* `iss` = `sub` = `client_id`, `aud` = o issuer como UMA string, curta, com `iat` e `jti` únicos.
|
|
288
324
|
*/
|
|
289
|
-
async authenticateClient(form
|
|
325
|
+
async authenticateClient(form) {
|
|
290
326
|
const clientId = str(form.client_id);
|
|
291
327
|
const type = str(form.client_assertion_type);
|
|
292
328
|
const assertion = str(form.client_assertion);
|
|
329
|
+
const bad = (why) => new PoppyError('invalid_client', why);
|
|
293
330
|
if (!clientId || type !== CLIENT_ASSERTION_TYPE || !assertion) {
|
|
294
|
-
throw
|
|
331
|
+
throw bad('private_key_jwt client authentication is required');
|
|
295
332
|
}
|
|
296
333
|
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
334
|
const now = this.#now();
|
|
323
335
|
let payload;
|
|
324
336
|
try {
|
|
325
|
-
({ payload } = await jwtVerify(
|
|
337
|
+
({ payload } = await jwtVerify(assertion, client.keys, {
|
|
326
338
|
algorithms: ASSERTION_ALGS,
|
|
327
339
|
issuer: client.clientId,
|
|
328
|
-
|
|
340
|
+
subject: clientId,
|
|
329
341
|
currentDate: now,
|
|
330
342
|
clockTolerance: CLOCK_SKEW_S,
|
|
331
|
-
requiredClaims: ['exp', 'jti'],
|
|
343
|
+
requiredClaims: ['exp', 'iat', 'jti'],
|
|
332
344
|
}));
|
|
333
345
|
}
|
|
334
|
-
catch
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
346
|
+
catch {
|
|
347
|
+
throw bad('client assertion verification failed');
|
|
348
|
+
}
|
|
349
|
+
if (typeof payload.aud !== 'string' || payload.aud !== this.urls.issuer) {
|
|
350
|
+
throw bad('aud must be the issuer, as a single string');
|
|
338
351
|
}
|
|
339
352
|
const nowS = Math.floor(now.getTime() / 1000);
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
if (
|
|
343
|
-
throw
|
|
344
|
-
}
|
|
345
|
-
if (payload.
|
|
346
|
-
throw
|
|
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) {
|
|
353
|
+
if (payload.iat > nowS + CLOCK_SKEW_S)
|
|
354
|
+
throw bad('iat is in the future');
|
|
355
|
+
if (payload.exp - payload.iat > this.cfg.assertionMaxLifetime) {
|
|
356
|
+
throw bad('the client assertion lifetime is too long');
|
|
357
|
+
}
|
|
358
|
+
if (typeof payload.jti !== 'string' || !payload.jti)
|
|
359
|
+
throw bad('jti is required');
|
|
352
360
|
const expiresAt = new Date((payload.exp + CLOCK_SKEW_S) * 1000);
|
|
353
|
-
if (!(await this.#replay.claim(replayKey(
|
|
354
|
-
throw
|
|
361
|
+
if (!(await this.#replay.claim(replayKey('client', clientId, payload.jti), expiresAt))) {
|
|
362
|
+
throw bad('jti was already used');
|
|
355
363
|
}
|
|
364
|
+
return client;
|
|
356
365
|
}
|
|
357
366
|
// ─── token endpoint ───────────────────────────────────────────────────────
|
|
358
|
-
/** `POST token_endpoint` (§4.2
|
|
359
|
-
async token(form, dpopHeader) {
|
|
367
|
+
/** `POST token_endpoint` (§4.2–§4.4, §4.7, extensão Mediated Sign-In). */
|
|
368
|
+
async token(form, dpopHeader, ctx) {
|
|
360
369
|
await this.#housekeeping();
|
|
361
|
-
const client = await this.authenticateClient(form
|
|
370
|
+
const client = await this.authenticateClient(form);
|
|
362
371
|
const grantType = str(form.grant_type);
|
|
363
|
-
if (grantType !==
|
|
364
|
-
grantType !== 'refresh_token' &&
|
|
372
|
+
if (grantType !== 'refresh_token' &&
|
|
365
373
|
!(grantType === 'authorization_code' && this.cfg.signIn.direct) &&
|
|
366
|
-
!(grantType === DEVICE_CODE_GRANT && this.cfg.signIn.device)
|
|
374
|
+
!(grantType === DEVICE_CODE_GRANT && this.cfg.signIn.device) &&
|
|
375
|
+
!(grantType === MEDIATED_SIGN_IN_GRANT && this.cfg.signIn.mediated)) {
|
|
367
376
|
throw new PoppyError('unsupported_grant_type', 'Unsupported grant_type');
|
|
368
377
|
}
|
|
369
378
|
const binding = await this.#tokenBinding(form, dpopHeader);
|
|
370
379
|
const scopeParam = form.scope;
|
|
371
380
|
const requestedScope = scopeParam === undefined ? null : parseScope(str(scopeParam) ?? '');
|
|
372
381
|
switch (grantType) {
|
|
373
|
-
case JWT_BEARER_GRANT:
|
|
374
|
-
return this.#jwtBearer(client, form, binding, requestedScope);
|
|
375
382
|
case 'refresh_token':
|
|
376
383
|
return this.#refresh(client, form, binding, requestedScope);
|
|
377
384
|
case 'authorization_code':
|
|
378
385
|
return this.#authorizationCode(client, form, binding, requestedScope);
|
|
386
|
+
case MEDIATED_SIGN_IN_GRANT:
|
|
387
|
+
return this.#mediated(client, form, binding, ctx);
|
|
379
388
|
default:
|
|
380
389
|
return this.#deviceCode(client, form, binding, requestedScope);
|
|
381
390
|
}
|
|
382
391
|
}
|
|
383
392
|
/**
|
|
384
|
-
*
|
|
385
|
-
*
|
|
393
|
+
* O `resource` (RFC 8707, §4.5) e a chave DPoP do token. DPoP é escolha da empresa: uma prova
|
|
394
|
+
* prende o token (RFC 9449 §5), sem prova ele sai Bearer, a não ser com `dpop.required`. Servidor
|
|
395
|
+
* MCP recebe Bearer: é o baseline do MCP, e os daqui não anunciam DPoP (§7).
|
|
386
396
|
*/
|
|
387
397
|
async #tokenBinding(form, dpopHeader) {
|
|
388
398
|
const rawResource = form.resource;
|
|
@@ -390,10 +400,16 @@ export class PoppyService {
|
|
|
390
400
|
throw new PoppyError('invalid_target', 'Request a token for one resource at a time');
|
|
391
401
|
}
|
|
392
402
|
const resource = str(rawResource) ?? null;
|
|
403
|
+
if (resource && this.#isMcp(resource))
|
|
404
|
+
return { jkt: null, resource: this.#mcpUrl(resource) };
|
|
405
|
+
if (resource && !this.#isConfiguredResource(resource)) {
|
|
406
|
+
throw new PoppyError('invalid_target', 'Unknown resource');
|
|
407
|
+
}
|
|
393
408
|
if (dpopHeader === undefined || dpopHeader === '') {
|
|
394
|
-
if (
|
|
395
|
-
|
|
396
|
-
|
|
409
|
+
if (this.cfg.dpop.required) {
|
|
410
|
+
throw new PoppyError('invalid_dpop_proof', 'A DPoP proof is required');
|
|
411
|
+
}
|
|
412
|
+
return { jkt: null, resource };
|
|
397
413
|
}
|
|
398
414
|
const result = await verifyDpopProof({
|
|
399
415
|
proof: dpopHeader,
|
|
@@ -410,15 +426,9 @@ export class PoppyService {
|
|
|
410
426
|
headers: result.error === 'use_dpop_nonce' ? { 'DPoP-Nonce': this.#nonces.issue() } : {},
|
|
411
427
|
});
|
|
412
428
|
}
|
|
413
|
-
|
|
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 };
|
|
429
|
+
return { jkt: result.jkt, resource };
|
|
420
430
|
}
|
|
421
|
-
/** `resource`s que exigem tokens próprios (§4.
|
|
431
|
+
/** `resource`s que exigem tokens próprios (§4.5): os declarados em `apis` e `agent.protocols`. */
|
|
422
432
|
#isConfiguredResource(resource) {
|
|
423
433
|
const entries = [
|
|
424
434
|
...this.cfg.apis.filter((a) => a.type !== 'mcp').map((a) => a.resource),
|
|
@@ -436,83 +446,21 @@ export class PoppyService {
|
|
|
436
446
|
const listed = this.cfg.apis.find((a) => a.type === 'mcp' && sameUrl(a.url, url));
|
|
437
447
|
return listed ? listed.url : url;
|
|
438
448
|
}
|
|
439
|
-
|
|
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
|
-
}
|
|
449
|
+
/** Refresh (§4.4): access token novo; o refresh token continua o mesmo (RFC 6749 §6). */
|
|
478
450
|
async #refresh(client, form, binding, requestedScope) {
|
|
479
451
|
const token = str(form.refresh_token);
|
|
480
452
|
if (!token)
|
|
481
453
|
throw new PoppyError('invalid_grant', 'refresh_token is required');
|
|
482
454
|
const now = this.#now();
|
|
483
|
-
const invalid = new PoppyError('invalid_grant', 'Unknown, expired or revoked
|
|
455
|
+
const invalid = new PoppyError('invalid_grant', 'Unknown, expired or revoked refresh token');
|
|
484
456
|
const row = await this.#grants.findRefreshToken(sha256(token));
|
|
485
457
|
if (!row || row.expiresAt <= now)
|
|
486
458
|
throw invalid;
|
|
487
459
|
const grant = await this.#grants.findGrant(row.grantId);
|
|
488
|
-
// Só o `client_id` a que foi emitido (§4.8).
|
|
489
460
|
if (!grant || !(await this.#grantUsable(grant, client.clientId, now)))
|
|
490
461
|
throw invalid;
|
|
491
|
-
const
|
|
492
|
-
|
|
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 });
|
|
462
|
+
const scopes = this.#narrow(parseScope(grant.scope), requestedScope);
|
|
463
|
+
return this.#issue(grant, scopes, binding);
|
|
516
464
|
}
|
|
517
465
|
async #authorizationCode(client, form, binding, requestedScope) {
|
|
518
466
|
const code = str(form.code);
|
|
@@ -541,18 +489,11 @@ export class PoppyService {
|
|
|
541
489
|
createHash('sha256').update(verifier).digest('base64url') !== row.codeChallenge) {
|
|
542
490
|
throw new PoppyError('invalid_grant', 'PKCE verification failed');
|
|
543
491
|
}
|
|
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
492
|
if (!(await this.#store.transitionRequest(row.id, 'approved', { status: 'consumed' }))) {
|
|
552
493
|
throw invalid;
|
|
553
494
|
}
|
|
554
495
|
const granted = parseScope(row.grantedScope);
|
|
555
|
-
const response = await this.#signIn(
|
|
496
|
+
const response = await this.#signIn(client.clientId, row.accountId, granted, binding, requestedScope);
|
|
556
497
|
await this.#store.setRequestData(row.id, { ...(row.data ?? {}), grantId: response.grantId });
|
|
557
498
|
return response.body;
|
|
558
499
|
}
|
|
@@ -582,17 +523,10 @@ export class PoppyService {
|
|
|
582
523
|
if (row.status !== 'approved' || !row.accountId) {
|
|
583
524
|
throw new PoppyError('invalid_grant', 'Unknown device_code');
|
|
584
525
|
}
|
|
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
526
|
if (!(await this.#store.transitionRequest(row.id, 'approved', { status: 'consumed' }))) {
|
|
593
527
|
throw new PoppyError('invalid_grant', 'device_code was used');
|
|
594
528
|
}
|
|
595
|
-
const result = await this.#signIn(
|
|
529
|
+
const result = await this.#signIn(client.clientId, row.accountId, parseScope(row.grantedScope), binding, requestedScope);
|
|
596
530
|
return result.body;
|
|
597
531
|
}
|
|
598
532
|
/** `POST device_authorization_endpoint` (§4.6). */
|
|
@@ -601,7 +535,7 @@ export class PoppyService {
|
|
|
601
535
|
if (!device)
|
|
602
536
|
throw new PoppyError('unauthorized_client', 'Device Sign-In is not offered');
|
|
603
537
|
await this.#housekeeping();
|
|
604
|
-
const client = await this.authenticateClient(form
|
|
538
|
+
const client = await this.authenticateClient(form);
|
|
605
539
|
const scopes = this.#signInScopes(form.scope, 'device');
|
|
606
540
|
const now = this.#now();
|
|
607
541
|
const deviceCode = randomId('pdc_', 32);
|
|
@@ -645,9 +579,9 @@ export class PoppyService {
|
|
|
645
579
|
interval: this.cfg.pollInterval,
|
|
646
580
|
};
|
|
647
581
|
}
|
|
648
|
-
/** `POST revocation_endpoint` (§4.
|
|
582
|
+
/** `POST revocation_endpoint` (§4.6, RFC 7009). */
|
|
649
583
|
async revoke(form) {
|
|
650
|
-
const client = await this.authenticateClient(form
|
|
584
|
+
const client = await this.authenticateClient(form);
|
|
651
585
|
const token = str(form.token);
|
|
652
586
|
if (!token)
|
|
653
587
|
throw new PoppyError('invalid_request', 'token is required');
|
|
@@ -671,16 +605,14 @@ export class PoppyService {
|
|
|
671
605
|
}
|
|
672
606
|
}
|
|
673
607
|
/**
|
|
674
|
-
* Revoga um
|
|
675
|
-
* deslogadas (§
|
|
608
|
+
* Revoga um refresh token (grant): os access tokens dele morrem junto (RFC 7009 §2.1), as
|
|
609
|
+
* Sessions continuam deslogadas (§5.5) e os Bearer de MCP no oidc-provider também caem.
|
|
676
610
|
*/
|
|
677
611
|
async revokeGrant(grantId) {
|
|
678
|
-
|
|
679
|
-
await this.#grants.revokeGrantById(grantId, now);
|
|
680
|
-
await this.#store.signOutSessionsOfGrant(grantId, now);
|
|
612
|
+
await this.#grants.revokeGrantById(grantId, this.#now());
|
|
681
613
|
await this.#mcp?.revoke?.(grantId);
|
|
682
614
|
}
|
|
683
|
-
// ─── Direct Sign-In (§4.
|
|
615
|
+
// ─── Direct Sign-In (§4.2) ────────────────────────────────────────────────
|
|
684
616
|
/**
|
|
685
617
|
* `GET authorization_endpoint` com o usuário já logado neste app. Erros no
|
|
686
618
|
* `client_id`/`redirect_uri` não redirecionam (RFC 6749 §4.1.2.1); os demais
|
|
@@ -701,7 +633,7 @@ export class PoppyService {
|
|
|
701
633
|
};
|
|
702
634
|
}
|
|
703
635
|
const redirectUri = str(params.redirect_uri);
|
|
704
|
-
if (!redirectUri || !client.redirectUris
|
|
636
|
+
if (!redirectUri || !redirectUriAllowed(client.redirectUris, redirectUri)) {
|
|
705
637
|
return { kind: 'error', description: 'redirect_uri is not registered for this client' };
|
|
706
638
|
}
|
|
707
639
|
const state = str(params.state);
|
|
@@ -808,7 +740,7 @@ export class PoppyService {
|
|
|
808
740
|
url.searchParams.set('iss', this.urls.issuer);
|
|
809
741
|
return url.toString();
|
|
810
742
|
}
|
|
811
|
-
// ─── Device Sign-In: tela (§4.
|
|
743
|
+
// ─── Device Sign-In: tela (§4.3) ──────────────────────────────────────────
|
|
812
744
|
async pendingDevice(userCodeInput) {
|
|
813
745
|
const userCode = normalizePoppyUserCode(userCodeInput);
|
|
814
746
|
if (!userCode)
|
|
@@ -852,49 +784,46 @@ export class PoppyService {
|
|
|
852
784
|
});
|
|
853
785
|
return ok ? { approved: granted, clientId: row.clientId } : null;
|
|
854
786
|
}
|
|
855
|
-
// ─── Mediated Sign-In
|
|
856
|
-
/**
|
|
857
|
-
async
|
|
787
|
+
// ─── extensão Mediated Sign-In ────────────────────────────────────────────
|
|
788
|
+
/** O grant de extensão no token endpoint: credenciais ou `sign_in_id` + `one_time_code`. */
|
|
789
|
+
async #mediated(client, form, binding, ctx) {
|
|
790
|
+
if (form.sign_in_id !== undefined)
|
|
791
|
+
return this.#mediatedCode(client, form, binding);
|
|
858
792
|
const mediated = this.cfg.signIn.mediated;
|
|
859
|
-
if (!
|
|
860
|
-
throw new
|
|
861
|
-
const
|
|
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
|
-
}
|
|
793
|
+
if (!ctx)
|
|
794
|
+
throw new Error('authkit: Mediated Sign-In precisa do HttpContext.');
|
|
795
|
+
const scopes = this.#signInScopes(form.scope, 'mediated');
|
|
867
796
|
const credentials = {};
|
|
868
797
|
for (const field of mediated.fields) {
|
|
869
|
-
const value =
|
|
870
|
-
if (
|
|
871
|
-
throw new PoppyError('invalid_request',
|
|
872
|
-
}
|
|
798
|
+
const value = str(form[field.name]);
|
|
799
|
+
if (!value)
|
|
800
|
+
throw new PoppyError('invalid_request', `${field.name} is required`);
|
|
873
801
|
credentials[field.name] = value;
|
|
874
802
|
}
|
|
875
|
-
|
|
803
|
+
// Por agente e pelo que identifica a conta — nunca pelos valores secretos em claro.
|
|
804
|
+
const identifier = mediated.fields
|
|
805
|
+
.filter((f) => !f.secret)
|
|
806
|
+
.map((f) => credentials[f.name])
|
|
807
|
+
.join('\u0000');
|
|
808
|
+
await this.#limit('mediated', `${client.clientId}\u0000${sha256(identifier)}`);
|
|
876
809
|
const result = await mediated.verify({
|
|
877
810
|
credentials,
|
|
878
811
|
scopes,
|
|
879
|
-
clientId:
|
|
880
|
-
userId: principal.userId,
|
|
881
|
-
sessionId: principal.sessionId,
|
|
812
|
+
clientId: client.clientId,
|
|
882
813
|
ctx,
|
|
883
814
|
});
|
|
884
|
-
if (
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
if (result
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
815
|
+
if (result?.status === 'denied') {
|
|
816
|
+
throw new PoppyError('access_denied', 'The Company refused this sign-in');
|
|
817
|
+
}
|
|
818
|
+
if (result?.status === 'complete') {
|
|
819
|
+
const done = await this.#signIn(client.clientId, result.accountId, scopes, binding, null);
|
|
820
|
+
return done.body;
|
|
821
|
+
}
|
|
822
|
+
if (result?.status !== 'code_required') {
|
|
823
|
+
throw new PoppyError('invalid_grant', 'Sign-in did not succeed');
|
|
893
824
|
}
|
|
894
|
-
if (result.status !== 'code_required')
|
|
895
|
-
return { status: 'failed' };
|
|
896
825
|
// `accountId: null` = sem conta elegível (anti-enumeração): a resposta é a mesma de uma conta
|
|
897
|
-
// real e nenhum código vale.
|
|
826
|
+
// real e nenhum código vale.
|
|
898
827
|
const decoy = result.accountId === null;
|
|
899
828
|
if (!decoy && result.code === undefined && !mediated.verifyCode) {
|
|
900
829
|
throw new Error('authkit: personalAgents.poppy.signIn.mediated — `verify` devolveu code_required sem `code` e não há `verifyCode`.');
|
|
@@ -907,8 +836,8 @@ export class PoppyService {
|
|
|
907
836
|
kind: 'mediated',
|
|
908
837
|
codeHash: null,
|
|
909
838
|
userCode: null,
|
|
910
|
-
clientId:
|
|
911
|
-
sessionId:
|
|
839
|
+
clientId: client.clientId,
|
|
840
|
+
sessionId: null,
|
|
912
841
|
accountId: result.accountId,
|
|
913
842
|
requestedScope: formatScope(scopes),
|
|
914
843
|
redirectUri: null,
|
|
@@ -925,33 +854,28 @@ export class PoppyService {
|
|
|
925
854
|
expiresAt,
|
|
926
855
|
createdAt: now,
|
|
927
856
|
});
|
|
928
|
-
|
|
929
|
-
status: 'code_required',
|
|
930
|
-
sign_in_id: id,
|
|
931
|
-
code: { sent_to: result.sentTo },
|
|
932
|
-
expires_at: expiresAt.toISOString(),
|
|
933
|
-
};
|
|
857
|
+
throw this.#codeRequired(id, result.sentTo, mediated.codeTtl);
|
|
934
858
|
}
|
|
935
|
-
|
|
936
|
-
|
|
859
|
+
#codeRequired(signInId, sentTo, expiresIn) {
|
|
860
|
+
return new PoppyError('code_required', 'A one-time code was sent to the user', {
|
|
861
|
+
extra: { sign_in_id: signInId, sent_to: sentTo, expires_in: expiresIn },
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
async #mediatedCode(client, form, binding) {
|
|
937
865
|
const mediated = this.cfg.signIn.mediated;
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
866
|
+
const invalid = new PoppyError('invalid_grant', 'Sign-in did not succeed');
|
|
867
|
+
const row = await this.#store.findRequest(str(form.sign_in_id) ?? '');
|
|
868
|
+
// Só o `client_id` que começou o sign-in termina. De outro = não existe.
|
|
869
|
+
if (row?.kind !== 'mediated' || row.clientId !== client.clientId)
|
|
870
|
+
throw invalid;
|
|
871
|
+
if (row.status !== 'pending')
|
|
872
|
+
throw invalid;
|
|
944
873
|
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
874
|
if (row.expiresAt <= now)
|
|
950
|
-
|
|
951
|
-
const code =
|
|
952
|
-
if (
|
|
953
|
-
throw new PoppyError('invalid_request', '
|
|
954
|
-
}
|
|
875
|
+
throw new PoppyError('expired_token', 'The sign-in expired');
|
|
876
|
+
const code = str(form.one_time_code);
|
|
877
|
+
if (!code)
|
|
878
|
+
throw new PoppyError('invalid_request', 'one_time_code is required');
|
|
955
879
|
const data = row.data ?? {};
|
|
956
880
|
let correct;
|
|
957
881
|
if (row.accountId === null) {
|
|
@@ -968,158 +892,226 @@ export class PoppyService {
|
|
|
968
892
|
accountId: row.accountId,
|
|
969
893
|
state: data.state,
|
|
970
894
|
clientId: row.clientId,
|
|
971
|
-
userId: principal.userId,
|
|
972
895
|
})) === true;
|
|
973
896
|
}
|
|
974
897
|
if (!correct) {
|
|
975
898
|
const attempts = row.attempts + 1;
|
|
976
899
|
if (attempts >= mediated.maxCodeAttempts) {
|
|
977
900
|
await this.#store.transitionRequest(row.id, 'pending', { status: 'failed', attempts });
|
|
978
|
-
|
|
901
|
+
throw invalid;
|
|
979
902
|
}
|
|
980
903
|
if (!(await this.#store.bumpAttempts(row.id, row.attempts)))
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
sign_in_id: row.id,
|
|
985
|
-
code: { sent_to: String(data.sentTo ?? '') },
|
|
986
|
-
expires_at: row.expiresAt.toISOString(),
|
|
987
|
-
};
|
|
904
|
+
throw invalid;
|
|
905
|
+
const left = Math.max(1, Math.ceil((row.expiresAt.getTime() - now.getTime()) / 1000));
|
|
906
|
+
throw this.#codeRequired(row.id, String(data.sentTo ?? ''), left);
|
|
988
907
|
}
|
|
989
908
|
if (!(await this.#store.transitionRequest(row.id, 'pending', { status: 'consumed' }))) {
|
|
990
|
-
|
|
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');
|
|
909
|
+
throw invalid;
|
|
995
910
|
}
|
|
996
|
-
const done = await this.#signIn(
|
|
997
|
-
return
|
|
911
|
+
const done = await this.#signIn(client.clientId, row.accountId, parseScope(row.requestedScope), binding, null);
|
|
912
|
+
return done.body;
|
|
998
913
|
}
|
|
999
|
-
// ─── resource server (§4.
|
|
914
|
+
// ─── Sessions e resource server (§4.5, §5.2–§5.4, §7) ─────────────────────
|
|
1000
915
|
/**
|
|
1001
|
-
* Verifica
|
|
1002
|
-
*
|
|
916
|
+
* Verifica uma request às APIs/conversas do app: o header `Poppy-Session` (§5.3) e, se houver, o
|
|
917
|
+
* access token (`Authorization: Bearer|DPoP`, com a prova DPoP). Um `sid` novo começa uma Session
|
|
918
|
+
* deslogada; a request é logada só quando traz access token. Nunca lê token da URL (§5.6).
|
|
1003
919
|
*/
|
|
1004
920
|
async verifyAccess(input, options = {}) {
|
|
1005
921
|
const target = this.#endpointFor(input.url, options.resource);
|
|
1006
|
-
const
|
|
1007
|
-
const
|
|
1008
|
-
const
|
|
1009
|
-
const
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
922
|
+
const resourceMetadata = target.mcp ? resourceMetadataUrl(target.mcp) : undefined;
|
|
923
|
+
const challenge = (status, error, description, extra = {}) => {
|
|
924
|
+
const metadata = extra.resourceMetadata ?? resourceMetadata;
|
|
925
|
+
const params = [
|
|
926
|
+
...(error ? [`error="${error}"`, `error_description="${quote(description)}"`] : []),
|
|
927
|
+
...(extra.scope ? [`scope="${quote(extra.scope)}"`] : []),
|
|
928
|
+
...(metadata ? [`resource_metadata="${quote(metadata)}"`] : []),
|
|
929
|
+
];
|
|
930
|
+
const schemes = extra.schemes ?? this.#acceptedSchemes(target.mcp !== null);
|
|
931
|
+
const wwwAuthenticate = schemes
|
|
932
|
+
.map((scheme) => {
|
|
933
|
+
const all = scheme === 'DPoP'
|
|
934
|
+
? [`algs="${this.cfg.dpop.algorithms.join(' ')}"`, ...params]
|
|
935
|
+
: params;
|
|
936
|
+
return all.length > 0 ? `${scheme} ${all.join(', ')}` : scheme;
|
|
937
|
+
})
|
|
938
|
+
.join(', ');
|
|
1014
939
|
return {
|
|
1015
940
|
ok: false,
|
|
1016
941
|
status,
|
|
1017
942
|
error,
|
|
1018
943
|
description,
|
|
1019
944
|
...(extra.scope ? { scope: extra.scope } : {}),
|
|
1020
|
-
wwwAuthenticate
|
|
945
|
+
wwwAuthenticate,
|
|
1021
946
|
headers: extra.headers ?? {},
|
|
1022
947
|
};
|
|
1023
948
|
};
|
|
949
|
+
const invalidSession = (description) => ({
|
|
950
|
+
ok: false,
|
|
951
|
+
status: 400,
|
|
952
|
+
error: 'invalid_session',
|
|
953
|
+
description,
|
|
954
|
+
wwwAuthenticate: null,
|
|
955
|
+
headers: {},
|
|
956
|
+
});
|
|
1024
957
|
const auth = header(input.headers, 'authorization');
|
|
1025
958
|
const authValue = Array.isArray(auth) ? undefined : auth;
|
|
1026
959
|
const match = authValue
|
|
1027
960
|
? /^(DPoP|Bearer)\s+([A-Za-z0-9\-._~+/]+=*)\s*$/i.exec(authValue)
|
|
1028
961
|
: null;
|
|
1029
|
-
if (!match)
|
|
1030
|
-
return
|
|
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
|
-
});
|
|
962
|
+
if (auth !== undefined && !match) {
|
|
963
|
+
return challenge(401, 'invalid_token', 'Malformed Authorization header');
|
|
1043
964
|
}
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
965
|
+
const usedScheme = match?.[1].toLowerCase() === 'dpop' ? 'DPoP' : 'Bearer';
|
|
966
|
+
const token = match?.[2] ?? null;
|
|
967
|
+
const now = this.#now();
|
|
968
|
+
let jwt;
|
|
969
|
+
try {
|
|
970
|
+
jwt = await verifySessionJwt({
|
|
971
|
+
header: header(input.headers, 'poppy-session'),
|
|
1050
972
|
method: input.method,
|
|
1051
973
|
url: input.url,
|
|
1052
974
|
accessToken: token,
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
nonce: { required: this.cfg.dpop.requireNonce, source: this.#nonces },
|
|
1058
|
-
now: () => now.getTime(),
|
|
975
|
+
audience: this.urls.issuer,
|
|
976
|
+
maxAge: this.cfg.sessionJwt.maxAge,
|
|
977
|
+
resolveClient: (clientId) => this.registry.resolve(clientId),
|
|
978
|
+
now,
|
|
1059
979
|
});
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
980
|
+
}
|
|
981
|
+
catch (error) {
|
|
982
|
+
if (error instanceof SessionJwtError)
|
|
983
|
+
return invalidSession(error.message);
|
|
984
|
+
throw error;
|
|
985
|
+
}
|
|
986
|
+
const replayUntil = new Date((jwt.iat + this.cfg.sessionJwt.maxAge + CLOCK_SKEW_S) * 1000);
|
|
987
|
+
if (!(await this.#replay.claim(replayKey('session', jwt.clientId, jwt.jti), replayUntil))) {
|
|
988
|
+
return invalidSession('jti was already used');
|
|
989
|
+
}
|
|
990
|
+
// Acesso à conta (§4.5).
|
|
991
|
+
let signedIn = null;
|
|
992
|
+
let resource = null;
|
|
993
|
+
if (token !== null) {
|
|
994
|
+
const row = await this.#store.findToken(sha256(token));
|
|
995
|
+
if (!row || row.expiresAt <= now) {
|
|
996
|
+
return challenge(401, 'invalid_token', 'The access token is unknown or expired', {
|
|
997
|
+
schemes: [usedScheme],
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
if (row.clientId !== jwt.clientId) {
|
|
1001
|
+
return invalidSession('The access token was issued to another client_id');
|
|
1002
|
+
}
|
|
1003
|
+
const tokenScheme = row.jkt ? 'DPoP' : 'Bearer';
|
|
1004
|
+
if (usedScheme !== tokenScheme) {
|
|
1005
|
+
return challenge(401, 'invalid_token', `This token uses the ${tokenScheme} scheme`, {
|
|
1006
|
+
schemes: [tokenScheme],
|
|
1064
1007
|
});
|
|
1065
1008
|
}
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1009
|
+
if (row.jkt) {
|
|
1010
|
+
const proof = await verifyDpopProof({
|
|
1011
|
+
proof: header(input.headers, 'dpop'),
|
|
1012
|
+
method: input.method,
|
|
1013
|
+
url: input.url,
|
|
1014
|
+
accessToken: token,
|
|
1015
|
+
expectedJkt: row.jkt,
|
|
1016
|
+
algorithms: this.cfg.dpop.algorithms,
|
|
1017
|
+
maxAge: this.cfg.dpop.maxAge,
|
|
1018
|
+
replay: this.#replay,
|
|
1019
|
+
nonce: { required: this.cfg.dpop.requireNonce, source: this.#nonces },
|
|
1020
|
+
now: () => now.getTime(),
|
|
1021
|
+
});
|
|
1022
|
+
if (!proof.ok) {
|
|
1023
|
+
return challenge(401, proof.error, proof.description, {
|
|
1024
|
+
schemes: ['DPoP'],
|
|
1025
|
+
headers: proof.error === 'use_dpop_nonce' ? { 'DPoP-Nonce': this.#nonces.issue() } : {},
|
|
1026
|
+
});
|
|
1071
1027
|
}
|
|
1072
1028
|
}
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1029
|
+
// Um token de um `resource` só vale nele; o default, onde não se exige um (§4.5).
|
|
1030
|
+
const required = target.mcp ?? target.resource;
|
|
1031
|
+
if ((row.resource ?? null) !== null || required !== null) {
|
|
1032
|
+
if (!row.resource || !required || !sameUrl(row.resource, required)) {
|
|
1033
|
+
return challenge(401, 'invalid_token', 'The token was not issued for this resource', {
|
|
1034
|
+
schemes: [tokenScheme],
|
|
1035
|
+
...(required ? { resourceMetadata: resourceMetadataUrl(required) } : {}),
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
const grant = row.grantId ? await this.#grants.findGrant(row.grantId) : null;
|
|
1040
|
+
if (!row.grantId ||
|
|
1041
|
+
!row.accountId ||
|
|
1042
|
+
!grant ||
|
|
1043
|
+
!(await this.#grantUsable(grant, row.clientId, now))) {
|
|
1044
|
+
return challenge(401, 'invalid_token', 'The access token was revoked', {
|
|
1045
|
+
schemes: [tokenScheme],
|
|
1082
1046
|
});
|
|
1083
1047
|
}
|
|
1048
|
+
signedIn = {
|
|
1049
|
+
accountId: row.accountId,
|
|
1050
|
+
grantId: row.grantId,
|
|
1051
|
+
scopes: parseScope(row.scope),
|
|
1052
|
+
jkt: row.jkt,
|
|
1053
|
+
};
|
|
1054
|
+
resource = row.resource;
|
|
1084
1055
|
}
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
session
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1056
|
+
// A Session (§5.3, passo 4) e a conta dela (§5.4).
|
|
1057
|
+
let session;
|
|
1058
|
+
try {
|
|
1059
|
+
session = await this.#sessionFor(jwt, now);
|
|
1060
|
+
}
|
|
1061
|
+
catch (error) {
|
|
1062
|
+
if (error instanceof PoppyError && error.status === 429) {
|
|
1063
|
+
return {
|
|
1064
|
+
ok: false,
|
|
1065
|
+
status: 429,
|
|
1066
|
+
error: null,
|
|
1067
|
+
description: error.description,
|
|
1068
|
+
wwwAuthenticate: null,
|
|
1069
|
+
headers: error.headers,
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
1072
|
+
if (error instanceof PoppyError)
|
|
1073
|
+
return invalidSession(error.description);
|
|
1074
|
+
throw error;
|
|
1075
|
+
}
|
|
1076
|
+
if (signedIn) {
|
|
1077
|
+
if (session.accountId && session.accountId !== signedIn.accountId) {
|
|
1078
|
+
return invalidSession('The Session used a different account');
|
|
1079
|
+
}
|
|
1080
|
+
if (!session.accountId &&
|
|
1081
|
+
!(await this.#store.bindSessionAccount(session.id, signedIn.accountId, now))) {
|
|
1082
|
+
return invalidSession('The Session used a different account');
|
|
1083
|
+
}
|
|
1098
1084
|
}
|
|
1099
|
-
const scopes = signedIn ? parseScope(row.scope) : [];
|
|
1100
1085
|
const principal = {
|
|
1101
|
-
userId:
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1086
|
+
userId: session.userId,
|
|
1087
|
+
agentId: session.agentId,
|
|
1088
|
+
accountId: signedIn?.accountId ?? null,
|
|
1089
|
+
clientId: session.clientId,
|
|
1090
|
+
scopes: signedIn?.scopes ?? [],
|
|
1091
|
+
sessionId: session.id,
|
|
1092
|
+
signedIn: signedIn !== null,
|
|
1093
|
+
resource,
|
|
1094
|
+
tokenType: signedIn ? (signedIn.jkt ? 'DPoP' : 'Bearer') : null,
|
|
1109
1095
|
};
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1096
|
+
const required = options.scopes ?? [];
|
|
1097
|
+
if (!signedIn && (options.signedIn || required.length > 0)) {
|
|
1098
|
+
return challenge(401, null, 'This request needs the account of the user', {
|
|
1099
|
+
...(required.length > 0 ? { scope: formatScope(required) } : {}),
|
|
1113
1100
|
});
|
|
1114
1101
|
}
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
return deny(403, 'insufficient_scope', 'The token lacks required account scopes', {
|
|
1102
|
+
if (required.some((s) => !principal.scopes.includes(s))) {
|
|
1103
|
+
return challenge(403, 'insufficient_scope', 'The token lacks required scopes', {
|
|
1118
1104
|
scope: formatScope(required),
|
|
1119
|
-
|
|
1105
|
+
schemes: [principal.tokenType],
|
|
1120
1106
|
});
|
|
1121
1107
|
}
|
|
1122
|
-
return { ok: true, principal,
|
|
1108
|
+
return { ok: true, principal, grantId: signedIn?.grantId ?? null };
|
|
1109
|
+
}
|
|
1110
|
+
/** Um challenge para cada esquema que o resource aceita (§7). */
|
|
1111
|
+
#acceptedSchemes(mcp) {
|
|
1112
|
+
if (mcp)
|
|
1113
|
+
return ['Bearer'];
|
|
1114
|
+
return this.cfg.dpop.required ? ['DPoP'] : ['Bearer', 'DPoP'];
|
|
1123
1115
|
}
|
|
1124
1116
|
/** O endpoint pedido: MCP? exige `resource`? */
|
|
1125
1117
|
#endpointFor(url, explicit) {
|
|
@@ -1143,97 +1135,106 @@ export class PoppyService {
|
|
|
1143
1135
|
}
|
|
1144
1136
|
return { mcp: null, resource: null };
|
|
1145
1137
|
}
|
|
1146
|
-
// ─── navegação (§
|
|
1138
|
+
// ─── navegação (§6) ───────────────────────────────────────────────────────
|
|
1139
|
+
/** Passo 1: gera o código de uso único e o valor do cookie que o prende a este navegador. */
|
|
1140
|
+
async startBrowserJoin() {
|
|
1141
|
+
await this.#housekeeping();
|
|
1142
|
+
const now = this.#now();
|
|
1143
|
+
const code = randomId('bc_');
|
|
1144
|
+
const cookie = randomBytes(32).toString('base64url');
|
|
1145
|
+
await this.#store.insertRequest({
|
|
1146
|
+
id: randomId('brw_'),
|
|
1147
|
+
kind: 'browser',
|
|
1148
|
+
codeHash: sha256(code),
|
|
1149
|
+
userCode: null,
|
|
1150
|
+
clientId: '',
|
|
1151
|
+
sessionId: null,
|
|
1152
|
+
accountId: null,
|
|
1153
|
+
requestedScope: '',
|
|
1154
|
+
redirectUri: null,
|
|
1155
|
+
codeChallenge: null,
|
|
1156
|
+
status: 'pending',
|
|
1157
|
+
intervalSeconds: null,
|
|
1158
|
+
data: { cookieHash: sha256(cookie) },
|
|
1159
|
+
expiresAt: new Date(now.getTime() + BROWSER_CODE_TTL_S * 1000),
|
|
1160
|
+
createdAt: now,
|
|
1161
|
+
});
|
|
1162
|
+
return { code, cookie, maxAge: BROWSER_CODE_TTL_S };
|
|
1163
|
+
}
|
|
1147
1164
|
/**
|
|
1148
|
-
*
|
|
1149
|
-
*
|
|
1165
|
+
* Passo 2: o agente autoriza o código, com o header da Session e, se logado, o access token.
|
|
1166
|
+
* `{ ok: true }` = 204; um código inválido lança `invalid_request`.
|
|
1150
1167
|
*/
|
|
1151
|
-
async
|
|
1152
|
-
const
|
|
1153
|
-
if (
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1168
|
+
async authorizeBrowserJoin(input, code) {
|
|
1169
|
+
const auth = await this.verifyAccess(input);
|
|
1170
|
+
if (!auth.ok)
|
|
1171
|
+
return auth;
|
|
1172
|
+
const invalid = new PoppyError('invalid_request', 'Unknown, expired or used code');
|
|
1173
|
+
if (typeof code !== 'string' || !code)
|
|
1174
|
+
throw invalid;
|
|
1175
|
+
const row = await this.#store.findRequestByCodeHash(sha256(code));
|
|
1176
|
+
if (row?.kind !== 'browser' || row.status !== 'pending' || row.expiresAt <= this.#now()) {
|
|
1177
|
+
throw invalid;
|
|
1161
1178
|
}
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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,
|
|
1179
|
+
const { principal, grantId } = auth;
|
|
1180
|
+
const moved = await this.#store.transitionRequest(row.id, 'pending', {
|
|
1181
|
+
status: 'approved',
|
|
1182
|
+
clientId: principal.clientId,
|
|
1183
|
+
sessionId: principal.sessionId,
|
|
1184
|
+
...(principal.accountId ? { accountId: principal.accountId } : {}),
|
|
1185
|
+
grantedScope: formatScope(principal.scopes),
|
|
1186
|
+
data: { ...(row.data ?? {}), userId: principal.userId, grantId },
|
|
1176
1187
|
});
|
|
1177
|
-
if (
|
|
1178
|
-
throw
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1188
|
+
if (!moved)
|
|
1189
|
+
throw invalid;
|
|
1190
|
+
return { ok: true };
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Passo 3: o navegador submete o código. O primeiro envio o consome, qualquer que seja o
|
|
1194
|
+
* resultado; `null` = sem cookie para a Session (página de erro).
|
|
1195
|
+
*/
|
|
1196
|
+
async completeBrowserJoin(code, cookie) {
|
|
1197
|
+
if (typeof code !== 'string' || !code)
|
|
1198
|
+
return null;
|
|
1199
|
+
const row = await this.#store.findRequestByCodeHash(sha256(code));
|
|
1200
|
+
if (row?.kind !== 'browser' || (row.status !== 'pending' && row.status !== 'approved')) {
|
|
1201
|
+
return null;
|
|
1190
1202
|
}
|
|
1191
|
-
const
|
|
1203
|
+
const moved = await this.#store.transitionRequest(row.id, row.status, { status: 'consumed' });
|
|
1204
|
+
if (!moved || row.status !== 'approved')
|
|
1205
|
+
return null;
|
|
1192
1206
|
const now = this.#now();
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
const
|
|
1207
|
+
const data = row.data ?? {};
|
|
1208
|
+
if (row.expiresAt <= now ||
|
|
1209
|
+
typeof cookie !== 'string' ||
|
|
1210
|
+
!cookie ||
|
|
1211
|
+
sha256(cookie) !== data.cookieHash ||
|
|
1212
|
+
!row.sessionId) {
|
|
1213
|
+
return null;
|
|
1214
|
+
}
|
|
1215
|
+
const session = await this.#store.findSession(row.sessionId);
|
|
1216
|
+
if (!session || session.endedAt || session.expiresAt <= now)
|
|
1217
|
+
return null;
|
|
1218
|
+
const grantId = typeof data.grantId === 'string' ? data.grantId : null;
|
|
1202
1219
|
return {
|
|
1203
1220
|
sessionId: session.id,
|
|
1204
|
-
clientId:
|
|
1205
|
-
userId,
|
|
1206
|
-
accountId:
|
|
1207
|
-
|
|
1208
|
-
|
|
1221
|
+
clientId: session.clientId,
|
|
1222
|
+
userId: session.userId,
|
|
1223
|
+
accountId: grantId ? row.accountId : null,
|
|
1224
|
+
grantId,
|
|
1225
|
+
scopes: grantId ? parseScope(row.grantedScope) : [],
|
|
1209
1226
|
};
|
|
1210
1227
|
}
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
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);
|
|
1228
|
+
/** O cookie age pela conta e pelos scopes do passo 2, enquanto o refresh token viver (§6). */
|
|
1229
|
+
async browserState(marker) {
|
|
1230
|
+
const session = await this.#store.findSession(marker.sessionId);
|
|
1230
1231
|
if (!session)
|
|
1231
1232
|
return null;
|
|
1232
1233
|
const now = this.#now();
|
|
1233
1234
|
const active = !session.endedAt && session.expiresAt > now;
|
|
1234
1235
|
let signedIn = false;
|
|
1235
|
-
if (active &&
|
|
1236
|
-
const grant = await this.#grants.findGrant(
|
|
1236
|
+
if (active && marker.grantId) {
|
|
1237
|
+
const grant = await this.#grants.findGrant(marker.grantId);
|
|
1237
1238
|
signedIn = !!grant && (await this.#grantUsable(grant, session.clientId, now));
|
|
1238
1239
|
}
|
|
1239
1240
|
return {
|
|
@@ -1241,9 +1242,9 @@ export class PoppyService {
|
|
|
1241
1242
|
sessionId: session.id,
|
|
1242
1243
|
clientId: session.clientId,
|
|
1243
1244
|
userId: session.userId,
|
|
1244
|
-
accountId: signedIn ?
|
|
1245
|
+
accountId: signedIn ? (marker.accountId ?? null) : null,
|
|
1245
1246
|
signedIn,
|
|
1246
|
-
scopes: signedIn ?
|
|
1247
|
+
scopes: signedIn ? (marker.scopes ?? []) : [],
|
|
1247
1248
|
};
|
|
1248
1249
|
}
|
|
1249
1250
|
// ─── console de conta ─────────────────────────────────────────────────────
|
|
@@ -1260,7 +1261,7 @@ export class PoppyService {
|
|
|
1260
1261
|
expiresAt: g.expiresAt,
|
|
1261
1262
|
}));
|
|
1262
1263
|
}
|
|
1263
|
-
/** Desconectar o agente pela conta (§
|
|
1264
|
+
/** Desconectar o agente pela conta (§5.5): revoga um refresh token DA CONTA. */
|
|
1264
1265
|
async revokeAccountGrant(accountId, grantId) {
|
|
1265
1266
|
if (!grantId.startsWith('pgrant_'))
|
|
1266
1267
|
return false;
|
|
@@ -1270,7 +1271,7 @@ export class PoppyService {
|
|
|
1270
1271
|
await this.revokeGrant(grantId);
|
|
1271
1272
|
return true;
|
|
1272
1273
|
}
|
|
1273
|
-
/** Revoga todos os
|
|
1274
|
+
/** Revoga todos os refresh tokens da conta (sair de tudo, conta suspensa…). */
|
|
1274
1275
|
async revokeAllAccountGrants(accountId) {
|
|
1275
1276
|
const live = (await this.#grants.listGrants(accountId)).filter((g) => g.id.startsWith('pgrant_'));
|
|
1276
1277
|
for (const g of live)
|
|
@@ -1278,9 +1279,8 @@ export class PoppyService {
|
|
|
1278
1279
|
return live.length;
|
|
1279
1280
|
}
|
|
1280
1281
|
/**
|
|
1281
|
-
* Bloqueio pelo console admin: revoga todos os
|
|
1282
|
-
*
|
|
1283
|
-
* Devolve quantos Account Tokens estavam vivos.
|
|
1282
|
+
* Bloqueio pelo console admin: revoga todos os refresh tokens do agente (todas as contas) e
|
|
1283
|
+
* encerra as Sessions dele — param na próxima request. Devolve quantos estavam vivos.
|
|
1284
1284
|
*/
|
|
1285
1285
|
async disconnectClient(clientId) {
|
|
1286
1286
|
const ids = (await this.#grants.unrevokedGrantIdsOfClient(clientId)).filter((id) => id.startsWith('pgrant_'));
|
|
@@ -1290,7 +1290,7 @@ export class PoppyService {
|
|
|
1290
1290
|
return ids.length;
|
|
1291
1291
|
}
|
|
1292
1292
|
// ─── internos ─────────────────────────────────────────────────────────────
|
|
1293
|
-
/** Scopes de um pedido de sign-in: obrigatórios, conhecidos e do tipo (§4.
|
|
1293
|
+
/** Scopes de um pedido de sign-in: obrigatórios, conhecidos e do tipo (§4.7). */
|
|
1294
1294
|
#signInScopes(raw, type) {
|
|
1295
1295
|
const allowed = type === 'mediated' ? this.cfg.signIn.mediated?.scopes : this.cfg.signIn[type]?.scopes;
|
|
1296
1296
|
const scopes = typeof raw === 'string' ? parseScope(raw) : [];
|
|
@@ -1309,34 +1309,52 @@ export class PoppyService {
|
|
|
1309
1309
|
}
|
|
1310
1310
|
return requested;
|
|
1311
1311
|
}
|
|
1312
|
-
/**
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
(
|
|
1321
|
-
|
|
1312
|
+
/**
|
|
1313
|
+
* §5.3, passo 4: busca a Session por `client_id` + `sid`. `sid` novo começa uma Session deslogada;
|
|
1314
|
+
* o conhecido precisa ter o mesmo User ID e Agent ID, e não pode ter terminado (§5.5).
|
|
1315
|
+
*/
|
|
1316
|
+
async #sessionFor(jwt, now) {
|
|
1317
|
+
// O `sid` é escolhido pelo agente: a chave inclui o `client_id`, para um não esbarrar no outro.
|
|
1318
|
+
const id = `ses_${createHash('sha256')
|
|
1319
|
+
.update(`${jwt.clientId}\u0000${jwt.sid}`)
|
|
1320
|
+
.digest('base64url')}`;
|
|
1321
|
+
let session = await this.#store.findSession(id);
|
|
1322
|
+
if (!session) {
|
|
1323
|
+
await this.#limit('session', jwt.clientId);
|
|
1324
|
+
const row = {
|
|
1325
|
+
id,
|
|
1326
|
+
clientId: jwt.clientId,
|
|
1327
|
+
userId: jwt.userId,
|
|
1328
|
+
agentId: jwt.agentId,
|
|
1329
|
+
accountId: null,
|
|
1330
|
+
grantId: null,
|
|
1331
|
+
scope: '',
|
|
1332
|
+
expiresAt: this.#sessionExpiry(now),
|
|
1333
|
+
createdAt: now,
|
|
1334
|
+
updatedAt: now,
|
|
1335
|
+
};
|
|
1336
|
+
try {
|
|
1337
|
+
await this.#store.insertSession(row);
|
|
1338
|
+
return { ...row, endedAt: null };
|
|
1339
|
+
}
|
|
1340
|
+
catch (error) {
|
|
1341
|
+
// Duas requests simultâneas com o mesmo `sid` novo: a outra ganhou.
|
|
1342
|
+
session = await this.#store.findSession(id);
|
|
1343
|
+
if (!session)
|
|
1344
|
+
throw error;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
if (session.endedAt || session.expiresAt <= now) {
|
|
1348
|
+
throw new PoppyError('invalid_session', 'The Session has ended');
|
|
1349
|
+
}
|
|
1350
|
+
if (session.userId !== jwt.userId || session.agentId !== jwt.agentId) {
|
|
1351
|
+
throw new PoppyError('invalid_session', 'The Session has another User ID or Agent ID');
|
|
1352
|
+
}
|
|
1353
|
+
if (now.getTime() - session.updatedAt.getTime() >= SESSION_TOUCH_INTERVAL_MS) {
|
|
1354
|
+
await this.#store.touchSession(session.id, this.#sessionExpiry(now), now);
|
|
1322
1355
|
}
|
|
1323
1356
|
return session;
|
|
1324
1357
|
}
|
|
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
1358
|
#sessionExpiry(now) {
|
|
1341
1359
|
return new Date(now.getTime() + this.cfg.sessionTtl * 1000);
|
|
1342
1360
|
}
|
|
@@ -1347,99 +1365,75 @@ export class PoppyService {
|
|
|
1347
1365
|
grant.clientId === clientId &&
|
|
1348
1366
|
(await this.#isAccountActive(grant.accountId)));
|
|
1349
1367
|
}
|
|
1350
|
-
/**
|
|
1351
|
-
|
|
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) {
|
|
1368
|
+
/** Fim comum dos sign-ins (§4.7): refresh token novo (grant + hash) e o access token. */
|
|
1369
|
+
async #signIn(clientId, accountId, granted, binding, requestedScope) {
|
|
1356
1370
|
if (!(await this.#isAccountActive(accountId))) {
|
|
1357
1371
|
throw new PoppyError('invalid_grant', 'The account cannot sign in');
|
|
1358
1372
|
}
|
|
1359
1373
|
const scopes = this.#narrow(granted, requestedScope);
|
|
1360
1374
|
const now = this.#now();
|
|
1361
1375
|
const grantId = `pgrant_${randomUUID()}`;
|
|
1362
|
-
const
|
|
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);
|
|
1376
|
+
const expiresAt = new Date(now.getTime() + this.cfg.refreshTokenTtl * 1000);
|
|
1373
1377
|
const grant = {
|
|
1374
1378
|
id: grantId,
|
|
1375
1379
|
accountId,
|
|
1376
|
-
clientId
|
|
1377
|
-
|
|
1378
|
-
|
|
1380
|
+
clientId,
|
|
1381
|
+
// O authorization server não vê User IDs (§5.3).
|
|
1382
|
+
agentSub: '',
|
|
1383
|
+
scope: formatScope(granted),
|
|
1379
1384
|
expiresAt,
|
|
1380
1385
|
revokedAt: null,
|
|
1381
1386
|
createdAt: now,
|
|
1382
1387
|
updatedAt: now,
|
|
1383
1388
|
};
|
|
1384
1389
|
await this.#grants.insertGrant(grant);
|
|
1385
|
-
const
|
|
1390
|
+
const refreshToken = randomId('poa_', 32);
|
|
1386
1391
|
await this.#grants.insertRefreshToken({
|
|
1387
|
-
tokenHash: sha256(
|
|
1392
|
+
tokenHash: sha256(refreshToken),
|
|
1388
1393
|
grantId,
|
|
1389
1394
|
expiresAt,
|
|
1390
1395
|
createdAt: now,
|
|
1391
1396
|
});
|
|
1392
|
-
const
|
|
1393
|
-
|
|
1394
|
-
return {
|
|
1395
|
-
body: {
|
|
1396
|
-
...body,
|
|
1397
|
-
refresh_token: accountToken,
|
|
1398
|
-
refresh_token_expires_in: this.cfg.accountTokenTtl,
|
|
1399
|
-
},
|
|
1400
|
-
grantId,
|
|
1401
|
-
};
|
|
1397
|
+
const body = await this.#issue(grant, scopes, binding);
|
|
1398
|
+
return { body: { ...body, refresh_token: refreshToken }, grantId };
|
|
1402
1399
|
}
|
|
1403
|
-
/** Emite um
|
|
1404
|
-
async #issue(
|
|
1400
|
+
/** Emite um access token do grant (§4.5). */
|
|
1401
|
+
async #issue(grant, scopes, binding) {
|
|
1405
1402
|
const now = this.#now();
|
|
1406
|
-
const ttl = this.cfg.
|
|
1407
|
-
const scope =
|
|
1403
|
+
const ttl = this.cfg.accessTokenTtl;
|
|
1404
|
+
const scope = formatScope(scopes);
|
|
1408
1405
|
let token = null;
|
|
1409
|
-
// Bearer de MCP
|
|
1410
|
-
//
|
|
1411
|
-
if (
|
|
1412
|
-
token =
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1406
|
+
// Bearer de MCP: vira access token do oidc-provider, para a integração MCP existente
|
|
1407
|
+
// aceitá-lo sem código no app (§7).
|
|
1408
|
+
if (binding.jkt === null && binding.resource && this.#isMcp(binding.resource)) {
|
|
1409
|
+
token =
|
|
1410
|
+
(await this.#mcp?.mint?.({
|
|
1411
|
+
grantId: grant.id,
|
|
1412
|
+
accountId: grant.accountId,
|
|
1413
|
+
clientId: grant.clientId,
|
|
1414
|
+
scope,
|
|
1415
|
+
resource: binding.resource,
|
|
1416
|
+
expiresIn: ttl,
|
|
1417
|
+
grantExpiresAt: grant.expiresAt,
|
|
1418
|
+
})) ?? null;
|
|
1419
|
+
}
|
|
1420
|
+
token ??= randomId('pat_', 32);
|
|
1423
1421
|
await this.#store.insertToken({
|
|
1424
1422
|
tokenHash: sha256(token),
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
accountId: input.signedIn ? input.signedIn.accountId : null,
|
|
1429
|
-
grantId: input.signedIn ? input.signedIn.id : null,
|
|
1423
|
+
clientId: grant.clientId,
|
|
1424
|
+
accountId: grant.accountId,
|
|
1425
|
+
grantId: grant.id,
|
|
1430
1426
|
scope,
|
|
1431
|
-
resource:
|
|
1432
|
-
jkt:
|
|
1427
|
+
resource: binding.resource,
|
|
1428
|
+
jkt: binding.jkt,
|
|
1433
1429
|
expiresAt: new Date(now.getTime() + ttl * 1000),
|
|
1434
1430
|
createdAt: now,
|
|
1435
1431
|
});
|
|
1436
1432
|
return {
|
|
1437
1433
|
access_token: token,
|
|
1438
|
-
token_type:
|
|
1434
|
+
token_type: binding.jkt ? 'DPoP' : 'Bearer',
|
|
1439
1435
|
expires_in: ttl,
|
|
1440
1436
|
scope,
|
|
1441
|
-
session_id: session.id,
|
|
1442
|
-
signed_in: input.signedIn !== null,
|
|
1443
1437
|
};
|
|
1444
1438
|
}
|
|
1445
1439
|
async #limit(action, key) {
|
|
@@ -1447,7 +1441,7 @@ export class PoppyService {
|
|
|
1447
1441
|
return;
|
|
1448
1442
|
const retryAfter = await this.#limiter({ action, key });
|
|
1449
1443
|
if (retryAfter !== null) {
|
|
1450
|
-
throw new PoppyError('
|
|
1444
|
+
throw new PoppyError('too_many_requests', 'Too many requests for this client_id', {
|
|
1451
1445
|
headers: { 'Retry-After': String(Math.max(1, retryAfter)) },
|
|
1452
1446
|
});
|
|
1453
1447
|
}
|