import { AuthAccessTokenType, AuthAccessWriteScope, AuthOrchestrationOperateScope, AuthStandardClientScopes, type AuthAccessTokenResult, type AuthBrowserSessionResult, type AuthClientMetadata, type AuthClientSession, type AuthCreatePairingCredentialInput, type AuthEnvironmentScope, type AuthMcpClientAccess, type AuthPairingLink, type AuthPairingCredentialResult, type AuthSessionId, type AuthSessionState, authScopeResponse, type ServerAuthDescriptor, type ServerAuthSessionMethod, type AuthWebSocketTicketResult, type AuthWebSocketTicketUse, DpopFailureReason, type DpopFailureReason as DpopFailureReasonType, mcpClientScopes, } from "@t3tools/contracts"; import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as HttpServerRequest from "effect/http/HttpServerRequest"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ServerConfig from "../config.ts"; import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; import * as PairingGrantStore from "./PairingGrantStore.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import * as SessionStore from "./SessionStore.ts"; import { REUSABLE_DEV_SESSION_EXPIRES_AT, resolveReusableDevAuth } from "./ReusableDevAuth.ts"; import { verifyRequestDpopProof } from "./dpop.ts"; import { DESKTOP_RENDERER_ORIGINS } from "../httpCors.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap"; export interface IssuedPairingLink { readonly id: string; readonly credential: string; readonly scopes: ReadonlyArray; readonly subject: string; readonly label?: string; readonly createdAt: DateTime.Utc; readonly expiresAt: DateTime.Utc; } export interface IssuedBearerSession { readonly sessionId: AuthSessionId; readonly token: string; readonly method: "bearer-access-token"; readonly scopes: ReadonlyArray; readonly subject: string; readonly client: AuthClientMetadata; readonly expiresAt: DateTime.Utc; } // MCP client tokens are restricted to /mcp; HTTP API and WebSocket RPC would bypass their access cap. const MCP_CLIENT_SUBJECT = "mcp-client"; const MCP_CLIENT_SESSION_TTL = Duration.days(30); export interface McpClientSession { readonly sessionId: AuthSessionId; readonly label: string; readonly access: AuthMcpClientAccess; /** Another T3 Code environment's link, whose work here stays within it. */ readonly peerLink: boolean; } export interface AuthenticatedSession { readonly sessionId: AuthSessionId; readonly subject: string; readonly method: ServerAuthSessionMethod; readonly scopes: ReadonlyArray; readonly proofKeyThumbprint?: string; readonly expiresAt?: DateTime.DateTime; } const serverAuthInternalErrorContext = { cause: Schema.Defect(), }; export class ServerAuthBootstrapCredentialValidationError extends Schema.TaggedError()( "ServerAuthBootstrapCredentialValidationError", { ...serverAuthInternalErrorContext, }, ) { override get message(): string { return "Failed to validate bootstrap credential."; } } export class ServerAuthSessionCredentialValidationError extends Schema.TaggedError()( "ServerAuthSessionCredentialValidationError", { ...serverAuthInternalErrorContext, }, ) { override get message(): string { return "Failed to validate session credential."; } } export class ServerAuthAuthenticatedSessionIssueError extends Schema.TaggedError()( "ServerAuthAuthenticatedSessionIssueError", { ...serverAuthInternalErrorContext, }, ) { override get message(): string { return "Failed to issue authenticated session."; } } export class ServerAuthAuthenticatedAccessTokenIssueError extends Schema.TaggedError()( "ServerAuthAuthenticatedAccessTokenIssueError", { ...serverAuthInternalErrorContext, }, ) { override get message(): string { return "Failed to issue authenticated access token."; } } export class ServerAuthPairingLinkCreationError extends Schema.TaggedError()( "ServerAuthPairingLinkCreationError", { ...serverAuthInternalErrorContext, }, ) { override get message(): string { return "Failed to create pairing link."; } } export class ServerAuthPairingLinksListError extends Schema.TaggedError()( "ServerAuthPairingLinksListError", { ...serverAuthInternalErrorContext, }, ) { override get message(): string { return "Failed to list pairing links."; } } export class ServerAuthPairingLinkRevocationError extends Schema.TaggedError()( "ServerAuthPairingLinkRevocationError", { ...serverAuthInternalErrorContext, }, ) { override get message(): string { return "Failed to revoke pairing link."; } } export class ServerAuthSessionTokenIssueError extends Schema.TaggedError()( "ServerAuthSessionTokenIssueError", { ...serverAuthInternalErrorContext, }, ) { override get message(): string { return "Failed to issue session token."; } } export class ServerAuthSessionsListError extends Schema.TaggedError()( "ServerAuthSessionsListError", { ...serverAuthInternalErrorContext, }, ) { override get message(): string { return "Failed to list sessions."; } } export class ServerAuthSessionRevocationError extends Schema.TaggedError()( "ServerAuthSessionRevocationError", { ...serverAuthInternalErrorContext, }, ) { override get message(): string { return "Failed to revoke session."; } } export class ServerAuthOtherSessionsRevocationError extends Schema.TaggedError()( "ServerAuthOtherSessionsRevocationError", { ...serverAuthInternalErrorContext, }, ) { override get message(): string { return "Failed to revoke other sessions."; } } export class ServerAuthWebSocketTokenIssueError extends Schema.TaggedError()( "ServerAuthWebSocketTokenIssueError", { ...serverAuthInternalErrorContext, }, ) { override get message(): string { return "Failed to issue websocket token."; } } export class ServerAuthDpopReplayStateRecordError extends Schema.TaggedError()( "ServerAuthDpopReplayStateRecordError", { ...serverAuthInternalErrorContext, }, ) { override get message(): string { return "Failed to record DPoP proof replay state."; } } export class ServerAuthDpopReplayKeyCalculationError extends Schema.TaggedError()( "ServerAuthDpopReplayKeyCalculationError", { ...serverAuthInternalErrorContext, }, ) { override get message(): string { return "Failed to calculate DPoP replay key."; } } export class ServerAuthLinkedCloudAccountVerificationError extends Schema.TaggedError()( "ServerAuthLinkedCloudAccountVerificationError", { ...serverAuthInternalErrorContext, }, ) { override get message(): string { return "Could not verify the linked cloud account."; } } export class ServerAuthLinkedCloudAccountReadError extends Schema.TaggedError()( "ServerAuthLinkedCloudAccountReadError", { ...serverAuthInternalErrorContext, }, ) { override get message(): string { return "Could not read the linked cloud account."; } } export class ServerAuthLinkedCloudAccountMissingError extends Schema.TaggedError()( "ServerAuthLinkedCloudAccountMissingError", {}, ) { override get message(): string { return "Cloud linked user is not installed for this environment."; } } export class ServerAuthCloudLinkJwtSigningError extends Schema.TaggedError()( "ServerAuthCloudLinkJwtSigningError", { ...serverAuthInternalErrorContext, }, ) { override get message(): string { return "Failed to sign cloud link JWT."; } } export class ServerAuthCloudMintPublicKeyMissingError extends Schema.TaggedError()( "ServerAuthCloudMintPublicKeyMissingError", {}, ) { override get message(): string { return "Cloud mint public key is not installed for this environment."; } } export class ServerAuthCloudRelayIssuerMissingError extends Schema.TaggedError()( "ServerAuthCloudRelayIssuerMissingError", {}, ) { override get message(): string { return "Cloud relay issuer is not installed for this environment."; } } export class ServerAuthCloudHealthJwtSigningError extends Schema.TaggedError()( "ServerAuthCloudHealthJwtSigningError", { ...serverAuthInternalErrorContext, }, ) { override get message(): string { return "Failed to sign cloud health JWT."; } } export class ServerAuthCloudMintJwtSigningError extends Schema.TaggedError()( "ServerAuthCloudMintJwtSigningError", { ...serverAuthInternalErrorContext, }, ) { override get message(): string { return "Failed to sign cloud mint JWT."; } } export const ServerAuthInternalError = Schema.Union([ ServerAuthBootstrapCredentialValidationError, ServerAuthSessionCredentialValidationError, ServerAuthAuthenticatedSessionIssueError, ServerAuthAuthenticatedAccessTokenIssueError, ServerAuthPairingLinkCreationError, ServerAuthPairingLinksListError, ServerAuthPairingLinkRevocationError, ServerAuthSessionTokenIssueError, ServerAuthSessionsListError, ServerAuthSessionRevocationError, ServerAuthOtherSessionsRevocationError, ServerAuthWebSocketTokenIssueError, ServerAuthDpopReplayStateRecordError, ServerAuthDpopReplayKeyCalculationError, ServerAuthLinkedCloudAccountVerificationError, ServerAuthLinkedCloudAccountReadError, ServerAuthLinkedCloudAccountMissingError, ServerAuthCloudLinkJwtSigningError, ServerAuthCloudMintPublicKeyMissingError, ServerAuthCloudRelayIssuerMissingError, ServerAuthCloudHealthJwtSigningError, ServerAuthCloudMintJwtSigningError, ]); export type ServerAuthInternalError = typeof ServerAuthInternalError.Type; export const isServerAuthInternalError = Schema.is(ServerAuthInternalError); export class ServerAuthMissingCredentialError extends Schema.TaggedError()( "ServerAuthMissingCredentialError", {}, ) { override get message(): string { return "Server authentication credential is missing."; } } export class ServerAuthInvalidCredentialError extends Schema.TaggedError()( "ServerAuthInvalidCredentialError", { diagnostic: Schema.optional(Schema.String), dpopFailureReason: Schema.optionalKey(DpopFailureReason), cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { return "Server authentication credential is invalid."; } } export const ServerAuthCredentialError = Schema.Union([ ServerAuthMissingCredentialError, ServerAuthInvalidCredentialError, ]); export type ServerAuthCredentialError = typeof ServerAuthCredentialError.Type; export const isServerAuthCredentialError = Schema.is(ServerAuthCredentialError); export const serverAuthCredentialReason = ( error: ServerAuthCredentialError, ): "missing_credential" | "invalid_credential" => error._tag === "ServerAuthMissingCredentialError" ? "missing_credential" : "invalid_credential"; export const serverAuthDpopFailureReason = ( error: ServerAuthCredentialError, ): DpopFailureReasonType | undefined => error._tag === "ServerAuthInvalidCredentialError" ? error.dpopFailureReason : undefined; export class ServerAuthInvalidScopeError extends Schema.TaggedError()( "ServerAuthInvalidScopeError", {}, ) { override get message(): string { return "The requested authentication scope is invalid."; } } export class ServerAuthScopeNotGrantedError extends Schema.TaggedError()( "ServerAuthScopeNotGrantedError", {}, ) { override get message(): string { return "The requested authentication scope was not granted."; } } export const ServerAuthInvalidRequestError = Schema.Union([ ServerAuthInvalidScopeError, ServerAuthScopeNotGrantedError, ]); export type ServerAuthInvalidRequestError = typeof ServerAuthInvalidRequestError.Type; export const isServerAuthInvalidRequestError = Schema.is(ServerAuthInvalidRequestError); export const serverAuthInvalidRequestReason = ( error: ServerAuthInvalidRequestError, ): "invalid_scope" | "scope_not_granted" => error._tag === "ServerAuthInvalidScopeError" ? "invalid_scope" : "scope_not_granted"; export class ServerAuthMcpApprovalCodeError extends Schema.TaggedError()( "ServerAuthMcpApprovalCodeError", { reason: Schema.Literals(["unknown_or_used", "not_a_pairing_code", "insufficient_scope"]) }, ) { override get message(): string { return this.reason === "insufficient_scope" ? "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." : this.reason === "not_a_pairing_code" ? "That is not a one-time pairing code." : "That pairing code is unknown, expired, or already used."; } } export class ServerAuthForbiddenOperationError extends Schema.TaggedError()( "ServerAuthForbiddenOperationError", {}, ) { override get message(): string { return "The current authentication session cannot revoke itself."; } } export class EnvironmentAuth extends Context.Service< EnvironmentAuth, { readonly getDescriptor: () => Effect.Effect; readonly getSessionState: ( request: HttpServerRequest.HttpServerRequest, ) => Effect.Effect; readonly createBrowserSession: ( credential: string, requestMetadata: AuthClientMetadata, previousSessionToken?: string, ) => Effect.Effect< { readonly response: AuthBrowserSessionResult; readonly sessionToken: string; readonly cookieName?: string; readonly expireNormalCookie?: boolean; }, ServerAuthInvalidCredentialError | ServerAuthInternalError >; readonly exchangeBootstrapCredentialForAccessToken: ( credential: string, requestedScopes: ReadonlyArray | undefined, requestMetadata: AuthClientMetadata, input?: { readonly proofKeyThumbprint?: string; }, ) => Effect.Effect< AuthAccessTokenResult, ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError >; readonly createPairingLink: (input?: { readonly ttl?: Duration.Duration; readonly label?: string; readonly scopes?: ReadonlyArray; readonly subject?: string; readonly proofKeyThumbprint?: string; readonly purpose?: "startup"; }) => Effect.Effect; readonly issuePairingCredential: ( input?: AuthCreatePairingCredentialInput, ) => Effect.Effect; readonly issueStartupPairingCredential: () => Effect.Effect< AuthPairingCredentialResult, ServerAuthInternalError >; readonly listPairingLinks: (input?: { readonly excludeSubjects?: ReadonlyArray; }) => Effect.Effect, ServerAuthInternalError>; readonly revokePairingLink: (id: string) => Effect.Effect; readonly issueSession: (input?: { readonly ttl?: Duration.Duration; readonly subject?: string; readonly scopes?: ReadonlyArray; readonly label?: string; }) => Effect.Effect; readonly listSessions: () => Effect.Effect< ReadonlyArray, ServerAuthInternalError >; readonly revokeSession: ( sessionId: AuthSessionId, ) => Effect.Effect; readonly listClientSessions: ( currentSessionId: AuthSessionId, ) => Effect.Effect, ServerAuthInternalError>; readonly revokeClientSession: ( currentSessionId: AuthSessionId, targetSessionId: AuthSessionId, ) => Effect.Effect; readonly revokeOtherClientSessions: ( currentSessionId: AuthSessionId, ) => Effect.Effect; readonly authenticateHttpRequest: ( request: HttpServerRequest.HttpServerRequest, ) => Effect.Effect; readonly authenticateWebSocketUpgrade: ( request: HttpServerRequest.HttpServerRequest, use: AuthWebSocketTicketUse, ) => Effect.Effect; readonly issueWebSocketTicket: ( session: Pick, input?: { readonly use?: AuthWebSocketTicketUse }, ) => Effect.Effect; readonly issueStartupPairingUrl: ( baseUrl: string, ) => Effect.Effect; readonly authenticateMcpClient: ( request: HttpServerRequest.HttpServerRequest, ) => Effect.Effect; readonly issueMcpClientSession: (input: { readonly label: string; readonly access: AuthMcpClientAccess; readonly peerLink: boolean; readonly client: AuthClientMetadata; }) => Effect.Effect< { readonly sessionId: AuthSessionId; readonly token: string; readonly expiresAt: DateTime.DateTime; }, ServerAuthInternalError >; /** Spends a code covering all granted scopes. Rejects desktop grants and leaves refused proof-bound codes unspent. */ readonly consumeMcpApprovalCode: ( code: string, access: AuthMcpClientAccess, ) => Effect.Effect; readonly authenticateBrowserSession: ( request: HttpServerRequest.HttpServerRequest, ) => Effect.Effect; } >()("t3/auth/EnvironmentAuth") {} type BootstrapExchangeResult = { readonly response: AuthBrowserSessionResult; readonly sessionToken: string; readonly cookieName?: string; readonly expireNormalCookie?: boolean; }; const AUTHORIZATION_PREFIX = "Bearer "; const DPOP_AUTHORIZATION_PREFIX = "DPoP "; const WEBSOCKET_TICKET_QUERY_PARAM = "wsTicket"; const WEBSOCKET_PROOF_QUERY_PARAM = "dpop"; const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => { const leftCanManage = left.scopes.includes(AuthAccessWriteScope); const rightCanManage = right.scopes.includes(AuthAccessWriteScope); if (leftCanManage !== rightCanManage) { return leftCanManage ? -1 : 1; } if (left.connected !== right.connected) { return left.connected ? -1 : 1; } return right.issuedAt.epochMilliseconds - left.issuedAt.epochMilliseconds; }; function toBootstrapExchangeError( cause: PairingGrantStore.BootstrapCredentialError, ): ServerAuthInvalidCredentialError | ServerAuthInternalError { if (PairingGrantStore.isBootstrapCredentialInternalError(cause)) { return new ServerAuthBootstrapCredentialValidationError({ cause }); } return new ServerAuthInvalidCredentialError({ cause, }); } const rejectMcpClientAudience = (session: S) => session.subject === MCP_CLIENT_SUBJECT ? Effect.fail( new ServerAuthInvalidCredentialError({ diagnostic: "MCP client sessions are only accepted by the MCP endpoint.", }), ).pipe(Effect.tap(() => Effect.logWarning("Rejected an MCP client session outside /mcp."))) : Effect.succeed(session); const mapSessionVerificationErrors = ( effect: Effect.Effect, ): Effect.Effect => effect.pipe( Effect.mapError((cause) => SessionStore.isSessionCredentialInvalidError(cause) ? new ServerAuthInvalidCredentialError({ cause }) : new ServerAuthSessionCredentialValidationError({ cause }), ), ); function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null { const header = request.headers["authorization"]; if (typeof header !== "string" || !header.startsWith(AUTHORIZATION_PREFIX)) { return null; } const token = header.slice(AUTHORIZATION_PREFIX.length).trim(); return token.length > 0 ? token : null; } function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string | null { const header = request.headers["authorization"]; if (typeof header !== "string" || !header.startsWith(DPOP_AUTHORIZATION_PREFIX)) { return null; } const token = header.slice(DPOP_AUTHORIZATION_PREFIX.length).trim(); return token.length > 0 ? token : null; } export function selectRequestCredential( request: HttpServerRequest.HttpServerRequest, cookieName: string, legacyCookieName: string | undefined, ) { const cookieToken = request.cookies[cookieName]; if (cookieToken !== undefined) { return { token: cookieToken, source: "cookie" } as const; } const bearerToken = parseBearerToken(request); if (bearerToken !== null) { return { token: bearerToken, source: "bearer" } as const; } const dpopToken = parseDpopToken(request); if (dpopToken !== null) { return { token: dpopToken, source: "dpop" } as const; } const legacyToken = legacyCookieName ? request.cookies[legacyCookieName] : undefined; if (legacyToken !== undefined) { return { token: legacyToken, source: "legacy-cookie" } as const; } return undefined; } type RequestCredentialSource = NonNullable>["source"]; export const isBrowserCookieCredential = (source: RequestCredentialSource | "dev-cookie") => source === "cookie" || source === "legacy-cookie" || source === "dev-cookie"; function firstHeaderValue(value: string | undefined): string | undefined { const first = value?.split(",")[0]?.trim().toLowerCase(); return first && first.length > 0 ? first : undefined; } // Missing Origin allows same-origin reads and non-browser clients; otherwise require // same-origin Sec-Fetch-Site, this server's host (including X-Forwarded-Host), or a trusted app origin. function isBrowserOriginAllowed( request: HttpServerRequest.HttpServerRequest, trustedOrigins: ReadonlySet, ): boolean { const origin = request.headers.origin; if (origin === undefined) return true; if (request.headers["sec-fetch-site"] === "same-origin") return true; if (trustedOrigins.has(origin)) return true; let originHost: string; try { originHost = new URL(origin).host.toLowerCase(); } catch { return false; } if (originHost === "") return false; return ( originHost === firstHeaderValue(request.headers.host) || originHost === firstHeaderValue(request.headers["x-forwarded-host"]) ); } /** @public */ export const make = Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; const secretStore = yield* ServerSecretStore.ServerSecretStore; const crypto = yield* Crypto.Crypto; const descriptor = yield* policy.getDescriptor(); const config = yield* ServerConfig.ServerConfig; const devAuth = resolveReusableDevAuth(config); const authenticateToken = ( token: string, ): Effect.Effect< AuthenticatedSession, ServerAuthInvalidCredentialError | ServerAuthInternalError > => sessions.verify(token).pipe( Effect.tapError((cause) => SessionStore.isSessionCredentialInvalidError(cause) ? Effect.logWarning("Rejected authenticated session credential.").pipe( Effect.annotateLogs({ reason: cause.message, }), ) : Effect.void, ), mapSessionVerificationErrors, Effect.flatMap(rejectMcpClientAudience), Effect.map((session) => ({ sessionId: session.sessionId, subject: session.subject, method: session.method, scopes: session.scopes, ...(session.proofKeyThumbprint ? { proofKeyThumbprint: session.proofKeyThumbprint } : {}), ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), })), ); const resolveRequestCredential = (request: HttpServerRequest.HttpServerRequest) => { const selectedCredential = selectRequestCredential( request, sessions.cookieName, sessions.legacyCookieName, ); const hasAuthorization = request.headers.authorization !== undefined; const devCookieToken = devAuth ? request.cookies[devAuth.cookieName] : undefined; return ( selectedCredential ?? (!hasAuthorization && devCookieToken !== undefined ? { token: devCookieToken, source: "dev-cookie" as const } : undefined) ); }; // Cookies span ports; reject cookie-authenticated requests and sockets from other same-site origins. const trustedBrowserOrigins = new Set([ ...(config.devUrl ? [config.devUrl.origin] : []), ...DESKTOP_RENDERER_ORIGINS, ...config.devAllowedOrigins, ]); const authenticateRequest = ( request: HttpServerRequest.HttpServerRequest, ): Effect.Effect => { const dpopToken = parseDpopToken(request); const credential = resolveRequestCredential(request); if (!credential?.token) { return Effect.fail(new ServerAuthMissingCredentialError({})); } if ( isBrowserCookieCredential(credential.source) && !isBrowserOriginAllowed(request, trustedBrowserOrigins) ) { return Effect.fail( new ServerAuthInvalidCredentialError({ diagnostic: "Browser sessions are only accepted from the app's own origin.", }), ); } return authenticateToken(credential.token).pipe( Effect.flatMap((session) => { if (session.proofKeyThumbprint) { if (!dpopToken || dpopToken !== credential.token) { return Effect.fail( new ServerAuthInvalidCredentialError({ diagnostic: "DPoP-bound access token requires DPoP authorization.", dpopFailureReason: "invalid_proof", }), ); } return verifyRequestDpopProof({ request, expectedThumbprint: session.proofKeyThumbprint, expectedAccessToken: dpopToken, }).pipe( Effect.provideService(ServerSecretStore.ServerSecretStore, secretStore), Effect.provideService(Crypto.Crypto, crypto), Effect.as(session), ); } if (dpopToken) { return Effect.fail( new ServerAuthInvalidCredentialError({ diagnostic: "DPoP authorization requires a proof-bound access token.", dpopFailureReason: "invalid_proof", }), ); } return Effect.succeed(session); }), ); }; const getSessionState: EnvironmentAuth["Service"]["getSessionState"] = (request) => authenticateRequest(request).pipe( Effect.map( (session) => ({ authenticated: true, auth: descriptor, ...authScopeResponse(session.scopes), sessionMethod: session.method, ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}), }) satisfies AuthSessionState, ), Effect.catchIf(isServerAuthCredentialError, () => Effect.succeed({ authenticated: false, auth: descriptor, } satisfies AuthSessionState), ), Effect.withSpan("EnvironmentAuth.getSessionState"), ); const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = Effect.fn( "EnvironmentAuth.createBrowserSession", )(function* (credential, requestMetadata, previousSessionToken) { if (devAuth?.matches(credential)) { return yield* sessions.verify(credential).pipe( mapSessionVerificationErrors, Effect.flatMap((session) => DateTime.now.pipe( Effect.map( (now) => ({ response: { authenticated: true, ...authScopeResponse(session.scopes), sessionMethod: session.method, expiresAt: DateTime.toUtc(DateTime.add(now, { days: 30 })), } satisfies AuthBrowserSessionResult, sessionToken: credential, cookieName: devAuth.cookieName, expireNormalCookie: true, }) satisfies BootstrapExchangeResult, ), ), ), Effect.withSpan("EnvironmentAuth.createBrowserSession"), ); } const previousSession = previousSessionToken === undefined ? undefined : yield* sessions.verify(previousSessionToken).pipe( Effect.catchIf(SessionStore.isSessionCredentialInvalidError, () => Effect.void), Effect.mapError((cause) => new ServerAuthSessionCredentialValidationError({ cause })), ); const grant = yield* bootstrapCredentials .consume(credential) .pipe(Effect.mapError(toBootstrapExchangeError)); const session = yield* sessions .issue({ method: "browser-session-cookie", subject: grant.subject, scopes: grant.scopes, ...(previousSession?.method === "browser-session-cookie" ? { replaceSessionId: previousSession.sessionId } : {}), client: { ...requestMetadata, ...(grant.label ? { label: grant.label } : {}), }, }) .pipe(Effect.mapError((cause) => new ServerAuthAuthenticatedSessionIssueError({ cause }))); return { response: { authenticated: true, ...authScopeResponse(session.scopes), sessionMethod: session.method, expiresAt: DateTime.toUtc(session.expiresAt), } satisfies AuthBrowserSessionResult, sessionToken: session.token, } satisfies BootstrapExchangeResult; }); type ResolvedBootstrapGrant = Pick< PairingGrantStore.BootstrapGrant, "scopes" | "subject" | "label" > & { readonly method: PairingGrantStore.BootstrapGrant["method"] | "reusable-dev-token"; }; const resolveBootstrapGrant = ( credential: string, input?: { readonly proofKeyThumbprint?: string; readonly requestedScopes?: ReadonlyArray; readonly requiredScopes?: ReadonlyArray; }, ): Effect.Effect< ResolvedBootstrapGrant, ServerAuthInvalidCredentialError | ServerAuthInternalError | ServerAuthScopeNotGrantedError > => { if (!devAuth?.matches(credential)) { return bootstrapCredentials .consume(credential, input) .pipe( Effect.mapError((cause) => cause._tag === "BootstrapCredentialScopeNotGrantedError" ? new ServerAuthScopeNotGrantedError({}) : toBootstrapExchangeError(cause), ), ); } return sessions.verify(credential).pipe( mapSessionVerificationErrors, Effect.filterOrFail( (session) => input?.requiredScopes === undefined || input.requiredScopes.every((scope) => session.scopes.includes(scope)), () => new ServerAuthScopeNotGrantedError({}), ), Effect.map( (session) => ({ method: "reusable-dev-token", scopes: session.scopes, subject: "reusable-dev-token-child", }) satisfies ResolvedBootstrapGrant, ), ); }; const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = (credential, requestedScopes, requestMetadata, input) => { return resolveBootstrapGrant(credential, { ...input, ...(requestedScopes !== undefined ? { requestedScopes } : {}), }).pipe( Effect.flatMap((grant) => Effect.gen(function* () { const grantedScopes = requestedScopes === undefined ? grant.scopes : [...new Set(requestedScopes)].filter((scope) => grant.scopes.includes(scope)); if (grantedScopes.length === 0) { return yield* new ServerAuthScopeNotGrantedError({}); } return yield* sessions .issue({ method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token", subject: grant.subject, scopes: grantedScopes, ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint, ttl: Duration.hours(1), } : {}), replaceActiveForSubjectAndMethod: grant.method === "desktop-bootstrap", client: { ...requestMetadata, ...(grant.label ? { label: grant.label } : {}), }, }) .pipe( Effect.mapError( (cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause }), ), ); }), ), Effect.flatMap((session) => DateTime.now.pipe( Effect.map( (now) => ({ access_token: session.token, issued_token_type: AuthAccessTokenType, token_type: input?.proofKeyThumbprint ? "DPoP" : "Bearer", expires_in: Math.max( 0, Math.floor( (session.expiresAt.epochMilliseconds - now.epochMilliseconds) / 1000, ), ), scope: encodeOAuthScope(session.scopes), }) satisfies AuthAccessTokenResult, ), ), ), Effect.withSpan("EnvironmentAuth.exchangeBootstrapCredentialForAccessToken"), ); }; const issuePairingCredentialForSubject = (input: { readonly scopes: ReadonlyArray; readonly subject: string; readonly label?: string; readonly purpose?: "startup"; }) => createPairingLink({ scopes: input.scopes, subject: input.subject, ...(input.label ? { label: input.label } : {}), ...(input.purpose ? { purpose: input.purpose } : {}), }).pipe( Effect.map( (issued) => ({ id: issued.id, credential: issued.credential, ...(issued.label ? { label: issued.label } : {}), expiresAt: issued.expiresAt, }) satisfies AuthPairingCredentialResult, ), ); const createPairingLink: EnvironmentAuth["Service"]["createPairingLink"] = Effect.fn( "EnvironmentAuth.createPairingLink", )( function* (input) { const createdAt = yield* DateTime.now; const issued = yield* bootstrapCredentials.issueOneTimeToken({ scopes: input?.scopes ?? AuthStandardClientScopes, subject: input?.subject ?? "one-time-token", ...(input?.ttl ? { ttl: input.ttl } : {}), ...(input?.label ? { label: input.label } : {}), ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}), ...(input?.purpose ? { purpose: input.purpose } : {}), }); return { id: issued.id, credential: issued.credential, scopes: input?.scopes ?? AuthStandardClientScopes, subject: input?.subject ?? "one-time-token", ...(issued.label ? { label: issued.label } : {}), createdAt: DateTime.toUtc(createdAt), expiresAt: DateTime.toUtc(issued.expiresAt), } satisfies IssuedPairingLink; }, Effect.mapError((cause) => new ServerAuthPairingLinkCreationError({ cause })), ); const listPairingLinks: EnvironmentAuth["Service"]["listPairingLinks"] = (input) => bootstrapCredentials.listActive().pipe( Effect.map((pairingLinks) => { const excludedSubjects = input?.excludeSubjects ?? [ INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT, ]; return pairingLinks .filter((pairingLink) => !excludedSubjects.includes(pairingLink.subject)) .map((link) => ({ ...link, ...authScopeResponse(link.scopes) })) .toSorted( (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds, ); }), Effect.mapError((cause) => new ServerAuthPairingLinksListError({ cause })), Effect.withSpan("EnvironmentAuth.listPairingLinks"), ); const revokePairingLink: EnvironmentAuth["Service"]["revokePairingLink"] = (id) => bootstrapCredentials.revoke(id).pipe( Effect.mapError((cause) => new ServerAuthPairingLinkRevocationError({ cause })), Effect.withSpan("EnvironmentAuth.revokePairingLink"), ); const issueSession: EnvironmentAuth["Service"]["issueSession"] = (input) => sessions .issue({ subject: input?.subject ?? DEFAULT_SESSION_SUBJECT, method: "bearer-access-token", scopes: input?.scopes ?? AuthStandardClientScopes, client: { ...(input?.label ? { label: input.label } : {}), deviceType: "bot", }, ...(input?.ttl ? { ttl: input.ttl } : {}), }) .pipe( Effect.map( (issued) => ({ sessionId: issued.sessionId, token: issued.token, method: "bearer-access-token", scopes: issued.scopes, subject: input?.subject ?? DEFAULT_SESSION_SUBJECT, client: issued.client, expiresAt: DateTime.toUtc(issued.expiresAt), }) satisfies IssuedBearerSession, ), Effect.mapError((cause) => new ServerAuthSessionTokenIssueError({ cause })), Effect.withSpan("EnvironmentAuth.issueSession"), ); const listSessions: EnvironmentAuth["Service"]["listSessions"] = () => sessions.listActive().pipe( Effect.map((activeSessions) => activeSessions.toSorted(bySessionPriority)), Effect.mapError((cause) => new ServerAuthSessionsListError({ cause })), Effect.withSpan("EnvironmentAuth.listSessions"), ); const revokeSession: EnvironmentAuth["Service"]["revokeSession"] = (sessionId) => sessions.revoke(sessionId).pipe( Effect.mapError((cause) => new ServerAuthSessionRevocationError({ cause })), Effect.withSpan("EnvironmentAuth.revokeSession"), ); const revokeOtherSessionsExcept = (sessionId: AuthSessionId) => sessions.revokeAllExcept(sessionId).pipe( Effect.mapError((cause) => new ServerAuthOtherSessionsRevocationError({ cause })), Effect.withSpan("EnvironmentAuth.revokeOtherSessionsExcept"), ); const issuePairingCredential: EnvironmentAuth["Service"]["issuePairingCredential"] = (input) => issuePairingCredentialForSubject({ scopes: input?.scopes ?? AuthStandardClientScopes, subject: "one-time-token", ...(input?.label ? { label: input.label } : {}), }).pipe(Effect.withSpan("EnvironmentAuth.issuePairingCredential")); const issueStartupPairingCredential: EnvironmentAuth["Service"]["issueStartupPairingCredential"] = () => { const fallback = issuePairingCredentialForSubject({ scopes: AuthStandardClientScopes, subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT, purpose: "startup", }); if (!devAuth) { return fallback.pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential")); } return sessions.verify(devAuth.credential).pipe( Effect.map( (session) => ({ id: session.sessionId, credential: devAuth.credential, label: "Reusable dev token", expiresAt: DateTime.toUtc(session.expiresAt ?? REUSABLE_DEV_SESSION_EXPIRES_AT), }) satisfies AuthPairingCredentialResult, ), Effect.catch((cause) => SessionStore.isSessionCredentialInvalidError(cause) ? fallback : Effect.fail(new ServerAuthPairingLinkCreationError({ cause })), ), Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential"), ); }; const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) => listSessions().pipe( Effect.map((clientSessions) => clientSessions.map((clientSession): AuthClientSession => ({ ...clientSession, ...authScopeResponse(clientSession.scopes), current: clientSession.sessionId === currentSessionId, })), ), Effect.withSpan("EnvironmentAuth.listClientSessions"), ); const revokeClientSession: EnvironmentAuth["Service"]["revokeClientSession"] = Effect.fn( "EnvironmentAuth.revokeClientSession", )(function* (currentSessionId, targetSessionId) { if (currentSessionId === targetSessionId) { return yield* new ServerAuthForbiddenOperationError({}); } return yield* revokeSession(targetSessionId); }); const revokeOtherClientSessions: EnvironmentAuth["Service"]["revokeOtherClientSessions"] = ( currentSessionId, ) => revokeOtherSessionsExcept(currentSessionId).pipe( Effect.withSpan("EnvironmentAuth.revokeOtherClientSessions"), ); const issueStartupPairingUrl: EnvironmentAuth["Service"]["issueStartupPairingUrl"] = (baseUrl) => issueStartupPairingCredential().pipe( Effect.map((issued) => { const url = new URL(baseUrl); url.pathname = "/pair"; url.searchParams.delete("token"); url.hash = new URLSearchParams([["token", issued.credential]]).toString(); return url.toString(); }), Effect.withSpan("EnvironmentAuth.issueStartupPairingUrl"), ); // Only the RPC socket's ticket is bound to the client's proof key. The media // routes reuse one ticket across , EventSource and several sockets, and // each of those would need its own single-use proof. const issueWebSocketTicket: EnvironmentAuth["Service"]["issueWebSocketTicket"] = ( session, input, ) => sessions .issueWebSocketToken(session.sessionId, { ...(input?.use ? { audience: input.use } : {}), ...(input?.use === "rpc" && session.proofKeyThumbprint ? { proofKeyThumbprint: session.proofKeyThumbprint } : {}), }) .pipe( Effect.mapError((cause) => new ServerAuthWebSocketTokenIssueError({ cause })), Effect.map( (issued) => ({ ticket: issued.token, expiresAt: DateTime.toUtc(issued.expiresAt), }) satisfies AuthWebSocketTicketResult, ), Effect.withSpan("EnvironmentAuth.issueWebSocketTicket"), ); const authenticateHttpRequest: EnvironmentAuth["Service"]["authenticateHttpRequest"] = ( request, ) => authenticateRequest(request).pipe(Effect.withSpan("EnvironmentAuth.authenticateHttpRequest")); const authenticateWebSocketUpgrade: EnvironmentAuth["Service"]["authenticateWebSocketUpgrade"] = Effect.fn("EnvironmentAuth.authenticateWebSocketUpgrade")(function* (request, use) { const requestUrl = HttpServerRequest.toURL(request); if (Option.isSome(requestUrl)) { const websocketTicket = requestUrl.value.searchParams.get(WEBSOCKET_TICKET_QUERY_PARAM); if (websocketTicket && websocketTicket.trim().length > 0) { const session = yield* sessions .verifyWebSocketToken(websocketTicket) .pipe(mapSessionVerificationErrors, Effect.flatMap(rejectMcpClientAudience)); if (session.audience !== undefined && session.audience !== use) { return yield* new ServerAuthInvalidCredentialError({ diagnostic: `WebSocket ticket was issued for the ${session.audience} route.`, }); } if (session.proofKeyThumbprint) { // WebSocket clients cannot set headers, so the proof travels in the query. // The proof hashes the ticket; consuming its jti prevents replay after first use. // Only encrypted transport prevents an active interceptor from racing that use. const proof = request.headers.dpop ?? requestUrl.value.searchParams.get(WEBSOCKET_PROOF_QUERY_PARAM) ?? undefined; if (!proof) { return yield* new ServerAuthInvalidCredentialError({ diagnostic: "DPoP-bound WebSocket ticket requires a DPoP proof.", dpopFailureReason: "invalid_proof", }); } yield* verifyRequestDpopProof({ request, proof, expectedThumbprint: session.proofKeyThumbprint, expectedAccessToken: websocketTicket, }).pipe( Effect.provideService(ServerSecretStore.ServerSecretStore, secretStore), Effect.provideService(Crypto.Crypto, crypto), ); } return { sessionId: session.sessionId, subject: session.subject, method: session.method, scopes: session.scopes, ...(session.proofKeyThumbprint ? { proofKeyThumbprint: session.proofKeyThumbprint } : {}), ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), }; } } return yield* authenticateRequest(request); }); const authenticateMcpClient: EnvironmentAuth["Service"]["authenticateMcpClient"] = (request) => { const token = parseBearerToken(request); if (token === null) return Effect.fail(new ServerAuthMissingCredentialError({})); return sessions.verify(token).pipe( mapSessionVerificationErrors, Effect.flatMap((session) => session.subject === MCP_CLIENT_SUBJECT && session.method === "bearer-access-token" ? Effect.succeed({ sessionId: session.sessionId, label: session.client.label ?? "MCP client", access: session.scopes.includes(AuthOrchestrationOperateScope) ? (session.runtimeModeCeiling ?? "approval-required") : "read-only", peerLink: session.peerLink === true, } satisfies McpClientSession) : Effect.fail( new ServerAuthInvalidCredentialError({ diagnostic: "Only MCP client sessions are accepted here.", }), ), ), Effect.withSpan("EnvironmentAuth.authenticateMcpClient"), ); }; const issueMcpClientSession: EnvironmentAuth["Service"]["issueMcpClientSession"] = (input) => sessions .issue({ subject: MCP_CLIENT_SUBJECT, method: "bearer-access-token", scopes: mcpClientScopes(input.access), ttl: MCP_CLIENT_SESSION_TTL, ...(input.access === "read-only" ? {} : { runtimeModeCeiling: input.access }), peerLink: input.peerLink, client: { ...input.client, label: input.label, deviceType: "bot" }, }) .pipe( Effect.map((issued) => ({ sessionId: issued.sessionId, token: issued.token, expiresAt: issued.expiresAt, })), Effect.mapError((cause) => new ServerAuthSessionTokenIssueError({ cause })), Effect.withSpan("EnvironmentAuth.issueMcpClientSession"), ); const authenticateBrowserSession: EnvironmentAuth["Service"]["authenticateBrowserSession"] = ( request, ) => { const credential = resolveRequestCredential(request); if (!credential || !isBrowserCookieCredential(credential.source)) { return Effect.fail(new ServerAuthMissingCredentialError({})); } return authenticateRequest(request).pipe( Effect.filterOrFail( (session) => session.method === "browser-session-cookie", () => new ServerAuthInvalidCredentialError({ diagnostic: "Not a browser session." }), ), Effect.withSpan("EnvironmentAuth.authenticateBrowserSession"), ); }; const consumeMcpApprovalCode: EnvironmentAuth["Service"]["consumeMcpApprovalCode"] = ( code, access, ) => resolveBootstrapGrant(code.trim(), { requiredScopes: mcpClientScopes(access) }).pipe( Effect.catchTags({ ServerAuthInvalidCredentialError: () => Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "unknown_or_used" })), ServerAuthScopeNotGrantedError: () => Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "insufficient_scope" })), }), Effect.flatMap((grant) => grant.method !== "one-time-token" && grant.method !== "reusable-dev-token" ? Effect.fail(new ServerAuthMcpApprovalCodeError({ reason: "not_a_pairing_code" })) : Effect.void, ), Effect.withSpan("EnvironmentAuth.consumeMcpApprovalCode"), ); return EnvironmentAuth.of({ getDescriptor: Effect.fn("EnvironmentAuth.getDescriptor")(function () { return Effect.succeed(descriptor); }), getSessionState, createBrowserSession, exchangeBootstrapCredentialForAccessToken, createPairingLink, issuePairingCredential, issueStartupPairingCredential, listPairingLinks, revokePairingLink, issueSession, listSessions, revokeSession, listClientSessions, revokeClientSession, revokeOtherClientSessions, authenticateHttpRequest, authenticateWebSocketUpgrade, issueWebSocketTicket, issueStartupPairingUrl, authenticateMcpClient, issueMcpClientSession, consumeMcpApprovalCode, authenticateBrowserSession, }); }); export const layer = Layer.effect(EnvironmentAuth, make).pipe( Layer.provideMerge(PairingGrantStore.layer), Layer.provideMerge(SessionStore.layer), Layer.provideMerge(EnvironmentAuthPolicy.layer), ); const layerStorage = Layer.mergeAll(ServerSecretStore.layer, SqlitePersistence.layerConfig); export const layerRuntime = layer.pipe( Layer.provideMerge(layerStorage), Layer.provideMerge(ServerEnvironment.layerIdentity), );