@lousho/build-ai-agent/auth answers both. You
give a route an ordered list of auth entries (jwt(), oidc(), basic(),
apiToken(), anonymous() or your own function); the first entry that
accepts the request returns a principal, and that principal goes into the
run, where your model / instructions / tools functions and memory scopes
can read it.
The auth list
Entries run in order, and each one does one of three things:
When every entry skips, the request gets a
401. An empty list refuses every
request. An entry that throws anything else is a bug: the request gets a 500
with a generic body, and the error is logged with console.error (never sent).
The built-in helpers never throw for a bad credential: a wrong password, an
expired token or a token for another audience all skip, so the next entry gets
its chance (two jwt() entries for two issuers work), and a request nobody
accepts ends in the same 401.
A Principal is:
Helpers
All helpers are created once, at start-up, and check their options then: a helper that cannot work throwsLOUSHO_AUTH_CONFIG_INVALID
instead of refusing every request later.
jwt()
Accepts Authorization: Bearer <JWT> signed by the key you configure. Give
exactly one key source:
- The header’s
algmust be one ofalgorithms;noneis always refused, and so is a token whose header marks an extensioncrit. - The algorithm must fit the key: a public key is never used as an HMAC secret, whatever the token’s header says, and a key set never supplies an HMAC key.
expis required;nbfandiatare honored. All three allowclockToleranceSecof clock skew (default 60, at most 300).issmust equal one ofissuerexactly (no trailing-slash tolerance), when you setissuer.aud(a string or a list) must contain one ofaudience.audienceis required; passallowAnyAudience: trueto skip that check on purpose.- A key set is fetched with a 5-second timeout, cached for 10 minutes, and
refetched at most once every 30 seconds when a token names a
kidit does not know (a failed fetch is retried on the same 30-second bound). Its URL is configuration only: thejku,x5uandjwkheaders of a token are ignored.
{ id: sub, type: 'user', authenticator: 'jwt', issuer: iss, claims };
a token without sub skips.
oidc()
A jwt() whose keys come from an OpenID Connect provider. It fetches
<issuer>/.well-known/openid-configuration once (or discoveryUrl), requires
the document’s issuer to equal yours exactly, and uses its jwks_uri (which
must be https). Algorithms default to RS256 and ES256; HMAC is not allowed.
authenticator: 'oidc' and the provider’s iss as issuer.
basic()
HTTP Basic credentials. User names and passwords are NFC-normalized and
compared in constant time, and every configured user is compared on every
request, so an unknown user takes as long as a wrong password. The 401
carries WWW-Authenticate: Basic realm="<realm>", charset="UTF-8" so browsers
show their login prompt.
basic() over HTTPS: the password travels with every request.
apiToken() and anonymous()
apiToken(token, { id }) accepts Authorization: Bearer <token> (compared in
constant time) as the service principal { id: id ?? 'api-token', type: 'service', authenticator: 'api-token' }.
It is what a token string has always meant: createRouteHandler({ auth: '<token>' })
and LOUSHO_API_TOKEN are apiToken().
anonymous() accepts everyone as { id: 'anonymous', type: 'user', authenticator: 'anonymous' }:
put it last for routes that serve signed-in and anonymous callers, and read
principal.authenticator in the run.
Your own entry
Any function of theRequest is an entry. Declare the challenges it answers a
401 with, or it advertises Bearer:
401, 403 and WWW-Authenticate
- Nobody accepted the request (or an entry threw
AuthError(401)):401with{ "error": "Unauthorized" }and oneWWW-Authenticateheader per distinct challenge of the list, in order (Bearerforjwt,oidc,apiToken;Basic realm="..."forbasic). - An entry threw
AuthError(403):403with{ "error": "Forbidden" }. - An entry failed in another way:
500with{ "error": "Internal Server Error" }.
401. Every auth response carries
Cache-Control: no-store. Tokens and passwords are never logged.
Where it runs
oidc() and jwt({ jwksUrl }) need outbound fetch to the key set, which
every listed runtime has. In a hand-written Worker, call createRouteHandler
or routeAuth() yourself with any helper.
createRouteHandler
auth takes the list, one entry, a token string, or (as before) a function
that returns a boolean. true accepts with the principal
{ id: 'anonymous', type: 'user', authenticator: 'custom' }, false is a
401. Without auth the route is open, and in production
(NODE_ENV=production) it logs one warning. GET <basePath>/health is never
behind auth. The accepted principal reaches every route: POST <basePath>,
/chat, approvals and the useChat endpoint. See Next.js.
The node server and auth.ts
createDeployedServer(agent, { auth }) takes the same list (or one entry).
When LOUSHO_API_TOKEN is set, apiToken(LOUSHO_API_TOKEN) is appended after
your entries, so an operator’s token keeps working. Channels under
/channels/<name> are not behind the list: they verify their own requests.
In an agent directory, put the list in auth.ts (or
auth.js), next to agent.ts:
lousho build --target=node-server (or docker) bundles it, and the built
server uses it (resolveAgentDir() returns it as auth; the manifest says
auth: true). With an auth.ts, a token baked in with
adapter.scaffold(..., { auth: { token } }) is not used; LOUSHO_API_TOKEN
still is. The cloudflare-worker target serves spec files only and keeps the
LOUSHO_API_TOKEN token; see Deployment.
Reading the principal in the run
The principal is on the run context next tosessionId and metadata:
principal is separate from metadata on purpose: metadata is whatever the
caller sent, principal is what auth verified. A dynamic run that is resumed
from its checkpoint keeps the principal it started with. Slack and Discord
channels set the sender as the principal (authenticator: 'slack' /
'discord'). Tools, approval policies and paused static runs do not see the
principal yet.
Security notes
- Route auth does not check who owns a session. Any caller who passes auth
and knows a session id can read its transcript (
GET /chat/:id), continue it, and decide its pending approvals. Use session ids that cannot be guessed, derive them from the principal on your side, or check ownership in your own route before calling the handler. basic()only over HTTPS; a bearer token is a password too, so terminate TLS in front of any server that is not on localhost.- Keep
issuerandaudienceset forjwt(): a token minted for another of your services is otherwise accepted here.