diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 384afb2d72c1c..67c9cef02297f 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -608,6 +608,9 @@ export class EnvironmentAuth extends Context.Service< readonly authenticateMcpClient: ( request: HttpServerRequest.HttpServerRequest, ) => Effect.Effect; + readonly authenticateMcpClientToken: ( + token: string, + ) => Effect.Effect; readonly issueMcpClientSession: (input: { readonly label: string; readonly access: AuthMcpClientAccess; @@ -1521,10 +1524,8 @@ export const make = Effect.gen(function* () { return yield* authenticateRequest(request); }); - const authenticateMcpClient: EnvironmentAuth["Service"]["authenticateMcpClient"] = (request) => { - const token = parseBearerToken(request); - if (token === null) return Effect.fail(new ServerAuthMissingCredentialError({})); - return sessions.verify(token).pipe( + const verifyMcpClientToken = (token: string) => + sessions.verify(token).pipe( mapSessionVerificationErrors, Effect.flatMap((session) => session.subject === MCP_CLIENT_SUBJECT && session.method === "bearer-access-token" @@ -1542,10 +1543,21 @@ export const make = Effect.gen(function* () { }), ), ), + ); + + const authenticateMcpClient: EnvironmentAuth["Service"]["authenticateMcpClient"] = (request) => { + const token = parseBearerToken(request); + if (token === null) return Effect.fail(new ServerAuthMissingCredentialError({})); + return verifyMcpClientToken(token).pipe( Effect.withSpan("EnvironmentAuth.authenticateMcpClient"), ); }; + const authenticateMcpClientToken: EnvironmentAuth["Service"]["authenticateMcpClientToken"] = ( + token, + ) => + verifyMcpClientToken(token).pipe(Effect.withSpan("EnvironmentAuth.authenticateMcpClientToken")); + const issueMcpClientSession: EnvironmentAuth["Service"]["issueMcpClientSession"] = (input) => sessions .issue({ @@ -1625,6 +1637,7 @@ export const make = Effect.gen(function* () { issueWebSocketTicket, issueStartupPairingUrl, authenticateMcpClient, + authenticateMcpClientToken, issueMcpClientSession, consumeMcpApprovalCode, }); diff --git a/apps/server/src/auth/McpOAuth.test.ts b/apps/server/src/auth/McpOAuth.test.ts index d72a716673e66..db9e0b51066dc 100644 --- a/apps/server/src/auth/McpOAuth.test.ts +++ b/apps/server/src/auth/McpOAuth.test.ts @@ -325,7 +325,7 @@ it.effect("registers loopback and https clients and never redirects for an unver ), ); -it.live("signs in with a pairing code and issues a token only /mcp accepts", () => +it.live("signs in with a pairing code, issues a token only /mcp accepts, and revokes only it", () => withRoutes((handler, auth) => Effect.gen(function* () { const clientId = yield* registeredClientId(handler); @@ -396,6 +396,20 @@ it.live("signs in with a pairing code and issues a token only /mcp accepts", () }), ); expect(ticket.status).toBe(401); + + // RFC 7009 answers alike for any token, so it reveals nothing about one. + const revoke = (presented: string) => + handler(at("/oauth/mcp/revoke", form({ token: presented }))).pipe( + Effect.map((response) => response.status), + ); + const other = yield* auth.issueSession(); + expect(yield* revoke("not-a-token")).toBe(200); + expect(yield* revoke(other.token)).toBe(200); + expect(yield* revoke(token.access_token)).toBe(200); + expect(yield* revoke(token.access_token)).toBe(200); + expect((yield* auth.listSessions()).map((listed) => listed.sessionId)).toEqual([ + other.sessionId, + ]); }), ), ); diff --git a/apps/server/src/auth/McpOAuth.ts b/apps/server/src/auth/McpOAuth.ts index fdb0e4044f1df..277ab96af891b 100644 --- a/apps/server/src/auth/McpOAuth.ts +++ b/apps/server/src/auth/McpOAuth.ts @@ -235,6 +235,10 @@ export class McpOAuth extends Context.Service< readonly urls: McpOAuthUrls; readonly token: AuthMcpTokenRequest; }) => Effect.Effect; + /** RFC 7009: ends the MCP client session `token` belongs to. Any other token is left alone. */ + readonly revoke: ( + token: Redacted.Redacted, + ) => Effect.Effect; } >()("t3/auth/McpOAuth") {} @@ -583,6 +587,21 @@ const make = Effect.gen(function* () { }, ); + const revoke: McpOAuth["Service"]["revoke"] = Effect.fn("McpOAuth.revoke")(function* (token) { + yield* environmentAuth.authenticateMcpClientToken(Redacted.value(token)).pipe( + Effect.flatMap((client) => + environmentAuth.revokeSession(client.sessionId).pipe( + Effect.andThen( + Effect.logInfo("Revoked an MCP client session at its holder's request.", { + client: client.label, + }), + ), + ), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, () => Effect.void), + ); + }); + const managedPolicy = serverSettings.managedPolicy; const maxRuntimeMode = managedPolicy?.limits?.maxRuntimeMode; return McpOAuth.of({ @@ -596,6 +615,7 @@ const make = Effect.gen(function* () { approve, deny, exchangeCode, + revoke, }); }); diff --git a/apps/server/src/auth/mcpOAuthHttp.ts b/apps/server/src/auth/mcpOAuthHttp.ts index 6e10362000adf..5db5b6d8fac18 100644 --- a/apps/server/src/auth/mcpOAuthHttp.ts +++ b/apps/server/src/auth/mcpOAuthHttp.ts @@ -205,6 +205,7 @@ export const layer = HttpApiBuilder.group( yield* noStore; return yield* oauth.exchangeCode({ request, urls: yield* requestUrls, token: payload }); }), - ); + ) + .handle("revoke", ({ payload }) => oauth.revoke(payload.token).pipe(Effect.orDie)); }), ); diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index 6923037b6f7c4..6622a9b75362b 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -36,6 +36,7 @@ import * as ServerSettings from "../serverSettings.ts"; import { descriptorOf, layerLinkingEnvironment, + linkTo, servePeer, type ServedPeer, } from "./PeerLinks.testkit.ts"; @@ -206,6 +207,34 @@ it.effect("links with a pairing code, and the peer holds its agents to the limit ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); +it.effect("linking again ends the session it replaces on the peer", () => + Effect.scoped( + Effect.gen(function* () { + const b = yield* serveB(box); + const a = yield* makeA(laptop); + yield* linkTo(a.links, b); + yield* probe(a); + const replaced = (yield* Ref.get(b.bearers)).at(-1)!; + + yield* linkTo(a.links, b, "read-only"); + yield* linkTo(a.links, b, "read-only"); + expect(yield* linkedSessions(b)).toHaveLength(1); + const presented = yield* b.auth + .authenticateMcpClient( + HttpServerRequest.fromWeb( + new Request(`${b.url}/mcp`, { headers: { authorization: replaced } }), + ), + ) + .pipe(Effect.flip); + expect(presented._tag).toBe("ServerAuthInvalidCredentialError"); + expect(yield* probe(a)).toEqual({ + caller: "T3 Code ยท Laptop", + modes: "approval-required/default", + }); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.effect( "each machine's Linked environments beta gates links, and a link waits while it is off", () => diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index 6fddf407cf4d4..ac21fc7dfe9a7 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -474,6 +474,29 @@ const make = Effect.gen(function* () { return { primary: first.origin, peer: first.descriptor, origins }; }); + /** + * Ends the session a replaced link's token holds, at the address a call with that token would + * have used. A peer without the endpoint keeps it until it expires. + */ + const revokeReplaced = Effect.fn("PeerLinks.revokeReplaced")(function* (row: PeerLinkRow) { + const stored = yield* secrets.get(row.secret_name); + if (Option.isNone(stored)) return; + const urls = yield* decodeUrls(row.urls_json); + const origin = yield* Effect.findFirst(urls, (candidate) => + probe(candidate).pipe( + Effect.option, + Effect.map( + (found) => Option.isSome(found) && found.value.environmentId === row.environment_id, + ), + ), + ); + if (Option.isNone(origin)) return; + const client = yield* clientFor(origin.value); + yield* client.mcpOAuth + .revoke({ payload: { token: Redacted.make(new TextDecoder().decode(stored.value)) } }) + .pipe(Effect.timeout(probeTimeout(origin.value))); + }); + /** The peer at `url`, with every address checked for credentials first. */ const answeringAt = Effect.fn("PeerLinks.answeringAt")(function* (urls: ReadonlyArray) { const origins = yield* Effect.forEach(urls, peerOrigin); @@ -569,10 +592,14 @@ const make = Effect.gen(function* () { `.pipe(Effect.catch(failStorage)); return previous; }).pipe(Effect.onError(() => secrets.remove(secretName).pipe(Effect.ignore))); - // Linking again replaces the old token. A racing link may leave its own - // behind; it is unused either way. + // Linking again replaces the old token, here and on the peer. A racing + // link may leave its own behind; it is unused either way. if (Option.isSome(previous) && previous.value.secret_name !== secretName) { - yield* secrets.remove(previous.value.secret_name).pipe(Effect.ignore); + const replaced = previous.value.secret_name; + yield* revokeReplaced(previous.value).pipe( + Effect.ignore, + Effect.ensuring(secrets.remove(replaced).pipe(Effect.ignore)), + ); } return linked; }); diff --git a/docs/internals/remote.md b/docs/internals/remote.md index 4a348382ac47b..2d1722847b84c 100644 --- a/docs/internals/remote.md +++ b/docs/internals/remote.md @@ -74,7 +74,10 @@ agents work in another. The linking environment signs in to the other's `/mcp` as an outside MCP client, with a pairing code from it, and keeps that session's token in its own secret store. The other environment needs nothing new: it sees one more OAuth client, lists it in Sharing & access, and revokes it there. -Unlinking on the linking side only forgets the token. +Linking again sends the replaced token to `/oauth/mcp/revoke` at the first of +the old link's addresses that answers as that peer, best-effort, so an older +peer keeps that session until it expires. Unlinking on the linking side only +forgets the token. - **The Linked environments beta (`linkedEnvironmentsEnabled`) gates both sides.** Off on the linking side, `PeerLinks.link`, link requests and diff --git a/packages/contracts/src/auth.ts b/packages/contracts/src/auth.ts index 371377d5e905a..86d90db957839 100644 --- a/packages/contracts/src/auth.ts +++ b/packages/contracts/src/auth.ts @@ -654,6 +654,12 @@ export const AuthMcpTokenResult = Schema.Struct({ }); export type AuthMcpTokenResult = typeof AuthMcpTokenResult.Type; +/** RFC 7009. The token revokes itself, so presenting it is the authority. */ +export const AuthMcpTokenRevocationRequest = Schema.Struct({ + token: Schema.RedactedFromValue(Schema.String), +}).pipe(HttpApiSchema.asFormUrlEncoded()); +export type AuthMcpTokenRevocationRequest = typeof AuthMcpTokenRevocationRequest.Type; + export class AuthMcpTokenError extends Schema.Error("AuthMcpTokenError")( { error: Schema.Literals([ diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 8b8cc6b97e2c1..528088d72565c 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -27,6 +27,7 @@ import { AuthMcpTokenError, AuthMcpTokenRequest, AuthMcpTokenResult, + AuthMcpTokenRevocationRequest, AuthPairingCredentialResult, AuthRevokeClientSessionInput, AuthRevokePairingLinkInput, @@ -721,6 +722,12 @@ class EnvironmentMcpOAuthHttpApi extends HttpApiGroup.make("mcpOAuth") success: AuthMcpTokenResult, error: AuthMcpTokenError, }), + ) + .add( + HttpApiEndpoint.post("revoke", "/oauth/mcp/revoke", { + payload: AuthMcpTokenRevocationRequest, + success: HttpApiSchema.Empty(200), + }), ) {} const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({