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.
The adapter contract
Section titled “The adapter contract”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.
Configure it
Section titled “Configure it”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.
import type { LTAuthAdapter, AuthPayload } from '@hotmeshio/long-tail';import { OAuth2Client } from 'google-auth-library';
const google = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);
export class GoogleAuthAdapter implements LTAuthAdapter { async authenticate(req): Promise<AuthPayload | null> { const header = req.headers.authorization; if (!header?.startsWith('Bearer ')) return null; try { const ticket = await google.verifyIdToken({ idToken: header.slice(7), audience: process.env.GOOGLE_CLIENT_ID, }); const { sub, email } = ticket.getPayload()!; return { userId: sub, email, role: 'member' }; } catch { return null; } }}await start({ database: { connectionString: process.env.DATABASE_URL }, auth: { adapter: new GoogleAuthAdapter() },});auth.adapter replaces the JWT adapter entirely.
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');Middleware
Section titled “Middleware”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. |
Service account keys
Section titled “Service account keys”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.
SSO for embedded deployments
Section titled “SSO for embedded deployments”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.
-
The person signs in to the host (OIDC, SAML, cookies, anything).
-
Host middleware validates the session and attaches the user to the request.
-
The dashboard loads, sees SSO enabled in
/api/settings, and callsPOST /api/auth/sso. Host cookies ride along. -
Long Tail calls your
resolve(req), provisions or updates the user inlt_users, and returns its own JWT. -
The dashboard uses that JWT for every later call. RBAC, claims and audit use the provisioned
lt_users.id.
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 |
Just-in-time provisioning and roles
Section titled “Just-in-time provisioning and roles”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.
The exchange
Section titled “The exchange”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" }] }}Host sessions and keepalive
Section titled “Host sessions and keepalive”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.
The requireAuth fallback
Section titled “The requireAuth fallback”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.
Environment
Section titled “Environment”| 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. |
