Skip to content
Closed
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
27 changes: 27 additions & 0 deletions apps/server/src/auth/EnvironmentAuth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,33 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodePlatformCrypto.layer))(
}).pipe(Effect.provide(layerEnvironmentAuth())),
);

it.effect("keeps MCP-subject browser cookies out of MCP listing and revocation", () =>
Effect.gen(function* () {
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
const sessions = yield* SessionStore.SessionStore;
const pairing = yield* serverAuth.createPairingLink({ subject: "mcp-client" });
const browser = yield* serverAuth.createBrowserSession(pairing.credential, requestMetadata);
const browserSession = yield* sessions.verify(browser.sessionToken);
const mcp = yield* serverAuth.issueMcpClientSession({
label: "Bot",
access: "read-only",
peerLink: false,
client: requestMetadata,
});

expect(browserSession.subject).toBe("mcp-client");
expect((yield* serverAuth.listMcpClientSessions()).map((entry) => entry.sessionId)).toEqual(
[mcp.sessionId],
);
expect(yield* serverAuth.revokeMcpClientSession(browserSession.sessionId)).toBe(false);
expect((yield* sessions.verify(browser.sessionToken)).sessionId).toBe(
browserSession.sessionId,
);
expect(yield* serverAuth.revokeMcpClientSession(mcp.sessionId)).toBe(true);
expect(yield* serverAuth.listMcpClientSessions()).toEqual([]);
}).pipe(Effect.provide(layerEnvironmentAuth())),
);

it.effect("narrows seeded desktop grants to the requested scopes", () =>
Effect.gen(function* () {
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
Expand Down
33 changes: 32 additions & 1 deletion apps/server/src/auth/EnvironmentAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,11 +548,13 @@ export class EnvironmentAuth extends Context.Service<
readonly authenticateMcpClient: (
request: HttpServerRequest.HttpServerRequest,
) => Effect.Effect<McpClientSession, ServerAuthCredentialError | ServerAuthInternalError>;
/** Issues the bearer credential an OAuth approval grants; 30 days unless `ttl` says otherwise. */
readonly issueMcpClientSession: (input: {
readonly label: string;
readonly access: AuthMcpClientAccess;
readonly peerLink: boolean;
readonly client: AuthClientMetadata;
readonly ttl?: Duration.Duration;
}) => Effect.Effect<
{
readonly sessionId: AuthSessionId;
Expand All @@ -561,6 +563,14 @@ export class EnvironmentAuth extends Context.Service<
},
ServerAuthInternalError
>;
readonly listMcpClientSessions: () => Effect.Effect<
ReadonlyArray<AuthClientSession>,
ServerAuthInternalError
>;
/** Revokes only an MCP client credential; false for any other session id. */
readonly revokeMcpClientSession: (
sessionId: AuthSessionId,
) => Effect.Effect<boolean, ServerAuthInternalError>;
/**
* Spends a code covering all granted scopes and returns the level it approves. A level-bound
* code must name `access`. Rejects desktop grants and leaves refused codes unspent.
Expand Down Expand Up @@ -1344,7 +1354,7 @@ export const make = Effect.gen(function* () {
subject: MCP_CLIENT_SUBJECT,
method: "bearer-access-token",
scopes: mcpClientScopes(input.access),
ttl: MCP_CLIENT_SESSION_TTL,
ttl: input.ttl ?? MCP_CLIENT_SESSION_TTL,
...(input.access === "read-only" ? {} : { runtimeModeCeiling: input.access }),
peerLink: input.peerLink,
client: { ...input.client, label: input.label, deviceType: "bot" },
Expand All @@ -1359,6 +1369,25 @@ export const make = Effect.gen(function* () {
Effect.withSpan("EnvironmentAuth.issueMcpClientSession"),
);

const listMcpClientSessions: EnvironmentAuth["Service"]["listMcpClientSessions"] = () =>
listSessions().pipe(
Effect.map((active) =>
active.filter(
(session) =>
session.subject === MCP_CLIENT_SUBJECT && session.method === "bearer-access-token",
),
),
Effect.withSpan("EnvironmentAuth.listMcpClientSessions"),
);

const revokeMcpClientSession: EnvironmentAuth["Service"]["revokeMcpClientSession"] = Effect.fn(
"EnvironmentAuth.revokeMcpClientSession",
)(function* (sessionId) {
const mcpClients = yield* listMcpClientSessions();
if (!mcpClients.some((session) => session.sessionId === sessionId)) return false;
return yield* revokeSession(sessionId);
});

const consumeMcpApprovalCode: EnvironmentAuth["Service"]["consumeMcpApprovalCode"] = (
code,
access,
Expand Down Expand Up @@ -1410,6 +1439,8 @@ export const make = Effect.gen(function* () {
issueStartupPairingUrl,
authenticateMcpClient,
issueMcpClientSession,
listMcpClientSessions,
revokeMcpClientSession,
consumeMcpApprovalCode,
});
});
Expand Down
98 changes: 96 additions & 2 deletions apps/server/src/cli/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AuthSessionId, AuthStandardClientScopes } from "@t3tools/contracts";
import { AuthMcpClientAccess, AuthSessionId, AuthStandardClientScopes } from "@t3tools/contracts";
import * as Console from "effect/Console";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand All @@ -10,8 +10,10 @@ import { Argument, Command, Flag, GlobalFlag } from "effect/cli";
import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";

import {
formatIssuedMcpClient,
formatIssuedPairingCredential,
formatIssuedSession,
formatMcpClientList,
formatPairingCredentialList,
formatSessionList,
} from "../cliAuthFormat.ts";
Expand Down Expand Up @@ -248,7 +250,99 @@ const sessionCommand = Command.make("session").pipe(
Command.withSubcommands([sessionIssueCommand, sessionListCommand, sessionRevokeCommand]),
);

const mcpIssueCommand = Command.make("issue", {
...authLocationFlags,
access: Flag.Literals("access", AuthMcpClientAccess.literals).pipe(
Flag.withDescription(
"What the client may do, as on the MCP sign-in page: read-only, approval-required (Supervised), auto-accept-edits, auto, or full-access.",
),
),
label: Flag.String("label").pipe(
Flag.withDescription("Name of the client, shown in `t3 auth mcp list`."),
),
ttl: ttlFlag.pipe(Flag.withDescription("TTL, for example `1h` or `90d`. Defaults to 30 days.")),
tokenOnly: tokenOnlyFlag,
json: jsonFlag,
}).pipe(
Command.withDescription(
"Issue a bearer token for an MCP client that cannot finish the OAuth sign-in.",
),
Command.withHandler((flags) =>
runWithEnvironmentAuth(
flags,
(environmentAuth) =>
Effect.gen(function* () {
const issued = yield* environmentAuth.issueMcpClientSession({
label: flags.label,
access: flags.access,
peerLink: false,
client: { deviceType: "bot" },
...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}),
});
yield* Console.log(
formatIssuedMcpClient(
{ ...issued, label: flags.label, access: flags.access },
{ json: flags.json, tokenOnly: flags.tokenOnly },
),
);
}),
{
quietLogs: flags.json || flags.tokenOnly,
},
),
),
);

const mcpListCommand = Command.make("list", {
...authLocationFlags,
json: jsonFlag,
}).pipe(
Command.withDescription(
"List active MCP client credentials, including OAuth sign-ins, without revealing tokens.",
),
Command.withHandler((flags) =>
runWithEnvironmentAuth(
flags,
(environmentAuth) =>
Effect.gen(function* () {
const clients = yield* environmentAuth.listMcpClientSessions();
yield* Console.log(formatMcpClientList(clients, { json: flags.json }));
}),
{
quietLogs: flags.json,
},
),
),
);

const mcpRevokeCommand = Command.make("revoke", {
...authLocationFlags,
sessionId: Argument.String("session-id").pipe(
Argument.withDescription("MCP client credential id to revoke, from `t3 auth mcp list`."),
Argument.withSchema(AuthSessionId),
),
}).pipe(
Command.withDescription("Revoke an MCP client credential. Its next /mcp request gets HTTP 401."),
Command.withHandler((flags) =>
runWithEnvironmentAuth(flags, (environmentAuth) =>
Effect.gen(function* () {
const revoked = yield* environmentAuth.revokeMcpClientSession(flags.sessionId);
yield* Console.log(
revoked
? `Revoked MCP client credential ${flags.sessionId}.\n`
: `No active MCP client credential found for ${flags.sessionId}.\n`,
);
}),
),
),
);

const mcpCommand = Command.make("mcp").pipe(
Command.withDescription("Manage bearer credentials for outside MCP clients."),
Command.withSubcommands([mcpIssueCommand, mcpListCommand, mcpRevokeCommand]),
);

export const authCommand = Command.make("auth").pipe(
Command.withDescription("Manage the local auth control plane for headless deployments."),
Command.withSubcommands([pairingCommand, sessionCommand]),
Command.withSubcommands([pairingCommand, sessionCommand, mcpCommand]),
);
Loading
Loading