Skip to content

Auth and SSO

Every API request passes through one authentication adapter before any route runs. The adapter reads the request and returns who the caller is, or null for a 401. Long Tail ships a JWT adapter. Swap in your own with one method, or mount Long Tail inside a host app and let SSO exchange the host’s session for a Long Tail token.

interface LTAuthAdapter {
authenticate(req: any): Promise<AuthPayload | null>;
}
interface AuthPayload {
userId: string;
email?: string;
role?: string;
[key: string]: any;
}

authenticate receives the raw request. Return an AuthPayload to admit it, null to reject it. The index signature lets you carry extra claims (a tenant id, for example) without changing the interface.

index.ts
await start({
database: { connectionString: process.env.DATABASE_URL },
auth: { secret: process.env.JWT_SECRET },
workers: [/* ... */],
});

JwtAuthAdapter reads a Bearer token from Authorization and verifies it with the secret. With neither secret nor adapter, it reads JWT_SECRET from the environment. Use a random string of at least 32 characters.

signToken(payload, expiresIn = '24h') issues tokens for tests or your own login route:

import { signToken } from '@hotmeshio/long-tail';
const token = signToken({ userId: '42', email: 'ops@acme.dev' }, '8h');

The embedded server applies auth to every API route. For your own routes:

Export Does
createAuthMiddleware(adapter) Express middleware that calls the adapter, sets req.auth, or answers 401
requireAuth The server’s default: the configured adapter, else JwtAuthAdapter
requireAdmin Runs after auth. Passes an admin or superadmin role claim, or a database superadmin. Otherwise 403.

The built-in adapter recognizes keys that start with lt_bot_, checks them with bcrypt, and returns the account’s userId:

Authorization: Bearer lt_bot_a1b2c3d4e5f6...

From there the request is indistinguishable from a person’s: same middleware, same RBAC, same identity propagation. Activities see a principal with type: 'bot'. See Identity to create one.

When Long Tail is mounted inside a host app at a subpath, people sign in to the host. SSO tells Long Tail how to read that identity and provisions a matching user.

  1. The person signs in to the host (OIDC, SAML, cookies, anything).

  2. Host middleware validates the session and attaches the user to the request.

  3. The dashboard loads, sees SSO enabled in /api/settings, and calls POST /api/auth/sso. Host cookies ride along.

  4. Long Tail calls your resolve(req), provisions or updates the user in lt_users, and returns its own JWT.

  5. The dashboard uses that JWT for every later call. RBAC, claims and audit use the provisioned lt_users.id.

index.ts
await start({
database: { connectionString: process.env.DATABASE_URL },
server: { enabled: false }, // the host owns the HTTP server
auth: {
secret: process.env.JWT_SECRET,
sso: {
resolve: (req) => {
const user = (req as any).user; // set by the host's auth middleware
if (!user) return null;
return {
externalId: user.id,
displayName: user.displayName,
email: user.email,
roles: ['operator', 'reviewer'],
};
},
roleMap: { admin: 'superadmin', operator: 'station-operator' },
defaultRoleType: 'member',
logoutUrl: '/auth/logout',
},
},
});

resolve returns an SSOIdentity or null:

Field Maps to
externalId lt_users.external_id, the stable lookup key
displayName lt_users.display_name
email lt_users.email
roles lt_user_roles, through roleMap
metadata lt_users.metadata

On first contact Long Tail creates the user. Later exchanges sync new roles. Roles resolve once per exchange and are baked into the JWT, so a role change in the host takes effect on the next token refresh.

With roleMap, only mapped roles are assigned. Without it, host role names pass through as Long Tail role names. Roles named superadmin or admin get that role type. Everything else is member with full-queue scope (read_all/write_all). Set narrower scopes through the Roles API or the dashboard’s scope picker.

POST /api/auth/sso is public: it needs no Bearer token.

{
"token": "<jwt>",
"user": {
"id": "lt-uuid",
"external_id": "host-user-id",
"display_name": "Jane Doe",
"roles": [{ "role": "operator", "type": "member" }]
}
}

On the exchange, resolve also receives the response: resolve(req, res). A host that owns its session cookie can set or slide it there (headers only; Long Tail writes the status and body after resolve returns).

auth: {
sso: {
keepaliveSeconds: 300,
resolve: (req, res) => {
const session = validateHostCookie(req);
if (!session) return null;
res?.setHeader('Set-Cookie', mintHostCookie(session));
return { externalId: session.userId, roles: session.roles };
},
},
},

With keepaliveSeconds, the dashboard re-runs the exchange on an interval while the person is active and the tab is visible, so the host session slides as it would under host-app navigation. Pair a sliding TTL with an absolute max age.

With SSO configured, a request without a Bearer token falls back to sso.resolve(req), so host backend calls that forward cookies authenticate directly. The fallback never passes res, so background traffic never slides the host session. The fallback does not apply at /mcp, which authenticates only with the Bearer credential it carries.

Without auth.sso, the login page, OAuth providers, service accounts and JWT auth work as before. SSO is additive.

Variable Required Use
JWT_SECRET Yes, for the JWT adapter Signs and verifies tokens
LT_OAUTH_ISSUER No Public base URL of the OAuth server MCP clients sign in through. Same as auth.oauthServer.issuer. See MCP.