eve
Version:
Filesystem-first framework for durable backend AI agents that run anywhere.
417 lines (416 loc) • 18.4 kB
TypeScript
/**
* Route-level HTTP authentication primitives.
*
* Strategy helpers return an {@link AuthFn} for route factories. Verifier
* helpers are lower-level pure functions for custom `fetch` handlers.
*/
import type { SessionAuthContext } from "#channel/types.js";
import { type RuntimeIpAllowList } from "#runtime/governance/network/ip-allow-list.js";
/**
* Result returned by the verifier helpers below. On success, `sessionAuth`
* is a fully-constructed {@link SessionAuthContext}; on failure no detail is
* returned so routes do not leak which credential check failed.
*/
export type VerifyResult = {
readonly ok: true;
readonly sessionAuth: SessionAuthContext;
} | {
readonly ok: false;
};
/**
* Credentials accepted by {@link verifyHttpBasic}.
*/
export interface HttpBasicCredentials {
readonly username: string;
readonly password: string;
}
/**
* Verifies an HTTP Basic credential against the supplied username and
* password. Returns `{ ok: true, sessionAuth }` on success or `{ ok: false }`
* on a missing or mismatched credential. The password is compared with
* constant-time hash equality so a timing side channel cannot leak it; the
* username is compared directly.
*/
export declare function verifyHttpBasic(authorizationHeader: string | null, credentials: HttpBasicCredentials): VerifyResult;
/**
* Configuration for {@link verifyJwtHmac}. Optional fields have defaults so
* adapters can omit unused matchers. `secret` is read by the adapter from its
* secret store (e.g. `process.env`) and passed in directly.
*/
export interface VerifyJwtHmacConfig {
readonly algorithm: "HS256" | "HS384" | "HS512";
readonly audiences: readonly string[];
readonly issuer: string;
readonly secret: string;
/**
* Tolerance in seconds for the `exp` and `nbf` claims. Defaults to 30.
*/
readonly clockSkewSeconds?: number;
/**
* AWS IAM-style `*`-wildcard patterns matched against the token `sub`.
* When supplied, the token is rejected unless one matches.
*/
readonly subjects?: readonly string[];
/**
* Per-claim membership matcher: each named claim must contain at least one
* of the listed values, or the token is rejected.
*/
readonly claims?: Readonly<Record<string, readonly string[]>>;
}
/**
* Verifies a bearer JWT signed with an HMAC secret. Pass the token without
* the `Bearer ` prefix (see {@link extractBearerToken}). Returns
* `{ ok: true, sessionAuth }` on success or `{ ok: false }` when verification
* fails or the token's claims don't match the supplied matchers.
*/
export declare function verifyJwtHmac(token: string | null, config: VerifyJwtHmacConfig): Promise<VerifyResult>;
/**
* Configuration for {@link verifyJwtEcdsa}.
*/
export interface VerifyJwtEcdsaConfig {
readonly algorithm: "ES256" | "ES384" | "ES512";
readonly audiences: readonly string[];
readonly issuer: string;
/**
* PEM-encoded ECDSA public key (`-----BEGIN PUBLIC KEY-----` ...).
*/
readonly publicKey: string;
/**
* Tolerance in seconds for the `exp` and `nbf` claims. Defaults to 30.
*/
readonly clockSkewSeconds?: number;
/**
* AWS IAM-style `*`-wildcard patterns matched against the token `sub`.
* When supplied, the token is rejected unless one matches.
*/
readonly subjects?: readonly string[];
/**
* Per-claim membership matcher: each named claim must contain at least one
* of the listed values, or the token is rejected.
*/
readonly claims?: Readonly<Record<string, readonly string[]>>;
}
/**
* Verifies a bearer JWT signed with an ECDSA private key against the supplied
* PEM public key. Pass the token without the `Bearer ` prefix (see
* {@link extractBearerToken}). Returns `{ ok: true, sessionAuth }` on success
* or `{ ok: false }` when verification fails or the token's claims do not
* match the configured `subjects`/`claims`.
*/
export declare function verifyJwtEcdsa(token: string | null, config: VerifyJwtEcdsaConfig): Promise<VerifyResult>;
/**
* Configuration for {@link verifyOidc}.
*/
export interface VerifyOidcConfig {
readonly audiences: readonly string[];
readonly issuer: string;
/**
* OIDC discovery URL. Defaults to
* `${issuer}/.well-known/openid-configuration` (any trailing slash on
* `issuer` is stripped first).
*/
readonly discoveryUrl?: string;
/**
* Tolerance in seconds for the `exp` and `nbf` claims. Defaults to 30.
*/
readonly clockSkewSeconds?: number;
/**
* AWS IAM-style `*`-wildcard patterns matched against the token `sub`.
* When supplied, the token is rejected unless one matches.
*/
readonly subjects?: readonly string[];
/**
* Per-claim membership matcher: each named claim must contain at least one
* of the listed values, or the token is rejected.
*/
readonly claims?: Readonly<Record<string, readonly string[]>>;
}
/**
* Verifies a bearer OIDC token against an issuer's discovery URL. Pass the
* token without the `Bearer ` prefix (see {@link extractBearerToken}). The
* token must satisfy the configured `subjects`/`claims` matchers; on success
* the principal is tagged `principalType: "service"`. Unlike
* {@link verifyVercelOidc}, this never grants the Vercel current-project
* bypass. Returns `{ ok: false }` on failed verification or matchers.
*/
export declare function verifyOidc(token: string | null, config: VerifyOidcConfig): Promise<VerifyResult>;
/**
* Extracts the bearer token from an `Authorization: Bearer <token>` header.
* Returns `null` if the header is missing, the scheme isn't `Bearer`, or the
* value after `Bearer ` is empty.
*/
export declare function extractBearerToken(authorizationHeader: string | null): string | null;
/**
* Parsed IP allowlist for repeated checks. Construct once at module load,
* then call {@link isIpAllowed} per request.
*/
export type IpAllowList = RuntimeIpAllowList;
/**
* Parses exact IP addresses or CIDR blocks into a reusable
* {@link IpAllowList}. Entries are trimmed; throws on an empty entry, a `*`
* wildcard (use exact IP/CIDR syntax), or an invalid address or CIDR prefix.
*/
export declare function createIpAllowList(entries: readonly string[]): IpAllowList;
/**
* Returns whether `ip` is permitted by `allowList`. `null` always returns
* `false`. Adapters that need to allow unknown IPs should not call this.
*/
export declare function isIpAllowed(ip: string | null, allowList: IpAllowList): boolean;
/**
* One challenge entry attached to the `www-authenticate` header on a failure
* response. For a Basic+Bearer prompt, pass both:
* `[{ scheme: "Basic", parameters: { realm: "agent" } }, { scheme: "Bearer" }]`
*/
export interface UnauthorizedChallenge {
readonly scheme: "Basic" | "Bearer";
readonly parameters?: Readonly<Record<string, string>>;
}
/**
* Options accepted by {@link createUnauthorizedResponse}.
*/
export interface UnauthorizedResponseOptions {
/**
* HTTP status code. Defaults to `401`. Use `403` for "authenticated but
* not allowed" outcomes.
*/
readonly status?: 401 | 403;
/**
* Machine-readable error code returned in the JSON body. Defaults to
* `"unauthorized"` (401) or `"forbidden"` (403).
*/
readonly code?: string;
/**
* Human-readable error message returned in the JSON body. Defaults to a
* generic prompt.
*/
readonly message?: string;
/**
* Optional `www-authenticate` challenge entries.
*/
readonly challenges?: readonly UnauthorizedChallenge[];
}
/**
* Builds a JSON failure response shaped like the framework's other auth
* failures: `cache-control: no-store`, one `www-authenticate` header per
* challenge, and a `{ ok: false, code, error }` body.
*/
export declare function createUnauthorizedResponse(opts?: UnauthorizedResponseOptions): Response;
/**
* Options accepted by auth error classes. The class chooses the HTTP status.
*/
export type AuthErrorOptions = Omit<UnauthorizedResponseOptions, "status">;
/**
* Error thrown by auth callbacks to reject a route with a structured 401
* response. `routeAuth` catches it and returns its response; other errors
* propagate through the normal channel failure path.
*/
export declare class UnauthenticatedError extends Error {
readonly response: Response;
constructor(opts?: AuthErrorOptions);
}
/**
* Error thrown by auth callbacks to reject a route with a structured 403
* response. `routeAuth` catches it and returns its response; other errors
* propagate through the normal channel failure path.
*/
export declare class ForbiddenError extends Error {
readonly response: Response;
constructor(opts?: AuthErrorOptions);
}
/**
* Route auth callback. Returned value semantics inside {@link routeAuth}:
*
* - A {@link SessionAuthContext} accepts the request and halts the walk.
* - `null` or `undefined` skips to the next entry.
*
* If every entry skips (including the empty `[]` case), the walker returns a
* 401. To reject with a specific response, throw an
* {@link UnauthenticatedError} or {@link ForbiddenError}. To accept anonymous
* traffic, include {@link none} as the final entry.
*/
export type AuthFn<TEvent = Request> = (event: TEvent) => SessionAuthContext | null | undefined | Promise<SessionAuthContext | null | undefined>;
/**
* Walks an `AuthFn` (or array) in order against `request`. The first entry
* returning a {@link SessionAuthContext} wins; entries returning `null` or
* `undefined` are skipped. If the walk exhausts without a winner (including
* the empty-array case), returns a 401 {@link createUnauthorizedResponse}.
*
* Channel factories that share this resolution policy (e.g. `eveChannel`, or
* a custom `defineChannel` route handler) should call `routeAuth` rather than
* re-implement the walk.
*/
export declare function routeAuth(request: Request, auth: AuthFn<Request> | readonly AuthFn<Request>[]): Promise<SessionAuthContext | Response>;
/**
* Returns an {@link AuthFn} for scaffolded apps that makes unfinished
* production auth fail as an intentional 401 rather than an internal route
* error. Replace it before serving real users:
*
* ```ts
* eveChannel({ auth: [localDev(), vercelOidc(), placeholderAuth()] });
* ```
*
* Outside production it returns `null`, so the auth walk keeps the same local
* development behavior as any other skipped entry.
*/
export declare function placeholderAuth(): AuthFn<Request>;
/**
* Returns an {@link AuthFn} that accepts any request anonymously, producing
* a synthetic principal with `principalType: "anonymous"`. Use it as the
* final entry in an `auth` array to opt routes into unauthenticated access:
*
* ```ts
* eveChannel({ auth: [none()] }); // every request accepted anonymously
* ```
*
* The returned `SessionAuthContext` halts the {@link routeAuth} walk, so
* `none()` terminates whatever array it appears in. It ignores its event
* argument, so `TEvent` is inferred from the surrounding array (defaulting
* to `unknown`) and composes with `AuthFn<Request>` entries without a type
* argument.
*/
export declare function none<TEvent = unknown>(): AuthFn<TEvent>;
/**
* Returns an {@link AuthFn} that authenticates requests during local
* development, keyed on the request URL's hostname (not the host process).
* A hostname is treated as loopback when it is `localhost` or any
* `*.localhost` subdomain (RFC 6761 routes the `.localhost` TLD to
* loopback), any IPv4 in `127.0.0.0/8`, or the IPv6 loopback `::1`.
*
* Matching requests get a synthetic principal with `principalType:
* "local-dev"`. Every other request returns `null`, skipping to the next
* entry under {@link routeAuth}, which makes `[localDev(), vercelOidc()]`
* the canonical "open on localhost, Vercel OIDC in prod" pattern.
*
* The check is not based on bare `process.env.VERCEL`: a deployment
* outside Vercel (Fly, Railway, raw container) leaves `VERCEL` unset and
* would then accept every public request. The one process-level exception
* is `vercel dev`, detected by `VERCEL=1` and `VERCEL_ENV=development`
* together. Only the local `vercel dev` server sets that pair (preview and
* production report `VERCEL_ENV=preview`/`production`), so it opens the
* dev server (which may serve over a non-loopback host) without opening a
* real deployment.
*
* Caveat: this assumes a sane edge in front of public origins. An origin
* that trusts an attacker-controlled `Host` header (no CDN, no normalizing
* reverse proxy) lets an attacker spoof `Host: localhost` and reach
* `localDev()`. Layer a real authenticator on such deployments.
*/
export declare function localDev(): AuthFn<Request>;
/**
* Options for {@link verifyVercelOidc} and {@link vercelOidc}.
*/
export interface VerifyVercelOidcOptions {
/**
* Optional `sub` patterns granting callers access on top of the always-on
* current-project bypass. Patterns use AWS IAM-style `*` wildcards and may
* target tokens minted by other Vercel projects (e.g.
* `"owner:acme:project:partner-agent:environment:*"`). The current project
* is always accepted regardless of this list.
*/
readonly subjects?: readonly string[];
}
/**
* Verifies a bearer JWT minted by Vercel OIDC.
*
* Acceptance rule:
*
* - Tokens whose `project_id` matches `VERCEL_PROJECT_ID` are **always**
* accepted regardless of `subjects`, so the deployment's own runtime
* callers (subagent, internal fetches) authenticate without being
* enumerated.
* - Tokens with an `external_sub` claim authenticate as
* `principalType: "user"` when they match the current `VERCEL_PROJECT_ID`
* (if set) and `VERCEL_TARGET_ENV` / `VERCEL_ENV` (if set). `external_sub`
* becomes the eve subject, `external_iss` or `connector_id` the eve issuer
* when present, and string-valued OIDC profile claims (`name`, `picture`,
* `email`) are exposed as auth attributes.
* - Tokens from other Vercel projects are accepted **only** when their `sub`
* matches one of {@link VerifyVercelOidcOptions.subjects}.
*
* The `environment` claim is not constrained: production, preview, and
* development tokens for the current project all authenticate. Non-user
* principals are tagged `"runtime"` when the token's `environment` matches
* the current deployment, otherwise `"service"`.
*/
export declare function verifyVercelOidc(token: string | null, opts?: VerifyVercelOidcOptions): Promise<VerifyResult>;
/**
* Allowed values for {@link VercelSubjectInput.environment}. Use `"*"`
* to match any of `production`, `preview`, and `development` for the
* named project.
*/
export type VercelSubjectEnvironment = "production" | "preview" | "development" | "*";
/**
* Strict input shape accepted by {@link vercelSubject}.
*
* The `sub` claim carries human-readable slugs/names, not IDs. Stable
* project and team IDs (`prj_...`, `team_...`) live on the separate
* `project_id` / `owner_id` claims, which `verifyVercelOidc` matches
* against `VERCEL_PROJECT_ID` for the current-project bind. This helper
* composes a `sub` matcher, so its inputs are slugs.
*/
export interface VercelSubjectInput {
/**
* Vercel team slug (a.k.a. "owner"; e.g. `"acme"`) as embedded in the
* `sub` claim, **not** the stable team ID `team_...` (`VERCEL_TEAM_ID`).
*
* Must not contain `*` or `:` so authors cannot widen the matcher to
* "any team with a project of this name". Hand-write the subject string
* when cross-team federation is intentional.
*/
readonly teamSlug: string;
/**
* Vercel project name (e.g. `"acme_website"`) as embedded in the `sub`
* claim, **not** the stable project ID `prj_...` (`VERCEL_PROJECT_ID`).
* Same constraints as {@link teamSlug}.
*/
readonly projectName: string;
/**
* Vercel deployment environment, or `"*"` to match any environment for
* the named project. Defaults to `"production"` so an unspecified
* environment cannot silently accept preview/development tokens.
*/
readonly environment?: VercelSubjectEnvironment;
}
/**
* Builds a Vercel OIDC `sub` matcher pattern from a typed input.
*
* Vercel-issued tokens carry a `sub` of the form
* `owner:[TEAM_SLUG]:project:[PROJECT_NAME]:environment:[ENVIRONMENT]`.
* Hand-writing it invites two foot-guns: a misspelled slug (silently
* rejecting all callers) and over-broad wildcards (silently accepting
* unrelated callers). This helper rejects malformed inputs at construction
* time and forces an explicit `environment`.
*
* Use it inside {@link VerifyVercelOidcOptions.subjects} to layer
* additional Vercel-project callers on top of the current-project bypass:
*
* ```ts
* vercelOidc({
* subjects: [vercelSubject({ teamSlug: "partner", projectName: "data" })],
* });
* ```
*
* The `sub` claim flips when a team or project name changes, so any policy
* built on this helper must update with it; the stable team/project IDs are
* not exposed in `sub`. See Vercel's OIDC reference for the token anatomy.
*/
export declare function vercelSubject(input: VercelSubjectInput): string;
/**
* Returns an HTTP route auth callback backed by Vercel OIDC. See
* {@link verifyVercelOidc} for the always-on current-project bypass and how
* `subjects` extends acceptance to other Vercel projects.
*/
export declare function vercelOidc(opts?: VerifyVercelOidcOptions): AuthFn<Request>;
/** Returns an {@link AuthFn} that verifies HTTP Basic credentials via {@link verifyHttpBasic}. */
export declare function httpBasic(credentials: HttpBasicCredentials): AuthFn<Request>;
/** Returns an {@link AuthFn} that verifies an HMAC-signed bearer JWT via {@link verifyJwtHmac}. */
export declare function jwtHmac(config: VerifyJwtHmacConfig): AuthFn<Request>;
/** Returns an {@link AuthFn} that verifies an ECDSA-signed bearer JWT via {@link verifyJwtEcdsa}. */
export declare function jwtEcdsa(config: VerifyJwtEcdsaConfig): AuthFn<Request>;
/**
* Returns an {@link AuthFn} that verifies an OIDC bearer token on the inbound
* request via {@link verifyOidc}. Use {@link vercelOidc} instead for
* Vercel-issued tokens: it preconfigures the issuer, audience, and runtime
* principal flag.
*/
export declare function oidc(config: VerifyOidcConfig): AuthFn<Request>;