UNPKG

@adonis-agora/agent

Version:

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

279 lines • 12.7 kB
import { scopeRole } from '../a2a/gate.js'; import { PERSONAL_AGENT_ROLE } from '../a2a/permission-tool.js'; import { globalPoppyAuthenticate } from '../poppy/auth.js'; /** * A typed refusal from an MCP auth strategy: the token was recognized and deliberately refused (a * client that is not allowed on this endpoint, an account that left the tenant, a revoked grant). * * Throw it from `verify` — or from the `toActor` of `authKitAuth()` / `apiKeyAuth()` — and the * provider answers with its `status` and message, plus the matching `WWW-Authenticate` challenge: * `401` → `error="invalid_token"`, `403` → `error="insufficient_scope"`. * * Inside {@link anyOf} it is also a stop signal: a strategy that throws `McpAuthError` has claimed * the token, so the remaining strategies are not tried. Any other error means "not my token" and * the next strategy gets its turn. * * ```ts * authKitAuth({ * toActor: ({ accountId, clientId }) => { * if (clientId === 'web') throw new McpAuthError('token was not issued for MCP') * return { id: accountId } * }, * }) * ``` */ export class McpAuthError extends Error { /** HTTP status of the refusal: `401` (the token does not authenticate) or `403` (it does, but may not do this). */ status; /** * The `WWW-Authenticate` error code, when not the status' default (`invalid_token` for 401, * `insufficient_scope` for 403) — e.g. Poppy's `sign_in_required` (§4.3). */ error; constructor(message, options = {}) { super(message, options.cause !== undefined ? { cause: options.cause } : undefined); this.name = 'McpAuthError'; this.status = options.status ?? 401; if (options.error !== undefined) this.error = options.error; } } /** * Resolve a `McpAuth` config value (a ready instance or a lazy factory) at provider boot. */ export async function resolveMcpAuth(auth, ctx) { if (typeof auth === 'function') { return auth(ctx); } return auth; } /** The `gty` authkit-server gives the access tokens it mints for a Poppy session (§6, MCP). */ export const POPPY_GRANT_TYPE = 'poppy'; /** * Authenticate MCP clients with `@adonis-agora/authkit-server`: verifies the bearer token against the * authkit OIDC provider's stored access tokens (`provider.AccessToken.find`) and advertises OAuth * metadata derived from the issuer so MCP clients can run the full OAuth login flow. * * The authkit peer is resolved lazily — the `verify`/`oauth` accessors touch the container only when * a request actually arrives (by then the app is fully booted and the authkit keystore/encryption * services are ready). `@adonis-agora/authkit-server` stays an optional dependency. Throws a clear * error at first use if the binding isn't resolvable (authkit not installed / provider not registered). */ export function authKitAuth(options = {}) { const scopes = options.scopes ?? ['openid', 'profile', 'email', 'offline_access', 'roles']; const toActor = options.toActor ?? (({ accountId }) => ({ id: accountId })); return async ({ app }) => { let servicePromise; const getService = () => { servicePromise ??= resolveAuthKitService(app); return servicePromise; }; const oauth = async () => { const service = await getService(); const issuer = service.config.issuer; return { issuer, authorizationEndpoint: `${issuer}/auth`, tokenEndpoint: `${issuer}/token`, scopesSupported: scopes, resourceName: options.resourceName ?? 'Agent MCP Server', }; }; return { oauth, async verify(token, context) { const service = await getService(); const at = await service.provider.AccessToken.find(token); if (!at) { // A signed-out Poppy Session's MCP token lives only in authkit's Poppy store: it is valid, // but this server needs an account — Poppy's `sign_in_required` (§4.3), not `invalid_token` // (which would only make the agent mint the same kind of token again). if (context !== undefined && (await isSignedOutPoppyToken(token, context.resource))) { throw new McpAuthError('sign in to use this server', { status: 403, error: 'sign_in_required', }); } // Not an access token this issuer knows — plain `Error`, so `anyOf` tries the next strategy. throw new Error('invalid or expired access token'); } if (at.isExpired) { throw new McpAuthError('invalid or expired access token'); } if ((options.audience ?? 'resource') === 'resource' && context !== undefined && !audienceNames(at.aud, context.resource)) { throw new McpAuthError('token was not issued for this MCP server'); } let grant; if (at.grantId !== undefined && service.provider.Grant !== undefined) { grant = await service.provider.Grant.find(at.grantId); if (grant === undefined) { // The user revoked the authorization; the token must not outlive it. throw new McpAuthError('authorization revoked'); } } const tokenScopes = (at.scope ?? '').split(' ').filter(Boolean); const personalAgent = at.gty === POPPY_GRANT_TYPE; const resolved = await toActor({ accountId: at.accountId, scopes: tokenScopes, clientId: at.clientId, grantId: at.grantId, grant, activeOrg: readActiveOrg(grant?.activeOrg), extra: at.extra, personalAgent, }); // A personal agent's token carries the user's account but only the scopes they granted: the // roles below put it behind `personalAgentGate` (the MCP provider wraps its authorizer in it), // as a Poppy conversation or a PACT delegation does. const actor = personalAgent ? { ...resolved, roles: [ ...new Set([ ...(resolved.roles ?? []), PERSONAL_AGENT_ROLE, ...tokenScopes.map(scopeRole), ]), ], } : resolved; return { token, clientId: at.clientId ?? '', scopes: tokenScopes, ...(at.exp !== undefined ? { expiresAt: at.exp } : {}), extra: { actor }, }; }, }; }; } /** A Bearer token authkit issued for `resource` to a Poppy Session that is not signed in. */ async function isSignedOutPoppyToken(token, resource) { const authenticate = globalPoppyAuthenticate(); if (authenticate === undefined) return false; try { const result = await authenticate({ method: 'POST', url: resource, headers: { authorization: `Bearer ${token}` }, }); return result.ok && !result.principal.signedIn; } catch { return false; } } /** Whether a token's `aud` names `resource` (a trailing slash on either side is tolerated). */ function audienceNames(aud, resource) { const trim = (value) => value.replace(/\/+$/, ''); const audiences = aud === undefined ? [] : Array.isArray(aud) ? aud : [aud]; return audiences.some((value) => trim(value) === trim(resource)); } /** Narrow the grant's stored `activeOrg` to {@link AuthKitActiveOrg}; anything without an `orgId` string is no org. */ function readActiveOrg(value) { if (typeof value !== 'object' || value === null) return undefined; const candidate = value; if (typeof candidate.orgId !== 'string' || candidate.orgId === '') return undefined; return { orgId: candidate.orgId, ...(typeof candidate.orgSlug === 'string' ? { orgSlug: candidate.orgSlug } : {}), ...(typeof candidate.orgRole === 'string' ? { orgRole: candidate.orgRole } : {}), }; } /** * Resolve the authkit `OidcService` from the container. `@adonis-agora/authkit-server` augments * `ContainerBindings` with `'authkit.server'`, so `make` is fully typed with no cast. */ async function resolveAuthKitService(app) { try { return await app.container.make('authkit.server'); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`[@adonis-agora/agent] \`authKitAuth()\` could not resolve the authkit server (${message}). Is \`@adonis-agora/authkit-server\` installed and its provider registered in adonisrc.ts?`); } } /** * Authenticate MCP clients with a static list of API keys (no OAuth metadata). Intended for trusted * gateway / machine-to-machine integrations where the host proxies auth and this server just * checks the key. Keys are compared with a constant-time compare to resist timing attacks. */ export function apiKeyAuth(options) { const keys = new Set(options.apiKeys); const toActor = options.toActor ?? ((apiKey) => ({ id: apiKey })); return async () => { return { async verify(token) { if (![...keys].some((key) => timingSafeEqual(key, token))) { throw new Error('invalid API key'); } const actor = await toActor(token); return { token, clientId: token, scopes: [], extra: { actor } }; }, }; }; } /** Constant-time string comparison (no `node:crypto` import at module top to keep the browser-safe build). */ function timingSafeEqual(a, b) { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) { diff |= a.charCodeAt(i) ^ b.charCodeAt(i); } return diff === 0; } // ── anyOf ─────────────────────────────────────────────────────────────────── /** * Accept more than one kind of bearer token on the same MCP endpoint — e.g. OAuth access tokens from * MCP clients that logged in through the browser AND personal access tokens for machine integrations: * * ```ts * auth: anyOf( * authKitAuth({ toActor }), * { verify: verifyPersonalAccessToken }, * ) * ``` * * Strategies are tried in order and the first that verifies the token wins. A strategy that throws a * plain `Error` passes ("not my token"); one that throws {@link McpAuthError} has claimed the token and * refused it, so the chain stops there with that refusal. When every strategy passes, the last error * is rethrown. * * OAuth metadata comes from the first strategy that exposes it, so the RFC 9728 document and the * `WWW-Authenticate` challenge still point MCP clients at the login flow. */ export function anyOf(...strategies) { if (strategies.length === 0) { throw new Error('[@adonis-agora/agent] `anyOf()` needs at least one MCP auth strategy'); } return async (ctx) => { const resolved = await Promise.all(strategies.map((strategy) => resolveMcpAuth(strategy, ctx))); const oauth = resolved.find((strategy) => strategy.oauth !== undefined)?.oauth; return { ...(oauth !== undefined ? { oauth } : {}), async verify(token, context) { let lastError; for (const strategy of resolved) { try { return await strategy.verify(token, context); } catch (error) { if (error instanceof McpAuthError) throw error; lastError = error; } } throw lastError; }, }; }; } //# sourceMappingURL=auth.js.map