@adonis-agora/authkit-server 0.85.1 → 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.
Files changed (38) hide show
  1. package/README.md +2 -2
  2. package/build/index.d.ts +1 -2
  3. package/build/index.js +0 -1
  4. package/build/src/agents/config.d.ts +1 -1
  5. package/build/src/agents/delegation_service.js +1 -1
  6. package/build/src/agents/poppy/client_registry.d.ts +5 -0
  7. package/build/src/agents/poppy/client_registry.js +35 -1
  8. package/build/src/agents/poppy/config.d.ts +111 -80
  9. package/build/src/agents/poppy/config.js +55 -35
  10. package/build/src/agents/poppy/dpop.d.ts +2 -2
  11. package/build/src/agents/poppy/dpop.js +3 -3
  12. package/build/src/agents/poppy/email_code_sign_in.d.ts +4 -4
  13. package/build/src/agents/poppy/email_code_sign_in.js +5 -5
  14. package/build/src/agents/poppy/errors.d.ts +7 -2
  15. package/build/src/agents/poppy/errors.js +5 -2
  16. package/build/src/agents/poppy/resource.d.ts +30 -25
  17. package/build/src/agents/poppy/resource.js +24 -20
  18. package/build/src/agents/poppy/safe_fetch.js +8 -2
  19. package/build/src/agents/poppy/service.d.ts +69 -62
  20. package/build/src/agents/poppy/service.js +540 -546
  21. package/build/src/agents/poppy/session_jwt.d.ts +30 -0
  22. package/build/src/agents/poppy/session_jwt.js +133 -0
  23. package/build/src/agents/poppy/store.d.ts +17 -22
  24. package/build/src/agents/poppy/store.js +22 -30
  25. package/build/src/agents/poppy/web_session.d.ts +23 -16
  26. package/build/src/agents/poppy/web_session.js +42 -24
  27. package/build/src/host/admin_sessions_service.js +3 -3
  28. package/build/src/host/controllers/account_apps_controller.js +1 -1
  29. package/build/src/host/controllers/poppy_consent_controller.d.ts +2 -2
  30. package/build/src/host/controllers/poppy_consent_controller.js +2 -2
  31. package/build/src/host/controllers/poppy_controller.d.ts +19 -11
  32. package/build/src/host/controllers/poppy_controller.js +93 -68
  33. package/build/src/host/csrf.d.ts +2 -4
  34. package/build/src/host/csrf.js +2 -3
  35. package/build/src/host/register_auth_host.d.ts +53 -0
  36. package/build/src/host/register_auth_host.js +66 -22
  37. package/build/src/schema/ensure.js +14 -9
  38. package/package.json +3 -3
@@ -1,28 +1,36 @@
1
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.
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
- * 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.
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 { decodeJwt, decodeProtectedHeader, jwtVerify } from 'jose';
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
- export const JWT_BEARER_GRANT = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
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 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'];
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
- function hostMatches(host, domains) {
99
- const h = host.toLowerCase().replace(/\.$/, '');
100
- return domains.some((d) => h === d || h.endsWith(`.${d}`));
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.1 — pode mudar de forma incompatível enquanto a spec for draft.
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
- /** `/.well-known/poppy.json` (§3) para o domínio pedido; `null` = domínio que não é nosso. */
167
- discoveryDocument(requestHost) {
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 bare = host.startsWith('www.') ? host.slice(4) : host;
174
- const domain = this.domains.find((d) => d === bare);
175
- if (!domain)
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 { signIn } = this.cfg;
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 (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;
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: '0.1',
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.resource ? { resource: p.resource } : {}),
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
- if (Object.keys(this.cfg.extensions).length > 0)
221
- doc.extensions = this.cfg.extensions;
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
- /** RFC 8414 com `poppy_domains` (§3.2). */
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 = [JWT_BEARER_GRANT, 'refresh_token'];
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
- poppy_domains: this.domains,
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` (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.
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, endpoint) {
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 new PoppyError('invalid_client', 'private_key_jwt client authentication is required');
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(token, client.keys, {
337
+ ({ payload } = await jwtVerify(assertion, client.keys, {
326
338
  algorithms: ASSERTION_ALGS,
327
339
  issuer: client.clientId,
328
- audience: options.audience,
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 (error) {
335
- if (error instanceof PoppyError)
336
- throw error;
337
- throw fail('JWT verification failed');
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
- 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) {
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(kind, clientId, String(payload.jti)), expiresAt))) {
354
- throw new PoppyError(error, 'jti was already used');
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, §4.3, §4.5, §4.6, §4.8). */
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, this.urls.token);
370
+ const client = await this.authenticateClient(form);
362
371
  const grantType = str(form.grant_type);
363
- if (grantType !== JWT_BEARER_GRANT &&
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
- * 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).
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 (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)');
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
- 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 };
429
+ return { jkt: result.jkt, resource };
420
430
  }
421
- /** `resource`s que exigem tokens próprios (§4.3): os `resource` de `apis` e `agent.protocols`. */
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
- 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
- }
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 Account Token');
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 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 });
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(session, row.accountId, granted, binding, requestedScope);
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(session, row.accountId, parseScope(row.grantedScope), binding, requestedScope);
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, this.urls.deviceAuthorization);
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.9, RFC 7009). */
582
+ /** `POST revocation_endpoint` (§4.6, RFC 7009). */
649
583
  async revoke(form) {
650
- const client = await this.authenticateClient(form, this.urls.revocation);
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 Account Token (grant): as Sessions logadas com ele continuam
675
- * deslogadas (§4.9) e os Bearer de MCP no oidc-provider morrem junto.
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
- const now = this.#now();
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.5) ────────────────────────────────────────────────
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.includes(redirectUri)) {
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.6) ──────────────────────────────────────────
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 (§4.7) ──────────────────────────────────────────────
856
- /** `POST auth.mediated.endpoint` — já autenticado pelo Session Token (DPoP). */
857
- async mediatedStart(principal, jkt, body, ctx) {
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 (!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
- }
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 = raw[field.name];
870
- if (typeof value !== 'string' || value === '') {
871
- throw new PoppyError('invalid_request', `credentials.${field.name} is required`);
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
- await this.#limit('mediated', `${principal.clientId}\u0000${principal.userId}`);
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: principal.clientId,
880
- userId: principal.userId,
881
- sessionId: principal.sessionId,
812
+ clientId: client.clientId,
882
813
  ctx,
883
814
  });
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 };
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. A conta da Session só é conferida no código (sem vazar aqui).
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: principal.clientId,
911
- sessionId: principal.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
- return {
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
- /** `POST {mediated}/{sign_in_id}` com o código de uso único. */
936
- async mediatedCode(principal, jkt, signInId, body) {
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
- 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;
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
- return { status: 'expired' };
951
- const code = body?.code;
952
- if (typeof code !== 'string' || code === '') {
953
- throw new PoppyError('invalid_request', 'code is required');
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
- return { status: 'failed' };
901
+ throw invalid;
979
902
  }
980
903
  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
- };
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
- 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');
909
+ throw invalid;
995
910
  }
996
- const done = await this.#signIn(session, row.accountId, parseScope(row.requestedScope), { jkt, resource: null }, null);
997
- return { status: 'complete', ...done.body };
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.3, §6) ───────────────────────────────────────────
914
+ // ─── Sessions e resource server (§4.5, §5.2–§5.4, §7) ─────────────────────
1000
915
  /**
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).
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 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(' ')}"`);
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: `${s} ${parts.join(', ')}`,
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 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
- });
962
+ if (auth !== undefined && !match) {
963
+ return challenge(401, 'invalid_token', 'Malformed Authorization header');
1043
964
  }
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'),
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
- 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(),
975
+ audience: this.urls.issuer,
976
+ maxAge: this.cfg.sessionJwt.maxAge,
977
+ resolveClient: (clientId) => this.registry.resolve(clientId),
978
+ now,
1059
979
  });
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() } : {},
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
- 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');
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
- 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',
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
- 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));
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: 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',
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
- 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,
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
- const required = options.scopes ?? [];
1116
- if (required.some((s) => !scopes.includes(s))) {
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
- scheme: principal.tokenType,
1105
+ schemes: [principal.tokenType],
1120
1106
  });
1121
1107
  }
1122
- return { ok: true, principal, jkt };
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 (§5) ───────────────────────────────────────────────────────
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
- * Confere a asserção de navegador (`typ` `poppy-browser+jwt`). `requestOrigin`
1149
- * é a origem em que o POST chegou — o cookie precisa nascer nela.
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 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;
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
- 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,
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 (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');
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 session = await this.#store.findSession(sessionId);
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
- 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);
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: client.clientId,
1205
- userId,
1206
- accountId: state?.signedIn ? state.accountId : null,
1207
- scopes: state?.scopes ?? [],
1208
- returnTo,
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
- #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);
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 && session.grantId) {
1236
- const grant = await this.#grants.findGrant(session.grantId);
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 ? session.accountId : null,
1245
+ accountId: signedIn ? (marker.accountId ?? null) : null,
1245
1246
  signedIn,
1246
- scopes: signedIn ? parseScope(session.scope) : [],
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 (§4.9): revoga um Account Token DA 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 Account Tokens da conta (sair de tudo, conta suspensa…). */
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 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.
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.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
- /** 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');
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
- * 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) {
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 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);
1376
+ const expiresAt = new Date(now.getTime() + this.cfg.refreshTokenTtl * 1000);
1373
1377
  const grant = {
1374
1378
  id: grantId,
1375
1379
  accountId,
1376
- clientId: session.clientId,
1377
- agentSub: session.userId,
1378
- scope,
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 accountToken = randomId('poa_', 32);
1390
+ const refreshToken = randomId('poa_', 32);
1386
1391
  await this.#grants.insertRefreshToken({
1387
- tokenHash: sha256(accountToken),
1392
+ tokenHash: sha256(refreshToken),
1388
1393
  grantId,
1389
1394
  expiresAt,
1390
1395
  createdAt: now,
1391
1396
  });
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
- };
1397
+ const body = await this.#issue(grant, scopes, binding);
1398
+ return { body: { ...body, refresh_token: refreshToken }, grantId };
1402
1399
  }
1403
- /** Emite um Session Token para o estado da Session (logado com `signedIn`, ou deslogado). */
1404
- async #issue(session, input) {
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.sessionTokenTtl;
1407
- const scope = input.signedIn ? formatScope(input.scopes) : '';
1403
+ const ttl = this.cfg.accessTokenTtl;
1404
+ const scope = formatScope(scopes);
1408
1405
  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);
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
- 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,
1423
+ clientId: grant.clientId,
1424
+ accountId: grant.accountId,
1425
+ grantId: grant.id,
1430
1426
  scope,
1431
- resource: input.resource,
1432
- jkt: input.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: input.jkt ? 'DPoP' : 'Bearer',
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('rate_limited', 'Too many requests for this client_id', {
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
  }