Authenticating users
Introduction
Section titled “Introduction”The kit never logs anyone in. Your app already knows who its users are, so the gateway asks an IdentityResolver who is calling, once per socket connection and once per request. Tenant isolation, roles and ownership of conversations and attachments all follow from the answer.
type Identity = { subject: string; tenant: string; roles: readonly string[] };Without any configuration, every caller is { subject: 'local', tenant: 'local', roles: ['owner'] }. That is fine for local development and not for anything shared.
Pointing at your session endpoint
Section titled “Pointing at your session endpoint”If your app has an endpoint that answers “who am I?” for a bearer token, set one variable and write no code:
AUTH_IDENTITY_URL=https://api.example.com/meAUTH_IDENTITY_TIMEOUT_MS=3000The server forwards the caller’s Authorization header to that URL with a GET. The JSON body must already be an identity (subject, tenant, roles). 401 and 403 mean the caller is not identified. The timeout defaults to 5000 ms.
Mapping your own response
Section titled “Mapping your own response”Most endpoints do not return that exact shape. Create the resolver in code and add map:
import { createHttpIdentityResolver } from '@pilotoo/agent-runtime';
export const identityResolver = createHttpIdentityResolver({ url: `${process.env.API_URL}/auth/me`, timeoutMs: 3000, map: (body) => { const me = body as { user?: { id: string; permissions: string[] }; tenantId?: string }; if (!me.user || !me.tenantId) return undefined; return { subject: me.user.id, tenant: me.tenantId, roles: me.user.permissions.map((code) => `permission:${code}`), }; },});PilotooModule.forRoot({ env: process.env, identityResolver });The mapped value is validated either way. A body that does not map to a valid identity makes the lookup fail, so the caller is reported as unavailable rather than unidentified.
Caching the lookup
Section titled “Caching the lookup”The resolver calls your endpoint on every request and socket connection. If that is too much, wrap it. This cache keeps a successful answer for 60 seconds:
import { createHash } from 'node:crypto';import type { Credentials, Identity, IdentityResolver } from '@pilotoo/agent-runtime';
export function cached(inner: IdentityResolver, ttlMs = 60_000): IdentityResolver { const entries = new Map<string, { identity: Identity; expires: number }>(); return { async resolve(credentials: Credentials) { const key = createHash('sha256') .update(JSON.stringify([credentials.authorization, credentials.headers])) .digest('hex'); const hit = entries.get(key); if (hit && hit.expires > Date.now()) return hit.identity;
const identity = await inner.resolve(credentials); if (identity) entries.set(key, { identity, expires: Date.now() + ttlMs }); return identity; }, };}Pass cached(identityResolver) as identityResolver.
roles are your own vocabulary. Settings that gate a feature take comma-separated role names, and unset means nobody:
| Setting | Gates |
|---|---|
AGENT_MANAGER_ROLES |
Creating and editing agents |
USAGE_ADMIN_ROLES |
Reading everyone’s usage |
SCHEDULE_ROLES, SCHEDULE_ADMIN_ROLES |
Using and administering schedules |
MODEL_GATEWAY_ROLES |
Serving the model gateway |
The kit matches role names, nothing more. If your app has permissions rather than roles, map each one to a role name in map, as permission:<code> above, and list those names in the settings:
AGENT_MANAGER_ROLES=permission:agents.manageUSAGE_ADMIN_ROLES=permission:usage.readChoosing the tenant by header
Section titled “Choosing the tenant by header”Some identity services pick the tenant from a header, for example one token valid for several tenants. List such headers:
PilotooModule.forRoot({ identityHeaders: ['x-tenant-id'] });The REST guard and the socket handshake then copy exactly those headers into Credentials.headers (names lower-case), and the HTTP resolver sends them to the identity URL beside Authorization. None longer than 256 characters, none sent twice. With no list, nothing is copied.
Writing your own resolver
Section titled “Writing your own resolver”Implement resolve(credentials) for any other scheme:
import type { IdentityResolver } from '@pilotoo/agent-runtime';
const resolver: IdentityResolver = { async resolve({ authorization }) { const session = await sessions.find(authorization); return session ? { subject: session.userId, tenant: session.orgId, roles: session.roles } : undefined; },};Answering undefined means unidentified. A thrown error means the answer is unavailable. createFixedIdentityResolver(identity) serves a host without authentication.
Guarding your own routes
Section titled “Guarding your own routes”IdentityGuard is app-wide, so your own controllers are guarded too. Mark a public route with @Anonymous() and read the caller with @CallerIdentity(); both come from @pilotoo/server.
Options
Section titled “Options”| Resolver | Use it for |
|---|---|
createHttpIdentityResolver({ url, fetch?, map?, timeoutMs? }) |
Asking your own endpoint. |
createFixedIdentityResolver(identity) |
A host without authentication. |
Your own IdentityResolver |
Anything else. |
The HTTP resolver forwards the caller’s Authorization header with a GET, reads 401 and 403 as unidentified, and never puts the token in an error message. The same resolver serves REST requests and the websocket handshake. A scheduled run has no request, so the host supplies the credentials it runs with.
Next steps
Section titled “Next steps”- Send the token from the client: Connecting a client.
- Gate features with roles: Agents, Usage.