@@ -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+
6694export 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+
409450export 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+
543619const 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