diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 942a7d6..0cea2ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/bun.lock b/bun.lock index 97bbd1e..48e9841 100644 --- a/bun.lock +++ b/bun.lock @@ -123,6 +123,7 @@ "devDependencies": { "@types/react": "^19", "@types/react-dom": "^19", + "jose": "^6.0.11", }, "peerDependencies": { "next": "*", diff --git a/demo/app/auth/__tests__/redirects-behind-proxy.test.ts b/demo/app/auth/__tests__/redirects-behind-proxy.test.ts new file mode 100644 index 0000000..101b368 --- /dev/null +++ b/demo/app/auth/__tests__/redirects-behind-proxy.test.ts @@ -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}/`); +}); diff --git a/packages/examples/package.json b/packages/examples/package.json index df7addd..c3d790b 100644 --- a/packages/examples/package.json +++ b/packages/examples/package.json @@ -50,7 +50,8 @@ }, "devDependencies": { "@types/react": "^19", - "@types/react-dom": "^19" + "@types/react-dom": "^19", + "jose": "^6.0.11" }, "scripts": { "typecheck": "tsc --noEmit", diff --git a/packages/examples/src/lib/__tests__/one-registry.test.ts b/packages/examples/src/lib/__tests__/one-registry.test.ts new file mode 100644 index 0000000..885bc2d --- /dev/null +++ b/packages/examples/src/lib/__tests__/one-registry.test.ts @@ -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); +}); diff --git a/packages/examples/src/lib/__tests__/two-issuers.test.ts b/packages/examples/src/lib/__tests__/two-issuers.test.ts new file mode 100644 index 0000000..101c9bb --- /dev/null +++ b/packages/examples/src/lib/__tests__/two-issuers.test.ts @@ -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 = {}; +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) => { + 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 }; + }; + + 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 }); + }); +}); diff --git a/packages/examples/src/lib/auth-config.ts b/packages/examples/src/lib/auth-config.ts index c6dfc75..baf2951 100644 --- a/packages/examples/src/lib/auth-config.ts +++ b/packages/examples/src/lib/auth-config.ts @@ -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. @@ -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, + config: AuthConfig +): (token: string) => Promise { + return async (token) => { + const principal = await verify(token); + return principal && { ...principal, principalId: canonicalPrincipalId(principal.principalId, config) }; + }; +} diff --git a/packages/examples/src/lib/auth-session.ts b/packages/examples/src/lib/auth-session.ts index 3d7a270..e21bd60 100644 --- a/packages/examples/src/lib/auth-session.ts +++ b/packages/examples/src/lib/auth-session.ts @@ -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"; @@ -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; +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", @@ -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. */ @@ -61,13 +66,12 @@ export async function getSessionPrincipal(): Promise { * 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; } /** diff --git a/packages/examples/src/lib/mcp-live.ts b/packages/examples/src/lib/mcp-live.ts index 8272ce5..e69477f 100644 --- a/packages/examples/src/lib/mcp-live.ts +++ b/packages/examples/src/lib/mcp-live.ts @@ -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"; @@ -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), diff --git a/packages/examples/src/lib/public-base-url.ts b/packages/examples/src/lib/public-base-url.ts index e45e97f..6384e7b 100644 --- a/packages/examples/src/lib/public-base-url.ts +++ b/packages/examples/src/lib/public-base-url.ts @@ -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; }