Skip to content
Merged
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
7 changes: 4 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ jobs:
bun-version: 1.3.8
- run: bun install --frozen-lockfile
# The published SDK must stay compilable and must not ship test files in dist. It builds
# BEFORE the tests because @open-deltat/client resolves to dist/, so anything importing the
# SDK by package name (the examples suites do) cannot load until this has run.
- run: cd packages/client && bun run build
# BEFORE the tests because @open-deltat/client and @open-deltat/mcp resolve to dist/, so anything
# importing them by package name (the examples suites and the demo's route tests do) cannot load
# until this has run. The MCP server's build compiles the client first.
- run: cd packages/mcp && bun run build
# Regression guard for the SDK and shared date logic (bun runs *.test.ts natively).
- run: bun test

Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

46 changes: 46 additions & 0 deletions demo/app/auth/__tests__/redirects-behind-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { afterAll, beforeAll, expect, test } from "bun:test";
import { NextRequest } from "next/server";

// Behind Coolify's proxy the app sees every request addressed to its own localhost:3000. Sign-in built
// its return address from that, and WorkOS refused "https://localhost:3000/callback" on delt.at. These
// drive the real route handlers with a request shaped the way production delivers it.

const INTERNAL = "http://localhost:3000";
const PUBLIC = "https://delt.at";
const ENV = {
AUTH_ISSUER: "https://idp.example",
AUTH_CLIENT_ID: "client_test",
PUBLIC_BASE_URL: PUBLIC,
} as const;
const saved = Object.fromEntries(Object.keys(ENV).map((k) => [k, process.env[k]]));

beforeAll(() => {
Object.assign(process.env, ENV);
});
afterAll(() => {
// Test files share one process; leave the environment as it was found.
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
});

test("sign-in asks the identity provider to return to the public site, not to localhost", async () => {
const { GET } = await import("../login/route");
const res = await GET(new NextRequest(`${INTERNAL}/auth/login?returnTo=/dashboard`));
const authorize = new URL(res.headers.get("location") ?? "");
expect(authorize.searchParams.get("redirect_uri")).toBe(`${PUBLIC}/callback`);
});

test("a callback sends the person on to the public site, also when the flow fails", async () => {
const { GET } = await import("../../callback/route");
const res = await GET(new NextRequest(`${INTERNAL}/callback?code=abc&state=xyz`));
const next = new URL(res.headers.get("location") ?? "");
expect(next.origin).toBe(PUBLIC);
});

test("signing out lands on the public site", async () => {
const { POST } = await import("../logout/route");
const res = await POST(new NextRequest(`${INTERNAL}/auth/logout`, { method: "POST" }));
expect(res.headers.get("location")).toBe(`${PUBLIC}/`);
});
3 changes: 2 additions & 1 deletion packages/examples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@
},
"devDependencies": {
"@types/react": "^19",
"@types/react-dom": "^19"
"@types/react-dom": "^19",
"jose": "^6.0.11"
},
"scripts": {
"typecheck": "tsc --noEmit",
Expand Down
25 changes: 25 additions & 0 deletions packages/examples/src/lib/__tests__/one-registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { expect, test } from "bun:test";
import { tmpPath } from "./meeting-fixtures";

// The demo's custom server (Bun) and the Next app it hosts each evaluate these modules, so a plain
// module-level instance existed twice per process: each copy loaded the files once at boot, and the
// /ws bridge answered "Unknown bookable" for every calendar created after a deploy. A query suffix
// makes Bun evaluate the module a second time, which is that second graph in miniature.

process.env.PUBLIC_BOOKABLES_PATH = tmpPath(`one-registry-${crypto.randomUUID()}`);
process.env.MEETING_REQUESTS_PATH = tmpPath(`one-meetings-${crypto.randomUUID()}`);

test("a second evaluation of the registry module shares the one registry, so it sees what the first wrote", async () => {
const first = await import("../public-registry");
const second = await import("../public-registry.ts?second-module-graph");
expect(second.publicRegistry).toBe(first.publicRegistry);

first.publicRegistry.register({ id: "made-after-boot", name: "After boot", slotMinutes: 30, timezone: "UTC" });
expect(second.publicRegistry.get("made-after-boot")?.name).toBe("After boot");
});

test("the meeting service is one per process too, so two graphs never overwrite each other's requests", async () => {
const first = await import("../meetings");
const second = await import("../meetings.ts?second-module-graph");
expect(second.meetings).toBe(first.meetings);
});
118 changes: 118 additions & 0 deletions packages/examples/src/lib/__tests__/two-issuers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { SignJWT, exportJWK, generateKeyPair } from "jose";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { OidcAdapter } from "@open-deltat/mcp";
import { canonicalVerifier, type AuthConfig } from "../auth-config";
import { handleMcpRequest } from "../mcp-endpoint";
import { createRateLimiter } from "../rate-limit";
import { T, annaContact, setupMeetings } from "./meeting-fixtures";

// Production's shape, not a stand-in for it: real RS256 tokens, a real JWKS served over HTTP, the real
// OidcAdapter, and the verifier the site's session and /mcp both use. The earlier endpoint tests
// faked verification with one invented issuer, which is the very assumption that failed in
// production: WorkOS signs the site's sessions as its User Management API and an MCP client's tokens
// as its AuthKit OAuth server, two issuers for one user, and the owner of a calendar was refused its
// own inbox through MCP.

const AUTHKIT = "https://example-idp.authkit.app";
const USER_MANAGEMENT = "https://api.workos.test/user_management/client_test";

const keys = await generateKeyPair("RS256");
const jwk = { ...(await exportJWK(keys.publicKey)), kid: "k1", alg: "RS256", use: "sig" };
const jwks = Bun.serve({ port: 0, fetch: () => Response.json({ keys: [jwk] }) });
const servers: { stop: (force?: boolean) => void }[] = [jwks];
afterAll(() => {
for (const s of servers) s.stop(true);
});

const config: AuthConfig = {
issuer: AUTHKIT,
clientId: "client_test",
authorizeUrl: `${AUTHKIT}/oauth2/authorize`,
tokenUrl: `${AUTHKIT}/oauth2/token`,
jwksUri: `http://localhost:${jwks.port}/jwks`,
userinfoUrl: `${AUTHKIT}/oauth2/userinfo`,
trustedIssuers: [AUTHKIT, USER_MANAGEMENT],
extraAuthorizeParams: {},
};
const adapter = new OidcAdapter({ jwksUri: config.jwksUri, trustedIssuers: config.trustedIssuers, tenant: "public" });
const verify = canonicalVerifier((token) => adapter.verify(token), config);

const token = (iss: string, sub: string) =>
new SignJWT({})
.setProtectedHeader({ alg: "RS256", kid: "k1" })
.setIssuer(iss)
.setSubject(sub)
.setIssuedAt()
.setExpirationTime("5m")
.sign(keys.privateKey);

const tokens: Record<string, string> = {};
beforeAll(async () => {
tokens.simonSite = await token(USER_MANAGEMENT, "user_simon"); // the site's session cookie
tokens.simonMcp = await token(AUTHKIT, "user_simon"); // Claude's MCP sign-in
tokens.annaMcp = await token(AUTHKIT, "user_anna");
tokens.stranger = await token("https://elsewhere.example", "user_simon");
});

describe("one person, two issuers", () => {
test("the site's token and an MCP client's token for one user verify to one id", async () => {
const site = await verify(tokens.simonSite ?? "");
const mcp = await verify(tokens.simonMcp ?? "");
expect(site?.principalId).toBe(`${AUTHKIT}#user_simon`);
expect(mcp?.principalId).toBe(site?.principalId);
});

test("another user stays another person, and an untrusted issuer gets no identity at all", async () => {
expect((await verify(tokens.annaMcp ?? ""))?.principalId).toBe(`${AUTHKIT}#user_anna`);
expect(await verify(tokens.stranger ?? "")).toBeNull();
});

test("a calendar made through the site is answered by its owner through MCP", async () => {
const meetings = setupMeetings();
const site = await verify(tokens.simonSite ?? "");
if (!site) throw new Error("the site token did not verify");
// Created through the site: the owner is whatever the site's session verified to.
meetings.registry.register({ id: "simon", name: "Simon", slotMinutes: 30, timezone: "Europe/Berlin", owner: site.principalId });
meetings.registry.updateOwned("simon", site.principalId, { bookingMode: "request" });

const server = Bun.serve({
port: 0,
fetch: (req) =>
handleMcpRequest(req, {
verify,
resourceMetadataUrl: "https://delt.at/.well-known/oauth-protected-resource/mcp",
service: meetings.service,
calendarOf: (id) => meetings.registry.get(id),
contactFor: async () => annaContact,
limiter: createRateLimiter({ limit: 1_000, windowMs: 60_000 }),
}),
});
servers.push(server);
const connect = async (bearer: string) => {
const client = new Client({ name: "test", version: "0" });
await client.connect(
new StreamableHTTPClientTransport(new URL(`http://localhost:${server.port}/mcp`), {
requestInit: { headers: { authorization: `Bearer ${bearer}` } },
})
);
return client;
};
const call = async (client: Client, name: string, args: Record<string, unknown>) => {
const result = await client.callTool({ name, arguments: args });
const [content] = result.content as { text: string }[];
return { isError: result.isError === true, body: JSON.parse(content?.text ?? "null") as Record<string, unknown> };
};

const anna = await connect(tokens.annaMcp ?? "");
const asked = await call(anna, "request_meeting", { calendar_id: "simon", start: new Date(T).toISOString() });
expect(asked.isError).toBe(false);

const simon = await connect(tokens.simonMcp ?? "");
const inbox = await call(simon, "list_meeting_requests", { calendar_id: "simon" });
expect(inbox.isError).toBe(false); // was "You do not own this calendar."
const approved = await call(simon, "approve_meeting_request", { request_id: asked.body.request_id });
expect(approved.body).toMatchObject({ status: "approved", booked: true });
});
});
17 changes: 17 additions & 0 deletions packages/examples/src/lib/auth-config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { VerifiedPrincipal } from "@open-deltat/mcp";

// The sign-in configuration and the one identity a person has under it. Pure (no request context),
// so the custom server (server.ts) can use it as well as the Next app: the registry it loads must
// read owners the same way the app writes them.
Expand Down Expand Up @@ -67,3 +69,18 @@ export function canonicalPrincipalId(principalId: string, config: AuthConfig | n
const iss = principalId.slice(0, at);
return config.trustedIssuers.includes(iss) ? `${config.issuer}#${principalId.slice(at + 1)}` : principalId;
}

/**
* A token verifier that answers with that one id. The site's session (auth-session.ts) and the hosted
* MCP endpoint (mcp-live.ts) both verify through this, so a person is the same person on either, and
* the test that signs real tokens from two issuers exercises exactly what production runs.
*/
export function canonicalVerifier(
verify: (token: string) => Promise<VerifiedPrincipal | null>,
config: AuthConfig
): (token: string) => Promise<VerifiedPrincipal | null> {
return async (token) => {
const principal = await verify(token);
return principal && { ...principal, principalId: canonicalPrincipalId(principal.principalId, config) };
};
}
24 changes: 14 additions & 10 deletions packages/examples/src/lib/auth-session.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { cookies } from "next/headers";
import { OidcAdapter, type VerifiedPrincipal } from "@open-deltat/mcp";
import { authConfig, canonicalPrincipalId, setting } from "./auth-config";
import { authConfig, canonicalVerifier, setting } from "./auth-config";

export { authConfig, canonicalPrincipalId, type AuthConfig } from "./auth-config";

Expand Down Expand Up @@ -33,13 +33,16 @@ export function authEnabled(): boolean {
return authConfig() !== null;
}

let adapter: OidcAdapter | null = null;
function getAdapter(): OidcAdapter | null {
if (adapter) return adapter;
type Verify = (token: string) => Promise<VerifiedPrincipal | null>;
const built: { verify: Verify | null } = { verify: null };

/** The session token verifier, built once sign-in is configured; null while it is not. */
function sessionVerifier(): Verify | null {
if (built.verify) return built.verify;
const config = authConfig();
if (!config) return null;
// Signed-in users share the public tenant with the anonymous secret-link flow.
adapter = new OidcAdapter({
const adapter = new OidcAdapter({
jwksUri: config.jwksUri,
trustedIssuers: config.trustedIssuers,
tenant: "public",
Expand All @@ -48,7 +51,9 @@ function getAdapter(): OidcAdapter | null {
// stamp an audience leave AUTH_AUDIENCE unset and fall back to issuer-only trust.
audience: setting("AUTH_AUDIENCE"),
});
return adapter;
// One person, one id, whichever of the provider's issuers signed the token (auth-config.ts).
built.verify = canonicalVerifier((token) => adapter.verify(token), config);
return built.verify;
}

/** The signed-in principal, or null. Fails closed on a missing, expired, or tampered cookie. */
Expand All @@ -61,13 +66,12 @@ export async function getSessionPrincipal(): Promise<VerifiedPrincipal | null> {
* the token itself: userinfo, which answers only for the token's own subject.
*/
export async function getSession(): Promise<{ principal: VerifiedPrincipal; token: string } | null> {
const verify = getAdapter();
const verify = sessionVerifier();
if (!verify) return null;
const token = (await cookies()).get(SESSION_COOKIE)?.value;
if (!token) return null;
const principal = await verify.verify(token);
// One person, one id, whichever of the provider's issuers signed this token (auth-config.ts).
return principal ? { principal: { ...principal, principalId: canonicalPrincipalId(principal.principalId) }, token } : null;
const principal = await verify(token);
return principal ? { principal, token } : null;
}

/**
Expand Down
7 changes: 2 additions & 5 deletions packages/examples/src/lib/mcp-live.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { OidcAdapter } from "@open-deltat/mcp";
import { authConfig, canonicalPrincipalId } from "./auth-config";
import { authConfig, canonicalVerifier } from "./auth-config";
import { meetings } from "./meetings";
import { publicRegistry } from "./public-registry";
import { publicBaseUrl } from "./public-base-url";
Expand Down Expand Up @@ -30,10 +30,7 @@ function buildDeps(): McpEndpointDeps | null {
return {
// One person, one id, whichever of the provider's issuers signed the token: an MCP client's token
// and the site's session come from different WorkOS issuers for the same user (auth-config.ts).
verify: async (token) => {
const principal = await adapter.verify(token);
return principal && { ...principal, principalId: canonicalPrincipalId(principal.principalId, config) };
},
verify: canonicalVerifier((token) => adapter.verify(token), config),
resourceMetadataUrl: `${publicBaseUrl}${METADATA_PATH}`,
service: meetings,
calendarOf: (id) => publicRegistry.get(id),
Expand Down
5 changes: 3 additions & 2 deletions packages/examples/src/lib/public-base-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ export const publicBaseUrl = publicBaseUrlFrom(process.env.PUBLIC_BASE_URL);

/**
* The origin to send people back to: the configured public one, else the request's own. The fallback
* is right only when the app is reached directly, as in local development.
* is right only when the app is reached directly, as in local development. Read when called, not when
* the module loads, so what a sign-in redirect uses never depends on which code loaded this first.
*/
export function siteOrigin(requestOrigin: string, base: string | null = publicBaseUrl): string {
export function siteOrigin(requestOrigin: string, base: string | null = publicBaseUrlFrom(process.env.PUBLIC_BASE_URL)): string {
return base ?? requestOrigin;
}
Loading