@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
JavaScript
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