1import {
2 AuthAccessTokenType,
3 AuthAccessWriteScope,
4 AuthMcpClientAccess,
5 AuthOrchestrationOperateScope,
6 AuthStandardClientScopes,
7 type AuthAccessTokenResult,
8 type AuthBrowserSessionResult,
9 type AuthClientMetadata,
10 type AuthClientSession,
11 type AuthCreatePairingCredentialInput,
12 type AuthEnvironmentScope,
13 type AuthPairingLink,
14 type AuthPairingCredentialResult,
15 type AuthSessionId,
16 type AuthSessionState,
17 authScopeResponse,
18 type ServerAuthDescriptor,
19 type ServerAuthSessionMethod,
20 type AuthWebSocketTicketResult,
21 type AuthWebSocketTicketUse,
22 DpopFailureReason,
23 type DpopFailureReason as DpopFailureReasonType,
24 mcpClientScopes,
25} from "@t3tools/contracts";
26import { isLocalLoopbackHost } from "@t3tools/shared/hostClassification";
27import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
28import * as Context from "effect/Context";
29import * as Digest from "effect/crypto/Digest";
30import * as DateTime from "effect/DateTime";
31import * as Duration from "effect/Duration";
32import * as Effect from "effect/Effect";
33import * as Layer from "effect/Layer";
34import * as Option from "effect/Option";
35import * as Redacted from "effect/Redacted";
36import * as Schema from "effect/Schema";
37import * as HttpServerRequest from "effect/http/HttpServerRequest";
38
39import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
40import * as ServerConfig from "../config.ts";
41import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts";
42import * as PairingGrantStore from "./PairingGrantStore.ts";
43import * as ServerSecretStore from "./ServerSecretStore.ts";
44import * as SessionStore from "./SessionStore.ts";
45import { REUSABLE_DEV_SESSION_EXPIRES_AT, resolveReusableDevAuth } from "./ReusableDevAuth.ts";
46import { verifyRequestDpopProof } from "./dpop.ts";
47import { DESKTOP_RENDERER_ORIGINS } from "../httpCors.ts";
48import * as SqlitePersistence from "../persistence/Sqlite.ts";
49import * as ManagedLimits from "../policy/managedLimits.ts";
50import * as ManagedPolicy from "../policy/ManagedPolicy.ts";
51import { MCP_APPROVAL_SUBJECT_PREFIX } from "../persistence/AuthPairingLinks.ts";
52import { requestUrl } from "../requestUrl.ts";
53
54const DEFAULT_SESSION_SUBJECT = "cli-issued-session";
55export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap";
56
57export interface IssuedPairingLink {
58 readonly id: string;
59 readonly credential: string;
60 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
61 readonly subject: string;
62 readonly label?: string;
63 readonly createdAt: DateTime.Utc;
64 readonly expiresAt: DateTime.Utc;
65}
66
67export interface IssuedBearerSession {
68 readonly sessionId: AuthSessionId;
69 readonly token: string;
70 readonly method: "bearer-access-token";
71 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
72 readonly subject: string;
73 readonly client: AuthClientMetadata;
74 readonly expiresAt: DateTime.Utc;
75}
76
77// MCP client tokens are restricted to /mcp; HTTP API and WebSocket RPC would bypass their access cap.
78const MCP_CLIENT_SUBJECT = "mcp-client";
79const MCP_CLIENT_SESSION_TTL = Duration.days(30);
80
81export interface McpClientSession {
82 readonly sessionId: AuthSessionId;
83 readonly label: string;
84 readonly access: AuthMcpClientAccess;
85 /** Another T3 Code environment's link, whose work here stays within it. */
86 readonly peerLink: boolean;
87}
88
89export interface AuthenticatedSession {
90 readonly sessionId: AuthSessionId;
91 readonly subject: string;
92 readonly method: ServerAuthSessionMethod;
93 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
94 readonly proofKeyThumbprint?: string;
95 readonly expiresAt?: DateTime.DateTime;
96}
97
98const serverAuthInternalErrorContext = {
99 cause: Schema.Defect(),
100};
101
102export class ServerAuthBootstrapCredentialValidationError extends Schema.TaggedError<ServerAuthBootstrapCredentialValidationError>()(
103 "ServerAuthBootstrapCredentialValidationError",
104 {
105 ...serverAuthInternalErrorContext,
106 },
107) {
108 override get message(): string {
109 return "Failed to validate bootstrap credential.";
110 }
111}
112
113export class ServerAuthSessionCredentialValidationError extends Schema.TaggedError<ServerAuthSessionCredentialValidationError>()(
114 "ServerAuthSessionCredentialValidationError",
115 {
116 ...serverAuthInternalErrorContext,
117 },
118) {
119 override get message(): string {
120 return "Failed to validate session credential.";
121 }
122}
123
124export class ServerAuthAuthenticatedSessionIssueError extends Schema.TaggedError<ServerAuthAuthenticatedSessionIssueError>()(
125 "ServerAuthAuthenticatedSessionIssueError",
126 {
127 ...serverAuthInternalErrorContext,
128 },
129) {
130 override get message(): string {
131 return "Failed to issue authenticated session.";
132 }
133}
134
135export class ServerAuthAuthenticatedAccessTokenIssueError extends Schema.TaggedError<ServerAuthAuthenticatedAccessTokenIssueError>()(
136 "ServerAuthAuthenticatedAccessTokenIssueError",
137 {
138 ...serverAuthInternalErrorContext,
139 },
140) {
141 override get message(): string {
142 return "Failed to issue authenticated access token.";
143 }
144}
145
146export class ServerAuthPairingLinkCreationError extends Schema.TaggedError<ServerAuthPairingLinkCreationError>()(
147 "ServerAuthPairingLinkCreationError",
148 {
149 ...serverAuthInternalErrorContext,
150 },
151) {
152 override get message(): string {
153 return "Failed to create pairing link.";
154 }
155}
156
157export class ServerAuthPairingLinksListError extends Schema.TaggedError<ServerAuthPairingLinksListError>()(
158 "ServerAuthPairingLinksListError",
159 {
160 ...serverAuthInternalErrorContext,
161 },
162) {
163 override get message(): string {
164 return "Failed to list pairing links.";
165 }
166}
167
168export class ServerAuthPairingLinkRevocationError extends Schema.TaggedError<ServerAuthPairingLinkRevocationError>()(
169 "ServerAuthPairingLinkRevocationError",
170 {
171 ...serverAuthInternalErrorContext,
172 },
173) {
174 override get message(): string {
175 return "Failed to revoke pairing link.";
176 }
177}
178
179export class ServerAuthSessionTokenIssueError extends Schema.TaggedError<ServerAuthSessionTokenIssueError>()(
180 "ServerAuthSessionTokenIssueError",
181 {
182 ...serverAuthInternalErrorContext,
183 },
184) {
185 override get message(): string {
186 return "Failed to issue session token.";
187 }
188}
189
190export class ServerAuthSessionsListError extends Schema.TaggedError<ServerAuthSessionsListError>()(
191 "ServerAuthSessionsListError",
192 {
193 ...serverAuthInternalErrorContext,
194 },
195) {
196 override get message(): string {
197 return "Failed to list sessions.";
198 }
199}
200
201export class ServerAuthSessionRevocationError extends Schema.TaggedError<ServerAuthSessionRevocationError>()(
202 "ServerAuthSessionRevocationError",
203 {
204 ...serverAuthInternalErrorContext,
205 },
206) {
207 override get message(): string {
208 return "Failed to revoke session.";
209 }
210}
211
212export class ServerAuthOtherSessionsRevocationError extends Schema.TaggedError<ServerAuthOtherSessionsRevocationError>()(
213 "ServerAuthOtherSessionsRevocationError",
214 {
215 ...serverAuthInternalErrorContext,
216 },
217) {
218 override get message(): string {
219 return "Failed to revoke other sessions.";
220 }
221}
222
223export class ServerAuthWebSocketTokenIssueError extends Schema.TaggedError<ServerAuthWebSocketTokenIssueError>()(
224 "ServerAuthWebSocketTokenIssueError",
225 {
226 ...serverAuthInternalErrorContext,
227 },
228) {
229 override get message(): string {
230 return "Failed to issue websocket token.";
231 }
232}
233
234export class ServerAuthDpopReplayStateRecordError extends Schema.TaggedError<ServerAuthDpopReplayStateRecordError>()(
235 "ServerAuthDpopReplayStateRecordError",
236 {
237 ...serverAuthInternalErrorContext,
238 },
239) {
240 override get message(): string {
241 return "Failed to record DPoP proof replay state.";
242 }
243}
244
245export class ServerAuthDpopReplayKeyCalculationError extends Schema.TaggedError<ServerAuthDpopReplayKeyCalculationError>()(
246 "ServerAuthDpopReplayKeyCalculationError",
247 {
248 ...serverAuthInternalErrorContext,
249 },
250) {
251 override get message(): string {
252 return "Failed to calculate DPoP replay key.";
253 }
254}
255
256export class ServerAuthLinkedCloudAccountVerificationError extends Schema.TaggedError<ServerAuthLinkedCloudAccountVerificationError>()(
257 "ServerAuthLinkedCloudAccountVerificationError",
258 {
259 ...serverAuthInternalErrorContext,
260 },
261) {
262 override get message(): string {
263 return "Could not verify the linked cloud account.";
264 }
265}
266
267export class ServerAuthLinkedCloudAccountReadError extends Schema.TaggedError<ServerAuthLinkedCloudAccountReadError>()(
268 "ServerAuthLinkedCloudAccountReadError",
269 {
270 ...serverAuthInternalErrorContext,
271 },
272) {
273 override get message(): string {
274 return "Could not read the linked cloud account.";
275 }
276}
277
278export class ServerAuthLinkedCloudAccountMissingError extends Schema.TaggedError<ServerAuthLinkedCloudAccountMissingError>()(
279 "ServerAuthLinkedCloudAccountMissingError",
280 {},
281) {
282 override get message(): string {
283 return "Cloud linked user is not installed for this environment.";
284 }
285}
286
287export class ServerAuthCloudLinkJwtSigningError extends Schema.TaggedError<ServerAuthCloudLinkJwtSigningError>()(
288 "ServerAuthCloudLinkJwtSigningError",
289 {
290 ...serverAuthInternalErrorContext,
291 },
292) {
293 override get message(): string {
294 return "Failed to sign cloud link JWT.";
295 }
296}
297
298export class ServerAuthCloudMintPublicKeyMissingError extends Schema.TaggedError<ServerAuthCloudMintPublicKeyMissingError>()(
299 "ServerAuthCloudMintPublicKeyMissingError",
300 {},
301) {
302 override get message(): string {
303 return "Cloud mint public key is not installed for this environment.";
304 }
305}
306
307export class ServerAuthCloudRelayIssuerMissingError extends Schema.TaggedError<ServerAuthCloudRelayIssuerMissingError>()(
308 "ServerAuthCloudRelayIssuerMissingError",
309 {},
310) {
311 override get message(): string {
312 return "Cloud relay issuer is not installed for this environment.";
313 }
314}
315
316export class ServerAuthCloudHealthJwtSigningError extends Schema.TaggedError<ServerAuthCloudHealthJwtSigningError>()(
317 "ServerAuthCloudHealthJwtSigningError",
318 {
319 ...serverAuthInternalErrorContext,
320 },
321) {
322 override get message(): string {
323 return "Failed to sign cloud health JWT.";
324 }
325}
326
327export class ServerAuthCloudMintJwtSigningError extends Schema.TaggedError<ServerAuthCloudMintJwtSigningError>()(
328 "ServerAuthCloudMintJwtSigningError",
329 {
330 ...serverAuthInternalErrorContext,
331 },
332) {
333 override get message(): string {
334 return "Failed to sign cloud mint JWT.";
335 }
336}
337
338export const ServerAuthInternalError = Schema.Union([
339 ServerAuthBootstrapCredentialValidationError,
340 ServerAuthSessionCredentialValidationError,
341 ServerAuthAuthenticatedSessionIssueError,
342 ServerAuthAuthenticatedAccessTokenIssueError,
343 ServerAuthPairingLinkCreationError,
344 ServerAuthPairingLinksListError,
345 ServerAuthPairingLinkRevocationError,
346 ServerAuthSessionTokenIssueError,
347 ServerAuthSessionsListError,
348 ServerAuthSessionRevocationError,
349 ServerAuthOtherSessionsRevocationError,
350 ServerAuthWebSocketTokenIssueError,
351 ServerAuthDpopReplayStateRecordError,
352 ServerAuthDpopReplayKeyCalculationError,
353 ServerAuthLinkedCloudAccountVerificationError,
354 ServerAuthLinkedCloudAccountReadError,
355 ServerAuthLinkedCloudAccountMissingError,
356 ServerAuthCloudLinkJwtSigningError,
357 ServerAuthCloudMintPublicKeyMissingError,
358 ServerAuthCloudRelayIssuerMissingError,
359 ServerAuthCloudHealthJwtSigningError,
360 ServerAuthCloudMintJwtSigningError,
361]);
362export type ServerAuthInternalError = typeof ServerAuthInternalError.Type;
363export const isServerAuthInternalError = Schema.is(ServerAuthInternalError);
364
365export class ServerAuthMissingCredentialError extends Schema.TaggedError<ServerAuthMissingCredentialError>()(
366 "ServerAuthMissingCredentialError",
367 {},
368) {
369 override get message(): string {
370 return "Server authentication credential is missing.";
371 }
372}
373
374export class ServerAuthInvalidCredentialError extends Schema.TaggedError<ServerAuthInvalidCredentialError>()(
375 "ServerAuthInvalidCredentialError",
376 {
377 diagnostic: Schema.optional(Schema.String),
378 dpopFailureReason: Schema.optionalKey(DpopFailureReason),
379 cause: Schema.optional(Schema.Defect()),
380 },
381) {
382 override get message(): string {
383 return "Server authentication credential is invalid.";
384 }
385}
386
387export const ServerAuthCredentialError = Schema.Union([
388 ServerAuthMissingCredentialError,
389 ServerAuthInvalidCredentialError,
390]);
391export type ServerAuthCredentialError = typeof ServerAuthCredentialError.Type;
392export const isServerAuthCredentialError = Schema.is(ServerAuthCredentialError);
393export const serverAuthCredentialReason = (
394 error: ServerAuthCredentialError,
395): "missing_credential" | "invalid_credential" =>
396 error._tag === "ServerAuthMissingCredentialError" ? "missing_credential" : "invalid_credential";
397
398export const serverAuthDpopFailureReason = (
399 error: ServerAuthCredentialError,
400): DpopFailureReasonType | undefined =>
401 error._tag === "ServerAuthInvalidCredentialError" ? error.dpopFailureReason : undefined;
402
403export class ServerAuthInvalidScopeError extends Schema.TaggedError<ServerAuthInvalidScopeError>()(
404 "ServerAuthInvalidScopeError",
405 {},
406) {
407 override get message(): string {
408 return "The requested authentication scope is invalid.";
409 }
410}
411
412export class ServerAuthScopeNotGrantedError extends Schema.TaggedError<ServerAuthScopeNotGrantedError>()(
413 "ServerAuthScopeNotGrantedError",
414 {},
415) {
416 override get message(): string {
417 return "The requested authentication scope was not granted.";
418 }
419}
420
421export const ServerAuthInvalidRequestError = Schema.Union([
422 ServerAuthInvalidScopeError,
423 ServerAuthScopeNotGrantedError,
424]);
425export type ServerAuthInvalidRequestError = typeof ServerAuthInvalidRequestError.Type;
426export const isServerAuthInvalidRequestError = Schema.is(ServerAuthInvalidRequestError);
427export const serverAuthInvalidRequestReason = (
428 error: ServerAuthInvalidRequestError,
429): "invalid_scope" | "scope_not_granted" =>
430 error._tag === "ServerAuthInvalidScopeError" ? "invalid_scope" : "scope_not_granted";
431
432export class ServerAuthMcpApprovalCodeError extends Schema.TaggedError<ServerAuthMcpApprovalCodeError>()(
433 "ServerAuthMcpApprovalCodeError",
434 { reason: Schema.Literals(["unknown_or_used", "not_a_pairing_code", "insufficient_scope"]) },
435) {
436 override get message(): string {
437 return this.reason === "insufficient_scope"
438 ? "That pairing code cannot grant this access. It has not been used, so choose an access level it grants, or create a code with the standard scopes."
439 : this.reason === "not_a_pairing_code"
440 ? "That is not a one-time pairing code."
441 : "That pairing code is unknown, expired, or already used.";
442 }
443}
444
445export class ServerAuthForbiddenOperationError extends Schema.TaggedError<ServerAuthForbiddenOperationError>()(
446 "ServerAuthForbiddenOperationError",
447 {},
448) {
449 override get message(): string {
450 return "The current authentication session cannot revoke itself.";
451 }
452}
453
454export class EnvironmentAuth extends Context.Service<
455 EnvironmentAuth,
456 {
457 readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>;
458 readonly getSessionState: (
459 request: HttpServerRequest.HttpServerRequest,
460 ) => Effect.Effect<AuthSessionState, ServerAuthInternalError>;
461 readonly createBrowserSession: (
462 credential: string,
463 requestMetadata: AuthClientMetadata,
464 previousSessionToken?: string,
465 ) => Effect.Effect<
466 {
467 readonly response: AuthBrowserSessionResult;
468 readonly sessionToken: string;
469 readonly cookieName?: string;
470 readonly expireNormalCookie?: boolean;
471 },
472 ServerAuthInvalidCredentialError | ServerAuthInternalError
473 >;
474 readonly exchangeBootstrapCredentialForAccessToken: (
475 credential: string,
476 requestedScopes: ReadonlyArray<AuthEnvironmentScope> | undefined,
477 requestMetadata: AuthClientMetadata,
478 input?: {
479 readonly proofKeyThumbprint?: string;
480 },
481 ) => Effect.Effect<
482 AuthAccessTokenResult,
483 ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError
484 >;
485 readonly createPairingLink: (input?: {
486 readonly ttl?: Duration.Duration;
487 readonly label?: string;
488 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
489 readonly subject?: string;
490 readonly proofKeyThumbprint?: string;
491 readonly purpose?: "startup";
492 readonly mcpApproval?: AuthMcpClientAccess;
493 }) => Effect.Effect<
494 IssuedPairingLink,
495 ServerAuthInternalError | ManagedLimits.ManagedPolicyRefusedError
496 >;
497 readonly issuePairingCredential: (
498 input?: AuthCreatePairingCredentialInput,
499 ) => Effect.Effect<
500 AuthPairingCredentialResult,
501 ServerAuthInternalError | ManagedLimits.ManagedPolicyRefusedError
502 >;
503 readonly issueStartupPairingCredential: () => Effect.Effect<
504 AuthPairingCredentialResult,
505 ServerAuthInternalError
506 >;
507 readonly listPairingLinks: (input?: {
508 readonly excludeSubjects?: ReadonlyArray<string>;
509 }) => Effect.Effect<ReadonlyArray<AuthPairingLink>, ServerAuthInternalError>;
510 readonly revokePairingLink: (id: string) => Effect.Effect<boolean, ServerAuthInternalError>;
511 readonly issueSession: (input?: {
512 readonly ttl?: Duration.Duration;
513 readonly subject?: string;
514 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
515 readonly label?: string;
516 }) => Effect.Effect<IssuedBearerSession, ServerAuthInternalError>;
517 readonly listSessions: () => Effect.Effect<
518 ReadonlyArray<AuthClientSession>,
519 ServerAuthInternalError
520 >;
521 readonly revokeSession: (
522 sessionId: AuthSessionId,
523 ) => Effect.Effect<boolean, ServerAuthInternalError>;
524 readonly listClientSessions: (
525 currentSessionId: AuthSessionId,
526 ) => Effect.Effect<ReadonlyArray<AuthClientSession>, ServerAuthInternalError>;
527 readonly revokeClientSession: (
528 currentSessionId: AuthSessionId,
529 targetSessionId: AuthSessionId,
530 ) => Effect.Effect<boolean, ServerAuthForbiddenOperationError | ServerAuthInternalError>;
531 readonly revokeOtherClientSessions: (
532 currentSessionId: AuthSessionId,
533 ) => Effect.Effect<number, ServerAuthInternalError>;
534 readonly authenticateHttpRequest: (
535 request: HttpServerRequest.HttpServerRequest,
536 ) => Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError>;
537 readonly authenticateWebSocketUpgrade: (
538 request: HttpServerRequest.HttpServerRequest,
539 use: AuthWebSocketTicketUse,
540 ) => Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError>;
541 readonly issueWebSocketTicket: (
542 session: Pick<AuthenticatedSession, "sessionId" | "proofKeyThumbprint">,
543 input?: { readonly use?: AuthWebSocketTicketUse },
544 ) => Effect.Effect<AuthWebSocketTicketResult, ServerAuthInternalError>;
545 readonly issueStartupPairingUrl: (
546 baseUrl: string,
547 ) => Effect.Effect<string, ServerAuthInternalError>;
548 readonly authenticateMcpClient: (
549 request: HttpServerRequest.HttpServerRequest,
550 ) => Effect.Effect<McpClientSession, ServerAuthCredentialError | ServerAuthInternalError>;
551 readonly issueMcpClientSession: (input: {
552 readonly label: string;
553 readonly access: AuthMcpClientAccess;
554 readonly peerLink: boolean;
555 readonly client: AuthClientMetadata;
556 }) => Effect.Effect<
557 {
558 readonly sessionId: AuthSessionId;
559 readonly token: string;
560 readonly expiresAt: DateTime.DateTime;
561 },
562 ServerAuthInternalError
563 >;
564 /**
565 * Spends a code covering all granted scopes and returns the level it approves. A level-bound
566 * code must name `access`. Rejects desktop grants and leaves refused codes unspent.
567 */
568 readonly consumeMcpApprovalCode: (
569 code: string,
570 access: AuthMcpClientAccess,
571 ) => Effect.Effect<
572 AuthMcpClientAccess,
573 ServerAuthMcpApprovalCodeError | ServerAuthInternalError
574 >;
575 }
576>()("t3/auth/EnvironmentAuth") {}
577
578type BootstrapExchangeResult = {
579 readonly response: AuthBrowserSessionResult;
580 readonly sessionToken: string;
581 readonly cookieName?: string;
582 readonly expireNormalCookie?: boolean;
583};
584
585const AUTHORIZATION_PREFIX = "Bearer ";
586const DPOP_AUTHORIZATION_PREFIX = "DPoP ";
587const WebSocketUpgradeQuery = Schema.Struct({
588 wsTicket: Schema.optionalKey(Schema.RedactedFromValue(Schema.String)),
589 dpop: Schema.optionalKey(Schema.RedactedFromValue(Schema.String)),
590});
591const decodeWebSocketUpgradeQuery = Schema.decodeEffect(WebSocketUpgradeQuery);
592
593const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => {
594 const leftCanManage = left.scopes.includes(AuthAccessWriteScope);
595 const rightCanManage = right.scopes.includes(AuthAccessWriteScope);
596 if (leftCanManage !== rightCanManage) {
597 return leftCanManage ? -1 : 1;
598 }
599 if (left.connected !== right.connected) {
600 return left.connected ? -1 : 1;
601 }
602 return right.issuedAt.epochMilliseconds - left.issuedAt.epochMilliseconds;
603};
604
605function toBootstrapExchangeError(
606 cause: PairingGrantStore.BootstrapCredentialError,
607): ServerAuthInvalidCredentialError | ServerAuthInternalError {
608 if (PairingGrantStore.isBootstrapCredentialInternalError(cause)) {
609 return new ServerAuthBootstrapCredentialValidationError({ cause });
610 }
611
612 return new ServerAuthInvalidCredentialError({
613 cause,
614 });
615}
616
617const rejectMcpClientAudience = <S extends { readonly subject: string }>(session: S) =>
618 session.subject === MCP_CLIENT_SUBJECT
619 ? Effect.fail(
620 new ServerAuthInvalidCredentialError({
621 diagnostic: "MCP client sessions are only accepted by the MCP endpoint.",
622 }),
623 ).pipe(Effect.tap(() => Effect.logWarning("Rejected an MCP client session outside /mcp.")))
624 : Effect.succeed(session);
625
626const mapSessionVerificationErrors = <A, R>(
627 effect: Effect.Effect<A, SessionStore.SessionCredentialError, R>,
628): Effect.Effect<A, ServerAuthInvalidCredentialError | ServerAuthInternalError, R> =>
629 effect.pipe(
630 Effect.mapError((cause) =>
631 SessionStore.isSessionCredentialInvalidError(cause)
632 ? new ServerAuthInvalidCredentialError({ cause })
633 : new ServerAuthSessionCredentialValidationError({ cause }),
634 ),
635 );
636
637export function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null {
638 const header = request.headers["authorization"];
639 if (typeof header !== "string" || !/^Bearer /i.test(header)) {
640 return null;
641 }
642 const token = header.slice(AUTHORIZATION_PREFIX.length).trim();
643 return token.length > 0 ? token : null;
644}
645
646function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string | null {
647 const header = request.headers["authorization"];
648 if (typeof header !== "string" || !/^DPoP /i.test(header)) {
649 return null;
650 }
651 const token = header.slice(DPOP_AUTHORIZATION_PREFIX.length).trim();
652 return token.length > 0 ? token : null;
653}
654
655export function selectRequestCredential(
656 request: HttpServerRequest.HttpServerRequest,
657 cookieName: string,
658 legacyCookieName: string | undefined,
659) {
660 const cookieToken = request.cookies[cookieName];
661 if (cookieToken) {
662 return { token: cookieToken, source: "cookie" } as const;
663 }
664
665 const bearerToken = parseBearerToken(request);
666 if (bearerToken !== null) {
667 return { token: bearerToken, source: "bearer" } as const;
668 }
669
670 const dpopToken = parseDpopToken(request);
671 if (dpopToken !== null) {
672 return { token: dpopToken, source: "dpop" } as const;
673 }
674
675 const legacyToken = legacyCookieName ? request.cookies[legacyCookieName] : undefined;
676 if (legacyToken !== undefined) {
677 return { token: legacyToken, source: "legacy-cookie" } as const;
678 }
679
680 return undefined;
681}
682
683type RequestCredentialSource = NonNullable<ReturnType<typeof selectRequestCredential>>["source"];
684
685export const isBrowserCookieCredential = (source: RequestCredentialSource | "dev-cookie") =>
686 source === "cookie" || source === "legacy-cookie" || source === "dev-cookie";
687
688function firstHeaderValue(value: string | undefined): string | undefined {
689 const first = value?.split(",")[0]?.trim().toLowerCase();
690 return first && first.length > 0 ? first : undefined;
691}
692
693// Missing Origin allows same-origin reads and non-browser clients; otherwise require
694// same-origin Sec-Fetch-Site, this server's host (including X-Forwarded-Host), or a trusted app origin.
695function isBrowserOriginAllowed(
696 request: HttpServerRequest.HttpServerRequest,
697 trustedOrigins: ReadonlySet<string>,
698): boolean {
699 const origin = request.headers.origin;
700 if (origin === undefined) return true;
701 if (request.headers["sec-fetch-site"] === "same-origin") return true;
702 if (trustedOrigins.has(origin)) return true;
703 let originHost: string;
704 try {
705 originHost = new URL(origin).host.toLowerCase();
706 } catch {
707 return false;
708 }
709 if (originHost === "") return false;
710 return (
711 originHost === firstHeaderValue(request.headers.host) ||
712 originHost === firstHeaderValue(request.headers["x-forwarded-host"])
713 );
714}
715
716/** @public */
717export const make = Effect.gen(function* () {
718 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
719 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
720 const sessions = yield* SessionStore.SessionStore;
721 const secretStore = yield* ServerSecretStore.ServerSecretStore;
722 const digest = yield* Digest.Digest;
723 const descriptor = yield* policy.getDescriptor();
724 const config = yield* ServerConfig.ServerConfig;
725 const managedPolicy = yield* ManagedPolicy.ManagedPolicy;
726 const devAuth = resolveReusableDevAuth(config);
727
728 const authenticateToken = (
729 token: string,
730 ): Effect.Effect<
731 AuthenticatedSession,
732 ServerAuthInvalidCredentialError | ServerAuthInternalError
733 > =>
734 sessions.verify(token).pipe(
735 Effect.tapError((cause) =>
736 SessionStore.isSessionCredentialInvalidError(cause)
737 ? Effect.logWarning("Rejected authenticated session credential.").pipe(
738 Effect.annotateLogs({
739 reason: cause.message,
740 }),
741 )
742 : Effect.void,
743 ),
744 mapSessionVerificationErrors,
745 Effect.flatMap(rejectMcpClientAudience),
746 Effect.map((session) => ({
747 sessionId: session.sessionId,
748 subject: session.subject,
749 method: session.method,
750 scopes: session.scopes,
751 ...(session.proofKeyThumbprint ? { proofKeyThumbprint: session.proofKeyThumbprint } : {}),
752 ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}),
753 })),
754 );
755
756 const resolveRequestCredential = (request: HttpServerRequest.HttpServerRequest) => {
757 const selectedCredential = selectRequestCredential(
758 request,
759 sessions.cookieName,
760 sessions.legacyCookieName,
761 );
762 const hasAuthorization = request.headers.authorization !== undefined;
763 const devCookieToken = devAuth ? request.cookies[devAuth.cookieName] : undefined;
764 return (
765 selectedCredential ??
766 (!hasAuthorization && devCookieToken !== undefined
767 ? { token: devCookieToken, source: "dev-cookie" as const }
768 : undefined)
769 );
770 };
771
772 // Cookies span ports; reject cookie-authenticated requests and sockets from other same-site origins.
773 const trustedBrowserOrigins = new Set<string>([
774 ...(config.devUrl ? [config.devUrl.origin] : []),
775 ...DESKTOP_RENDERER_ORIGINS,
776 ...config.devAllowedOrigins,
777 ]);
778
779 const authenticateRequest = (
780 request: HttpServerRequest.HttpServerRequest,
781 ): Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError> => {
782 const dpopToken = parseDpopToken(request);
783 const credential = resolveRequestCredential(request);
784 if (!credential?.token) {
785 return Effect.fail(new ServerAuthMissingCredentialError({}));
786 }
787 if (
788 isBrowserCookieCredential(credential.source) &&
789 !isBrowserOriginAllowed(request, trustedBrowserOrigins)
790 ) {
791 return Effect.fail(
792 new ServerAuthInvalidCredentialError({
793 diagnostic: "Browser sessions are only accepted from the app's own origin.",
794 }),
795 );
796 }
797 return authenticateToken(credential.token).pipe(
798 Effect.flatMap((session) => {
799 if (session.proofKeyThumbprint) {
800 if (!dpopToken || dpopToken !== credential.token) {
801 return Effect.fail(
802 new ServerAuthInvalidCredentialError({
803 diagnostic: "DPoP-bound access token requires DPoP authorization.",
804 dpopFailureReason: "invalid_proof",
805 }),
806 );
807 }
808 return verifyRequestDpopProof({
809 request,
810 expectedThumbprint: session.proofKeyThumbprint,
811 expectedAccessToken: dpopToken,
812 }).pipe(
813 Effect.provideService(ServerSecretStore.ServerSecretStore, secretStore),
814 Effect.provideService(Digest.Digest, digest),
815 Effect.as(session),
816 );
817 }
818 if (dpopToken) {
819 return Effect.fail(
820 new ServerAuthInvalidCredentialError({
821 diagnostic: "DPoP authorization requires a proof-bound access token.",
822 dpopFailureReason: "invalid_proof",
823 }),
824 );
825 }
826 return Effect.succeed(session);
827 }),
828 );
829 };
830
831 const getSessionState: EnvironmentAuth["Service"]["getSessionState"] = (request) =>
832 authenticateRequest(request).pipe(
833 Effect.map(
834 (session) =>
835 ({
836 authenticated: true,
837 auth: descriptor,
838 ...authScopeResponse(session.scopes),
839 sessionMethod: session.method,
840 ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}),
841 }) satisfies AuthSessionState,
842 ),
843 Effect.catchIf(isServerAuthCredentialError, () =>
844 Effect.succeed({
845 authenticated: false,
846 auth: descriptor,
847 } satisfies AuthSessionState),
848 ),
849 Effect.withSpan("EnvironmentAuth.getSessionState"),
850 );
851
852 const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = Effect.fn(
853 "EnvironmentAuth.createBrowserSession",
854 )(function* (credential, requestMetadata, previousSessionToken) {
855 if (devAuth?.matches(credential)) {
856 return yield* sessions.verify(credential).pipe(
857 mapSessionVerificationErrors,
858 Effect.flatMap((session) =>
859 DateTime.now.pipe(
860 Effect.map(
861 (now) =>
862 ({
863 response: {
864 authenticated: true,
865 ...authScopeResponse(session.scopes),
866 sessionMethod: session.method,
867 expiresAt: DateTime.toUtc(DateTime.add(now, { days: 30 })),
868 } satisfies AuthBrowserSessionResult,
869 sessionToken: credential,
870 cookieName: devAuth.cookieName,
871 expireNormalCookie: true,
872 }) satisfies BootstrapExchangeResult,
873 ),
874 ),
875 ),
876 Effect.withSpan("EnvironmentAuth.createBrowserSession"),
877 );
878 }
879 const previousSession =
880 previousSessionToken === undefined
881 ? undefined
882 : yield* sessions.verify(previousSessionToken).pipe(
883 Effect.catchIf(SessionStore.isSessionCredentialInvalidError, () => Effect.void),
884 Effect.mapError((cause) => new ServerAuthSessionCredentialValidationError({ cause })),
885 );
886 const grant = yield* bootstrapCredentials
887 .consume(credential)
888 .pipe(Effect.mapError(toBootstrapExchangeError));
889 const session = yield* sessions
890 .issue({
891 method: "browser-session-cookie",
892 subject: grant.subject,
893 scopes: grant.scopes,
894 ...(previousSession?.method === "browser-session-cookie"
895 ? { replaceSessionId: previousSession.sessionId }
896 : {}),
897 client: {
898 ...requestMetadata,
899 ...(grant.label ? { label: grant.label } : {}),
900 },
901 })
902 .pipe(Effect.mapError((cause) => new ServerAuthAuthenticatedSessionIssueError({ cause })));
903 return {
904 response: {
905 authenticated: true,
906 ...authScopeResponse(session.scopes),
907 sessionMethod: session.method,
908 expiresAt: DateTime.toUtc(session.expiresAt),
909 } satisfies AuthBrowserSessionResult,
910 sessionToken: session.token,
911 } satisfies BootstrapExchangeResult;
912 });
913
914 type ResolvedBootstrapGrant = Pick<
915 PairingGrantStore.BootstrapGrant,
916 "scopes" | "subject" | "label"
917 > & {
918 readonly method: PairingGrantStore.BootstrapGrant["method"] | "reusable-dev-token";
919 };
920 const resolveBootstrapGrant = (
921 credential: string,
922 input?: {
923 readonly proofKeyThumbprint?: string;
924 readonly requestedScopes?: ReadonlyArray<AuthEnvironmentScope>;
925 readonly requiredScopes?: ReadonlyArray<AuthEnvironmentScope>;
926 /** Only an agent sign-in opts in to spending a code issued for it. */
927 readonly mcpApproval?: AuthMcpClientAccess;
928 },
929 ): Effect.Effect<
930 ResolvedBootstrapGrant,
931 ServerAuthInvalidCredentialError | ServerAuthInternalError | ServerAuthScopeNotGrantedError
932 > => {
933 if (!devAuth?.matches(credential)) {
934 return bootstrapCredentials
935 .consume(credential, input)
936 .pipe(
937 Effect.mapError((cause) =>
938 cause._tag === "BootstrapCredentialScopeNotGrantedError"
939 ? new ServerAuthScopeNotGrantedError({})
940 : toBootstrapExchangeError(cause),
941 ),
942 );
943 }
944 return sessions.verify(credential).pipe(
945 mapSessionVerificationErrors,
946 Effect.filterOrFail(
947 (session) =>
948 input?.requiredScopes === undefined ||
949 input.requiredScopes.every((scope) => session.scopes.includes(scope)),
950 () => new ServerAuthScopeNotGrantedError({}),
951 ),
952 Effect.map(
953 (session) =>
954 ({
955 method: "reusable-dev-token",
956 scopes: session.scopes,
957 subject: "reusable-dev-token-child",
958 }) satisfies ResolvedBootstrapGrant,
959 ),
960 );
961 };
962
963 const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] =
964 (credential, requestedScopes, requestMetadata, input) => {
965 return resolveBootstrapGrant(credential, {
966 ...input,
967 ...(requestedScopes !== undefined ? { requestedScopes } : {}),
968 }).pipe(
969 Effect.flatMap((grant) =>
970 Effect.gen(function* () {
971 const grantedScopes =
972 requestedScopes === undefined
973 ? grant.scopes
974 : [...new Set(requestedScopes)].filter((scope) => grant.scopes.includes(scope));
975 if (grantedScopes.length === 0) {
976 return yield* new ServerAuthScopeNotGrantedError({});
977 }
978 return yield* sessions
979 .issue({
980 method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token",
981 subject: grant.subject,
982 scopes: grantedScopes,
983 ...(input?.proofKeyThumbprint
984 ? {
985 proofKeyThumbprint: input.proofKeyThumbprint,
986 ttl: Duration.hours(1),
987 }
988 : {}),
989 replaceActiveForSubjectAndMethod: grant.method === "desktop-bootstrap",
990 client: {
991 ...requestMetadata,
992 ...(grant.label ? { label: grant.label } : {}),
993 },
994 })
995 .pipe(
996 Effect.mapError(
997 (cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause }),
998 ),
999 );
1000 }),
1001 ),
1002 Effect.flatMap((session) =>
1003 DateTime.now.pipe(
1004 Effect.map(
1005 (now) =>
1006 ({
1007 access_token: session.token,
1008 issued_token_type: AuthAccessTokenType,
1009 token_type: input?.proofKeyThumbprint ? "DPoP" : "Bearer",
1010 expires_in: Math.max(
1011 0,
1012 Math.floor(
1013 (session.expiresAt.epochMilliseconds - now.epochMilliseconds) / 1000,
1014 ),
1015 ),
1016 scope: encodeOAuthScope(session.scopes),
1017 }) satisfies AuthAccessTokenResult,
1018 ),
1019 ),
1020 ),
1021 Effect.withSpan("EnvironmentAuth.exchangeBootstrapCredentialForAccessToken"),
1022 );
1023 };
1024
1025 const toPairingCredential = (issued: IssuedPairingLink): AuthPairingCredentialResult => ({
1026 id: issued.id,
1027 credential: issued.credential,
1028 ...(issued.label ? { label: issued.label } : {}),
1029 expiresAt: issued.expiresAt,
1030 });
1031
1032 const issuePairingLink = Effect.fn("EnvironmentAuth.issuePairingLink")(
1033 function* (input: Parameters<EnvironmentAuth["Service"]["createPairingLink"]>[0]) {
1034 const createdAt = yield* DateTime.now;
1035 const issued = yield* bootstrapCredentials.issueOneTimeToken({
1036 scopes: input?.scopes ?? AuthStandardClientScopes,
1037 subject: input?.subject ?? "one-time-token",
1038 ...(input?.ttl ? { ttl: input.ttl } : {}),
1039 ...(input?.label ? { label: input.label } : {}),
1040 ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}),
1041 ...(input?.purpose ? { purpose: input.purpose } : {}),
1042 ...(input?.mcpApproval ? { mcpApproval: input.mcpApproval } : {}),
1043 });
1044 return {
1045 id: issued.id,
1046 credential: issued.credential,
1047 scopes: input?.scopes ?? AuthStandardClientScopes,
1048 subject: input?.subject ?? "one-time-token",
1049 ...(issued.label ? { label: issued.label } : {}),
1050 createdAt: DateTime.toUtc(createdAt),
1051 expiresAt: DateTime.toUtc(issued.expiresAt),
1052 } satisfies IssuedPairingLink;
1053 },
1054 Effect.mapError((cause) => new ServerAuthPairingLinkCreationError({ cause })),
1055 );
1056
1057 const createPairingLink: EnvironmentAuth["Service"]["createPairingLink"] = Effect.fn(
1058 "EnvironmentAuth.createPairingLink",
1059 )(function* (input) {
1060 // The startup link is for a browser on this machine; every other link lets a device pair.
1061 if (input?.purpose !== "startup" && !ManagedLimits.allowsRemoteClients(managedPolicy)) {
1062 return yield* ManagedLimits.refuseRemoteClients(
1063 managedPolicy,
1064 "so this server cannot create pairing links",
1065 );
1066 }
1067 return yield* issuePairingLink(input);
1068 });
1069
1070 const listPairingLinks: EnvironmentAuth["Service"]["listPairingLinks"] = (input) =>
1071 bootstrapCredentials.listActive().pipe(
1072 Effect.map((pairingLinks) => {
1073 const excludedSubjects = input?.excludeSubjects ?? [
1074 INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT,
1075 ];
1076 return pairingLinks
1077 .filter(
1078 (pairingLink) =>
1079 !excludedSubjects.includes(pairingLink.subject) &&
1080 !pairingLink.subject.startsWith(MCP_APPROVAL_SUBJECT_PREFIX),
1081 )
1082 .map((link) => ({ ...link, ...authScopeResponse(link.scopes) }))
1083 .toSorted(
1084 (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds,
1085 );
1086 }),
1087 Effect.mapError((cause) => new ServerAuthPairingLinksListError({ cause })),
1088 Effect.withSpan("EnvironmentAuth.listPairingLinks"),
1089 );
1090
1091 const revokePairingLink: EnvironmentAuth["Service"]["revokePairingLink"] = (id) =>
1092 bootstrapCredentials.revoke(id).pipe(
1093 Effect.mapError((cause) => new ServerAuthPairingLinkRevocationError({ cause })),
1094 Effect.withSpan("EnvironmentAuth.revokePairingLink"),
1095 );
1096
1097 const issueSession: EnvironmentAuth["Service"]["issueSession"] = (input) =>
1098 sessions
1099 .issue({
1100 subject: input?.subject ?? DEFAULT_SESSION_SUBJECT,
1101 method: "bearer-access-token",
1102 scopes: input?.scopes ?? AuthStandardClientScopes,
1103 client: {
1104 ...(input?.label ? { label: input.label } : {}),
1105 deviceType: "bot",
1106 },
1107 ...(input?.ttl ? { ttl: input.ttl } : {}),
1108 })
1109 .pipe(
1110 Effect.map(
1111 (issued) =>
1112 ({
1113 sessionId: issued.sessionId,
1114 token: issued.token,
1115 method: "bearer-access-token",
1116 scopes: issued.scopes,
1117 subject: input?.subject ?? DEFAULT_SESSION_SUBJECT,
1118 client: issued.client,
1119 expiresAt: DateTime.toUtc(issued.expiresAt),
1120 }) satisfies IssuedBearerSession,
1121 ),
1122 Effect.mapError((cause) => new ServerAuthSessionTokenIssueError({ cause })),
1123 Effect.withSpan("EnvironmentAuth.issueSession"),
1124 );
1125
1126 const listSessions: EnvironmentAuth["Service"]["listSessions"] = () =>
1127 sessions.listActive().pipe(
1128 Effect.map((activeSessions) => activeSessions.toSorted(bySessionPriority)),
1129 Effect.mapError((cause) => new ServerAuthSessionsListError({ cause })),
1130 Effect.withSpan("EnvironmentAuth.listSessions"),
1131 );
1132
1133 const revokeSession: EnvironmentAuth["Service"]["revokeSession"] = (sessionId) =>
1134 sessions.revoke(sessionId).pipe(
1135 Effect.mapError((cause) => new ServerAuthSessionRevocationError({ cause })),
1136 Effect.withSpan("EnvironmentAuth.revokeSession"),
1137 );
1138
1139 const revokeOtherSessionsExcept = (sessionId: AuthSessionId) =>
1140 sessions.revokeAllExcept(sessionId).pipe(
1141 Effect.mapError((cause) => new ServerAuthOtherSessionsRevocationError({ cause })),
1142 Effect.withSpan("EnvironmentAuth.revokeOtherSessionsExcept"),
1143 );
1144
1145 const issuePairingCredential: EnvironmentAuth["Service"]["issuePairingCredential"] = (input) =>
1146 createPairingLink({
1147 scopes: input?.access
1148 ? mcpClientScopes(input.access)
1149 : (input?.scopes ?? AuthStandardClientScopes),
1150 subject: "one-time-token",
1151 ...(input?.label ? { label: input.label } : {}),
1152 ...(input?.access ? { mcpApproval: input.access } : {}),
1153 }).pipe(
1154 Effect.map((issued) => ({
1155 ...toPairingCredential(issued),
1156 ...(input?.access ? { access: input.access } : {}),
1157 })),
1158 Effect.withSpan("EnvironmentAuth.issuePairingCredential"),
1159 );
1160
1161 const issueStartupPairingCredential: EnvironmentAuth["Service"]["issueStartupPairingCredential"] =
1162 () => {
1163 // Unguarded: startup already refused auth policies the managed policy leaves out.
1164 const fallback = issuePairingLink({
1165 scopes: AuthStandardClientScopes,
1166 subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT,
1167 purpose: "startup",
1168 }).pipe(Effect.map(toPairingCredential));
1169 if (!devAuth) {
1170 return fallback.pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential"));
1171 }
1172 return sessions.verify(devAuth.credential).pipe(
1173 Effect.map(
1174 (session) =>
1175 ({
1176 id: session.sessionId,
1177 credential: devAuth.credential,
1178 label: "Reusable dev token",
1179 expiresAt: DateTime.toUtc(session.expiresAt ?? REUSABLE_DEV_SESSION_EXPIRES_AT),
1180 }) satisfies AuthPairingCredentialResult,
1181 ),
1182 Effect.catch((cause) =>
1183 SessionStore.isSessionCredentialInvalidError(cause)
1184 ? fallback
1185 : Effect.fail(new ServerAuthPairingLinkCreationError({ cause })),
1186 ),
1187 Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential"),
1188 );
1189 };
1190
1191 const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) =>
1192 listSessions().pipe(
1193 Effect.map((clientSessions) =>
1194 clientSessions.map((clientSession): AuthClientSession => ({
1195 ...clientSession,
1196 ...authScopeResponse(clientSession.scopes),
1197 current: clientSession.sessionId === currentSessionId,
1198 })),
1199 ),
1200 Effect.withSpan("EnvironmentAuth.listClientSessions"),
1201 );
1202
1203 const revokeClientSession: EnvironmentAuth["Service"]["revokeClientSession"] = Effect.fn(
1204 "EnvironmentAuth.revokeClientSession",
1205 )(function* (currentSessionId, targetSessionId) {
1206 if (currentSessionId === targetSessionId) {
1207 return yield* new ServerAuthForbiddenOperationError({});
1208 }
1209 return yield* revokeSession(targetSessionId);
1210 });
1211
1212 const revokeOtherClientSessions: EnvironmentAuth["Service"]["revokeOtherClientSessions"] = (
1213 currentSessionId,
1214 ) =>
1215 revokeOtherSessionsExcept(currentSessionId).pipe(
1216 Effect.withSpan("EnvironmentAuth.revokeOtherClientSessions"),
1217 );
1218
1219 const issueStartupPairingUrl: EnvironmentAuth["Service"]["issueStartupPairingUrl"] = (baseUrl) =>
1220 issueStartupPairingCredential().pipe(
1221 Effect.map((issued) => {
1222 const url = new URL(baseUrl);
1223 url.pathname = "/pair";
1224 url.searchParams.delete("token");
1225 url.hash = new URLSearchParams([["token", issued.credential]]).toString();
1226 return url.toString();
1227 }),
1228 Effect.withSpan("EnvironmentAuth.issueStartupPairingUrl"),
1229 );
1230
1231 // Only the RPC socket's ticket is bound to the client's proof key. The media
1232 // routes reuse one ticket across <img>, EventSource and several sockets, and
1233 // each of those would need its own single-use proof.
1234 const issueWebSocketTicket: EnvironmentAuth["Service"]["issueWebSocketTicket"] = (
1235 session,
1236 input,
1237 ) =>
1238 sessions
1239 .issueWebSocketToken(session.sessionId, {
1240 ...(input?.use ? { audience: input.use } : {}),
1241 ...(input?.use === "rpc" && session.proofKeyThumbprint
1242 ? { proofKeyThumbprint: session.proofKeyThumbprint }
1243 : {}),
1244 })
1245 .pipe(
1246 Effect.mapError((cause) => new ServerAuthWebSocketTokenIssueError({ cause })),
1247 Effect.map(
1248 (issued) =>
1249 ({
1250 ticket: Redacted.make(issued.token),
1251 expiresAt: DateTime.toUtc(issued.expiresAt),
1252 }) satisfies AuthWebSocketTicketResult,
1253 ),
1254 Effect.withSpan("EnvironmentAuth.issueWebSocketTicket"),
1255 );
1256
1257 const authenticateHttpRequest: EnvironmentAuth["Service"]["authenticateHttpRequest"] = (
1258 request,
1259 ) =>
1260 authenticateRequest(request).pipe(Effect.withSpan("EnvironmentAuth.authenticateHttpRequest"));
1261
1262 const authenticateWebSocketUpgrade: EnvironmentAuth["Service"]["authenticateWebSocketUpgrade"] =
1263 Effect.fn("EnvironmentAuth.authenticateWebSocketUpgrade")(function* (request, use) {
1264 const url = requestUrl(request);
1265 if (Option.isSome(url)) {
1266 const query = yield* decodeWebSocketUpgradeQuery(
1267 Object.fromEntries(url.value.searchParams),
1268 ).pipe(Effect.orDie);
1269 const websocketTicket = query.wsTicket && Redacted.value(query.wsTicket);
1270 if (websocketTicket && websocketTicket.trim().length > 0) {
1271 const session = yield* sessions
1272 .verifyWebSocketToken(websocketTicket)
1273 .pipe(mapSessionVerificationErrors, Effect.flatMap(rejectMcpClientAudience));
1274 if (session.audience !== undefined && session.audience !== use) {
1275 return yield* new ServerAuthInvalidCredentialError({
1276 diagnostic: `WebSocket ticket was issued for the ${session.audience} route.`,
1277 });
1278 }
1279 if (session.proofKeyThumbprint) {
1280 // WebSocket clients cannot set headers, so the proof travels in the query.
1281 // The proof hashes the ticket; consuming its jti prevents replay after first use.
1282 // Only encrypted transport prevents an active interceptor from racing that use.
1283 const proof = request.headers.dpop ?? (query.dpop && Redacted.value(query.dpop));
1284 if (!proof) {
1285 return yield* new ServerAuthInvalidCredentialError({
1286 diagnostic: "DPoP-bound WebSocket ticket requires a DPoP proof.",
1287 dpopFailureReason: "invalid_proof",
1288 });
1289 }
1290 yield* verifyRequestDpopProof({
1291 request,
1292 proof,
1293 expectedThumbprint: session.proofKeyThumbprint,
1294 expectedAccessToken: websocketTicket,
1295 }).pipe(
1296 Effect.provideService(ServerSecretStore.ServerSecretStore, secretStore),
1297 Effect.provideService(Digest.Digest, digest),
1298 );
1299 }
1300 return {
1301 sessionId: session.sessionId,
1302 subject: session.subject,
1303 method: session.method,
1304 scopes: session.scopes,
1305 ...(session.proofKeyThumbprint
1306 ? { proofKeyThumbprint: session.proofKeyThumbprint }
1307 : {}),
1308 ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}),
1309 };
1310 }
1311 }
1312
1313 return yield* authenticateRequest(request);
1314 });
1315
1316 const authenticateMcpClient: EnvironmentAuth["Service"]["authenticateMcpClient"] = (request) => {
1317 const token = parseBearerToken(request);
1318 if (token === null) return Effect.fail(new ServerAuthMissingCredentialError({}));
1319 return sessions.verify(token).pipe(
1320 mapSessionVerificationErrors,
1321 Effect.flatMap((session) =>
1322 session.subject === MCP_CLIENT_SUBJECT && session.method === "bearer-access-token"
1323 ? Effect.succeed({
1324 sessionId: session.sessionId,
1325 label: session.client.label ?? "MCP client",
1326 access: session.scopes.includes(AuthOrchestrationOperateScope)
1327 ? (session.runtimeModeCeiling ?? "approval-required")
1328 : "read-only",
1329 peerLink: session.peerLink === true,
1330 } satisfies McpClientSession)
1331 : Effect.fail(
1332 new ServerAuthInvalidCredentialError({
1333 diagnostic: "Only MCP client sessions are accepted here.",
1334 }),
1335 ),
1336 ),
1337 Effect.withSpan("EnvironmentAuth.authenticateMcpClient"),
1338 );
1339 };
1340
1341 const issueMcpClientSession: EnvironmentAuth["Service"]["issueMcpClientSession"] = (input) =>
1342 sessions
1343 .issue({
1344 subject: MCP_CLIENT_SUBJECT,
1345 method: "bearer-access-token",
1346 scopes: mcpClientScopes(input.access),
1347 ttl: MCP_CLIENT_SESSION_TTL,
1348 ...(input.access === "read-only" ? {} : { runtimeModeCeiling: input.access }),
1349 peerLink: input.peerLink,
1350 client: { ...input.client, label: input.label, deviceType: "bot" },
1351 })
1352 .pipe(
1353 Effect.map((issued) => ({
1354 sessionId: issued.sessionId,
1355 token: issued.token,
1356 expiresAt: issued.expiresAt,
1357 })),
1358 Effect.mapError((cause) => new ServerAuthSessionTokenIssueError({ cause })),
1359 Effect.withSpan("EnvironmentAuth.issueMcpClientSession"),
1360 );
1361
1362 const consumeMcpApprovalCode: EnvironmentAuth["Service"]["consumeMcpApprovalCode"] = (
1363 code,
1364 access,
1365 ) =>
1366 resolveBootstrapGrant(code.trim(), {
1367 requiredScopes: mcpClientScopes(access),
1368 mcpApproval: access,
1369 }).pipe(
1370 Effect.catchTags({
1371 ServerAuthInvalidCredentialError: () =>
1372 Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "unknown_or_used" })),
1373 ServerAuthScopeNotGrantedError: () =>
1374 Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "insufficient_scope" })),
1375 }),
1376 Effect.flatMap((grant) =>
1377 grant.method !== "one-time-token" && grant.method !== "reusable-dev-token"
1378 ? Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "not_a_pairing_code" }))
1379 : Effect.succeed(mcpApprovalLevel(grant.subject) ?? access),
1380 ),
1381 Effect.withSpan("EnvironmentAuth.consumeMcpApprovalCode"),
1382 );
1383
1384 yield* revokePairedClientsThePolicyDisallows(managedPolicy, {
1385 sessions,
1386 pairingLinks: bootstrapCredentials,
1387 });
1388
1389 return EnvironmentAuth.of({
1390 getDescriptor: Effect.fn("EnvironmentAuth.getDescriptor")(function () {
1391 return Effect.succeed(descriptor);
1392 }),
1393 getSessionState,
1394 createBrowserSession,
1395 exchangeBootstrapCredentialForAccessToken,
1396 createPairingLink,
1397 issuePairingCredential,
1398 issueStartupPairingCredential,
1399 listPairingLinks,
1400 revokePairingLink,
1401 issueSession,
1402 listSessions,
1403 revokeSession,
1404 listClientSessions,
1405 revokeClientSession,
1406 revokeOtherClientSessions,
1407 authenticateHttpRequest,
1408 authenticateWebSocketUpgrade,
1409 issueWebSocketTicket,
1410 issueStartupPairingUrl,
1411 authenticateMcpClient,
1412 issueMcpClientSession,
1413 consumeMcpApprovalCode,
1414 });
1415});
1416
1417const isMcpClientAccess = Schema.is(AuthMcpClientAccess);
1418
1419/** The level an approval code's subject names, or undefined for any other code. */
1420const mcpApprovalLevel = (subject: string): AuthMcpClientAccess | undefined => {
1421 const level = subject.startsWith(MCP_APPROVAL_SUBJECT_PREFIX)
1422 ? subject.slice(MCP_APPROVAL_SUBJECT_PREFIX.length)
1423 : undefined;
1424 return isMcpClientAccess(level) ? level : undefined;
1425};
1426
1427/** Subjects of sessions and links a pairing link created, keyed by how the client paired. */
1428const LINK_PAIRED_SUBJECTS = { link: "one-time-token", connect: "cloud-connect" } as const;
1429
1430/**
1431 * Revokes sessions and unused links from pairing the policy no longer allows, so a policy rolled
1432 * out to a machine that already had phones paired takes effect on the next start.
1433 */
1434const revokePairedClientsThePolicyDisallows = Effect.fn(
1435 "EnvironmentAuth.revokePairedClientsThePolicyDisallows",
1436)(function* (
1437 policy: ManagedPolicy.ManagedPolicy["Service"],
1438 stores: {
1439 readonly sessions: SessionStore.SessionStore["Service"];
1440 readonly pairingLinks: PairingGrantStore.PairingGrantStore["Service"];
1441 },
1442) {
1443 const remoteAllowed = ManagedLimits.allowsRemoteClients(policy);
1444 const disallowed = new Set<string>();
1445 if (!remoteAllowed) disallowed.add(LINK_PAIRED_SUBJECTS.link);
1446 if (!ManagedLimits.allowsConnect(policy)) disallowed.add(LINK_PAIRED_SUBJECTS.connect);
1447 if (disallowed.size === 0) return;
1448 const sessions = (yield* stores.sessions.listActive()).filter(
1449 (session) =>
1450 disallowed.has(session.subject) ||
1451 // The startup link also pairs browsers on this machine; only a device elsewhere goes.
1452 (!remoteAllowed &&
1453 session.subject === INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT &&
1454 session.client.ipAddress !== undefined &&
1455 !isLocalLoopbackHost(session.client.ipAddress)),
1456 );
1457 const links = (yield* stores.pairingLinks.listActive()).filter(
1458 (link) =>
1459 disallowed.has(link.subject) ||
1460 // Approval codes let an agent elsewhere sign in, as plain codes do.
1461 (!remoteAllowed && link.subject.startsWith(MCP_APPROVAL_SUBJECT_PREFIX)),
1462 );
1463 yield* Effect.forEach(sessions, (session) => stores.sessions.revoke(session.sessionId), {
1464 discard: true,
1465 });
1466 yield* Effect.forEach(links, (link) => stores.pairingLinks.revoke(link.id), { discard: true });
1467 if (sessions.length > 0 || links.length > 0) {
1468 yield* Effect.logInfo("Revoked clients the managed policy does not allow.", {
1469 sessions: sessions.length,
1470 pairingLinks: links.length,
1471 });
1472 }
1473});
1474
1475export const layer = Layer.effect(EnvironmentAuth, make).pipe(
1476 Layer.provideMerge(PairingGrantStore.layer),
1477 Layer.provideMerge(SessionStore.layer),
1478 Layer.provideMerge(EnvironmentAuthPolicy.layer),
1479);
1480
1481const layerStorage = Layer.mergeAll(ServerSecretStore.layer, SqlitePersistence.layerCliConfig);
1482
1483export const layerRuntime = layer.pipe(
1484 Layer.provideMerge(ManagedPolicy.layer),
1485 Layer.provideMerge(layerStorage),
1486 Layer.provideMerge(ServerEnvironment.layerIdentity),
1487);