@adonis-agora/authkit-server 1.0.0-rc.1 → 1.0.0-rc.3
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/CHANGELOG.md +34 -0
- package/README.md +4 -5
- package/build/commands/clients_create.d.ts +4 -0
- package/build/commands/clients_create.js +97 -0
- package/build/commands/commands.json +14 -0
- package/build/index.d.ts +8 -11
- package/build/index.js +5 -9
- package/build/providers/authkit_server_provider.js +6 -8
- package/build/src/agents/config.d.ts +9 -13
- package/build/src/agents/config.js +17 -11
- package/build/src/agents/delegation_service.js +3 -3
- package/build/src/agents/delegation_store.d.ts +3 -2
- package/build/src/agents/delegation_store.js +6 -5
- package/build/src/agents/extension.d.ts +261 -0
- package/build/src/agents/extension.js +40 -0
- package/build/src/agents/middleware.js +1 -1
- package/build/src/agents/protocol.d.ts +4 -4
- package/build/src/agents/registry_store.d.ts +3 -2
- package/build/src/agents/registry_store.js +1 -1
- package/build/src/agents/runtime.d.ts +26 -7
- package/build/src/agents/runtime.js +110 -75
- package/build/src/audit/audit_sink.d.ts +1 -1
- package/build/src/audit/audit_sink.js +6 -1
- package/build/src/define_config.d.ts +16 -12
- package/build/src/define_config.js +11 -7
- package/build/src/host/access_token_verifier.js +7 -0
- package/build/src/host/account_api/account_api_controller.js +8 -6
- package/build/src/host/account_session_sign_in.d.ts +12 -0
- package/build/src/host/account_session_sign_in.js +19 -0
- package/build/src/host/admin_api/api_clients_controller.d.ts +37 -0
- package/build/src/host/admin_api/api_clients_controller.js +31 -1
- package/build/src/host/admin_api/dto.d.ts +3 -1
- package/build/src/host/admin_api/dto.js +2 -0
- package/build/src/host/admin_clients_service.d.ts +20 -0
- package/build/src/host/admin_clients_service.js +55 -0
- package/build/src/host/admin_console/console_clients_controller.d.ts +41 -0
- package/build/src/host/admin_console/console_clients_controller.js +34 -1
- package/build/src/host/admin_console/console_personal_agents_controller.d.ts +1 -6
- package/build/src/host/admin_console/console_personal_agents_controller.js +50 -30
- package/build/src/host/admin_sessions_service.js +8 -5
- package/build/src/host/admin_validators.d.ts +29 -0
- package/build/src/host/admin_validators.js +30 -0
- package/build/src/host/auth_host_config.d.ts +6 -2
- package/build/src/host/controllers/account_apps_controller.js +15 -11
- package/build/src/host/csrf.d.ts +3 -3
- package/build/src/host/csrf.js +3 -13
- package/build/src/host/default_mailer.d.ts +11 -6
- package/build/src/host/default_mailer.js +16 -12
- package/build/src/host/i18n.d.ts +3 -14
- package/build/src/host/i18n.js +15 -13
- package/build/src/host/register_auth_host.js +30 -61
- package/build/src/host/ui-dist/assets/index-u0bMxkLg.js +67 -0
- package/build/src/host/ui-dist/index.html +1 -1
- package/build/src/provider/build_provider.js +37 -6
- package/build/src/provider/oidc_service.d.ts +1 -1
- package/build/src/provider/oidc_service.js +1 -1
- package/build/src/schema/ensure.d.ts +14 -0
- package/build/src/schema/ensure.js +43 -150
- package/build/src/service_tenants/client_metadata.d.ts +39 -0
- package/build/src/service_tenants/client_metadata.js +80 -0
- package/build/src/service_tenants/config.d.ts +44 -0
- package/build/src/service_tenants/config.js +44 -0
- package/build/src/service_tenants/provider_hooks.d.ts +52 -0
- package/build/src/service_tenants/provider_hooks.js +124 -0
- package/build/src/service_tenants/tenant_verifier.d.ts +73 -0
- package/build/src/service_tenants/tenant_verifier.js +104 -0
- package/package.json +2 -2
- package/build/src/agents/poppy/client_registry.d.ts +0 -50
- package/build/src/agents/poppy/client_registry.js +0 -260
- package/build/src/agents/poppy/config.d.ts +0 -396
- package/build/src/agents/poppy/config.js +0 -228
- package/build/src/agents/poppy/dpop.d.ts +0 -61
- package/build/src/agents/poppy/dpop.js +0 -158
- package/build/src/agents/poppy/email_code_sign_in.d.ts +0 -27
- package/build/src/agents/poppy/email_code_sign_in.js +0 -140
- package/build/src/agents/poppy/errors.d.ts +0 -25
- package/build/src/agents/poppy/errors.js +0 -25
- package/build/src/agents/poppy/replay.d.ts +0 -25
- package/build/src/agents/poppy/replay.js +0 -106
- package/build/src/agents/poppy/resource.d.ts +0 -100
- package/build/src/agents/poppy/resource.js +0 -129
- package/build/src/agents/poppy/safe_fetch.d.ts +0 -48
- package/build/src/agents/poppy/safe_fetch.js +0 -262
- package/build/src/agents/poppy/service.d.ts +0 -293
- package/build/src/agents/poppy/service.js +0 -1458
- package/build/src/agents/poppy/session_jwt.d.ts +0 -30
- package/build/src/agents/poppy/session_jwt.js +0 -133
- package/build/src/agents/poppy/store.d.ts +0 -106
- package/build/src/agents/poppy/store.js +0 -261
- package/build/src/agents/poppy/web_session.d.ts +0 -39
- package/build/src/agents/poppy/web_session.js +0 -122
- package/build/src/host/controllers/poppy_consent_controller.d.ts +0 -19
- package/build/src/host/controllers/poppy_consent_controller.js +0 -199
- package/build/src/host/controllers/poppy_controller.d.ts +0 -33
- package/build/src/host/controllers/poppy_controller.js +0 -219
- package/build/src/host/poppy_session_key.d.ts +0 -9
- package/build/src/host/poppy_session_key.js +0 -9
- package/build/src/host/ui-dist/assets/index-D0EIeyKK.js +0 -67
|
@@ -1,1458 +0,0 @@
|
|
|
1
|
-
/**
|
|
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
|
-
*
|
|
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.
|
|
9
|
-
*/
|
|
10
|
-
import { createHash, randomBytes, randomInt, randomUUID } from 'node:crypto';
|
|
11
|
-
import { jwtVerify } from 'jose';
|
|
12
|
-
import { formatScope, parseScope } from '../config.js';
|
|
13
|
-
import { redirectUriAllowed } from './client_registry.js';
|
|
14
|
-
import { normalizeHtu, verifyDpopProof } from './dpop.js';
|
|
15
|
-
import { PoppyError } from './errors.js';
|
|
16
|
-
import { replayKey } from './replay.js';
|
|
17
|
-
import { SessionJwtError, verifySessionJwt } from './session_jwt.js';
|
|
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';
|
|
21
|
-
export const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
|
|
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'];
|
|
25
|
-
const CLOCK_SKEW_S = 30;
|
|
26
|
-
/** Sem vogais: o código não forma palavras (RFC 8628 §6.1). */
|
|
27
|
-
const USER_CODE_ALPHABET = 'BCDFGHJKLMNPQRSTVWXZ';
|
|
28
|
-
/** Pedido de autorização (Direct) aguardando o consentimento. */
|
|
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;
|
|
34
|
-
/** Deriva as URLs da ORIGEM do issuer configurado — nunca do Host da request. */
|
|
35
|
-
export function poppyUrls(oidcIssuer, prefix) {
|
|
36
|
-
const origin = new URL(oidcIssuer).origin;
|
|
37
|
-
const issuer = `${origin}${prefix}`;
|
|
38
|
-
return {
|
|
39
|
-
origin,
|
|
40
|
-
issuer,
|
|
41
|
-
metadata: `${origin}/.well-known/oauth-authorization-server${prefix}`,
|
|
42
|
-
token: `${issuer}/oauth/token`,
|
|
43
|
-
revocation: `${issuer}/oauth/revoke`,
|
|
44
|
-
authorization: `${issuer}/oauth/authorize`,
|
|
45
|
-
deviceAuthorization: `${issuer}/oauth/device`,
|
|
46
|
-
device: `${issuer}/device`,
|
|
47
|
-
browserSession: `${issuer}/browser-session`,
|
|
48
|
-
discovery: `${origin}/.well-known/poppy.json`,
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
function sha256(value) {
|
|
52
|
-
return createHash('sha256').update(value).digest('hex');
|
|
53
|
-
}
|
|
54
|
-
function randomId(prefix, bytes = 16) {
|
|
55
|
-
return `${prefix}${randomBytes(bytes).toString('base64url')}`;
|
|
56
|
-
}
|
|
57
|
-
function newUserCode() {
|
|
58
|
-
let code = '';
|
|
59
|
-
for (let i = 0; i < 8; i++)
|
|
60
|
-
code += USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)];
|
|
61
|
-
return `${code.slice(0, 4)}-${code.slice(4)}`;
|
|
62
|
-
}
|
|
63
|
-
/** Aceita o código como o usuário digitar: minúsculas, sem hífen, com espaços. */
|
|
64
|
-
export function normalizePoppyUserCode(input) {
|
|
65
|
-
if (typeof input !== 'string')
|
|
66
|
-
return null;
|
|
67
|
-
const letters = input.toUpperCase().replace(/[^A-Z]/g, '');
|
|
68
|
-
if (letters.length !== 8)
|
|
69
|
-
return null;
|
|
70
|
-
return `${letters.slice(0, 4)}-${letters.slice(4)}`;
|
|
71
|
-
}
|
|
72
|
-
function str(value) {
|
|
73
|
-
if (Array.isArray(value))
|
|
74
|
-
return undefined;
|
|
75
|
-
return typeof value === 'string' && value !== '' ? value : undefined;
|
|
76
|
-
}
|
|
77
|
-
function trimSlash(value) {
|
|
78
|
-
return value.replace(/\/+$/, '');
|
|
79
|
-
}
|
|
80
|
-
function sameUrl(a, b) {
|
|
81
|
-
const na = normalizeHtu(a);
|
|
82
|
-
const nb = normalizeHtu(b);
|
|
83
|
-
return na !== null && nb !== null && trimSlash(na) === trimSlash(nb);
|
|
84
|
-
}
|
|
85
|
-
/** `true` quando `url` está "debaixo" de `base` (mesma origem, path igual ou filho). */
|
|
86
|
-
function underUrl(url, base) {
|
|
87
|
-
const u = normalizeHtu(url);
|
|
88
|
-
const b = normalizeHtu(base);
|
|
89
|
-
if (!u || !b)
|
|
90
|
-
return false;
|
|
91
|
-
const ub = trimSlash(b);
|
|
92
|
-
return trimSlash(u) === ub || u.startsWith(`${ub}/`);
|
|
93
|
-
}
|
|
94
|
-
function header(headers, name) {
|
|
95
|
-
const lower = name.toLowerCase();
|
|
96
|
-
for (const [key, value] of Object.entries(headers)) {
|
|
97
|
-
if (key.toLowerCase() === lower)
|
|
98
|
-
return value;
|
|
99
|
-
}
|
|
100
|
-
return undefined;
|
|
101
|
-
}
|
|
102
|
-
function quote(value) {
|
|
103
|
-
return value.replace(/["\\]/g, '');
|
|
104
|
-
}
|
|
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}`;
|
|
110
|
-
}
|
|
111
|
-
/** Slot global onde o `@adonis-agora/agent` registra o endpoint de conversas Poppy. */
|
|
112
|
-
export const POPPY_ENDPOINTS_SLOT = Symbol.for('@adonis-agora/poppy:endpoints');
|
|
113
|
-
/**
|
|
114
|
-
* `agent.protocols` vindo do slot `Symbol.for('@adonis-agora/poppy:endpoints')`
|
|
115
|
-
* (`{ conversations: 'https://…' }`), usado quando o config não declara
|
|
116
|
-
* `agent.protocols` — o mesmo padrão do slot de resources do MCP.
|
|
117
|
-
*/
|
|
118
|
-
export function registeredConversationProtocols() {
|
|
119
|
-
const slot = globalThis[POPPY_ENDPOINTS_SLOT];
|
|
120
|
-
const url = slot?.conversations;
|
|
121
|
-
if (typeof url !== 'string')
|
|
122
|
-
return [];
|
|
123
|
-
try {
|
|
124
|
-
const parsed = new URL(url);
|
|
125
|
-
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:')
|
|
126
|
-
return [];
|
|
127
|
-
}
|
|
128
|
-
catch {
|
|
129
|
-
return [];
|
|
130
|
-
}
|
|
131
|
-
return [{ type: 'poppy', endpoint: url }];
|
|
132
|
-
}
|
|
133
|
-
/**
|
|
134
|
-
* @experimental Poppy (Personal Agent Protocol) Draft 0.3 — pode mudar de forma incompatível enquanto a spec for draft.
|
|
135
|
-
*/
|
|
136
|
-
export class PoppyService {
|
|
137
|
-
cfg;
|
|
138
|
-
urls;
|
|
139
|
-
registry;
|
|
140
|
-
#store;
|
|
141
|
-
#grants;
|
|
142
|
-
#replay;
|
|
143
|
-
#limiter;
|
|
144
|
-
#nonces;
|
|
145
|
-
#isAccountActive;
|
|
146
|
-
#mcp;
|
|
147
|
-
#now;
|
|
148
|
-
#housekeepingAt = 0;
|
|
149
|
-
constructor(deps) {
|
|
150
|
-
this.cfg = deps.cfg;
|
|
151
|
-
this.urls = deps.urls;
|
|
152
|
-
this.registry = deps.registry;
|
|
153
|
-
this.#store = deps.store;
|
|
154
|
-
this.#grants = deps.grants;
|
|
155
|
-
this.#replay = deps.replay;
|
|
156
|
-
this.#limiter = deps.limiter;
|
|
157
|
-
this.#nonces = deps.nonces;
|
|
158
|
-
this.#isAccountActive = deps.isAccountActive ?? (async () => true);
|
|
159
|
-
this.#mcp = deps.mcp;
|
|
160
|
-
this.#now = deps.now ?? (() => new Date());
|
|
161
|
-
}
|
|
162
|
-
/** Um nonce DPoP novo (header `DPoP-Nonce`), quando o app exige nonces. */
|
|
163
|
-
nonce() {
|
|
164
|
-
return this.cfg.dpop.requireNonce ? this.#nonces.issue() : null;
|
|
165
|
-
}
|
|
166
|
-
/** Domínios da empresa (o principal + aliases). */
|
|
167
|
-
get domains() {
|
|
168
|
-
return [this.cfg.organization.domain, ...this.cfg.organization.aliases];
|
|
169
|
-
}
|
|
170
|
-
/** Descrição de um scope para o usuário. */
|
|
171
|
-
describe(ids) {
|
|
172
|
-
return ids.map((id) => ({ id, description: this.cfg.scopes[id] ?? id }));
|
|
173
|
-
}
|
|
174
|
-
// ─── descoberta ───────────────────────────────────────────────────────────
|
|
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) {
|
|
184
|
-
const hostWithPort = requestHost
|
|
185
|
-
.toLowerCase()
|
|
186
|
-
.replace(/\.$/, '')
|
|
187
|
-
.replace(/\.(:\d+)$/, '$1');
|
|
188
|
-
const host = hostWithPort.replace(/:\d+$/, '');
|
|
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))
|
|
196
|
-
return null;
|
|
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;
|
|
208
|
-
const auth = { issuer: this.urls.issuer };
|
|
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;
|
|
213
|
-
const doc = {
|
|
214
|
-
protocol_version: POPPY_PROTOCOL_VERSION,
|
|
215
|
-
organization: { name: this.cfg.organization.name, domain },
|
|
216
|
-
auth,
|
|
217
|
-
};
|
|
218
|
-
const protocols = this.cfg.agent?.protocols ?? registeredConversationProtocols();
|
|
219
|
-
if (protocols.length > 0) {
|
|
220
|
-
doc.agent = {
|
|
221
|
-
protocols: protocols.map((p) => ({
|
|
222
|
-
type: p.type,
|
|
223
|
-
endpoint: p.endpoint,
|
|
224
|
-
...(p.access ? { access: p.access } : {}),
|
|
225
|
-
})),
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
if (this.cfg.web) {
|
|
229
|
-
doc.web = this.cfg.web.browserSession
|
|
230
|
-
? { browser_session_endpoint: this.#browserSessionEndpointFor(hostWithPort) }
|
|
231
|
-
: {};
|
|
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 };
|
|
237
|
-
if (this.cfg.apis.length > 0) {
|
|
238
|
-
doc.apis = this.cfg.apis.map((a) => ({
|
|
239
|
-
type: a.type,
|
|
240
|
-
url: a.url,
|
|
241
|
-
description: a.description,
|
|
242
|
-
}));
|
|
243
|
-
}
|
|
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;
|
|
255
|
-
return doc;
|
|
256
|
-
}
|
|
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). */
|
|
268
|
-
authorizationServerMetadata() {
|
|
269
|
-
const { signIn } = this.cfg;
|
|
270
|
-
const grants = ['refresh_token'];
|
|
271
|
-
if (signIn.direct)
|
|
272
|
-
grants.push('authorization_code');
|
|
273
|
-
if (signIn.device)
|
|
274
|
-
grants.push(DEVICE_CODE_GRANT);
|
|
275
|
-
if (signIn.mediated)
|
|
276
|
-
grants.push(MEDIATED_SIGN_IN_GRANT);
|
|
277
|
-
return {
|
|
278
|
-
issuer: this.urls.issuer,
|
|
279
|
-
token_endpoint: this.urls.token,
|
|
280
|
-
revocation_endpoint: this.urls.revocation,
|
|
281
|
-
...(signIn.direct ? { authorization_endpoint: this.urls.authorization } : {}),
|
|
282
|
-
...(signIn.device ? { device_authorization_endpoint: this.urls.deviceAuthorization } : {}),
|
|
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
|
-
],
|
|
287
|
-
scopes_supported: Object.keys(this.cfg.scopes),
|
|
288
|
-
response_types_supported: signIn.direct ? ['code'] : [],
|
|
289
|
-
response_modes_supported: signIn.direct ? ['query'] : [],
|
|
290
|
-
grant_types_supported: grants,
|
|
291
|
-
code_challenge_methods_supported: ['S256'],
|
|
292
|
-
token_endpoint_auth_methods_supported: ['private_key_jwt'],
|
|
293
|
-
token_endpoint_auth_signing_alg_values_supported: ASSERTION_ALGS,
|
|
294
|
-
revocation_endpoint_auth_methods_supported: ['private_key_jwt'],
|
|
295
|
-
revocation_endpoint_auth_signing_alg_values_supported: ASSERTION_ALGS,
|
|
296
|
-
dpop_signing_alg_values_supported: this.cfg.dpop.algorithms,
|
|
297
|
-
authorization_response_iss_parameter_supported: true,
|
|
298
|
-
client_id_metadata_document_supported: true,
|
|
299
|
-
};
|
|
300
|
-
}
|
|
301
|
-
/**
|
|
302
|
-
* O endpoint no host pedido (o cookie do site é do domínio que o agente abriu). No host do issuer
|
|
303
|
-
* é o do issuer — com a porta dele: o `Host` pode vir sem porta (ou o controller só ter o
|
|
304
|
-
* hostname), e `http://localhost/poppy/…` apontaria para outro servidor.
|
|
305
|
-
*/
|
|
306
|
-
#browserSessionEndpointFor(requestHost) {
|
|
307
|
-
const issuer = new URL(this.urls.origin);
|
|
308
|
-
let requested;
|
|
309
|
-
try {
|
|
310
|
-
requested = new URL(`${issuer.protocol}//${requestHost}`);
|
|
311
|
-
}
|
|
312
|
-
catch {
|
|
313
|
-
return this.urls.browserSession;
|
|
314
|
-
}
|
|
315
|
-
if (requested.hostname === issuer.hostname.toLowerCase())
|
|
316
|
-
return this.urls.browserSession;
|
|
317
|
-
const path = new URL(this.urls.browserSession).pathname;
|
|
318
|
-
return `${issuer.protocol}//${requested.host}${path}`;
|
|
319
|
-
}
|
|
320
|
-
// ─── autenticação do agente ───────────────────────────────────────────────
|
|
321
|
-
/**
|
|
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.
|
|
324
|
-
*/
|
|
325
|
-
async authenticateClient(form) {
|
|
326
|
-
const clientId = str(form.client_id);
|
|
327
|
-
const type = str(form.client_assertion_type);
|
|
328
|
-
const assertion = str(form.client_assertion);
|
|
329
|
-
const bad = (why) => new PoppyError('invalid_client', why);
|
|
330
|
-
if (!clientId || type !== CLIENT_ASSERTION_TYPE || !assertion) {
|
|
331
|
-
throw bad('private_key_jwt client authentication is required');
|
|
332
|
-
}
|
|
333
|
-
const client = await this.registry.resolve(clientId);
|
|
334
|
-
const now = this.#now();
|
|
335
|
-
let payload;
|
|
336
|
-
try {
|
|
337
|
-
({ payload } = await jwtVerify(assertion, client.keys, {
|
|
338
|
-
algorithms: ASSERTION_ALGS,
|
|
339
|
-
issuer: client.clientId,
|
|
340
|
-
subject: clientId,
|
|
341
|
-
currentDate: now,
|
|
342
|
-
clockTolerance: CLOCK_SKEW_S,
|
|
343
|
-
requiredClaims: ['exp', 'iat', 'jti'],
|
|
344
|
-
}));
|
|
345
|
-
}
|
|
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');
|
|
351
|
-
}
|
|
352
|
-
const nowS = Math.floor(now.getTime() / 1000);
|
|
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');
|
|
360
|
-
const expiresAt = new Date((payload.exp + CLOCK_SKEW_S) * 1000);
|
|
361
|
-
if (!(await this.#replay.claim(replayKey('client', clientId, payload.jti), expiresAt))) {
|
|
362
|
-
throw bad('jti was already used');
|
|
363
|
-
}
|
|
364
|
-
return client;
|
|
365
|
-
}
|
|
366
|
-
// ─── token endpoint ───────────────────────────────────────────────────────
|
|
367
|
-
/** `POST token_endpoint` (§4.2–§4.4, §4.7, extensão Mediated Sign-In). */
|
|
368
|
-
async token(form, dpopHeader, ctx) {
|
|
369
|
-
await this.#housekeeping();
|
|
370
|
-
const client = await this.authenticateClient(form);
|
|
371
|
-
const grantType = str(form.grant_type);
|
|
372
|
-
if (grantType !== 'refresh_token' &&
|
|
373
|
-
!(grantType === 'authorization_code' && this.cfg.signIn.direct) &&
|
|
374
|
-
!(grantType === DEVICE_CODE_GRANT && this.cfg.signIn.device) &&
|
|
375
|
-
!(grantType === MEDIATED_SIGN_IN_GRANT && this.cfg.signIn.mediated)) {
|
|
376
|
-
throw new PoppyError('unsupported_grant_type', 'Unsupported grant_type');
|
|
377
|
-
}
|
|
378
|
-
const binding = await this.#tokenBinding(form, dpopHeader);
|
|
379
|
-
const scopeParam = form.scope;
|
|
380
|
-
const requestedScope = scopeParam === undefined ? null : parseScope(str(scopeParam) ?? '');
|
|
381
|
-
switch (grantType) {
|
|
382
|
-
case 'refresh_token':
|
|
383
|
-
return this.#refresh(client, form, binding, requestedScope);
|
|
384
|
-
case 'authorization_code':
|
|
385
|
-
return this.#authorizationCode(client, form, binding, requestedScope);
|
|
386
|
-
case MEDIATED_SIGN_IN_GRANT:
|
|
387
|
-
return this.#mediated(client, form, binding, ctx);
|
|
388
|
-
default:
|
|
389
|
-
return this.#deviceCode(client, form, binding, requestedScope);
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
/**
|
|
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).
|
|
396
|
-
*/
|
|
397
|
-
async #tokenBinding(form, dpopHeader) {
|
|
398
|
-
const rawResource = form.resource;
|
|
399
|
-
if (Array.isArray(rawResource)) {
|
|
400
|
-
throw new PoppyError('invalid_target', 'Request a token for one resource at a time');
|
|
401
|
-
}
|
|
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
|
-
}
|
|
408
|
-
if (dpopHeader === undefined || dpopHeader === '') {
|
|
409
|
-
if (this.cfg.dpop.required) {
|
|
410
|
-
throw new PoppyError('invalid_dpop_proof', 'A DPoP proof is required');
|
|
411
|
-
}
|
|
412
|
-
return { jkt: null, resource };
|
|
413
|
-
}
|
|
414
|
-
const result = await verifyDpopProof({
|
|
415
|
-
proof: dpopHeader,
|
|
416
|
-
method: 'POST',
|
|
417
|
-
url: this.urls.token,
|
|
418
|
-
algorithms: this.cfg.dpop.algorithms,
|
|
419
|
-
maxAge: this.cfg.dpop.maxAge,
|
|
420
|
-
replay: this.#replay,
|
|
421
|
-
nonce: { required: this.cfg.dpop.requireNonce, source: this.#nonces },
|
|
422
|
-
now: () => this.#now().getTime(),
|
|
423
|
-
});
|
|
424
|
-
if (!result.ok) {
|
|
425
|
-
throw new PoppyError(result.error, result.description, {
|
|
426
|
-
headers: result.error === 'use_dpop_nonce' ? { 'DPoP-Nonce': this.#nonces.issue() } : {},
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
|
-
return { jkt: result.jkt, resource };
|
|
430
|
-
}
|
|
431
|
-
/** `resource`s que exigem tokens próprios (§4.5): os declarados em `apis` e `agent.protocols`. */
|
|
432
|
-
#isConfiguredResource(resource) {
|
|
433
|
-
const entries = [
|
|
434
|
-
...this.cfg.apis.filter((a) => a.type !== 'mcp').map((a) => a.resource),
|
|
435
|
-
...(this.cfg.agent?.protocols ?? []).map((p) => p.resource),
|
|
436
|
-
].filter((r) => !!r);
|
|
437
|
-
return entries.some((r) => sameUrl(r, resource));
|
|
438
|
-
}
|
|
439
|
-
#isMcp(url) {
|
|
440
|
-
if (this.cfg.apis.some((a) => a.type === 'mcp' && sameUrl(a.url, url)))
|
|
441
|
-
return true;
|
|
442
|
-
return this.#mcp?.isMcpResource(url) ?? false;
|
|
443
|
-
}
|
|
444
|
-
/** A URL canônica do MCP (a de `apis`, quando listado). */
|
|
445
|
-
#mcpUrl(url) {
|
|
446
|
-
const listed = this.cfg.apis.find((a) => a.type === 'mcp' && sameUrl(a.url, url));
|
|
447
|
-
return listed ? listed.url : url;
|
|
448
|
-
}
|
|
449
|
-
/** Refresh (§4.4): access token novo; o refresh token continua o mesmo (RFC 6749 §6). */
|
|
450
|
-
async #refresh(client, form, binding, requestedScope) {
|
|
451
|
-
const token = str(form.refresh_token);
|
|
452
|
-
if (!token)
|
|
453
|
-
throw new PoppyError('invalid_grant', 'refresh_token is required');
|
|
454
|
-
const now = this.#now();
|
|
455
|
-
const invalid = new PoppyError('invalid_grant', 'Unknown, expired or revoked refresh token');
|
|
456
|
-
const row = await this.#grants.findRefreshToken(sha256(token));
|
|
457
|
-
if (!row || row.expiresAt <= now)
|
|
458
|
-
throw invalid;
|
|
459
|
-
const grant = await this.#grants.findGrant(row.grantId);
|
|
460
|
-
if (!grant || !(await this.#grantUsable(grant, client.clientId, now)))
|
|
461
|
-
throw invalid;
|
|
462
|
-
const scopes = this.#narrow(parseScope(grant.scope), requestedScope);
|
|
463
|
-
return this.#issue(grant, scopes, binding);
|
|
464
|
-
}
|
|
465
|
-
async #authorizationCode(client, form, binding, requestedScope) {
|
|
466
|
-
const code = str(form.code);
|
|
467
|
-
if (!code)
|
|
468
|
-
throw new PoppyError('invalid_request', 'code is required');
|
|
469
|
-
const now = this.#now();
|
|
470
|
-
const row = await this.#store.findRequestByCodeHash(sha256(code));
|
|
471
|
-
const invalid = new PoppyError('invalid_grant', 'Unknown, expired or used authorization code');
|
|
472
|
-
if (row?.kind !== 'authorize' || row.clientId !== client.clientId)
|
|
473
|
-
throw invalid;
|
|
474
|
-
if (row.status === 'consumed') {
|
|
475
|
-
// Código reusado: revoga o que ele já entregou (RFC 6749 §4.1.2).
|
|
476
|
-
const grantId = row.data?.grantId;
|
|
477
|
-
if (typeof grantId === 'string')
|
|
478
|
-
await this.revokeGrant(grantId);
|
|
479
|
-
throw invalid;
|
|
480
|
-
}
|
|
481
|
-
if (row.status !== 'approved' || row.expiresAt <= now || !row.accountId)
|
|
482
|
-
throw invalid;
|
|
483
|
-
if (str(form.redirect_uri) !== row.redirectUri) {
|
|
484
|
-
throw new PoppyError('invalid_grant', 'redirect_uri does not match the authorization request');
|
|
485
|
-
}
|
|
486
|
-
const verifier = str(form.code_verifier);
|
|
487
|
-
if (!verifier ||
|
|
488
|
-
!/^[A-Za-z0-9\-._~]{43,128}$/.test(verifier) ||
|
|
489
|
-
createHash('sha256').update(verifier).digest('base64url') !== row.codeChallenge) {
|
|
490
|
-
throw new PoppyError('invalid_grant', 'PKCE verification failed');
|
|
491
|
-
}
|
|
492
|
-
if (!(await this.#store.transitionRequest(row.id, 'approved', { status: 'consumed' }))) {
|
|
493
|
-
throw invalid;
|
|
494
|
-
}
|
|
495
|
-
const granted = parseScope(row.grantedScope);
|
|
496
|
-
const response = await this.#signIn(client.clientId, row.accountId, granted, binding, requestedScope);
|
|
497
|
-
await this.#store.setRequestData(row.id, { ...(row.data ?? {}), grantId: response.grantId });
|
|
498
|
-
return response.body;
|
|
499
|
-
}
|
|
500
|
-
async #deviceCode(client, form, binding, requestedScope) {
|
|
501
|
-
const deviceCode = str(form.device_code);
|
|
502
|
-
if (!deviceCode)
|
|
503
|
-
throw new PoppyError('invalid_request', 'device_code is required');
|
|
504
|
-
const row = await this.#store.findRequestByCodeHash(sha256(deviceCode));
|
|
505
|
-
if (row?.kind !== 'device' || row.clientId !== client.clientId) {
|
|
506
|
-
throw new PoppyError('invalid_grant', 'Unknown device_code');
|
|
507
|
-
}
|
|
508
|
-
const now = this.#now();
|
|
509
|
-
if (row.status === 'denied')
|
|
510
|
-
throw new PoppyError('access_denied', 'The user denied access');
|
|
511
|
-
if (row.status === 'consumed')
|
|
512
|
-
throw new PoppyError('invalid_grant', 'device_code was used');
|
|
513
|
-
if (row.expiresAt <= now)
|
|
514
|
-
throw new PoppyError('expired_token', 'The device code expired');
|
|
515
|
-
if (row.status === 'pending') {
|
|
516
|
-
const interval = row.intervalSeconds ?? this.cfg.pollInterval;
|
|
517
|
-
const tooSoon = row.lastPolledAt !== null && now.getTime() - row.lastPolledAt.getTime() < interval * 1000;
|
|
518
|
-
await this.#store.markPolled(row.id, now);
|
|
519
|
-
throw tooSoon
|
|
520
|
-
? new PoppyError('slow_down', 'Poll less often')
|
|
521
|
-
: new PoppyError('authorization_pending', 'Waiting for the user');
|
|
522
|
-
}
|
|
523
|
-
if (row.status !== 'approved' || !row.accountId) {
|
|
524
|
-
throw new PoppyError('invalid_grant', 'Unknown device_code');
|
|
525
|
-
}
|
|
526
|
-
if (!(await this.#store.transitionRequest(row.id, 'approved', { status: 'consumed' }))) {
|
|
527
|
-
throw new PoppyError('invalid_grant', 'device_code was used');
|
|
528
|
-
}
|
|
529
|
-
const result = await this.#signIn(client.clientId, row.accountId, parseScope(row.grantedScope), binding, requestedScope);
|
|
530
|
-
return result.body;
|
|
531
|
-
}
|
|
532
|
-
/** `POST device_authorization_endpoint` (§4.6). */
|
|
533
|
-
async deviceAuthorization(form) {
|
|
534
|
-
const device = this.cfg.signIn.device;
|
|
535
|
-
if (!device)
|
|
536
|
-
throw new PoppyError('unauthorized_client', 'Device Sign-In is not offered');
|
|
537
|
-
await this.#housekeeping();
|
|
538
|
-
const client = await this.authenticateClient(form);
|
|
539
|
-
const scopes = this.#signInScopes(form.scope, 'device');
|
|
540
|
-
const now = this.#now();
|
|
541
|
-
const deviceCode = randomId('pdc_', 32);
|
|
542
|
-
const expiresAt = new Date(now.getTime() + this.cfg.deviceCodeTtl * 1000);
|
|
543
|
-
let userCode = newUserCode();
|
|
544
|
-
for (let attempt = 0;; attempt++) {
|
|
545
|
-
try {
|
|
546
|
-
await this.#store.insertRequest({
|
|
547
|
-
id: randomId('dev_'),
|
|
548
|
-
kind: 'device',
|
|
549
|
-
codeHash: sha256(deviceCode),
|
|
550
|
-
userCode,
|
|
551
|
-
clientId: client.clientId,
|
|
552
|
-
sessionId: null,
|
|
553
|
-
accountId: null,
|
|
554
|
-
requestedScope: formatScope(scopes),
|
|
555
|
-
redirectUri: null,
|
|
556
|
-
codeChallenge: null,
|
|
557
|
-
status: 'pending',
|
|
558
|
-
intervalSeconds: this.cfg.pollInterval,
|
|
559
|
-
data: null,
|
|
560
|
-
expiresAt,
|
|
561
|
-
createdAt: now,
|
|
562
|
-
});
|
|
563
|
-
break;
|
|
564
|
-
}
|
|
565
|
-
catch (error) {
|
|
566
|
-
if (attempt >= 2)
|
|
567
|
-
throw error;
|
|
568
|
-
userCode = newUserCode();
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
const complete = new URL(this.urls.device);
|
|
572
|
-
complete.searchParams.set('user_code', userCode);
|
|
573
|
-
return {
|
|
574
|
-
device_code: deviceCode,
|
|
575
|
-
user_code: userCode,
|
|
576
|
-
verification_uri: this.urls.device,
|
|
577
|
-
verification_uri_complete: complete.toString(),
|
|
578
|
-
expires_in: this.cfg.deviceCodeTtl,
|
|
579
|
-
interval: this.cfg.pollInterval,
|
|
580
|
-
};
|
|
581
|
-
}
|
|
582
|
-
/** `POST revocation_endpoint` (§4.6, RFC 7009). */
|
|
583
|
-
async revoke(form) {
|
|
584
|
-
const client = await this.authenticateClient(form);
|
|
585
|
-
const token = str(form.token);
|
|
586
|
-
if (!token)
|
|
587
|
-
throw new PoppyError('invalid_request', 'token is required');
|
|
588
|
-
const hint = str(form.token_type_hint);
|
|
589
|
-
if (hint && hint !== 'refresh_token' && hint !== 'access_token') {
|
|
590
|
-
throw new PoppyError('unsupported_token_type', 'Unsupported token_type_hint');
|
|
591
|
-
}
|
|
592
|
-
const hash = sha256(token);
|
|
593
|
-
const refresh = await this.#grants.findRefreshToken(hash);
|
|
594
|
-
if (refresh) {
|
|
595
|
-
const grant = await this.#grants.findGrant(refresh.grantId);
|
|
596
|
-
// De outro agente: RFC 7009 — token inválido para este cliente, nada acontece.
|
|
597
|
-
if (grant && grant.clientId === client.clientId && grant.id.startsWith('pgrant_')) {
|
|
598
|
-
await this.revokeGrant(grant.id);
|
|
599
|
-
}
|
|
600
|
-
return;
|
|
601
|
-
}
|
|
602
|
-
const sessionToken = await this.#store.findToken(hash);
|
|
603
|
-
if (sessionToken && sessionToken.clientId === client.clientId) {
|
|
604
|
-
await this.#store.deleteToken(hash);
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
/**
|
|
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.
|
|
610
|
-
*/
|
|
611
|
-
async revokeGrant(grantId) {
|
|
612
|
-
await this.#grants.revokeGrantById(grantId, this.#now());
|
|
613
|
-
await this.#mcp?.revoke?.(grantId);
|
|
614
|
-
}
|
|
615
|
-
// ─── Direct Sign-In (§4.2) ────────────────────────────────────────────────
|
|
616
|
-
/**
|
|
617
|
-
* `GET authorization_endpoint` com o usuário já logado neste app. Erros no
|
|
618
|
-
* `client_id`/`redirect_uri` não redirecionam (RFC 6749 §4.1.2.1); os demais
|
|
619
|
-
* voltam ao agente com `error`, `state` e `iss`.
|
|
620
|
-
*/
|
|
621
|
-
async startAuthorization(params, accountId) {
|
|
622
|
-
const direct = this.cfg.signIn.direct;
|
|
623
|
-
if (!direct)
|
|
624
|
-
return { kind: 'error', description: 'Direct Sign-In is not offered' };
|
|
625
|
-
let client;
|
|
626
|
-
try {
|
|
627
|
-
client = await this.registry.resolve(params.client_id);
|
|
628
|
-
}
|
|
629
|
-
catch (error) {
|
|
630
|
-
return {
|
|
631
|
-
kind: 'error',
|
|
632
|
-
description: error instanceof PoppyError ? error.description : 'invalid client',
|
|
633
|
-
};
|
|
634
|
-
}
|
|
635
|
-
const redirectUri = str(params.redirect_uri);
|
|
636
|
-
if (!redirectUri || !redirectUriAllowed(client.redirectUris, redirectUri)) {
|
|
637
|
-
return { kind: 'error', description: 'redirect_uri is not registered for this client' };
|
|
638
|
-
}
|
|
639
|
-
const state = str(params.state);
|
|
640
|
-
const back = (error, description) => ({
|
|
641
|
-
kind: 'redirect',
|
|
642
|
-
url: this.#redirect(redirectUri, { error, error_description: description, state }),
|
|
643
|
-
});
|
|
644
|
-
if (str(params.response_type) !== 'code') {
|
|
645
|
-
return back('unsupported_response_type', 'response_type must be code');
|
|
646
|
-
}
|
|
647
|
-
const challenge = str(params.code_challenge);
|
|
648
|
-
if (str(params.code_challenge_method) !== 'S256' ||
|
|
649
|
-
!challenge ||
|
|
650
|
-
!/^[A-Za-z0-9_-]{43}$/.test(challenge)) {
|
|
651
|
-
return back('invalid_request', 'PKCE with code_challenge_method=S256 is required');
|
|
652
|
-
}
|
|
653
|
-
let scopes;
|
|
654
|
-
try {
|
|
655
|
-
scopes = this.#signInScopes(params.scope, 'direct');
|
|
656
|
-
}
|
|
657
|
-
catch (error) {
|
|
658
|
-
return back('invalid_scope', error instanceof PoppyError ? error.description : 'invalid scope');
|
|
659
|
-
}
|
|
660
|
-
await this.#housekeeping();
|
|
661
|
-
const now = this.#now();
|
|
662
|
-
const id = randomId('azr_');
|
|
663
|
-
await this.#store.insertRequest({
|
|
664
|
-
id,
|
|
665
|
-
kind: 'authorize',
|
|
666
|
-
codeHash: null,
|
|
667
|
-
userCode: null,
|
|
668
|
-
clientId: client.clientId,
|
|
669
|
-
sessionId: null,
|
|
670
|
-
accountId,
|
|
671
|
-
requestedScope: formatScope(scopes),
|
|
672
|
-
redirectUri,
|
|
673
|
-
codeChallenge: challenge,
|
|
674
|
-
status: 'pending',
|
|
675
|
-
intervalSeconds: null,
|
|
676
|
-
data: state !== undefined ? { state } : {},
|
|
677
|
-
expiresAt: new Date(now.getTime() + AUTHORIZE_REQUEST_TTL_S * 1000),
|
|
678
|
-
createdAt: now,
|
|
679
|
-
});
|
|
680
|
-
return {
|
|
681
|
-
kind: 'consent',
|
|
682
|
-
request: {
|
|
683
|
-
id,
|
|
684
|
-
clientId: client.clientId,
|
|
685
|
-
agentName: client.name,
|
|
686
|
-
agentOrigin: new URL(client.clientId).host,
|
|
687
|
-
logoUri: client.logoUri,
|
|
688
|
-
scopes: this.describe(scopes),
|
|
689
|
-
},
|
|
690
|
-
};
|
|
691
|
-
}
|
|
692
|
-
/**
|
|
693
|
-
* `POST` da tela de consentimento Direct. Devolve para onde mandar o
|
|
694
|
-
* navegador (`code`/`error`, `state`, `iss`), ou `null` quando o pedido não
|
|
695
|
-
* vale mais (expirou, decidido em outra aba, de outra conta).
|
|
696
|
-
*/
|
|
697
|
-
async decideAuthorization(input) {
|
|
698
|
-
const row = await this.#store.findRequest(input.requestId);
|
|
699
|
-
const now = this.#now();
|
|
700
|
-
if (row?.kind !== 'authorize' ||
|
|
701
|
-
row.status !== 'pending' ||
|
|
702
|
-
row.expiresAt <= now ||
|
|
703
|
-
row.accountId !== input.accountId ||
|
|
704
|
-
!row.redirectUri) {
|
|
705
|
-
return null;
|
|
706
|
-
}
|
|
707
|
-
const state = typeof row.data?.state === 'string' ? row.data.state : undefined;
|
|
708
|
-
const requested = parseScope(row.requestedScope);
|
|
709
|
-
const granted = input.allow ? requested.filter((s) => input.scopes.includes(s)) : [];
|
|
710
|
-
if (granted.length === 0) {
|
|
711
|
-
if (!(await this.#store.transitionRequest(row.id, 'pending', { status: 'denied' })))
|
|
712
|
-
return null;
|
|
713
|
-
return {
|
|
714
|
-
url: this.#redirect(row.redirectUri, { error: 'access_denied', state }),
|
|
715
|
-
approved: null,
|
|
716
|
-
clientId: row.clientId,
|
|
717
|
-
};
|
|
718
|
-
}
|
|
719
|
-
const code = randomBytes(32).toString('base64url');
|
|
720
|
-
if (!(await this.#store.transitionRequest(row.id, 'pending', {
|
|
721
|
-
status: 'approved',
|
|
722
|
-
codeHash: sha256(code),
|
|
723
|
-
grantedScope: formatScope(granted),
|
|
724
|
-
expiresAt: new Date(now.getTime() + this.cfg.authorizationCodeTtl * 1000),
|
|
725
|
-
}))) {
|
|
726
|
-
return null;
|
|
727
|
-
}
|
|
728
|
-
return {
|
|
729
|
-
url: this.#redirect(row.redirectUri, { code, state }),
|
|
730
|
-
approved: granted,
|
|
731
|
-
clientId: row.clientId,
|
|
732
|
-
};
|
|
733
|
-
}
|
|
734
|
-
/** `redirect_uri` + params + `iss` (RFC 9207). */
|
|
735
|
-
#redirect(redirectUri, params) {
|
|
736
|
-
const url = new URL(redirectUri);
|
|
737
|
-
for (const [k, v] of Object.entries(params))
|
|
738
|
-
if (v !== undefined)
|
|
739
|
-
url.searchParams.set(k, v);
|
|
740
|
-
url.searchParams.set('iss', this.urls.issuer);
|
|
741
|
-
return url.toString();
|
|
742
|
-
}
|
|
743
|
-
// ─── Device Sign-In: tela (§4.3) ──────────────────────────────────────────
|
|
744
|
-
async pendingDevice(userCodeInput) {
|
|
745
|
-
const userCode = normalizePoppyUserCode(userCodeInput);
|
|
746
|
-
if (!userCode)
|
|
747
|
-
return null;
|
|
748
|
-
const row = await this.#store.findRequestByUserCode(userCode);
|
|
749
|
-
if (row?.kind !== 'device' || row.status !== 'pending' || row.expiresAt <= this.#now()) {
|
|
750
|
-
return null;
|
|
751
|
-
}
|
|
752
|
-
const name = await this.registry.displayName(row.clientId);
|
|
753
|
-
return {
|
|
754
|
-
id: row.id,
|
|
755
|
-
clientId: row.clientId,
|
|
756
|
-
agentName: name,
|
|
757
|
-
agentOrigin: new URL(row.clientId).host,
|
|
758
|
-
logoUri: null,
|
|
759
|
-
scopes: this.describe(parseScope(row.requestedScope)),
|
|
760
|
-
userCode: row.userCode ?? undefined,
|
|
761
|
-
};
|
|
762
|
-
}
|
|
763
|
-
/** Aprova (scopes marcados) ou nega um device code. `null` = não está mais pendente. */
|
|
764
|
-
async decideDevice(input) {
|
|
765
|
-
const userCode = normalizePoppyUserCode(input.userCode);
|
|
766
|
-
const row = userCode ? await this.#store.findRequestByUserCode(userCode) : null;
|
|
767
|
-
if (row?.kind !== 'device' || row.status !== 'pending' || row.expiresAt <= this.#now()) {
|
|
768
|
-
return null;
|
|
769
|
-
}
|
|
770
|
-
const granted = input.allow
|
|
771
|
-
? parseScope(row.requestedScope).filter((s) => input.scopes.includes(s))
|
|
772
|
-
: [];
|
|
773
|
-
if (granted.length === 0) {
|
|
774
|
-
const ok = await this.#store.transitionRequest(row.id, 'pending', {
|
|
775
|
-
status: 'denied',
|
|
776
|
-
accountId: input.accountId,
|
|
777
|
-
});
|
|
778
|
-
return ok ? { approved: null, clientId: row.clientId } : null;
|
|
779
|
-
}
|
|
780
|
-
const ok = await this.#store.transitionRequest(row.id, 'pending', {
|
|
781
|
-
status: 'approved',
|
|
782
|
-
accountId: input.accountId,
|
|
783
|
-
grantedScope: formatScope(granted),
|
|
784
|
-
});
|
|
785
|
-
return ok ? { approved: granted, clientId: row.clientId } : null;
|
|
786
|
-
}
|
|
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);
|
|
792
|
-
const mediated = this.cfg.signIn.mediated;
|
|
793
|
-
if (!ctx)
|
|
794
|
-
throw new Error('authkit: Mediated Sign-In precisa do HttpContext.');
|
|
795
|
-
const scopes = this.#signInScopes(form.scope, 'mediated');
|
|
796
|
-
const credentials = {};
|
|
797
|
-
for (const field of mediated.fields) {
|
|
798
|
-
const value = str(form[field.name]);
|
|
799
|
-
if (!value)
|
|
800
|
-
throw new PoppyError('invalid_request', `${field.name} is required`);
|
|
801
|
-
credentials[field.name] = value;
|
|
802
|
-
}
|
|
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)}`);
|
|
809
|
-
const result = await mediated.verify({
|
|
810
|
-
credentials,
|
|
811
|
-
scopes,
|
|
812
|
-
clientId: client.clientId,
|
|
813
|
-
ctx,
|
|
814
|
-
});
|
|
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');
|
|
824
|
-
}
|
|
825
|
-
// `accountId: null` = sem conta elegível (anti-enumeração): a resposta é a mesma de uma conta
|
|
826
|
-
// real e nenhum código vale.
|
|
827
|
-
const decoy = result.accountId === null;
|
|
828
|
-
if (!decoy && result.code === undefined && !mediated.verifyCode) {
|
|
829
|
-
throw new Error('authkit: personalAgents.poppy.signIn.mediated — `verify` devolveu code_required sem `code` e não há `verifyCode`.');
|
|
830
|
-
}
|
|
831
|
-
const now = this.#now();
|
|
832
|
-
const expiresAt = new Date(now.getTime() + mediated.codeTtl * 1000);
|
|
833
|
-
const id = randomId('sgn_');
|
|
834
|
-
await this.#store.insertRequest({
|
|
835
|
-
id,
|
|
836
|
-
kind: 'mediated',
|
|
837
|
-
codeHash: null,
|
|
838
|
-
userCode: null,
|
|
839
|
-
clientId: client.clientId,
|
|
840
|
-
sessionId: null,
|
|
841
|
-
accountId: result.accountId,
|
|
842
|
-
requestedScope: formatScope(scopes),
|
|
843
|
-
redirectUri: null,
|
|
844
|
-
codeChallenge: null,
|
|
845
|
-
status: 'pending',
|
|
846
|
-
intervalSeconds: null,
|
|
847
|
-
data: {
|
|
848
|
-
sentTo: result.sentTo,
|
|
849
|
-
...(!decoy && result.code !== undefined
|
|
850
|
-
? { codeHash: sha256(`${id}:${result.code}`) }
|
|
851
|
-
: {}),
|
|
852
|
-
...(result.state !== undefined ? { state: result.state } : {}),
|
|
853
|
-
},
|
|
854
|
-
expiresAt,
|
|
855
|
-
createdAt: now,
|
|
856
|
-
});
|
|
857
|
-
throw this.#codeRequired(id, result.sentTo, mediated.codeTtl);
|
|
858
|
-
}
|
|
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) {
|
|
865
|
-
const mediated = this.cfg.signIn.mediated;
|
|
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;
|
|
873
|
-
const now = this.#now();
|
|
874
|
-
if (row.expiresAt <= now)
|
|
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');
|
|
879
|
-
const data = row.data ?? {};
|
|
880
|
-
let correct;
|
|
881
|
-
if (row.accountId === null) {
|
|
882
|
-
// Identificador sem conta (anti-enumeração): todo código é um código errado.
|
|
883
|
-
correct = false;
|
|
884
|
-
}
|
|
885
|
-
else if (typeof data.codeHash === 'string') {
|
|
886
|
-
correct = sha256(`${row.id}:${code}`) === data.codeHash;
|
|
887
|
-
}
|
|
888
|
-
else {
|
|
889
|
-
correct =
|
|
890
|
-
(await mediated.verifyCode?.({
|
|
891
|
-
code,
|
|
892
|
-
accountId: row.accountId,
|
|
893
|
-
state: data.state,
|
|
894
|
-
clientId: row.clientId,
|
|
895
|
-
})) === true;
|
|
896
|
-
}
|
|
897
|
-
if (!correct) {
|
|
898
|
-
const attempts = row.attempts + 1;
|
|
899
|
-
if (attempts >= mediated.maxCodeAttempts) {
|
|
900
|
-
await this.#store.transitionRequest(row.id, 'pending', { status: 'failed', attempts });
|
|
901
|
-
throw invalid;
|
|
902
|
-
}
|
|
903
|
-
if (!(await this.#store.bumpAttempts(row.id, row.attempts)))
|
|
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);
|
|
907
|
-
}
|
|
908
|
-
if (!(await this.#store.transitionRequest(row.id, 'pending', { status: 'consumed' }))) {
|
|
909
|
-
throw invalid;
|
|
910
|
-
}
|
|
911
|
-
const done = await this.#signIn(client.clientId, row.accountId, parseScope(row.requestedScope), binding, null);
|
|
912
|
-
return done.body;
|
|
913
|
-
}
|
|
914
|
-
// ─── Sessions e resource server (§4.5, §5.2–§5.4, §7) ─────────────────────
|
|
915
|
-
/**
|
|
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).
|
|
919
|
-
*/
|
|
920
|
-
async verifyAccess(input, options = {}) {
|
|
921
|
-
const target = this.#endpointFor(input.url, options.resource);
|
|
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(', ');
|
|
939
|
-
return {
|
|
940
|
-
ok: false,
|
|
941
|
-
status,
|
|
942
|
-
error,
|
|
943
|
-
description,
|
|
944
|
-
...(extra.scope ? { scope: extra.scope } : {}),
|
|
945
|
-
wwwAuthenticate,
|
|
946
|
-
headers: extra.headers ?? {},
|
|
947
|
-
};
|
|
948
|
-
};
|
|
949
|
-
const invalidSession = (description) => ({
|
|
950
|
-
ok: false,
|
|
951
|
-
status: 400,
|
|
952
|
-
error: 'invalid_session',
|
|
953
|
-
description,
|
|
954
|
-
wwwAuthenticate: null,
|
|
955
|
-
headers: {},
|
|
956
|
-
});
|
|
957
|
-
const auth = header(input.headers, 'authorization');
|
|
958
|
-
const authValue = Array.isArray(auth) ? undefined : auth;
|
|
959
|
-
const match = authValue
|
|
960
|
-
? /^(DPoP|Bearer)\s+([A-Za-z0-9\-._~+/]+=*)\s*$/i.exec(authValue)
|
|
961
|
-
: null;
|
|
962
|
-
if (auth !== undefined && !match) {
|
|
963
|
-
return challenge(401, 'invalid_token', 'Malformed Authorization header');
|
|
964
|
-
}
|
|
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'),
|
|
972
|
-
method: input.method,
|
|
973
|
-
url: input.url,
|
|
974
|
-
accessToken: token,
|
|
975
|
-
audience: this.urls.issuer,
|
|
976
|
-
maxAge: this.cfg.sessionJwt.maxAge,
|
|
977
|
-
resolveClient: (clientId) => this.registry.resolve(clientId),
|
|
978
|
-
now,
|
|
979
|
-
});
|
|
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],
|
|
1007
|
-
});
|
|
1008
|
-
}
|
|
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
|
-
});
|
|
1027
|
-
}
|
|
1028
|
-
}
|
|
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],
|
|
1046
|
-
});
|
|
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;
|
|
1055
|
-
}
|
|
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
|
-
}
|
|
1084
|
-
}
|
|
1085
|
-
const principal = {
|
|
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,
|
|
1095
|
-
};
|
|
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) } : {}),
|
|
1100
|
-
});
|
|
1101
|
-
}
|
|
1102
|
-
if (required.some((s) => !principal.scopes.includes(s))) {
|
|
1103
|
-
return challenge(403, 'insufficient_scope', 'The token lacks required scopes', {
|
|
1104
|
-
scope: formatScope(required),
|
|
1105
|
-
schemes: [principal.tokenType],
|
|
1106
|
-
});
|
|
1107
|
-
}
|
|
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'];
|
|
1115
|
-
}
|
|
1116
|
-
/** O endpoint pedido: MCP? exige `resource`? */
|
|
1117
|
-
#endpointFor(url, explicit) {
|
|
1118
|
-
if (this.#isMcp(url))
|
|
1119
|
-
return { mcp: this.#mcpUrl(url), resource: null };
|
|
1120
|
-
if (explicit !== undefined) {
|
|
1121
|
-
if (explicit && this.#isMcp(explicit))
|
|
1122
|
-
return { mcp: this.#mcpUrl(explicit), resource: null };
|
|
1123
|
-
return { mcp: null, resource: explicit };
|
|
1124
|
-
}
|
|
1125
|
-
const entries = [
|
|
1126
|
-
...this.cfg.apis
|
|
1127
|
-
.filter((a) => a.type !== 'mcp')
|
|
1128
|
-
.map((a) => ({ base: a.url, resource: a.resource })),
|
|
1129
|
-
...(this.cfg.agent?.protocols ?? []).map((p) => ({ base: p.endpoint, resource: p.resource })),
|
|
1130
|
-
];
|
|
1131
|
-
for (const entry of entries) {
|
|
1132
|
-
if (entry.resource && (underUrl(url, entry.base) || underUrl(url, entry.resource))) {
|
|
1133
|
-
return { mcp: null, resource: entry.resource };
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
return { mcp: null, resource: null };
|
|
1137
|
-
}
|
|
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
|
-
}
|
|
1164
|
-
/**
|
|
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`.
|
|
1167
|
-
*/
|
|
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;
|
|
1178
|
-
}
|
|
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 },
|
|
1187
|
-
});
|
|
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;
|
|
1202
|
-
}
|
|
1203
|
-
const moved = await this.#store.transitionRequest(row.id, row.status, { status: 'consumed' });
|
|
1204
|
-
if (!moved || row.status !== 'approved')
|
|
1205
|
-
return null;
|
|
1206
|
-
const now = this.#now();
|
|
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;
|
|
1219
|
-
return {
|
|
1220
|
-
sessionId: session.id,
|
|
1221
|
-
clientId: session.clientId,
|
|
1222
|
-
userId: session.userId,
|
|
1223
|
-
accountId: grantId ? row.accountId : null,
|
|
1224
|
-
grantId,
|
|
1225
|
-
scopes: grantId ? parseScope(row.grantedScope) : [],
|
|
1226
|
-
};
|
|
1227
|
-
}
|
|
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);
|
|
1231
|
-
if (!session)
|
|
1232
|
-
return null;
|
|
1233
|
-
const now = this.#now();
|
|
1234
|
-
const active = !session.endedAt && session.expiresAt > now;
|
|
1235
|
-
let signedIn = false;
|
|
1236
|
-
if (active && marker.grantId) {
|
|
1237
|
-
const grant = await this.#grants.findGrant(marker.grantId);
|
|
1238
|
-
signedIn = !!grant && (await this.#grantUsable(grant, session.clientId, now));
|
|
1239
|
-
}
|
|
1240
|
-
return {
|
|
1241
|
-
active,
|
|
1242
|
-
sessionId: session.id,
|
|
1243
|
-
clientId: session.clientId,
|
|
1244
|
-
userId: session.userId,
|
|
1245
|
-
accountId: signedIn ? (marker.accountId ?? null) : null,
|
|
1246
|
-
signedIn,
|
|
1247
|
-
scopes: signedIn ? (marker.scopes ?? []) : [],
|
|
1248
|
-
};
|
|
1249
|
-
}
|
|
1250
|
-
// ─── console de conta ─────────────────────────────────────────────────────
|
|
1251
|
-
async listGrants(accountId) {
|
|
1252
|
-
const now = this.#now();
|
|
1253
|
-
return (await this.#grants.listGrants(accountId))
|
|
1254
|
-
.filter((g) => g.id.startsWith('pgrant_') && g.expiresAt > now)
|
|
1255
|
-
.map((g) => ({
|
|
1256
|
-
id: g.id,
|
|
1257
|
-
clientId: g.clientId,
|
|
1258
|
-
scopes: this.describe(parseScope(g.scope)),
|
|
1259
|
-
createdAt: g.createdAt,
|
|
1260
|
-
updatedAt: g.updatedAt,
|
|
1261
|
-
expiresAt: g.expiresAt,
|
|
1262
|
-
}));
|
|
1263
|
-
}
|
|
1264
|
-
/** Desconectar o agente pela conta (§5.5): revoga um refresh token DA CONTA. */
|
|
1265
|
-
async revokeAccountGrant(accountId, grantId) {
|
|
1266
|
-
if (!grantId.startsWith('pgrant_'))
|
|
1267
|
-
return false;
|
|
1268
|
-
const grant = await this.#grants.findGrant(grantId);
|
|
1269
|
-
if (!grant || grant.accountId !== accountId || grant.revokedAt)
|
|
1270
|
-
return false;
|
|
1271
|
-
await this.revokeGrant(grantId);
|
|
1272
|
-
return true;
|
|
1273
|
-
}
|
|
1274
|
-
/** Revoga todos os refresh tokens da conta (sair de tudo, conta suspensa…). */
|
|
1275
|
-
async revokeAllAccountGrants(accountId) {
|
|
1276
|
-
const live = (await this.#grants.listGrants(accountId)).filter((g) => g.id.startsWith('pgrant_'));
|
|
1277
|
-
for (const g of live)
|
|
1278
|
-
await this.revokeGrant(g.id);
|
|
1279
|
-
return live.length;
|
|
1280
|
-
}
|
|
1281
|
-
/**
|
|
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
|
-
*/
|
|
1285
|
-
async disconnectClient(clientId) {
|
|
1286
|
-
const ids = (await this.#grants.unrevokedGrantIdsOfClient(clientId)).filter((id) => id.startsWith('pgrant_'));
|
|
1287
|
-
for (const id of ids)
|
|
1288
|
-
await this.revokeGrant(id);
|
|
1289
|
-
await this.#store.endSessionsOfClient(clientId, this.#now());
|
|
1290
|
-
return ids.length;
|
|
1291
|
-
}
|
|
1292
|
-
// ─── internos ─────────────────────────────────────────────────────────────
|
|
1293
|
-
/** Scopes de um pedido de sign-in: obrigatórios, conhecidos e do tipo (§4.7). */
|
|
1294
|
-
#signInScopes(raw, type) {
|
|
1295
|
-
const allowed = type === 'mediated' ? this.cfg.signIn.mediated?.scopes : this.cfg.signIn[type]?.scopes;
|
|
1296
|
-
const scopes = typeof raw === 'string' ? parseScope(raw) : [];
|
|
1297
|
-
if (scopes.length === 0)
|
|
1298
|
-
throw new PoppyError('invalid_scope', 'scope is required');
|
|
1299
|
-
if (!allowed || scopes.some((s) => !allowed.includes(s))) {
|
|
1300
|
-
throw new PoppyError('invalid_scope', `Unknown or unavailable scope for ${type} sign-in`);
|
|
1301
|
-
}
|
|
1302
|
-
return scopes;
|
|
1303
|
-
}
|
|
1304
|
-
#narrow(available, requested) {
|
|
1305
|
-
if (requested === null)
|
|
1306
|
-
return available;
|
|
1307
|
-
if (requested.some((s) => !available.includes(s))) {
|
|
1308
|
-
throw new PoppyError('invalid_scope', 'scope can only narrow the granted scopes');
|
|
1309
|
-
}
|
|
1310
|
-
return requested;
|
|
1311
|
-
}
|
|
1312
|
-
/**
|
|
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);
|
|
1355
|
-
}
|
|
1356
|
-
return session;
|
|
1357
|
-
}
|
|
1358
|
-
#sessionExpiry(now) {
|
|
1359
|
-
return new Date(now.getTime() + this.cfg.sessionTtl * 1000);
|
|
1360
|
-
}
|
|
1361
|
-
async #grantUsable(grant, clientId, now) {
|
|
1362
|
-
return (grant.id.startsWith('pgrant_') &&
|
|
1363
|
-
grant.revokedAt === null &&
|
|
1364
|
-
grant.expiresAt > now &&
|
|
1365
|
-
grant.clientId === clientId &&
|
|
1366
|
-
(await this.#isAccountActive(grant.accountId)));
|
|
1367
|
-
}
|
|
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) {
|
|
1370
|
-
if (!(await this.#isAccountActive(accountId))) {
|
|
1371
|
-
throw new PoppyError('invalid_grant', 'The account cannot sign in');
|
|
1372
|
-
}
|
|
1373
|
-
const scopes = this.#narrow(granted, requestedScope);
|
|
1374
|
-
const now = this.#now();
|
|
1375
|
-
const grantId = `pgrant_${randomUUID()}`;
|
|
1376
|
-
const expiresAt = new Date(now.getTime() + this.cfg.refreshTokenTtl * 1000);
|
|
1377
|
-
const grant = {
|
|
1378
|
-
id: grantId,
|
|
1379
|
-
accountId,
|
|
1380
|
-
clientId,
|
|
1381
|
-
// O authorization server não vê User IDs (§5.3).
|
|
1382
|
-
agentSub: '',
|
|
1383
|
-
scope: formatScope(granted),
|
|
1384
|
-
expiresAt,
|
|
1385
|
-
revokedAt: null,
|
|
1386
|
-
createdAt: now,
|
|
1387
|
-
updatedAt: now,
|
|
1388
|
-
};
|
|
1389
|
-
await this.#grants.insertGrant(grant);
|
|
1390
|
-
const refreshToken = randomId('poa_', 32);
|
|
1391
|
-
await this.#grants.insertRefreshToken({
|
|
1392
|
-
tokenHash: sha256(refreshToken),
|
|
1393
|
-
grantId,
|
|
1394
|
-
expiresAt,
|
|
1395
|
-
createdAt: now,
|
|
1396
|
-
});
|
|
1397
|
-
const body = await this.#issue(grant, scopes, binding);
|
|
1398
|
-
return { body: { ...body, refresh_token: refreshToken }, grantId };
|
|
1399
|
-
}
|
|
1400
|
-
/** Emite um access token do grant (§4.5). */
|
|
1401
|
-
async #issue(grant, scopes, binding) {
|
|
1402
|
-
const now = this.#now();
|
|
1403
|
-
const ttl = this.cfg.accessTokenTtl;
|
|
1404
|
-
const scope = formatScope(scopes);
|
|
1405
|
-
let token = null;
|
|
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);
|
|
1421
|
-
await this.#store.insertToken({
|
|
1422
|
-
tokenHash: sha256(token),
|
|
1423
|
-
clientId: grant.clientId,
|
|
1424
|
-
accountId: grant.accountId,
|
|
1425
|
-
grantId: grant.id,
|
|
1426
|
-
scope,
|
|
1427
|
-
resource: binding.resource,
|
|
1428
|
-
jkt: binding.jkt,
|
|
1429
|
-
expiresAt: new Date(now.getTime() + ttl * 1000),
|
|
1430
|
-
createdAt: now,
|
|
1431
|
-
});
|
|
1432
|
-
return {
|
|
1433
|
-
access_token: token,
|
|
1434
|
-
token_type: binding.jkt ? 'DPoP' : 'Bearer',
|
|
1435
|
-
expires_in: ttl,
|
|
1436
|
-
scope,
|
|
1437
|
-
};
|
|
1438
|
-
}
|
|
1439
|
-
async #limit(action, key) {
|
|
1440
|
-
if (!this.#limiter)
|
|
1441
|
-
return;
|
|
1442
|
-
const retryAfter = await this.#limiter({ action, key });
|
|
1443
|
-
if (retryAfter !== null) {
|
|
1444
|
-
throw new PoppyError('too_many_requests', 'Too many requests for this client_id', {
|
|
1445
|
-
headers: { 'Retry-After': String(Math.max(1, retryAfter)) },
|
|
1446
|
-
});
|
|
1447
|
-
}
|
|
1448
|
-
}
|
|
1449
|
-
/** Limpeza preguiçosa: no máximo uma vez por minuto. */
|
|
1450
|
-
async #housekeeping() {
|
|
1451
|
-
const now = this.#now();
|
|
1452
|
-
if (now.getTime() - this.#housekeepingAt < 60_000)
|
|
1453
|
-
return;
|
|
1454
|
-
this.#housekeepingAt = now.getTime();
|
|
1455
|
-
await this.#store.deleteRequestsExpiredBefore(new Date(now.getTime() - 3600_000));
|
|
1456
|
-
await this.#store.deleteTokensExpiredBefore(now);
|
|
1457
|
-
}
|
|
1458
|
-
}
|