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