Skip to content
Open
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
21 changes: 17 additions & 4 deletions apps/server/src/auth/EnvironmentAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,9 @@ export class EnvironmentAuth extends Context.Service<
readonly authenticateMcpClient: (
request: HttpServerRequest.HttpServerRequest,
) => Effect.Effect<McpClientSession, ServerAuthCredentialError | ServerAuthInternalError>;
readonly authenticateMcpClientToken: (
token: string,
) => Effect.Effect<McpClientSession, ServerAuthCredentialError | ServerAuthInternalError>;
readonly issueMcpClientSession: (input: {
readonly label: string;
readonly access: AuthMcpClientAccess;
Expand Down Expand Up @@ -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"
Expand All @@ -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({
Expand Down Expand Up @@ -1625,6 +1637,7 @@ export const make = Effect.gen(function* () {
issueWebSocketTicket,
issueStartupPairingUrl,
authenticateMcpClient,
authenticateMcpClientToken,
issueMcpClientSession,
consumeMcpApprovalCode,
});
Expand Down
16 changes: 15 additions & 1 deletion apps/server/src/auth/McpOAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
]);
}),
),
);
Expand Down
20 changes: 20 additions & 0 deletions apps/server/src/auth/McpOAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,10 @@ export class McpOAuth extends Context.Service<
readonly urls: McpOAuthUrls;
readonly token: AuthMcpTokenRequest;
}) => Effect.Effect<AuthMcpTokenResult, AuthMcpTokenError>;
/** RFC 7009: ends the MCP client session `token` belongs to. Any other token is left alone. */
readonly revoke: (
token: Redacted.Redacted<string>,
) => Effect.Effect<void, EnvironmentAuth.ServerAuthInternalError>;
}
>()("t3/auth/McpOAuth") {}

Expand Down Expand Up @@ -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({
Expand All @@ -596,6 +615,7 @@ const make = Effect.gen(function* () {
approve,
deny,
exchangeCode,
revoke,
});
});

Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/auth/mcpOAuthHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}),
);
29 changes: 29 additions & 0 deletions apps/server/src/peer/PeerLinks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import * as ServerSettings from "../serverSettings.ts";
import {
descriptorOf,
layerLinkingEnvironment,
linkTo,
servePeer,
type ServedPeer,
} from "./PeerLinks.testkit.ts";
Expand Down Expand Up @@ -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",
() =>
Expand Down
33 changes: 30 additions & 3 deletions apps/server/src/peer/PeerLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>) {
const origins = yield* Effect.forEach(urls, peerOrigin);
Expand Down Expand Up @@ -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;
});
Expand Down
5 changes: 4 additions & 1 deletion docs/internals/remote.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/contracts/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>("AuthMcpTokenError")(
{
error: Schema.Literals([
Expand Down
7 changes: 7 additions & 0 deletions packages/contracts/src/environmentHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
AuthMcpTokenError,
AuthMcpTokenRequest,
AuthMcpTokenResult,
AuthMcpTokenRevocationRequest,
AuthPairingCredentialResult,
AuthRevokeClientSessionInput,
AuthRevokePairingLinkInput,
Expand Down Expand Up @@ -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({
Expand Down
Loading