Serve, observe and deploy
Authentication and API keys
Who may call Mayura's server: tokens from identity providers verified with their published keys, and API keys issued and checked on your own storage, each mapped explicitly to what the caller may do.
Mayura's server asks your authenticate callback who each request's bearer token belongs to and what it may do (see
Server and client). mayura/auth and mayura/keys give you that callback for the common
kinds of caller:
- people and services signed in with an identity provider, whose tokens are JWTs (
mayura/auth); - users who sign in to your own application with better-auth (
mayura/auth/better-auth); - programs holding an API key you issued (
mayura/keys), kept in any Mayura store.
They run on every runtime Mayura supports, with no dependency. None grants anything by default: a valid token proves who someone is, and your mapping decides what they may do here.
import { chainAuthenticators, jwtAuthenticator, jwtVerifier, mapCapabilities, principalId, remoteJwks } from 'mayura/auth';
import { createKeyManager, keyAuthenticator } from 'mayura/keys';
import type { AggregateStore } from 'mayura/storage-contracts';
declare const store: AggregateStore; // any Mayura store, initialized
const people = jwtAuthenticator({
verifier: jwtVerifier({
issuer: 'https://auth.example.com',
audience: 'https://agents.example.com',
algorithms: ['RS256'],
keys: remoteJwks({ url: 'https://auth.example.com/.well-known/jwks.json' }),
}),
identity: claims => typeof claims.sub === 'string' ? {
principalId: principalId('example', claims.sub),
projectId: 'acme',
agentIds: ['support'],
capabilities: mapCapabilities(claims['permissions'], { 'agents:use': ['runs:submit', 'runs:read'] }),
} : null,
});
const keys = createKeyManager({ store, prefix: 'acme' });
export const authenticate = chainAuthenticators(people, keyAuthenticator(keys));
// createAgentServer({ ..., authenticate })Tokens from an identity provider
jwtVerifier checks a JWT with WebCrypto alone: RS, PS and ES 256/384/512 and EdDSA (Ed25519), and HS 256/384/512
only with hmacSecret.
| Option | Notes |
|---|---|
issuer |
The iss tokens must name, or a list. Compared exactly. |
audience |
The aud a token must include one of. false only for issuers whose tokens carry none; check what stands for it in your mapping. |
algorithms |
The algorithms the issuer signs with. Nothing else is accepted, and never none. |
keys |
remoteJwks({ url }), staticKeys(jwks) or hmacSecret(secret). |
types |
Token types accepted (typ), such as ['at+jwt']. Any by default. |
clockSkewMs |
Allowed clock difference for exp, nbf and iat. 5 s by default, at most 60 s. |
maxLifetimeMs |
Refuse tokens meant to last longer (exp - iat). |
maxTokenBytes |
16 KiB by default. |
A token passes only when its signature is the issuer's, its algorithm is listed, its iss, aud and times hold, and it
has an exp. verify answers { ok: true, header, claims } or { ok: false, reason }, where reason is one of
malformed, too_large, algorithm, type, key, signature, issuer, audience, expired, not_yet_valid and
lifetime. It throws only when keys cannot be reached, and the server then answers that authentication is unavailable,
never that the caller is someone.
remoteJwks fetches the issuer's keys and keeps them for the response's Cache-Control: max-age, capped by maxAgeMs
(10 minutes by default). A token signed by a key it has not seen refetches them, at most every minRefreshMs (30 s), so
rotated keys are found and made-up key ids cannot make it fetch on every request. One fetch serves every request
waiting for it, bounded by timeoutMs (5 s) and maxBytes (256 KiB). While the JWKS cannot be reached, keys past
their age stay in use for one more maxAgeMs, then verification fails. Redirects are not followed, and only https
URLs are accepted (http only on this machine). A key is never taken for an algorithm it does not fit: RSA keys under
2048 bits, keys for encryption, keys for another algorithm, and symmetric keys in a JWKS are all refused. keyFilter (on remoteJwks and staticKeys) narrows the keys a token
may use by its claims, for example to keys bound to its issuer; it can only refuse keys.
jwtAuthenticator({ verifier, identity }) is the server callback. It only looks at tokens naming its issuer (so it
never fetches keys for anyone else's tokens), and turns what identity returns into the server's identity:
principalIdandprojectIdmust be ids the server accepts.principalId(namespace, subject)makes one from any subject:example/user_123, or for a subject with other characters (such as Auth0'sauth0|123) a stable SHA-256 form,example/_<base64url>.capabilitiesare the server's permissions.mapCapabilities(values, table)maps a provider's permissions or roles through a table you write; anything not in it grants nothing.- The identity lasts no longer than the token, and no longer than
maxIdentityMs(60 s by default), after which the token is checked again. - A mapping that returns something the server cannot use is a configuration error, never a caller let in.
chainAuthenticators(...) combines several: each token goes to the first that accepts it (by its issuer or key
prefix), and a token none accepts is refused. serverIdentity(grant, { credentialExpiresAtMs }) checks and bounds an
identity for authenticators of your own.
Testing
mayura/auth/testing has testIssuer({ issuer, algorithm }): it makes keys, signs tokens with sign(claims), serves
its JWKS through fetch (give it to remoteJwks), and can rotate(). Use it to test your mapping with real signatures
and no network.
Identity provider packages
Each @mayurajs/auth-* package is a jwtAuthenticator already set up for one provider: its issuer, keys and
algorithms, the checks it advises, and its claims read into a session object your identity mapping receives. None
uses the provider's SDK, and none grants anything your mapping does not.
| Package | Provider | What identity receives |
|---|---|---|
@mayurajs/auth-clerk |
Clerk session tokens: RS256 from your Frontend API, azp checked against your origins, pending sessions refused; PEM key for no network |
user, session, active organization, role and permissions (decoded from version 2 feature bitmasks), impersonator |
@mayurajs/auth-auth0 |
Auth0 access tokens: RS256 from your tenant for your API (or HS256 with its signing secret) | subject, application, user or machine, scopes, permissions, organization |
@mayurajs/auth-neon |
Neon Auth (managed better-auth) JWTs: EdDSA, issuer and audience your auth URL's origin; banned users refused | user, email and whether verified, name, role |
@mayurajs/auth-supabase |
Supabase Auth access tokens: ES256 or RS256 from your project (legacy HS256 only with its secret); only the authenticated role, no anonymous users unless allowed, aal2 if required |
user, email, phone, role, assurance level, session, app and user metadata |
@mayurajs/auth-firebase |
Firebase Authentication ID tokens, checked as Firebase specifies with Google's keys; anonymous users and unlisted tenants refused | uid, email and whether verified, phone, name, sign-in provider, tenant, sign-in time, custom claims |
@mayurajs/auth-workos |
WorkOS AuthKit session tokens, and with connect Connect access tokens from your AuthKit domain; machine tokens only with allowMachines |
kind, user or machine, session, application, organization, role and roles, permissions, scopes |
@mayurajs/auth-google |
Google ID tokens for your client IDs, optionally one Workspace domain; and googleSignIn for better-auth's Google login |
account id, email and whether verified, Workspace domain, name, picture |
@mayurajs/auth-entra |
Microsoft Entra ID access tokens for your API from the tenants you list (or any), with Microsoft's keys, tenant-bound keys kept to their tenant; app-only tokens only with allowApps, v1.0 tokens only when asked |
object id, tenant, application, user or app, roles, scopes, name, username |
@mayurajs/auth-okta |
Okta access tokens from a custom authorization server for its audience (never the org authorization server's); machine tokens only with allowMachines, DPoP-bound tokens refused |
login, user id, client, user or machine, scopes, groups |
@mayurajs/auth-cognito |
Amazon Cognito user pool access tokens for your app clients (ID tokens instead with tokenUse: 'id', never both); machine tokens only with allowMachines |
user id, user name, client, user or machine, groups, scopes, email (ID tokens) |
@mayurajs/auth-facebook |
Facebook Login access tokens, checked with Facebook per request (opaque; cacheTtlMs to keep answers), and iOS Limited Login tokens with Facebook's keys; and facebookSignIn for better-auth |
kind, app-scoped user id, granted permissions, email and name (Limited Login), expiry |
Sign-in with better-auth
To have users sign in to your own application, with their accounts in your own database, use
better-auth (1.7). mayura/auth/better-auth connects your better-auth instance to
Mayura's server. It imports nothing from better-auth, so your instance, database and plugins are what run, on any
runtime better-auth runs on.
import { DatabaseSync } from 'node:sqlite';
import { betterAuth } from 'better-auth';
import { bearer, jwt, organization } from 'better-auth/plugins';
import { apiKey } from '@better-auth/api-key';
import { createAgentServer } from 'mayura/server';
import { chainAuthenticators, mapCapabilities } from 'mayura/auth';
import { betterAuthApiKeyAuthenticator, betterAuthAuthenticator, betterAuthPermissions, withBetterAuth } from 'mayura/auth/better-auth';
const auth = betterAuth({
database: new DatabaseSync('auth.sqlite'), // or a pg Pool, a mysql2 pool, mongodbAdapter(db), ...
emailAndPassword: { enabled: true },
plugins: [bearer(), jwt(), organization(), apiKey({ defaultPrefix: 'acme_' })],
});
const authenticate = chainAuthenticators(
betterAuthAuthenticator(auth, {
identity: ({ user, session }) => session.activeOrganizationId ? {
principalId: `user/${user.id}`,
projectId: session.activeOrganizationId,
agentIds: ['support'],
capabilities: ['runs:submit', 'runs:read'],
} : null,
}),
betterAuthApiKeyAuthenticator(auth, {
prefix: 'acme_',
identity: key => ({
principalId: `user/${key.referenceId}`,
projectId: 'acme',
agentIds: ['support'],
capabilities: mapCapabilities(betterAuthPermissions(key.permissions), { 'runs:submit': ['runs:submit'], 'runs:read': ['runs:read'] }),
}),
}),
);
declare const options: Omit<Parameters<typeof createAgentServer>[0], 'authenticate'>;
const server = createAgentServer({ ...options, authenticate });
export const fetchHandler = withBetterAuth(auth, request => server.fetch(request));- Sessions.
betterAuthAuthenticatortakes better-auth session tokens as bearer tokens (itsbearerplugin gives clients one in theset-auth-tokenheader). Each request asks better-auth for the session, so signing out or revoking a session stops it at once, and an identity lasts no longer than the session.identitydecides what the user may do, for example from their role in the active organization. - API keys.
betterAuthApiKeyAuthenticatorchecks keys from better-auth's apiKey plugin, which applies each key's rate limit, remaining uses and expiry. Give the plugin adefaultPrefix, and the sameprefixhere, so only those tokens are checked.betterAuthPermissionsturns better-auth's{ runs: ['read'] }intoruns:readformapCapabilities. Mayura's own keys (mayura/keys, below) do the same on any Mayura store without better-auth. - JWTs.
betterAuthJwtVerifier({ baseURL })checks tokens from better-auth'sjwtplugin against its JWKS, with no call to better-auth per request; give it tojwtAuthenticator. - One handler.
withBetterAuth(auth, next)sends requests under/api/auth(better-auth's own path) to better-auth: sign-up, sign-in, sessions, organizations, keys and JWKS. Everything else goes tonext, such as Mayura's server. - What better-auth returns is checked when it arrives: a session or key in a shape Mayura does not know is refused, and an error from better-auth (such as its database being down) makes the server answer that authentication is unavailable.
- With pnpm, if better-auth's types refuse its own apiKey plugin, two copies of
@better-auth/corewere installed:@better-auth/api-key1.7.7 asks for@better-auth/utils0.4.2, which better-auth uses, while pnpm may install a newer one for it. Adding@better-auth/utils@0.4.2to your dependencies leaves one copy.
API keys
createKeyManager({ store, prefix }) issues and checks API keys in any Mayura store: Postgres, SQLite, libSQL, MySQL,
MongoDB, D1 or DynamoDB, or memoryAggregateStore() from mayura/keys/testing while trying it out.
import { createKeyManager } from 'mayura/keys';
import type { AggregateStore } from 'mayura/storage-contracts';
declare const store: AggregateStore;
const keys = createKeyManager({ store, prefix: 'acme' });
const { key, record } = await keys.create({
projectId: 'acme',
ownerId: 'service/billing',
agentIds: ['invoices'],
capabilities: ['runs:submit', 'runs:read'],
name: 'billing worker',
expiresInMs: 90 * 24 * 60 * 60_000,
rateLimit: { limit: 60, windowMs: 60_000 },
credits: { remaining: 10_000, refill: { amount: 10_000, intervalMs: 30 * 24 * 60 * 60_000 } },
});
console.log(key); // shown once: acme_<43 random characters><6-character checksum>
console.log(record.display); // acme_4f…9Xk2
await keys.revoke(record.keyId);- The secret is shown once. A key is
<prefix>_<random><checksum>, 256 random bits by default, with a CRC-32 checksum as GitHub's tokens have, so mistyped and made-up keys are refused before any storage read. Only the key's SHA-256 is stored, as base64 (the form Unkey also accepts, should you move keys there). Records hold an id (key_...) and a display form, never the secret. - Each key carries its grant: the project, the owner (its principal), agents and capabilities. With
create(input, { grantor: identity }), a key holds no more than whoever makes it. - Limits.
expiresInMs(and the manager'smaxExpiresInMs), a fixed-windowrateLimit, andcreditsspent per verification (cost, 1 by default) thatrefillsets back to its amount each interval. Credits are spent only after every other check passes, exactly, even when requests race; a rate-limited request spends none. - Lifecycle.
update,disableandenable,revoke(for good), androtate(keyId, { overlapMs }): a new key with the same grant and credits, while the old one keeps working for the overlap (up tomaxRotationOverlapMs, 7 days), or stops at once.get,list({ projectId, ownerId })andaudit(keyId)(every change, never the secret). An owner holds at mostmaxKeysPerOwnerkeys (100) that still work. - Revocation is immediate. Each verification reads storage.
cacheTtlMs(up to 60 s) keeps keys read for that long instead, and a revoked key then works for that long more in each process. - Where a store's writes conflict, the manager retries a few times, then fails rather than guess. When a key was
last used is written at most once per
lastUsedIntervalMs(5 minutes) per process, and never fails a request. namespacekeeps several key sets apart in one store, such asliveandtest.
keyAuthenticator(keys, { cost, maxIdentityMs }) is the server callback: it accepts tokens with the manager's prefix,
spends cost credits, and makes the key's grant the identity, for no longer than the key or maxIdentityMs. It works
with any KeyVerifier, such as a key service's.
Keys issued by a key service are checked with these packages, each a KeyVerifier for keyAuthenticator. Each sends
only tokens with your keys' prefix to the service, never caches its answers (so a key revoked there is refused at
once), spends the request's cost there, and grants only what your identity callback returns from what the service
knows about the key. When the service cannot answer, the server answers that authentication is unavailable.
| Package | Service | What identity receives |
|---|---|---|
@mayurajs/km-unkey |
Unkey keys.verifyKey, limited to your keyspaces, with an optional permission query checked before rate limits and credits; self-hosted Unkey too |
key id, keyspace, name, metadata, permissions, roles, linked identity, expiry, credits left |
@mayurajs/km-reqkey |
ReqKey validation for your API, with the key's details read alongside | key id, consumer, allowed APIs, tag, metadata, expiry, consumer's credits |
mayura/keys/testing has keyManagerConformance, the manager's behaviour that depends on its store, which Mayura runs
over every store it ships; run it over a store of your own the same way.