Skip to content

OAuth and delegation

A tool often needs to act as the person who started the workflow: read their calendar, call an API with their token. Long Tail keeps each person’s OAuth tokens encrypted, hands tools a short-lived delegation token instead of the credential, and lets an external MCP server trade that token for exactly the access it was scoped for. This page covers OAuth sign-in, token storage, delegation tokens and service tokens.

Person signs in with OAuth ─▶ JWT issued, provider tokens stored encrypted
Workflow starts ───────────▶ envelope carries userId
Activity calls an MCP tool ▶ delegation token minted (scoped, 5-minute TTL), passed as _auth.token
External server ───────────▶ validates the token, asks the delegation API for a fresh provider token
─▶ calls the external API as that person

Providers: Google (OpenID Connect with PKCE), GitHub (authorization code), Microsoft (Entra ID with PKCE) and a Mock provider for local testing.

.env
OAUTH_ENCRYPTION_KEY=<64 hex characters> # 32 bytes, required for OAuth
OAUTH_GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
OAUTH_GOOGLE_CLIENT_SECRET=your-client-secret
OAUTH_GITHUB_CLIENT_ID=your-github-client-id
OAUTH_GITHUB_CLIENT_SECRET=your-github-client-secret
OAUTH_MICROSOFT_CLIENT_ID=your-azure-app-id
OAUTH_MICROSOFT_CLIENT_SECRET=your-azure-secret
OAUTH_MICROSOFT_TENANT_ID=common

Generate the key:

Terminal window
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

A provider appears on the login page only when it is configured. Password login always works.

  1. The login page reads GET /api/auth/oauth/providers.

  2. “Sign in with Google” goes to GET /api/auth/oauth/google, which sets CSRF state and a PKCE verifier and redirects to the provider.

  3. The provider returns to GET /api/auth/oauth/google/callback. Long Tail exchanges the code and reads the user’s email and name.

  4. An existing user (matched by email or provider id) signs in. A new one is created when autoProvision is on.

  5. Provider tokens are encrypted with AES-256-GCM into lt_oauth_tokens, one set per user per provider, refreshed automatically when the provider supports it.

  6. A Long Tail JWT is issued, the same as a password login.

People manage their connections on the Credentials page (/credentials). In server code, getFreshAccessToken(userId, provider) and listOAuthConnections(userId) read them.

The authenticated user’s id rides the envelope into every activity. getToolContext() returns it wherever the code runs, as a proxy activity, an MCP tool or a YAML workflow worker:

import { getToolContext } from '@hotmeshio/long-tail';
const ctx = getToolContext();
if (ctx) {
ctx.principal.id; // user or bot id
ctx.principal.type; // 'user' | 'bot'
ctx.principal.roles; // ['reviewer', 'engineer']
ctx.credentials.delegationToken; // scoped JWT
}

A cron-triggered workflow has no user. Activities that need one should check and fail clearly.

A delegation token is a JWT signed with JWT_SECRET that names one user, a set of scopes and a short lifetime:

{
"type": "delegation",
"sub": "user-123",
"scopes": ["oauth:google:read", "files:read"],
"workflowId": "wf-abc",
"serverId": "ext-server",
"iss": "long-tail",
"iat": 1711461600,
"exp": 1711461900
}

When an activity calls an MCP tool, Long Tail reads the user from context, mints a token with the mcp:tool:call scope, and passes _auth: { userId, token } in the tool’s arguments. A tool that needs no auth ignores _auth. The MCP client derives _auth from the ambient context, so YAML workflow workers get it with no wiring.

In server code, createDelegationToken(userId, scopes, ttlSeconds, metadata) mints one (default 300 seconds, max 3600), and validateDelegationToken with requireScope checks one. Both live in services/auth/delegation.

A delegation token
Reaches Only the resources its scopes name
Lives 5 minutes by default, 1 hour at most
Refreshes Never. Mint a new one.
Authenticates as Its sub only
Reaches the admin API Never

External MCP servers call these endpoints:

GET /api/delegation/oauth/:provider/token
Authorization: Bearer <delegation-token>

Returns a fresh provider access token for the token’s user. Requires the scope oauth:<provider>:read:

{
"access_token": "ya29.a0AfH6...",
"expires_at": "2024-03-26T18:00:00.000Z",
"scopes": ["openid", "email", "profile"],
"provider": "google"
}
POST /api/delegation/validate
Authorization: Bearer <service-token>
Content-Type: application/json
{ "token": "<delegation-token>" }

Returns the token’s claims, so an external server can verify a token without holding JWT_SECRET:

{
"valid": true,
"userId": "user-123",
"scopes": ["oauth:google:read"],
"workflowId": "wf-abc",
"expiresAt": "2024-03-26T17:05:00.000Z"
}

An external MCP server authenticates to the delegation API with a service token: a long-lived key prefixed lt_svc_, stored as a bcrypt hash. Issue one in server code with database access:

const { id, rawToken } = await generateServiceToken(
'ext-calendar-server', // name
serverId, // lt_mcp_servers id, or null
['delegation:validate'], // scopes
);

The raw token is returned once. listServiceTokens(serverId) lists a server’s tokens without hashes, and revokeServiceToken(id) deletes one.

  1. Register the server with Long Tail as a remote MCP server.

  2. Give it a service token (the example reads LT_SERVICE_TOKEN).

  3. Accept tool calls with _auth.token in the arguments.

  4. Validate the delegation token with POST /api/delegation/validate.

  5. Fetch the user’s provider token with GET /api/delegation/oauth/:provider/token.

  6. Call the external API and return the result.

The repo’s examples/external-mcp-server/ is a working server, and docker-compose.external.yml runs it beside the main stack:

Terminal window
docker compose -f docker-compose.yml -f docker-compose.external.yml up -d --build

The mock provider implements the authorization code flow and signs in a preconfigured test user.

Terminal window
docker compose --profile test up -d --build
.env
OAUTH_ENCRYPTION_KEY=<64 hex characters>
OAUTH_MOCK_CLIENT_ID=test-client
OAUTH_MOCK_CLIENT_SECRET=test-secret
MOCK_OAUTH_AUTH_URL=http://mock-oauth:9080/authorize
MOCK_OAUTH_TOKEN_URL=http://mock-oauth:9080/token
MOCK_OAUTH_USERINFO_URL=http://mock-oauth:9080/userinfo

Open http://localhost:3000/login and choose Sign in with Mock (Test). To exercise the chain, sign in with the password you set and start a workflow that calls MCP tools:

Terminal window
TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/login \
-H 'Content-Type: application/json' \
-d "{\"username\":\"superadmin\",\"password\":\"$LT_PASSWORD\"}" | jq -r .token)
curl -s -X POST http://localhost:3000/api/insight \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"prompt":"List all files","wait":false}'

Each tool call the workflow makes carries _auth.userId and a fresh delegation token.

Token Lifetime Scope Created by Used by
User JWT 24 hours Full API, RBAC-scoped Login Dashboard, API
Bot API key (lt_bot_) Until revoked Full API, RBAC-scoped A builder Scripts, CI, MCP clients
Delegation token 5 minutes Named scopes Workflow activities MCP tools, delegation API
Service token (lt_svc_) Until revoked Server-specific Server code External MCP servers
Provider access token Provider-set Provider scopes OAuth flow External APIs