Authentication
Secure your API with built-in authentication providers.
Changed in 0.3.0
createJWTAuthProvider now verifies the signature (it used to read the claims without checking it), createAgentServer throws when auth.enabled is true without a provider, generated tokens are 256-bit random values, and listing needs a user. See Upgrading to 0.3.0.
Which one to use
| You have | Use |
|---|---|
| a handful of service keys | createTokenAuthProvider({ tokens }) |
| API keys in your own database | createTokenAuthProvider({ validateFn }) |
| tokens signed with a key you hold (HS*, RS*, ES*) | createJWTAuthProvider |
| an identity provider with rotating keys (JWKS), or your own session | an AuthProvider on a JWT library, or resolveContext (cookies, bearer tokens you verify yourself) |
Token-Based Authentication
Simple API key authentication for internal services.
import { createTokenAuthProvider } from '@cognipeer/agent-server';
const authProvider = createTokenAuthProvider({
// Static token mapping: token -> userId
tokens: {
'api-key-1': 'user-1',
'api-key-2': 'user-2',
[process.env.ADMIN_API_KEY]: 'admin',
},
});
const agentServer = createAgentServer({
// ...
auth: {
enabled: true,
provider: authProvider,
headerName: 'Authorization', // Default
tokenPrefix: 'Bearer ', // Default
excludeRoutes: ['/docs', '/docs/*'],
},
});When validateFn is set it is asked first and decides alone: the static tokens are not consulted. To accept both, look the static map up inside your validateFn.
Dynamic Token Validation
const authProvider = createTokenAuthProvider({
// Custom validation function
validateFn: async (token) => {
const user = await database.findUserByApiKey(token);
if (user) {
return {
valid: true,
userId: user.id,
};
}
return {
valid: false,
error: 'Invalid API key',
};
},
});JWT Authentication
import { createJWTAuthProvider } from '@cognipeer/agent-server';
const authProvider = createJWTAuthProvider({
secret: process.env.JWT_SECRET!,
algorithm: 'HS256', // Default; HS256/384/512 (secret), RS256/384/512 and ES256/384/512 (the PEM public key as the secret)
issuer: 'my-app', // Optional - check the issuer
audience: 'my-api', // Optional - check the audience (a string or a list; the token's `aud` may be either)
extractUserId: (payload) => payload.sub as string, // Default: sub, then userId, then user_id (a non-empty string)
});The provider verifies the signature with the key you configure, then checks exp, nbf, iss and aud. The key type comes from the configuration and never from the token: a token that says alg: "none", names any algorithm other than the configured one, or is signed with another key is refused. A secret is required (creating the provider without one throws). A token without exp is accepted unless you set requireExp: true; clockToleranceSec allows for clock drift.
No JWKS
The built-in provider has one static key: it does not fetch keys, so it cannot follow a key rotation. For an identity provider that publishes a JWKS, verify the token in front of the server (a gateway, an identity-aware proxy), or write an AuthProvider that verifies it with a JWT library such as jose:
import { createRemoteJWKSet, jwtVerify } from 'jose';
const keys = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'));
const authProvider: AuthProvider = {
async validateToken(token) {
try {
const { payload } = await jwtVerify(token, keys, { issuer: 'https://auth.example.com', audience: 'my-api' });
return { valid: true, userId: String(payload.sub), metadata: payload };
} catch {
return { valid: false, error: 'Invalid token' };
}
},
};Configuration Options
Auth Configuration
interface AuthConfig {
// Enable authentication
enabled: boolean;
// Auth provider instance. Required when `enabled` is true: createAgentServer throws without one,
// so a missing provider cannot leave the server open. To name the user some other way, set
// `enabled: false` and use `resolveUserId` (or `resolveContext`).
provider?: AuthProvider;
// Header name for token (default: 'Authorization')
headerName?: string;
// Token prefix to strip (default: 'Bearer ')
tokenPrefix?: string;
// Routes that don't require authentication
excludeRoutes?: string[];
// Custom userId resolver (when not using token auth)
resolveUserId?: (ctx: { headers?: Record<string, string | string[] | undefined>; query: Record<string, string>; body?: unknown; context?: RequestContext }) => Promise<string | undefined>;
}Exclude Routes
auth: {
enabled: true,
provider: authProvider,
excludeRoutes: [
'/docs', // Exact match
'/docs/*', // Wildcard - matches /docs/openapi.json
'/agents', // Public agent list
'/health', // Health check
],
}Client Usage
Token Auth
# Using cURL
curl -H "Authorization: Bearer your-api-key" \
http://localhost:3000/api/agents/conversations
# Using fetch
fetch('/api/agents/conversations', {
headers: {
'Authorization': 'Bearer your-api-key',
},
});JWT Auth
// Login to get JWT
const { token } = await login(username, password);
// Use token in requests
const response = await fetch('/api/agents/conversations', {
headers: {
'Authorization': `Bearer ${token}`,
},
});Custom Auth Provider
Create your own authentication provider:
interface AuthProvider {
validateToken(token: string): Promise<AuthResult>;
generateToken?(userId: string, metadata?: Record<string, unknown>): Promise<string>;
revokeToken?(token: string): Promise<void>;
}
interface AuthResult {
valid: boolean;
userId?: string;
metadata?: Record<string, unknown>;
error?: string;
}
class MyAuthProvider implements AuthProvider {
async validateToken(token: string): Promise<AuthResult> {
try {
// Validate with your auth service
const user = await myAuthService.validateToken(token);
if (user) {
return {
valid: true,
userId: user.id,
};
}
return {
valid: false,
error: 'Invalid token',
};
} catch (error) {
return {
valid: false,
error: 'Authentication failed',
};
}
}
}
const authProvider = new MyAuthProvider();User ID Resolution
From Token
The userId is automatically extracted from token validation:
const authProvider = createTokenAuthProvider({
tokens: {
'api-key-1': 'user-123', // 'user-123' becomes ctx.user.id
},
});From a header, the query or the body
For unauthenticated scenarios, use resolveUserId:
auth: {
enabled: false, // No token auth
resolveUserId: async (ctx) => {
// From a header (set by your gateway)
return ctx.headers?.['x-user-id'] as string | undefined;
// Or from the query: return ctx.query?.userId as string | undefined;
},
}Listing needs a user
GET /conversations and GET /tasks return one user's records, so a request that names no user is refused with 401 identity_required - unless the server sets allowAnonymousList: true (a single-user tool or demo). Token auth, resolveContext with a user, resolveUserId or - without any of those - a ?userId= query parameter all name a user. They are tried in that order and the first one wins: with token auth, a ?userId= or a userId in a create body does not change whose conversations are listed or created; it is only taken when nothing else says who is asking. See Request Context.
Several users
const agentServer = createAgentServer({
basePath: '/api/agents',
storage,
auth: {
enabled: true,
provider: authProvider,
},
});
// A conversation belongs to the user who created it:
// user A lists and opens only A's conversations, user B only B's (403 on someone else's)The built-in storage providers hold every user's data in one namespace; they separate users, not tenants. For tenants, run a server per tenant (own database or tablePrefix), or scope in your own storage provider using the request context (Request Context).
Security Best Practices
- Use HTTPS - always, in production. Send tokens in the
Authorizationheader, never in a URL. - Let the server decide who is asking. Prefer
resolveContextor token auth over auserIdthe client names: with either, a client-nameduserIdis ignored. - Short-lived tokens, rotated secrets. Use short expirations for JWTs (set
requireExp: trueso a token withoutexpis refused) and rotate secrets; the built-in JWT provider holds one static key and cannot follow a JWKS. - Validate issuer and audience - for JWTs, set
issuerandaudience. - Hide internal errors -
maskServerErrors: truewhenever the audience is not the operator. - Decide who may approve and who may raise a budget -
hitl.authorizeResolveandauthorizeBudget(Approvals, Budgets). - Rate limit - the server implements no limit of its own; use the
rateLimithook (Embedding & limits). - Restrict CORS to exact origins;
"*"is for public, token-less servers only. - Validate task callback URLs - the server POSTs to whatever
callbackUrla task names (Task System). - Protect or disable Swagger in production (Swagger UI).
- Log authentication failures - monitor them.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
401 AUTHENTICATION_ERROR "Authentication required" | No token in the header. The header is Authorization and the value Bearer <token> unless you set auth.headerName / auth.tokenPrefix. A custom header such as X-API-Key needs headerName: 'X-API-Key'; a value without the prefix is used as it is. |
401 "Invalid signature" / "Unsupported algorithm" | The token was signed with another key or algorithm than the provider is configured with. The algorithm is the configured one (default HS256), never the token's. |
401 "Token expired" / "Token not yet valid" | exp / nbf; allow for clock drift with clockToleranceSec. |
401 "No user ID in token" | The token has no non-empty sub, userId or user_id; set extractUserId. |
| Swagger UI is behind the login | Add /docs and /docs/* to auth.excludeRoutes (patterns are matched against the route path relative to basePath; * matches anything). |
createAgentServer throws about auth.provider | auth.enabled: true needs a provider. To name the user another way, set enabled: false and use resolveUserId or resolveContext. |
A token made by generateToken stops working after a restart | They are kept in memory only. Issue and store tokens yourself and validate them with validateFn. |
Error Responses
The body is always { "error": { "code", "message" } } (see Error Handling):
// 401 Unauthorized
{ "error": { "code": "AUTHENTICATION_ERROR", "message": "Authentication required" } }
// 401 Unauthorized (invalid token)
{ "error": { "code": "AUTHENTICATION_ERROR", "message": "Invalid token" } }
// 401 Unauthorized (a list that names no user)
{ "error": { "code": "identity_required", "message": "Listing needs to know who is asking: sign in. ..." } }
// 403 Forbidden (signed in, but it is someone else's)
{ "error": { "code": "FORBIDDEN", "message": "You do not have access to this conversation" } }
