Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 30 additions & 28 deletions examples/notion/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ server and adds native React widgets for the tool results.
- 1:1 Sidecar tool folders for the public Notion MCP tool names.
- An explicit `authorize` tool that returns the user's Notion OAuth link before
they run other Notion tools.
- WorkOS AuthKit as the MCP authorization server.
- WorkOS Vault storage for each user's upstream Notion MCP token.
- WorkOS-managed API keys as the MCP bearer credential.
- WorkOS Vault storage for the upstream Notion MCP token, keyed by the API
key owner.
- Streamable HTTP calls to `https://mcp.notion.com/mcp` with the official MCP
TypeScript SDK.
- Native widgets for search, fetch, read/query, metadata, and write results.
Expand Down Expand Up @@ -73,39 +74,40 @@ This example is intentionally not a root npm workspace, so Vercel installs

## Auth

Hosted Notion MCP requires a Notion-audience OAuth token. This example keeps
Sidecar spec-compliant by making this MCP server the resource server and using
WorkOS AuthKit as its authorization server. It stores the separate upstream
Notion MCP token in WorkOS Vault, keyed by the authenticated WorkOS user id.
Hosted Notion MCP requires a Notion-audience OAuth token. This example uses a
WorkOS-managed API key as the MCP bearer credential. The server validates the
incoming bearer token with WorkOS API Keys and stores the separate upstream
Comment on lines +77 to +79

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uhuh

Notion MCP token in WorkOS Vault under the validated key owner.

This keeps the Sidecar dev harness simple: paste the WorkOS organization API key
into the bearer-token field and the harness sends it as the standard MCP
`Authorization: Bearer ...` header.

Configure:

```sh
WORKOS_CLIENT_ID=client_...
WORKOS_API_KEY_NOTION=sk_...
WORKOS_AUTHKIT_DOMAIN=your-subdomain.authkit.app
SIDECAR_MCP_URL=https://sidecar-notion.vercel.app/mcp
SIDECAR_PUBLIC_URL=https://sidecar-notion.vercel.app
```

WorkOS MCP auth requires Connect configuration in the WorkOS dashboard:

1. Enable Client ID Metadata Document (CIMD). Dynamic Client Registration
(DCR) is optional for clients that have not adopted CIMD yet.
2. Add `https://sidecar-notion.vercel.app/mcp` as a Resource Indicator.
3. Use the AuthKit domain shown in WorkOS, for example
`your-subdomain.authkit.app`, as `WORKOS_AUTHKIT_DOMAIN`.

`auth.ts` verifies AuthKit access tokens against the AuthKit issuer and JWKS
endpoint. Tool execution reads the user's Notion MCP token from WorkOS Vault
with an object name derived from the WorkOS user id and a Vault key context
containing `user_id` and `data_type=notion_mcp_token`.

When a user has no stored Notion token, ask the model to run `authorize` first.
That tool returns a Notion OAuth link for the current authenticated WorkOS user.
If the user calls another Notion tool before linking, the tool result also
includes the same authorization link. The flow uses Notion's MCP OAuth
discovery, dynamic client registration, PKCE, and the local callback route at
`/notion/oauth/callback`. The callback stores the Notion access token and
refresh token in WorkOS Vault. Access tokens are refreshed from Vault before
upstream tool calls when they are close to expiry.
Create an organization-owned or user-owned API key in WorkOS AuthKit, then
either paste it into the local harness bearer-token dialog or set it as
`MCP_BEARER` before starting dev. User-owned keys still require an organization
membership; WorkOS uses that membership to decide which organization the key can
access.

`auth.ts` validates `Authorization: Bearer <WorkOS API key>` with WorkOS. Tool
execution reads the Notion MCP token from WorkOS Vault with an object name
derived from the validated key owner and a Vault key context containing
`user_id`, `organization_id` when present, and `data_type=notion_mcp_token`.

When Vault has no stored Notion token for the configured owner id, ask the model
to run `authorize` first. That tool returns a Notion OAuth link. If the user
calls another Notion tool before linking, the tool result also includes the same
authorization link. The flow uses Notion's MCP OAuth discovery, dynamic client
registration, PKCE, and the local callback route at `/notion/oauth/callback`.
The callback stores the Notion access token and refresh token in WorkOS Vault.
Comment on lines +105 to +111

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dwerwer

Access tokens are refreshed from Vault before upstream tool calls when they are
close to expiry.
255 changes: 174 additions & 81 deletions examples/notion/auth.ts
Original file line number Diff line number Diff line change
@@ -1,142 +1,235 @@
/**
* WorkOS AuthKit authentication for the hosted Notion MCP example.
* WorkOS API-key authentication for the private Notion MCP example.
*
* Sidecar is the MCP resource server, AuthKit is the OAuth authorization
* server, and WorkOS Vault stores the per-user upstream Notion MCP token.
* The Sidecar dev harness and MCP clients send a standard bearer token. This
* file validates that bearer value with WorkOS API Keys, then uses the API key
* owner as the stable Vault owner for the upstream Notion token.
*/
import "dotenv/config";
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
import { WorkOS } from "@workos-inc/node";
import { auth, type AuthSession } from "sidecar-ai";

const DEFAULT_AUTHKIT_ISSUER = "https://signin.workos.com";

/** Verified AuthKit claims accepted by this example. */
export type WorkOSMcpClaims = JWTPayload & {
scope?: string;
scp?: string[];
client_id?: string;
org_id?: string;
organization_id?: string;
/** Claims attached to validated WorkOS API-key sessions in this example. */
export type NotionApiKeyClaims = {
sub: string;
scope: string;
kind: "workos-api-key";
apiKeyId: string;
ownerType: "organization" | "user";
ownerId: string;
organizationId?: string;
permissions: string[];
};

/** Session shape available to every Notion tool through `ctx.auth`. */
export type NotionSession = AuthSession<
WorkOSMcpClaims,
NotionApiKeyClaims,
{
workosUserId: string;
workosOrganizationId?: string;
workosApiKeyId: string;
workosApiKeyName: string;
workosApiKeyPermissions: string[];
}
>;

let workosClient: WorkOS | undefined;
let warnedMissingWorkosKey = false;

/** Minimal API key shape returned by WorkOS validation. */
type ValidatedWorkosApiKey = {
id: string;
owner: {
type: "organization" | "user";
id: string;
organizationId?: string;
organization_id?: string;
};
name: string;
permissions: string[];
};

const appAuth = auth({
resource: mcpResource(),
authorizationServers: [authKitIssuer()],
authorizationServers: [metadataOnlyAuthorizationServer()],
scopes: {},
async session(request): Promise<NotionSession | null> {
const token = request.bearerToken();
if (!token) {
return null;
}

const claims = await verifyAuthKitToken(token).catch((error) => {
console.warn(JSON.stringify({
event: "sidecar.notion.auth.invalid_token",
message: error instanceof Error ? error.message : "Invalid AuthKit token.",
}));
return null;
});
if (!claims) {
const apiKey = await validateWorkosApiKey(token);
if (!apiKey) {
return null;
}

const workosUserId = subject(claims);
const workosOrganizationId = organizationId(claims);

return {
userId: workosUserId,
subject: workosUserId,
clientId: optionalString(claims.client_id),
scopes: scopesFromClaims(claims),
expiresAt: expiresAt(claims),
token,
claims,
workosUserId,
workosOrganizationId,
};
return sessionFromApiKey(token, apiKey);
},
});

export default appAuth;

/** Verifies an AuthKit-issued JWT against the issuer's JWKS endpoint. */
async function verifyAuthKitToken(token: string): Promise<WorkOSMcpClaims> {
const issuer = authKitIssuer();
const jwks = createRemoteJWKSet(new URL("/oauth2/jwks", issuer));
const { payload } = await jwtVerify(token, jwks, { issuer });
return payload as WorkOSMcpClaims;
}

/** Returns the MCP resource URL advertised to clients. */
function mcpResource(): string {
return process.env.SIDECAR_MCP_URL ?? "http://127.0.0.1:3101/mcp";
}

/** Returns the AuthKit issuer URL configured in WorkOS. */
function authKitIssuer(): string {
const explicit = process.env.WORKOS_AUTHKIT_ISSUER ?? process.env.AUTHKIT_ISSUER;
if (explicit) {
return normalizeIssuer(explicit);
/**
* Returns the metadata-only authorization server URL required by Sidecar auth.
*
* This example authenticates with WorkOS-managed API keys instead of an OAuth
* login flow into Sidecar. Clients should send `Authorization: Bearer <api key>`
* directly and should not attempt OAuth discovery against this URL.
*/
function metadataOnlyAuthorizationServer(): string {
return process.env.SIDECAR_NOTION_AUTH_SERVER ?? "https://sidecar.ai/notion-workos-api-key";
}

/** Validates a bearer token with WorkOS API Keys. */
async function validateWorkosApiKey(value: string): Promise<ValidatedWorkosApiKey | null> {
try {
const result = await createApiKeyValidation(value);
return normalizeValidatedApiKey(result.apiKey);
} catch (error) {
console.warn(JSON.stringify({
event: "sidecar.notion.auth.api_key_validation_failed",
message: error instanceof Error ? error.message : "WorkOS API key validation failed.",
}));
return null;
}
}

const domain = process.env.WORKOS_AUTHKIT_DOMAIN ?? process.env.AUTHKIT_DOMAIN;
if (domain) {
return normalizeIssuer(domain.startsWith("http") ? domain : `https://${domain}`);
/** Supports both current and older WorkOS SDK validation method names. */
async function createApiKeyValidation(value: string): Promise<{ apiKey: unknown }> {
const apiKeys = workos().apiKeys as {
validateApiKey?: (payload: { value: string }) => Promise<{ apiKey: unknown }>;
createValidation?: (payload: { value: string }) => Promise<{ apiKey: unknown }>;
};
const validate = apiKeys.validateApiKey ?? apiKeys.createValidation;
if (!validate) {
throw new Error("Installed @workos-inc/node does not expose API key validation.");
}

return DEFAULT_AUTHKIT_ISSUER;
return await validate.call(apiKeys, { value });
}

/** Ensures issuer strings are URL-like and do not carry a trailing slash. */
function normalizeIssuer(value: string): string {
const url = new URL(value);
if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
throw new Error("WORKOS_AUTHKIT_ISSUER must be https:// except for localhost development.");
/** Normalizes WorkOS API key validation output across SDK response shapes. */
function normalizeValidatedApiKey(value: unknown): ValidatedWorkosApiKey | null {
if (!isRecord(value)) {
return null;
}
if (typeof value.id !== "string" || typeof value.name !== "string" || !isRecord(value.owner)) {
return null;
}
const ownerType = value.owner.type;
const ownerId = value.owner.id;
if ((ownerType !== "organization" && ownerType !== "user") || typeof ownerId !== "string") {
return null;
}

url.pathname = url.pathname.replace(/\/+$/, "");
return url.toString().replace(/\/$/, "");
return {
id: value.id,
name: value.name,
owner: {
type: ownerType,
id: ownerId,
organizationId: optionalString(value.owner.organizationId) ?? optionalString(value.owner.organization_id),
},
permissions: Array.isArray(value.permissions)
? value.permissions.filter((permission): permission is string => typeof permission === "string")
: [],
};
}

/** Extracts the authenticated user id from required JWT subject. */
function subject(claims: WorkOSMcpClaims): string {
if (!claims.sub) {
throw new Error("AuthKit token is missing required subject claim.");
}
return claims.sub;
/** Builds a Sidecar session from a validated WorkOS API key. */
function sessionFromApiKey(token: string, apiKey: ValidatedWorkosApiKey): NotionSession {
const owner = apiKeyOwner(apiKey);
const claims: NotionApiKeyClaims = {
sub: owner.userId,
scope: permissionsScope(apiKey.permissions),
kind: "workos-api-key",
apiKeyId: apiKey.id,
ownerType: apiKey.owner.type,
ownerId: apiKey.owner.id,
organizationId: owner.organizationId,
permissions: apiKey.permissions,
};

return {
userId: owner.userId,
subject: owner.userId,
clientId: apiKey.id,
scopes: apiKey.permissions,
token,
claims,
workosUserId: owner.userId,
workosOrganizationId: owner.organizationId,
workosApiKeyId: apiKey.id,
workosApiKeyName: apiKey.name,
workosApiKeyPermissions: apiKey.permissions,
};
}

/** Extracts a WorkOS organization id when the token contains one. */
function organizationId(claims: WorkOSMcpClaims): string | undefined {
return optionalString(claims.org_id) ?? optionalString(claims.organization_id);
/** Returns the Vault owner identity represented by a WorkOS API key. */
function apiKeyOwner(apiKey: ValidatedWorkosApiKey): { userId: string; organizationId?: string } {
if (apiKey.owner.type === "organization") {
return {
userId: apiKey.owner.id,
organizationId: apiKey.owner.id,
};
}

return {
userId: apiKey.owner.id,
organizationId: apiKey.owner.organizationId,
};
}

/** Converts JWT expiry seconds into a Date for Sidecar's auth context. */
function expiresAt(claims: WorkOSMcpClaims): Date | undefined {
return typeof claims.exp === "number" ? new Date(claims.exp * 1000) : undefined;
/** Creates the WorkOS SDK client lazily so imports stay cheap. */
function workos(): WorkOS {
workosClient ??= new WorkOS(requiredEnv("WORKOS_API_KEY_NOTION", "WORKOS_API_KEY"), {
clientId: process.env.WORKOS_CLIENT_ID,
});
return workosClient;
}

/** Reads OAuth scopes from either `scope` or `scp` claim conventions. */
function scopesFromClaims(claims: WorkOSMcpClaims): string[] {
if (Array.isArray(claims.scp)) {
return claims.scp.filter((scope): scope is string => typeof scope === "string" && scope.length > 0);
/** Reads the first present environment variable from a prioritized list. */
function requiredEnv(...names: string[]): string {
for (const name of names) {
const value = process.env[name];
if (value) {
return value;
}
}
if (typeof claims.scope === "string") {
return claims.scope.split(/\s+/).filter(Boolean);

warnMissingWorkosKeyOnce(names);
throw new Error(`Set one of ${names.join(", ")} before validating WorkOS API keys.`);
}

/** Warns once when the server-side WorkOS key is missing. */
function warnMissingWorkosKeyOnce(names: string[]): void {
if (warnedMissingWorkosKey) {
return;
}
return [];
warnedMissingWorkosKey = true;
console.warn(JSON.stringify({
event: "sidecar.notion.auth.missing_workos_api_key",
message: `Set one of ${names.join(", ")} before calling the Notion MCP example.`,
}));
}

/** Formats API key permissions as an OAuth-style scope string for diagnostics. */
function permissionsScope(permissions: string[]): string {
return permissions.length ? permissions.join(" ") : "notion";
}

/** Returns true for non-array objects. */
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}

/** Returns a string only when the claim is present. */
/** Returns a non-empty string value. */
function optionalString(value: unknown): string | undefined {
return typeof value === "string" && value ? value : undefined;
}
Loading