UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

210 lines • 9.56 kB
import { randomBytes } from 'node:crypto'; import { OAuthError, PoppyError } from './errors.js'; import { readJson } from './http.js'; import { requireSecureUrl, scopeSet, sha256b64url, sleep } from './util.js'; /** The sign-in types this Company offers that can grant every requested scope (4.4). */ export function eligibleSignInTypes(company, scope) { const auth = company.document.auth; const wanted = [...scopeSet(scope)]; return ['direct', 'device', 'mediated'].filter((t) => { const cfg = auth[t]; return cfg && wanted.every((s) => cfg.scopes.includes(s)); }); } function assertScopeAllowed(company, type, scope) { const cfg = company.document.auth[type]; if (!cfg) throw new PoppyError('unsupported_sign_in', `${type} sign-in is not offered`); if (scopeSet(scope).size === 0) { throw new PoppyError('invalid_scope', 'sign-in requests MUST include scope (4.4)'); } const extra = [...scopeSet(scope)].filter((s) => !cfg.scopes.includes(s)); if (extra.length > 0) { throw new PoppyError('invalid_scope', `${type} sign-in can't grant ${extra.join(', ')} (offers: ${cfg.scopes.join(' ')})`); } } /** Step 1 of 4.5: PKCE (S256) + random `state`, and the authorization URL to open. */ export async function startDirectSignIn(company, scope) { assertScopeAllowed(company, 'direct', scope); const endpoint = company.metadata.authorization_endpoint; if (!endpoint) throw new PoppyError('unsupported_sign_in', 'no authorization_endpoint'); // Make sure there is a Session for the code exchange to sign in. await company.token(); const state = randomBytes(16).toString('base64url'); const codeVerifier = randomBytes(32).toString('base64url'); const redirectUri = company.agent.identity.redirectUri; const url = new URL(endpoint); url.searchParams.set('response_type', 'code'); url.searchParams.set('client_id', company.agent.identity.clientId); url.searchParams.set('redirect_uri', redirectUri); url.searchParams.set('scope', scope); url.searchParams.set('state', state); url.searchParams.set('code_challenge', sha256b64url(codeVerifier)); url.searchParams.set('code_challenge_method', 'S256'); return { url: url.toString(), state, codeVerifier, redirectUri, scope, issuer: company.issuer }; } /** * Steps 3–4 of 4.5: checks `state` and the RFC 9207 `iss` (exact match with the issuer we * started with), then exchanges the code with PKCE, DPoP and `session_id`. */ export async function completeDirectSignIn(company, pending, callback) { if (callback.get('state') !== pending.state) { throw new PoppyError('state_mismatch', 'Direct Sign-In callback state does not match'); } const iss = callback.get('iss'); if (iss !== pending.issuer) { throw new PoppyError('issuer_mismatch', `Direct Sign-In callback iss "${iss ?? '(missing)'}" != "${pending.issuer}" (RFC 9207)`); } const error = callback.get('error'); if (error) { throw new OAuthError(error, { description: callback.get('error_description') ?? undefined }); } const code = callback.get('code'); if (!code) throw new PoppyError('invalid_callback', 'callback has neither code nor error'); const resp = await withLiveSession(company, (sessionId) => company.tokens.authorizationCode({ code, redirectUri: pending.redirectUri, codeVerifier: pending.codeVerifier, sessionId, dpop: company.dpop, })); return company.applySignIn(resp, 'direct', pending.scope); } /** * Runs a sign-in exchange with the current Session. The stored Session may have ended at the * Company (expired, ended, the Company's data reset) — 4.2: on `invalid_session` the agent starts a * new Session and the exchange is retried once with it (the code is not consumed by the refusal). */ async function withLiveSession(company, exchange) { const sessionId = (await company.token()).sessionId; try { return await exchange(sessionId); } catch (e) { if (!(e instanceof OAuthError) || e.code !== 'invalid_session') throw e; company.agent.log('invalid_session during sign-in: starting a new Session'); return exchange((await company.token({ forceRenew: true })).sessionId); } } /** RFC 8628 device flow: start, show the code, poll every `interval`, +5s on `slow_down`. */ export async function deviceSignIn(company, scope, opts) { assertScopeAllowed(company, 'device', scope); const d = await company.tokens.deviceAuthorization(scope); opts.display({ userCode: d.user_code, verificationUri: d.verification_uri, verificationUriComplete: d.verification_uri_complete, expiresIn: d.expires_in, }); const wait = opts.sleep ?? sleep; let interval = typeof d.interval === 'number' && d.interval > 0 ? d.interval : 5; const deadline = Date.now() + d.expires_in * 1000; for (;;) { await wait(interval * 1000, opts.signal); if (Date.now() > deadline) throw new OAuthError('expired_token'); try { const resp = await withLiveSession(company, (sessionId) => company.tokens.deviceToken({ deviceCode: d.device_code, sessionId, dpop: company.dpop, })); return await company.applySignIn(resp, 'device', scope); } catch (e) { if (!(e instanceof OAuthError)) throw e; if (e.code === 'authorization_pending') { opts.onPoll?.('authorization_pending', interval); continue; } if (e.code === 'slow_down') { interval += 5; // RFC 8628 section 3.5 opts.onPoll?.('slow_down', interval); continue; } throw e; // access_denied, expired_token, anything else: stop asking. } } } /** * One device-code exchange (4.6), for a server that cannot hold a polling loop open (the user * comes back and says "approved"): `'pending'` while the User has not decided, the sign-in when * they approved. `access_denied` / `expired_token` throw. */ export async function pollDeviceSignInOnce(company, deviceCode, scope) { try { const resp = await withLiveSession(company, (sessionId) => company.tokens.deviceToken({ deviceCode, sessionId, dpop: company.dpop })); return await company.applySignIn(resp, 'device', scope); } catch (e) { if (e instanceof OAuthError && (e.code === 'authorization_pending' || e.code === 'slow_down')) { return 'pending'; } throw e; } } async function mediatedRequest(company, url, body, scope) { const res = await company.fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify(body), }); const json = await readJson(res); if (!json || typeof json.status !== 'string') { throw new PoppyError(json?.error ?? `http_${res.status}`, undefined, { status: res.status, description: 'mediated sign-in failed', }); } switch (json.status) { case 'complete': { const resp = json; if (String(resp.token_type).toLowerCase() !== 'dpop') { throw new OAuthError('invalid_response', { description: 'expected a DPoP Session Token' }); } return { status: 'complete', result: await company.applySignIn(resp, 'mediated', scope) }; } case 'code_required': if (!json.sign_in_id || !/^[A-Za-z0-9_-]{1,256}$/.test(json.sign_in_id)) { throw new PoppyError('invalid_response', 'code_required without a valid sign_in_id'); } return { status: 'code_required', signInId: json.sign_in_id, sentTo: json.code?.sent_to, expiresAt: json.expires_at, }; case 'failed': case 'expired': return { status: json.status }; default: throw new PoppyError('invalid_response', `unknown mediated status ${String(json.status)}`); } } /** * Mediated Sign-In (4.7). Credentials go ONLY to `auth.mediated.endpoint`, with the current * Session Token. Secret values are never logged or echoed by this module. */ export async function mediatedSignIn(company, scope, credentials) { assertScopeAllowed(company, 'mediated', scope); const mediated = company.document.auth.mediated; const endpoint = requireSecureUrl(mediated.endpoint, 'auth.mediated.endpoint', company.security); const missing = mediated.fields.filter((f) => typeof credentials[f.name] !== 'string'); if (missing.length > 0) { throw new PoppyError('missing_credentials', `missing: ${missing.map((f) => f.name).join(', ')}`); } const sent = {}; for (const f of mediated.fields) sent[f.name] = credentials[f.name] ?? ''; return mediatedRequest(company, endpoint.toString(), { scope, credentials: sent }, scope); } /** Submits only the one-time code, to `{endpoint}/{sign_in_id}` (4.7). */ export function submitMediatedCode(company, scope, signInId, code) { const base = company.document.auth.mediated.endpoint.replace(/\/+$/, ''); return mediatedRequest(company, `${base}/${encodeURIComponent(signInId)}`, { code }, scope); } //# sourceMappingURL=signin.js.map