Skip to content

Commit 2c8be58

Browse files
feat(server): outside agents sign in to the T3 MCP server with OAuth (#16336)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 2f85686 commit 2c8be58

29 files changed

Lines changed: 2531 additions & 67 deletions

‎apps/server/src/auth/EnvironmentAuth.ts‎

Lines changed: 161 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@ import {
22
AuthAccessTokenType,
33
AuthAccessWriteScope,
44
AuthAdministrativeScopes,
5+
AuthOrchestrationOperateScope,
6+
AuthOrchestrationReadScope,
57
AuthStandardClientScopes,
68
type AuthAccessTokenResult,
79
type AuthBrowserSessionResult,
810
type AuthClientMetadata,
911
type AuthClientSession,
1012
type AuthCreatePairingCredentialInput,
1113
type AuthEnvironmentScope,
14+
type AuthMcpClientAccess,
1215
type AuthPairingLink,
1316
type AuthPairingCredentialResult,
1417
type AuthSessionId,
@@ -63,6 +66,31 @@ export interface IssuedBearerSession {
6366
readonly expiresAt: DateTime.Utc;
6467
}
6568

69+
/**
70+
* Sessions an MCP client (an agent T3 Code did not launch) obtains through
71+
* OAuth. They are accepted only by `/mcp`, where every action is capped by the
72+
* access the user approved; the HTTP API and WebSocket reject them so an agent
73+
* token cannot reach the full RPC surface around that cap.
74+
*
75+
* A read-only grant holds `orchestration:read` alone. Any other grant also
76+
* holds `orchestration:operate` and carries its runtime-mode ceiling.
77+
*/
78+
const MCP_CLIENT_SUBJECT = "mcp-client";
79+
const MCP_CLIENT_SESSION_TTL = Duration.days(30);
80+
81+
export const mcpClientScopes = (
82+
access: AuthMcpClientAccess,
83+
): ReadonlyArray<AuthEnvironmentScope> =>
84+
access === "read-only"
85+
? [AuthOrchestrationReadScope]
86+
: [AuthOrchestrationReadScope, AuthOrchestrationOperateScope];
87+
88+
export interface McpClientSession {
89+
readonly sessionId: AuthSessionId;
90+
readonly label: string;
91+
readonly access: AuthMcpClientAccess;
92+
}
93+
6694
export interface AuthenticatedSession {
6795
readonly sessionId: AuthSessionId;
6896
readonly subject: string;
@@ -406,6 +434,19 @@ export const serverAuthInvalidRequestReason = (
406434
): "invalid_scope" | "scope_not_granted" =>
407435
error._tag === "ServerAuthInvalidScopeError" ? "invalid_scope" : "scope_not_granted";
408436

437+
export class ServerAuthMcpApprovalCodeError extends Schema.TaggedError<ServerAuthMcpApprovalCodeError>()(
438+
"ServerAuthMcpApprovalCodeError",
439+
{ reason: Schema.Literals(["unknown_or_used", "not_a_pairing_code", "insufficient_scope"]) },
440+
) {
441+
override get message(): string {
442+
return this.reason === "insufficient_scope"
443+
? "That pairing code cannot grant this access, and it is now used up. Create one with the standard scopes, or choose Read only with a new code."
444+
: this.reason === "not_a_pairing_code"
445+
? "That is not a one-time pairing code."
446+
: "That pairing code is unknown, expired, or already used.";
447+
}
448+
}
449+
409450
export class ServerAuthForbiddenOperationError extends Schema.TaggedError<ServerAuthForbiddenOperationError>()(
410451
"ServerAuthForbiddenOperationError",
411452
{},
@@ -502,6 +543,32 @@ export class EnvironmentAuth extends Context.Service<
502543
readonly issueStartupPairingUrl: (
503544
baseUrl: string,
504545
) => Effect.Effect<string, ServerAuthInternalError>;
546+
/** Only bearer `mcp-client` sessions; never cookies or proof-bound tokens. */
547+
readonly authenticateMcpClient: (
548+
request: HttpServerRequest.HttpServerRequest,
549+
) => Effect.Effect<McpClientSession, ServerAuthCredentialError | ServerAuthInternalError>;
550+
readonly issueMcpClientSession: (input: {
551+
readonly label: string;
552+
readonly access: AuthMcpClientAccess;
553+
readonly client: AuthClientMetadata;
554+
}) => Effect.Effect<
555+
{ readonly token: string; readonly expiresAt: DateTime.DateTime },
556+
ServerAuthInternalError
557+
>;
558+
/**
559+
* Spends a one-time pairing code as approval for an MCP client with the
560+
* given access; the code must hold every scope that access grants.
561+
* Proof-bound codes (T3 Connect) are refused without being spent, and
562+
* desktop bootstrap grants never qualify.
563+
*/
564+
readonly consumeMcpApprovalCode: (
565+
code: string,
566+
access: AuthMcpClientAccess,
567+
) => Effect.Effect<void, ServerAuthMcpApprovalCodeError | ServerAuthInternalError>;
568+
/** A browser cookie session only; a bearer header never counts as one. */
569+
readonly authenticateBrowserSession: (
570+
request: HttpServerRequest.HttpServerRequest,
571+
) => Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError>;
505572
}
506573
>()("t3/auth/EnvironmentAuth") {}
507574

@@ -540,6 +607,15 @@ export function toBootstrapExchangeError(
540607
});
541608
}
542609

610+
const rejectMcpClientAudience = <S extends { readonly subject: string }>(session: S) =>
611+
session.subject === MCP_CLIENT_SUBJECT
612+
? Effect.fail(
613+
new ServerAuthInvalidCredentialError({
614+
diagnostic: "MCP client sessions are only accepted by the MCP endpoint.",
615+
}),
616+
).pipe(Effect.tap(() => Effect.logWarning("Rejected an MCP client session outside /mcp.")))
617+
: Effect.succeed(session);
618+
543619
const mapSessionVerificationErrors = <A, R>(
544620
effect: Effect.Effect<A, SessionStore.SessionCredentialError, R>,
545621
): Effect.Effect<A, ServerAuthInvalidCredentialError | ServerAuthInternalError, R> =>
@@ -624,6 +700,8 @@ export const make = Effect.gen(function* () {
624700
)
625701
: Effect.void,
626702
),
703+
mapSessionVerificationErrors,
704+
Effect.flatMap(rejectMcpClientAudience),
627705
Effect.map((session) => ({
628706
sessionId: session.sessionId,
629707
subject: session.subject,
@@ -632,7 +710,6 @@ export const make = Effect.gen(function* () {
632710
...(session.proofKeyThumbprint ? { proofKeyThumbprint: session.proofKeyThumbprint } : {}),
633711
...(session.expiresAt ? { expiresAt: session.expiresAt } : {}),
634712
})),
635-
mapSessionVerificationErrors,
636713
);
637714

638715
const authenticateRequest = (
@@ -1079,21 +1156,99 @@ export const make = Effect.gen(function* () {
10791156
const websocketTicket = requestUrl.value.searchParams.get(WEBSOCKET_TICKET_QUERY_PARAM);
10801157
if (websocketTicket && websocketTicket.trim().length > 0) {
10811158
return yield* sessions.verifyWebSocketToken(websocketTicket).pipe(
1159+
mapSessionVerificationErrors,
1160+
Effect.flatMap(rejectMcpClientAudience),
10821161
Effect.map((session) => ({
10831162
sessionId: session.sessionId,
10841163
subject: session.subject,
10851164
method: session.method,
10861165
scopes: session.scopes,
10871166
...(session.expiresAt ? { expiresAt: session.expiresAt } : {}),
10881167
})),
1089-
mapSessionVerificationErrors,
10901168
);
10911169
}
10921170
}
10931171

10941172
return yield* authenticateRequest(request);
10951173
});
10961174

1175+
const authenticateMcpClient: EnvironmentAuth["Service"]["authenticateMcpClient"] = (request) => {
1176+
const token = parseBearerToken(request);
1177+
if (token === null) return Effect.fail(new ServerAuthMissingCredentialError({}));
1178+
return sessions.verify(token).pipe(
1179+
mapSessionVerificationErrors,
1180+
Effect.flatMap((session) =>
1181+
session.subject === MCP_CLIENT_SUBJECT && session.method === "bearer-access-token"
1182+
? Effect.succeed({
1183+
sessionId: session.sessionId,
1184+
label: session.client.label ?? "MCP client",
1185+
access: session.scopes.includes(AuthOrchestrationOperateScope)
1186+
? (session.runtimeModeCeiling ?? "approval-required")
1187+
: "read-only",
1188+
} satisfies McpClientSession)
1189+
: Effect.fail(
1190+
new ServerAuthInvalidCredentialError({
1191+
diagnostic: "Only MCP client sessions are accepted here.",
1192+
}),
1193+
),
1194+
),
1195+
Effect.withSpan("EnvironmentAuth.authenticateMcpClient"),
1196+
);
1197+
};
1198+
1199+
const issueMcpClientSession: EnvironmentAuth["Service"]["issueMcpClientSession"] = (input) =>
1200+
sessions
1201+
.issue({
1202+
subject: MCP_CLIENT_SUBJECT,
1203+
method: "bearer-access-token",
1204+
scopes: mcpClientScopes(input.access),
1205+
ttl: MCP_CLIENT_SESSION_TTL,
1206+
...(input.access === "read-only" ? {} : { runtimeModeCeiling: input.access }),
1207+
client: { ...input.client, label: input.label, deviceType: "bot" },
1208+
})
1209+
.pipe(
1210+
Effect.map((issued) => ({ token: issued.token, expiresAt: issued.expiresAt })),
1211+
Effect.mapError((cause) => new ServerAuthSessionTokenIssueError({ cause })),
1212+
Effect.withSpan("EnvironmentAuth.issueMcpClientSession"),
1213+
);
1214+
1215+
const authenticateBrowserSession: EnvironmentAuth["Service"]["authenticateBrowserSession"] = (
1216+
request,
1217+
) => {
1218+
const token =
1219+
request.cookies[sessions.cookieName] ??
1220+
(sessions.legacyCookieName ? request.cookies[sessions.legacyCookieName] : undefined) ??
1221+
(devAuth ? request.cookies[devAuth.cookieName] : undefined);
1222+
if (!token) return Effect.fail(new ServerAuthMissingCredentialError({}));
1223+
return authenticateToken(token).pipe(
1224+
Effect.filterOrFail(
1225+
(session) => session.method === "browser-session-cookie" && !session.proofKeyThumbprint,
1226+
() => new ServerAuthInvalidCredentialError({ diagnostic: "Not a browser session." }),
1227+
),
1228+
Effect.withSpan("EnvironmentAuth.authenticateBrowserSession"),
1229+
);
1230+
};
1231+
1232+
const consumeMcpApprovalCode: EnvironmentAuth["Service"]["consumeMcpApprovalCode"] = (
1233+
code,
1234+
access,
1235+
) =>
1236+
// No proof key: a code bound to a T3 Connect client's key fails without being spent.
1237+
resolveBootstrapGrant(code.trim()).pipe(
1238+
Effect.catchTags({
1239+
ServerAuthInvalidCredentialError: () =>
1240+
Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "unknown_or_used" })),
1241+
}),
1242+
Effect.flatMap((grant) =>
1243+
grant.method !== "one-time-token" && grant.method !== "reusable-dev-token"
1244+
? Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "not_a_pairing_code" }))
1245+
: mcpClientScopes(access).every((scope) => grant.scopes.includes(scope))
1246+
? Effect.void
1247+
: Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "insufficient_scope" })),
1248+
),
1249+
Effect.withSpan("EnvironmentAuth.consumeMcpApprovalCode"),
1250+
);
1251+
10971252
return EnvironmentAuth.of({
10981253
getDescriptor: () =>
10991254
Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuth.getDescriptor")),
@@ -1116,6 +1271,10 @@ export const make = Effect.gen(function* () {
11161271
authenticateWebSocketUpgrade,
11171272
issueWebSocketTicket,
11181273
issueStartupPairingUrl,
1274+
authenticateMcpClient,
1275+
issueMcpClientSession,
1276+
consumeMcpApprovalCode,
1277+
authenticateBrowserSession,
11191278
});
11201279
});
11211280

0 commit comments

Comments
 (0)