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