diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 3c05047e4a4c3..14f12059ce5ce 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -1,14 +1,6 @@ -// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync or sign. -import * as NodeCrypto from "node:crypto"; - import * as NodePlatformCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { authScopeResponse, AuthStandardClientScopes } from "@t3tools/contracts"; -import { - computeDpopAccessTokenHash, - computeDpopJwkThumbprint, - type DpopPublicJwk, -} from "@t3tools/shared/dpop"; import { expect, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -16,6 +8,7 @@ import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as ServerConfig from "../config.ts"; +import { makeDpopKey } from "./dpop.testkit.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; @@ -80,36 +73,6 @@ const requestMetadata = { ipAddress: "192.168.1.23", }; -const makeDpopKey = () => { - const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256" }); - const publicJwk = publicKey.export({ format: "jwk" }) as DpopPublicJwk; - let proofCount = 0; - return { - thumbprint: computeDpopJwkThumbprint(publicJwk), - sign: (input: { - readonly method: string; - readonly url: string; - readonly token: string; - readonly iat: number; - }) => { - const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); - const header = encode({ typ: "dpop+jwt", alg: "ES256", jwk: publicJwk }); - const payload = encode({ - htm: input.method, - htu: input.url, - jti: `proof-${++proofCount}`, - iat: input.iat, - ath: computeDpopAccessTokenHash(input.token), - }); - const signature = NodeCrypto.sign("sha256", Buffer.from(`${header}.${payload}`), { - key: privateKey, - dsaEncoding: "ieee-p1363", - }).toString("base64url"); - return `${header}.${payload}.${signature}`; - }, - }; -}; - it.layer(Layer.mergeAll(NodeServices.layer, NodePlatformCrypto.layer))( "EnvironmentAuth.layer", (it) => { diff --git a/apps/server/src/auth/McpOAuth.test.ts b/apps/server/src/auth/McpOAuth.test.ts index a397576340a0f..c7cf59db93863 100644 --- a/apps/server/src/auth/McpOAuth.test.ts +++ b/apps/server/src/auth/McpOAuth.test.ts @@ -10,6 +10,7 @@ import { mcpClientScopes, } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; @@ -24,6 +25,7 @@ import * as Schema from "effect/Schema"; import * as CloudLink from "../cloud/CloudLink.ts"; import * as ServerConfig from "../config.ts"; +import { makeDpopKey } from "./dpop.testkit.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as Sqlite from "../persistence/Sqlite.ts"; @@ -130,17 +132,19 @@ const withRoutes = ( const json = (response: Response) => Effect.promise(() => response.json() as Promise); -/** `credential` is a browser cookie, or `Bearer ` for a session such as T3 Connect's. */ -const postJson = (path: string, body: unknown, credential?: string) => +/** `credential` is a browser cookie, `Bearer `, or the headers of a DPoP request. */ +const postJson = (path: string, body: unknown, credential?: string | Record) => at(path, { method: "POST", headers: { "content-type": "application/json", ...(credential === undefined ? {} - : credential.startsWith("Bearer ") - ? { authorization: credential } - : { cookie: credential }), + : typeof credential !== "string" + ? credential + : credential.startsWith("Bearer ") + ? { authorization: credential } + : { cookie: credential }), }, body: encodeJson(body), }); @@ -149,7 +153,7 @@ const decide = ( handler: Handler, authorization: Record, decision: Record, - cookie?: string, + cookie?: string | Record, ) => handler(postJson("/oauth/mcp/decision", { authorization, decision }, cookie)).pipe( Effect.flatMap((response) => @@ -742,6 +746,50 @@ it.live("one-click approves only access the browser session holds the scopes for connect, ); expect(new URL(viaConnect.redirectTo!).searchParams.get("code")).toEqual(expect.any(String)); + + // The hosted app may load and decide with two credentials minted for the same browser key. + const key = makeDpopKey(); + const dpopSession = (thumbprint: string) => + auth.issuePairingCredential({ scopes: [...AuthStandardClientScopes] }).pipe( + Effect.flatMap((pairing) => + auth.exchangeBootstrapCredentialForAccessToken( + pairing.credential, + undefined, + { deviceType: "desktop" }, + { proofKeyThumbprint: thumbprint }, + ), + ), + Effect.map((exchanged) => exchanged.access_token), + ); + const iat = Math.floor((yield* Clock.currentTimeMillis) / 1_000); + const dpopHeaders = (signer: typeof key, token: string, path: string) => ({ + authorization: `DPoP ${token}`, + dpop: signer.sign({ method: "POST", url: `${ORIGIN}${path}`, token, iat }), + }); + const loadedWith = yield* dpopSession(key.thumbprint); + const loaded = yield* handler( + postJson( + "/oauth/mcp/approval", + params, + dpopHeaders(key, loadedWith, "/oauth/mcp/approval"), + ), + ).pipe(Effect.flatMap(json<{ csrfToken?: string }>)); + const renewed = yield* dpopSession(key.thumbprint); + const sameKey = yield* decide( + handler, + params, + { _tag: "browser-session", access: "auto", csrfToken: loaded.csrfToken! }, + dpopHeaders(key, renewed, "/oauth/mcp/decision"), + ); + expect(new URL(sameKey.redirectTo!).searchParams.get("code")).toEqual(expect.any(String)); + const otherKey = makeDpopKey(); + const elsewhere = yield* decide( + handler, + params, + { _tag: "browser-session", access: "auto", csrfToken: loaded.csrfToken! }, + dpopHeaders(otherKey, yield* dpopSession(otherKey.thumbprint), "/oauth/mcp/decision"), + ); + expect(elsewhere.status).toBe(400); }), ), ); diff --git a/apps/server/src/auth/McpOAuth.ts b/apps/server/src/auth/McpOAuth.ts index 7625fe436a8ea..426cb2213f3ef 100644 --- a/apps/server/src/auth/McpOAuth.ts +++ b/apps/server/src/auth/McpOAuth.ts @@ -307,11 +307,11 @@ const make = Effect.gen(function* () { ) : Effect.succeed(false); - const csrfToken = (sessionId: string, authorization: AuthorizationRequest) => + const csrfToken = (holder: string, authorization: AuthorizationRequest) => sign( "mcp-csrf", JSON.stringify([ - sessionId, + holder, authorization.client.clientId, authorization.redirectUri, authorization.codeChallenge, @@ -413,9 +413,13 @@ const make = Effect.gen(function* () { // Approving manages access, and a session may only hand out scopes it holds. session.scopes.includes(AuthAccessWriteScope) && session.scopes.includes(AuthOrchestrationReadScope) - ? csrfToken(session.sessionId, authorization).pipe( - Effect.map((token) => ({ csrfToken: token, scopes: session.scopes })), - ) + ? // The hosted app may get a fresh credential per call, all bound to one browser key. + csrfToken( + session.proofKeyThumbprint === undefined + ? session.sessionId + : `dpop:${session.proofKeyThumbprint}`, + authorization, + ).pipe(Effect.map((token) => ({ csrfToken: token, scopes: session.scopes }))) : Effect.undefined, ), Effect.orElseSucceed(() => undefined), diff --git a/apps/server/src/auth/dpop.testkit.ts b/apps/server/src/auth/dpop.testkit.ts new file mode 100644 index 0000000000000..f0f00c6ccc439 --- /dev/null +++ b/apps/server/src/auth/dpop.testkit.ts @@ -0,0 +1,39 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no generateKeyPairSync or sign. +import * as NodeCrypto from "node:crypto"; + +import { + computeDpopAccessTokenHash, + computeDpopJwkThumbprint, + type DpopPublicJwk, +} from "@t3tools/shared/dpop"; + +/** A client's DPoP key, signing proofs the way a browser or the mobile app does. */ +export const makeDpopKey = () => { + const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", { namedCurve: "P-256" }); + const publicJwk = publicKey.export({ format: "jwk" }) as DpopPublicJwk; + let proofCount = 0; + return { + thumbprint: computeDpopJwkThumbprint(publicJwk), + sign: (input: { + readonly method: string; + readonly url: string; + readonly token: string; + readonly iat: number; + }) => { + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + const header = encode({ typ: "dpop+jwt", alg: "ES256", jwk: publicJwk }); + const payload = encode({ + htm: input.method, + htu: input.url, + jti: `proof-${++proofCount}`, + iat: input.iat, + ath: computeDpopAccessTokenHash(input.token), + }); + const signature = NodeCrypto.sign("sha256", Buffer.from(`${header}.${payload}`), { + key: privateKey, + dsaEncoding: "ieee-p1363", + }).toString("base64url"); + return `${header}.${payload}.${signature}`; + }, + }; +}; diff --git a/packages/client-runtime/src/state/connectMcpApproval.ts b/packages/client-runtime/src/state/connectMcpApproval.ts index 979754aa14158..eaa2f2f16131b 100644 --- a/packages/client-runtime/src/state/connectMcpApproval.ts +++ b/packages/client-runtime/src/state/connectMcpApproval.ts @@ -9,6 +9,7 @@ import type { EnvironmentId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import type * as HttpClient from "effect/http/HttpClient"; import type { Atom } from "effect/reactivity"; import * as Schema from "effect/Schema"; @@ -75,8 +76,16 @@ const callConnectMcpApproval = Effect.fn("clientRuntime.state.connectMcpApproval }), }, signer: yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner), - remoteAuthorization: yield* Effect.serviceOption( - RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization, + // A token for an unsaved environment is not cached, so reuse this one until it is rejected. + remoteAuthorization: Option.some( + RemoteEnvironmentAuthorization.RemoteEnvironmentAuthorization.of({ + ...remote, + // Untraced: the relay authorization it may call owns the span. + authorizeDpopHttp: Effect.fnUntraced(function* (request) { + if (request.rejectedAccessToken === undefined) return authorized; + return yield* remote.authorizeDpopHttp(request); + }), + }), ), group: "mcpOAuth", method: "POST",