Chained OAuth is a v2 auth option where the plugin manages its own sessions and stores third-party tokens. Unlike auth forwarding (v1), the plugin has full control over the OAuth flow and token storage.
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Platform │ │ Plugin │ │ External │ │ Platform │
│ (User) │ │ │ │ OAuth │ │ (MCP) │
└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │ │
│ 1. Redirect │ │ │
│ (user JWT) │ │ │
│────────────────>│ │ │
│ │ │ │
│ │ 2. Redirect │ │
│ │────────────────>│ │
│ │ │ │
│ │ 3. Callback │ │
│ │ (auth code) │ │
│ │<────────────────│ │
│ │ │ │
│ 4. Redirect │ │ │
│ (plugin token) │ │ │
│<────────────────│ │ │
│ │ │ │
│ │ │ 5. MCP Request │
│ │ │ (plugin token + │
│ │ │ assertion) │
│ │<────────────────────────────────────
│ │ │ │
const app = mcp({
name: "my-plugin",
version: "1.0.0",
pluginManifest: {
specVersion: 2,
pluginId: "my-plugin",
name: "My Plugin",
version: "1.0.0",
signingKey: process.env.PLUGIN_SIGNING_KEY!,
auth: {
type: "chained",
authorizationEndpoint: "https://plugin.example.com/auth/authorize",
callbackEndpoint: "https://plugin.example.com/auth/callback",
tokenEndpoint: "https://plugin.example.com/auth/token", // optional
requiredUserContext: ["user_id", "email", "organization_id"],
externalServices: [
{
name: "Monday.com",
authorizationEndpoint: "https://auth.monday.com/oauth2/authorize",
requiredScopes: ["me:read", "boards:read"],
},
],
sessionConfig: {
maxSessionDuration: 86400000, // 24 hours
supportsRefresh: true,
},
},
mcpUrl: "https://plugin.example.com/mcp",
},
});import {
mcp,
createPendingSession,
activateSession,
generatePluginToken,
generateChainedState,
parseChainedState,
InMemorySessionStore, // Use a real store in production
} from "@futuritywork/plugins";
const sessionStore = new InMemorySessionStore();
const app = mcp({
name: "my-plugin",
version: "1.0.0",
chainedAuth: {
sessionStore,
platformJwksUrl: "https://platform.example.com/.well-known/jwks.json",
pluginSigningKey: process.env.PLUGIN_SIGNING_KEY!,
handlers: {
onAuthorize,
onCallback,
onToken, // optional
},
},
});async function onAuthorize(
userContext: UserContext,
platformState: string,
platformCallback: string
): Promise<Response> {
// Create pending session
const session = createPendingSession(
userContext,
platformState,
platformCallback
);
await sessionStore.create(session);
// Generate chained state (includes platform state + session ID)
const chainedState = generateChainedState(platformState, session.id);
// Redirect to external OAuth
const authUrl = new URL("https://external.oauth.com/authorize");
authUrl.searchParams.set("client_id", process.env.EXTERNAL_CLIENT_ID!);
authUrl.searchParams.set("redirect_uri", "https://plugin.example.com/auth/callback");
authUrl.searchParams.set("state", chainedState);
authUrl.searchParams.set("scope", "read write");
return Response.redirect(authUrl.toString(), 302);
}async function onCallback(req: Request): Promise<Response> {
const url = new URL(req.url);
const code = url.searchParams.get("code")!;
const state = url.searchParams.get("state")!;
// Parse chained state
const { platform: platformState, session: sessionId } = parseChainedState(state);
// Get pending session
const session = await sessionStore.get(sessionId);
if (!session || session.state !== "pending") {
return new Response("Invalid session", { status: 400 });
}
// Exchange code for external token
const tokenResponse = await fetch("https://external.oauth.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: process.env.EXTERNAL_CLIENT_ID!,
client_secret: process.env.EXTERNAL_CLIENT_SECRET!,
redirect_uri: "https://plugin.example.com/auth/callback",
}),
});
const externalTokens = await tokenResponse.json();
// Activate session with encrypted external tokens
const updates = activateSession(session, {
external: encryptToken(externalTokens), // implement your encryption
});
await sessionStore.update(sessionId, updates);
// Generate plugin token
const pluginToken = await generatePluginToken(
sessionId,
session.platformId,
process.env.PLUGIN_SIGNING_KEY!
);
// Redirect back to platform
const callbackUrl = new URL(session.platformCallback);
callbackUrl.searchParams.set("token", pluginToken);
callbackUrl.searchParams.set("state", platformState);
return Response.redirect(callbackUrl.toString(), 302);
}async function onToken(req: Request): Promise<Response> {
const body = await req.text();
const params = new URLSearchParams(body);
const refreshToken = params.get("refresh_token")!;
// Validate refresh token
const { sessionId, platformId } = await validatePluginToken(
refreshToken,
process.env.PLUGIN_PUBLIC_KEY!
);
const session = await sessionStore.get(sessionId);
if (!session || !isSessionValid(session)) {
return Response.json({ error: "invalid_grant" }, { status: 400 });
}
// Optionally refresh external tokens here
// Issue new tokens
const newAccessToken = await generatePluginToken(
sessionId,
platformId,
process.env.PLUGIN_SIGNING_KEY!,
{ expiresIn: "1h" }
);
const newRefreshToken = await generateRefreshToken(
sessionId,
platformId,
process.env.PLUGIN_SIGNING_KEY!
);
return Response.json({
access_token: newAccessToken,
refresh_token: newRefreshToken,
token_type: "Bearer",
expires_in: 3600,
});
}To prevent replay attacks and ensure platform identity, each MCP request must include a platform assertion.
On each MCP request, the platform must include:
POST /mcp HTTP/1.1
Authorization: Bearer <plugin_token>
X-Platform-Assertion: <platform_assertion_jwt>
Content-Type: application/json
{"jsonrpc":"2.0","method":"tools/call",...}The platform signs a JWT with these claims:
{
iss: "https://platform.example.com", // Platform identifier
iat: 1706000000, // Issued at (must be within 30s)
ath: "abc123...", // SHA-256 hash of plugin token (base64url)
req_hash: "def456..." // SHA-256 hash of request (base64url)
}The req_hash is computed as:
SHA-256(METHOD + "\n" + PATH + "\n" + BODY)
import { validateAuthenticatedRequest } from "@futuritywork/plugins";
app.middleware(async (req, next) => {
try {
const { session, platformId } = await validateAuthenticatedRequest(
req,
process.env.PLUGIN_PUBLIC_KEY!,
"https://platform.example.com/.well-known/jwks.json",
sessionStore,
{ assertionMaxAge: 30 } // seconds
);
// Session and platform verified - proceed with request
// Access external tokens from session.externalTokens
return next(req);
} catch (error) {
return Response.json(
{ error: "unauthorized", message: error.message },
{ status: 401 }
);
}
});- Each session is bound to a specific platform (
platformId) - Plugin tokens include the platform ID (
pidclaim) - Platform assertions must match the session's platform
| Protection | Mechanism |
|---|---|
| Time-based | Assertion iat must be within 30 seconds |
| Token-bound | ath hash must match the plugin token |
| Request-bound | req_hash must match the HTTP request |
| Platform-bound | iss must match session's platform |
| Token | Signed By | Contains | Lifetime |
|---|---|---|---|
| User Context JWT | Platform | user_id, platform_id | 60 seconds |
| Plugin Token | Plugin | session_id, platform_id | 1 hour |
| Refresh Token | Plugin | session_id, platform_id, type | 7 days |
| Platform Assertion | Platform | ath, req_hash | 30 seconds |
Implement this interface for production use:
interface SessionStore {
create(session: PendingSession): Promise<void>;
get(sessionId: string): Promise<Session | null>;
update(sessionId: string, updates: Partial<Session>): Promise<void>;
delete(sessionId: string): Promise<void>;
}
interface Session {
id: string;
platformId: string;
userId: string;
organizationId?: string;
email?: string;
displayName?: string;
state: "pending" | "active" | "expired" | "revoked";
externalTokens?: Record<string, EncryptedToken>;
platformState: string;
platformCallback: string;
createdAt: Date;
expiresAt?: Date;
}Platforms discover plugin auth configuration from:
GET /.well-known/futurity/plugin
Response includes:
auth.type: "chained"- indicates chained authauth.authorizationEndpoint- where to redirect usersauth.requiredUserContext- what user info to include in JWT
// Platform creates user context JWT
const userContextJwt = await new SignJWT({
sub: user.id,
email: user.email,
org_id: user.organizationId,
})
.setProtectedHeader({ alg: "RS256" })
.setIssuer("https://platform.example.com")
.setIssuedAt()
.setExpirationTime("60s")
.sign(platformPrivateKey);
// Redirect user to plugin
const authUrl = new URL(plugin.auth.authorizationEndpoint);
authUrl.searchParams.set("token", userContextJwt);
authUrl.searchParams.set("state", generateState());
authUrl.searchParams.set("redirect_uri", "https://platform.example.com/callback");
redirect(authUrl);// Create platform assertion
const tokenHash = await sha256Base64Url(pluginToken);
const requestHash = await sha256Base64Url(`POST\n/mcp\n${body}`);
const assertion = await new SignJWT({
ath: tokenHash,
req_hash: requestHash,
})
.setProtectedHeader({ alg: "RS256" })
.setIssuer("https://platform.example.com")
.setIssuedAt()
.sign(platformPrivateKey);
// Make request
const response = await fetch("https://plugin.example.com/mcp", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${pluginToken}`,
"X-Platform-Assertion": assertion,
},
body,
});Platforms must serve their public keys at:
GET /.well-known/jwks.json
{
"keys": [
{
"kty": "RSA",
"kid": "platform-key-1",
"alg": "RS256",
"use": "sig",
"n": "...",
"e": "AQAB"
}
]
}The v1 authForwarding format is still supported for backward compatibility:
// v1 format (still works)
{
specVersion: 1,
authForwarding: {
tokenEndpoint: "...",
authorizationEndpoint: "...",
}
}
// v2 format (recommended)
{
specVersion: 2,
auth: {
type: "forwarding", // or "chained"
tokenEndpoint: "...",
authorizationEndpoint: "...",
}
}The PluginManifestSchemaCompat automatically transforms v1 manifests to v2 format internally.