diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index f4b32db07c7..b88eb18e57f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,7 +1,7 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { homedir } from "node:os"; +import * as NodeOS from "node:os"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -60,7 +60,7 @@ const desktopEnvironmentLayer = Layer.unwrap( const processArch = yield* HostProcessArchitecture; return DesktopEnvironment.layer({ dirname: __dirname, - homeDirectory: homedir(), + homeDirectory: NodeOS.homedir(), platform, processArch, ...metadata, diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index cf3c40f57c7..873e9fc3d37 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -19,9 +19,9 @@ import { signPayload, timingSafeEqualBase64Url, } from "../auth/utils.ts"; -import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { resolveAttachmentPathById } from "../attachmentStore.ts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; @@ -181,7 +181,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i break; } case "attachment": { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; const attachmentPath = resolveAttachmentPathById({ attachmentsDir: config.attachmentsDir, attachmentId: input.resource.attachmentId, @@ -225,7 +225,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i } } - const secretStore = yield* ServerSecretStore; + const secretStore = yield* ServerSecretStore.ServerSecretStore; const signingSecret = yield* secretStore .getOrCreateRandom(SIGNING_SECRET_NAME, 32) .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); @@ -244,7 +244,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) return null; - const secretStore = yield* ServerSecretStore; + const secretStore = yield* ServerSecretStore.ServerSecretStore; const signingSecret = yield* secretStore .getOrCreateRandom(SIGNING_SECRET_NAME, 32) .pipe(Effect.orElseSucceed(() => null)); @@ -255,7 +255,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; if (claims.kind === "attachment") { - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; const attachmentPath = resolveAttachmentPathById({ attachmentsDir: config.attachmentsDir, attachmentId: claims.attachmentId, diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index b917cadb980..335e0685197 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -53,29 +53,25 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { it.effect("classifies invalid bootstrap credential failures for the HTTP boundary", () => Effect.sync(() => { const error = EnvironmentAuth.toBootstrapExchangeError( - new PairingGrantStore.BootstrapCredentialInvalidError({ - message: "Unknown bootstrap credential.", - }), + new PairingGrantStore.UnknownBootstrapCredentialError({}), ); expect(error._tag).toBe("ServerAuthInvalidCredentialError"); - if (error._tag === "ServerAuthInvalidCredentialError") { - expect(error.reason).toBe("invalid_credential"); - } }), ); it.effect("maps unexpected bootstrap failures to 500", () => Effect.sync(() => { - const error = EnvironmentAuth.toBootstrapExchangeError( - new PairingGrantStore.BootstrapCredentialInternalError({ - message: "Failed to consume bootstrap credential.", - cause: new Error("sqlite is unavailable"), - }), - ); + const cause = new PairingGrantStore.BootstrapCredentialConsumeError({ + cause: new Error("sqlite is unavailable"), + }); + const error = EnvironmentAuth.toBootstrapExchangeError(cause); - expect(error._tag).toBe("ServerAuthInternalError"); + expect(error._tag).toBe("ServerAuthBootstrapCredentialValidationError"); expect(error.message).toBe("Failed to validate bootstrap credential."); + if (error._tag === "ServerAuthBootstrapCredentialValidationError") { + expect(error.cause).toBe(cause); + } }), ); @@ -117,10 +113,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { ) .pipe(Effect.flip); - expect(error._tag).toBe("ServerAuthInvalidRequestError"); - if (error._tag === "ServerAuthInvalidRequestError") { - expect(error.reason).toBe("scope_not_granted"); - } + expect(error._tag).toBe("ServerAuthScopeNotGrantedError"); }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index d8c0079089f..dd53a83ca95 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -20,12 +20,12 @@ import { import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; 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/unstable/http/HttpServerRequest"; import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; @@ -67,123 +67,429 @@ export interface AuthenticatedSession { readonly expiresAt?: DateTime.DateTime; } -export class ServerAuthInternalError extends Data.TaggedError("ServerAuthInternalError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +const serverAuthInternalErrorContext = { + cause: Schema.Defect(), +}; + +export class ServerAuthBootstrapCredentialValidationError extends Schema.TaggedErrorClass()( + "ServerAuthBootstrapCredentialValidationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to validate bootstrap credential."; + } +} + +export class ServerAuthSessionCredentialValidationError extends Schema.TaggedErrorClass()( + "ServerAuthSessionCredentialValidationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to validate session credential."; + } +} + +export class ServerAuthAuthenticatedSessionIssueError extends Schema.TaggedErrorClass()( + "ServerAuthAuthenticatedSessionIssueError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue authenticated session."; + } +} + +export class ServerAuthAuthenticatedAccessTokenIssueError extends Schema.TaggedErrorClass()( + "ServerAuthAuthenticatedAccessTokenIssueError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue authenticated access token."; + } +} + +export class ServerAuthPairingLinkCreationError extends Schema.TaggedErrorClass()( + "ServerAuthPairingLinkCreationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to create pairing link."; + } +} + +export class ServerAuthPairingLinksListError extends Schema.TaggedErrorClass()( + "ServerAuthPairingLinksListError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to list pairing links."; + } +} + +export class ServerAuthPairingLinkRevocationError extends Schema.TaggedErrorClass()( + "ServerAuthPairingLinkRevocationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to revoke pairing link."; + } +} + +export class ServerAuthSessionTokenIssueError extends Schema.TaggedErrorClass()( + "ServerAuthSessionTokenIssueError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue session token."; + } +} + +export class ServerAuthSessionsListError extends Schema.TaggedErrorClass()( + "ServerAuthSessionsListError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to list sessions."; + } +} + +export class ServerAuthSessionRevocationError extends Schema.TaggedErrorClass()( + "ServerAuthSessionRevocationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to revoke session."; + } +} + +export class ServerAuthOtherSessionsRevocationError extends Schema.TaggedErrorClass()( + "ServerAuthOtherSessionsRevocationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to revoke other sessions."; + } +} + +export class ServerAuthWebSocketTokenIssueError extends Schema.TaggedErrorClass()( + "ServerAuthWebSocketTokenIssueError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue websocket token."; + } +} + +export class ServerAuthDpopReplayStateRecordError extends Schema.TaggedErrorClass()( + "ServerAuthDpopReplayStateRecordError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to record DPoP proof replay state."; + } +} + +export class ServerAuthDpopReplayKeyCalculationError extends Schema.TaggedErrorClass()( + "ServerAuthDpopReplayKeyCalculationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to calculate DPoP replay key."; + } +} + +export class ServerAuthLinkedCloudAccountVerificationError extends Schema.TaggedErrorClass()( + "ServerAuthLinkedCloudAccountVerificationError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Could not verify the linked cloud account."; + } +} + +export class ServerAuthLinkedCloudAccountReadError extends Schema.TaggedErrorClass()( + "ServerAuthLinkedCloudAccountReadError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Could not read the linked cloud account."; + } +} + +export class ServerAuthLinkedCloudAccountMissingError extends Schema.TaggedErrorClass()( + "ServerAuthLinkedCloudAccountMissingError", + {}, +) { + override get message(): string { + return "Cloud linked user is not installed for this environment."; + } +} + +export class ServerAuthCloudLinkJwtSigningError extends Schema.TaggedErrorClass()( + "ServerAuthCloudLinkJwtSigningError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to sign cloud link JWT."; + } +} + +export class ServerAuthCloudMintPublicKeyMissingError extends Schema.TaggedErrorClass()( + "ServerAuthCloudMintPublicKeyMissingError", + {}, +) { + override get message(): string { + return "Cloud mint public key is not installed for this environment."; + } +} + +export class ServerAuthCloudRelayIssuerMissingError extends Schema.TaggedErrorClass()( + "ServerAuthCloudRelayIssuerMissingError", + {}, +) { + override get message(): string { + return "Cloud relay issuer is not installed for this environment."; + } +} -export class ServerAuthInvalidCredentialError extends Data.TaggedError( +export class ServerAuthCloudHealthJwtSigningError extends Schema.TaggedErrorClass()( + "ServerAuthCloudHealthJwtSigningError", + { + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to sign cloud health JWT."; + } +} + +export class ServerAuthCloudMintJwtSigningError extends Schema.TaggedErrorClass()( + "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.TaggedErrorClass()( + "ServerAuthMissingCredentialError", + {}, +) { + override get message(): string { + return "Server authentication credential is missing."; + } +} + +export class ServerAuthInvalidCredentialError extends Schema.TaggedErrorClass()( "ServerAuthInvalidCredentialError", -)<{ - readonly reason: "missing_credential" | "invalid_credential"; - readonly cause?: unknown; -}> {} - -export class ServerAuthInvalidRequestError extends Data.TaggedError( - "ServerAuthInvalidRequestError", -)<{ - readonly reason: "invalid_scope" | "scope_not_granted"; -}> {} - -export class ServerAuthForbiddenOperationError extends Data.TaggedError( - "ServerAuthForbiddenOperationError", -)<{ - readonly reason: "current_session_revoke_not_allowed"; -}> {} + { + diagnostic: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return "Server authentication credential is invalid."; + } +} -export interface EnvironmentAuthShape { - readonly getDescriptor: () => Effect.Effect; - readonly getSessionState: ( - request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect; - readonly createBrowserSession: ( - credential: string, - requestMetadata: AuthClientMetadata, - ) => Effect.Effect< - { - readonly response: AuthBrowserSessionResult; - readonly sessionToken: string; - }, - 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; - }) => 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 revokeOtherSessionsExcept: ( - 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< - AuthenticatedSession, - ServerAuthInvalidCredentialError | ServerAuthInternalError - >; - readonly authenticateWebSocketUpgrade: ( - request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect< - AuthenticatedSession, - ServerAuthInvalidCredentialError | ServerAuthInternalError - >; - readonly issueWebSocketTicket: ( - session: Pick, - ) => Effect.Effect; - readonly issueStartupPairingUrl: ( - baseUrl: string, - ) => Effect.Effect; +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 class ServerAuthInvalidScopeError extends Schema.TaggedErrorClass()( + "ServerAuthInvalidScopeError", + {}, +) { + override get message(): string { + return "The requested authentication scope is invalid."; + } } -export class EnvironmentAuth extends Context.Service()( - "t3/auth/EnvironmentAuth", -) {} +export class ServerAuthScopeNotGrantedError extends Schema.TaggedErrorClass()( + "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 ServerAuthForbiddenOperationError extends Schema.TaggedErrorClass()( + "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, + ) => Effect.Effect< + { + readonly response: AuthBrowserSessionResult; + readonly sessionToken: string; + }, + 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; + }) => 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 revokeOtherSessionsExcept: ( + 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, + ) => Effect.Effect; + readonly issueWebSocketTicket: ( + session: Pick, + ) => Effect.Effect; + readonly issueStartupPairingUrl: ( + baseUrl: string, + ) => Effect.Effect; + } +>()("t3/auth/EnvironmentAuth") {} type BootstrapExchangeResult = { readonly response: AuthBrowserSessionResult; @@ -206,23 +512,14 @@ const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => return right.issuedAt.epochMilliseconds - left.issuedAt.epochMilliseconds; }; -const toInternalError = - (message: string) => - (cause: unknown): ServerAuthInternalError => - new ServerAuthInternalError({ message, cause }); - export function toBootstrapExchangeError( cause: PairingGrantStore.BootstrapCredentialError, ): ServerAuthInvalidCredentialError | ServerAuthInternalError { - if (cause._tag === "BootstrapCredentialInternalError") { - return new ServerAuthInternalError({ - message: "Failed to validate bootstrap credential.", - cause, - }); + if (PairingGrantStore.isBootstrapCredentialInternalError(cause)) { + return new ServerAuthBootstrapCredentialValidationError({ cause }); } return new ServerAuthInvalidCredentialError({ - reason: "invalid_credential", cause, }); } @@ -231,17 +528,11 @@ const mapSessionVerificationErrors = ( effect: Effect.Effect, ): Effect.Effect => effect.pipe( - Effect.catchTags({ - SessionCredentialInvalidError: (cause) => - Effect.fail(new ServerAuthInvalidCredentialError({ reason: "invalid_credential", cause })), - SessionCredentialInternalError: (cause) => - Effect.fail( - new ServerAuthInternalError({ - message: "Failed to validate session credential.", - cause, - }), - ), - }), + Effect.mapError((cause) => + SessionStore.isSessionCredentialInvalidError(cause) + ? new ServerAuthInvalidCredentialError({ cause }) + : new ServerAuthSessionCredentialValidationError({ cause }), + ), ); function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null { @@ -262,7 +553,7 @@ function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string | return token.length > 0 ? token : null; } -export const make = Effect.fn("makeEnvironmentAuth")(function* () { +export const make = Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; @@ -277,12 +568,14 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { ServerAuthInvalidCredentialError | ServerAuthInternalError > => sessions.verify(token).pipe( - Effect.tapErrorTag("SessionCredentialInvalidError", (cause) => - Effect.logWarning("Rejected authenticated session credential.").pipe( - Effect.annotateLogs({ - reason: cause.message, - }), - ), + Effect.tapError((cause) => + SessionStore.isSessionCredentialInvalidError(cause) + ? Effect.logWarning("Rejected authenticated session credential.").pipe( + Effect.annotateLogs({ + reason: cause.message, + }), + ) + : Effect.void, ), Effect.map((session) => ({ sessionId: session.sessionId, @@ -295,13 +588,15 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { mapSessionVerificationErrors, ); - const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => { + const authenticateRequest = ( + request: HttpServerRequest.HttpServerRequest, + ): Effect.Effect => { const cookieToken = request.cookies[sessions.cookieName]; const bearerToken = parseBearerToken(request); const dpopToken = parseDpopToken(request); const credential = cookieToken ?? bearerToken ?? dpopToken; if (!credential) { - return Effect.fail(new ServerAuthInvalidCredentialError({ reason: "missing_credential" })); + return Effect.fail(new ServerAuthMissingCredentialError({})); } return authenticateToken(credential).pipe( Effect.flatMap((session) => { @@ -309,8 +604,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { if (!dpopToken || dpopToken !== credential) { return Effect.fail( new ServerAuthInvalidCredentialError({ - reason: "invalid_credential", - cause: "DPoP-bound access token requires DPoP authorization.", + diagnostic: "DPoP-bound access token requires DPoP authorization.", }), ); } @@ -327,8 +621,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { if (dpopToken) { return Effect.fail( new ServerAuthInvalidCredentialError({ - reason: "invalid_credential", - cause: "DPoP authorization requires a proof-bound access token.", + diagnostic: "DPoP authorization requires a proof-bound access token.", }), ); } @@ -337,7 +630,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { ); }; - const getSessionState: EnvironmentAuthShape["getSessionState"] = (request) => + const getSessionState: EnvironmentAuth["Service"]["getSessionState"] = (request) => authenticateRequest(request).pipe( Effect.map( (session) => @@ -349,7 +642,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}), }) satisfies AuthSessionState, ), - Effect.catchTag("ServerAuthInvalidCredentialError", () => + Effect.catchIf(isServerAuthCredentialError, () => Effect.succeed({ authenticated: false, auth: descriptor, @@ -358,7 +651,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { Effect.withSpan("EnvironmentAuth.getSessionState"), ); - const createBrowserSession: EnvironmentAuthShape["createBrowserSession"] = ( + const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = ( credential, requestMetadata, ) => @@ -376,13 +669,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { }, }) .pipe( - Effect.mapError( - (cause) => - new ServerAuthInternalError({ - message: "Failed to issue authenticated session.", - cause, - }), - ), + Effect.mapError((cause) => new ServerAuthAuthenticatedSessionIssueError({ cause })), ), ), Effect.map( @@ -400,7 +687,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { Effect.withSpan("EnvironmentAuth.createBrowserSession"), ); - const exchangeBootstrapCredentialForAccessToken: EnvironmentAuthShape["exchangeBootstrapCredentialForAccessToken"] = + const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = (credential, requestedScopes, requestMetadata, input) => bootstrapCredentials.consume(credential, input).pipe( Effect.mapError(toBootstrapExchangeError), @@ -408,9 +695,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { Effect.gen(function* () { const grantedScopes = requestedScopes ?? grant.scopes; if (!grantedScopes.every((scope) => grant.scopes.includes(scope))) { - return yield* new ServerAuthInvalidRequestError({ - reason: "scope_not_granted", - }); + return yield* new ServerAuthScopeNotGrantedError({}); } return yield* sessions .issue({ @@ -430,11 +715,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { }) .pipe( Effect.mapError( - (cause) => - new ServerAuthInternalError({ - message: "Failed to issue authenticated access token.", - cause, - }), + (cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause }), ), ); }), @@ -482,7 +763,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { ), ); - const createPairingLink: EnvironmentAuthShape["createPairingLink"] = Effect.fn( + const createPairingLink: EnvironmentAuth["Service"]["createPairingLink"] = Effect.fn( "EnvironmentAuth.createPairingLink", )( function* (input) { @@ -504,10 +785,10 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { expiresAt: DateTime.toUtc(issued.expiresAt), } satisfies IssuedPairingLink; }, - Effect.mapError(toInternalError("Failed to create pairing link.")), + Effect.mapError((cause) => new ServerAuthPairingLinkCreationError({ cause })), ); - const listPairingLinks: EnvironmentAuthShape["listPairingLinks"] = (input) => + const listPairingLinks: EnvironmentAuth["Service"]["listPairingLinks"] = (input) => bootstrapCredentials.listActive().pipe( Effect.map((pairingLinks) => { const excludedSubjects = input?.excludeSubjects ?? [ @@ -519,19 +800,17 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds, ); }), - Effect.mapError(toInternalError("Failed to list pairing links.")), + Effect.mapError((cause) => new ServerAuthPairingLinksListError({ cause })), Effect.withSpan("EnvironmentAuth.listPairingLinks"), ); - const revokePairingLink: EnvironmentAuthShape["revokePairingLink"] = (id) => - bootstrapCredentials - .revoke(id) - .pipe( - Effect.mapError(toInternalError("Failed to revoke pairing link.")), - Effect.withSpan("EnvironmentAuth.revokePairingLink"), - ); + const revokePairingLink: EnvironmentAuth["Service"]["revokePairingLink"] = (id) => + bootstrapCredentials.revoke(id).pipe( + Effect.mapError((cause) => new ServerAuthPairingLinkRevocationError({ cause })), + Effect.withSpan("EnvironmentAuth.revokePairingLink"), + ); - const issueSession: EnvironmentAuthShape["issueSession"] = (input) => + const issueSession: EnvironmentAuth["Service"]["issueSession"] = (input) => sessions .issue({ subject: input?.subject ?? DEFAULT_SESSION_SUBJECT, @@ -556,49 +835,46 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { expiresAt: DateTime.toUtc(issued.expiresAt), }) satisfies IssuedBearerSession, ), - Effect.mapError(toInternalError("Failed to issue session token.")), + Effect.mapError((cause) => new ServerAuthSessionTokenIssueError({ cause })), Effect.withSpan("EnvironmentAuth.issueSession"), ); - const listSessions: EnvironmentAuthShape["listSessions"] = () => + const listSessions: EnvironmentAuth["Service"]["listSessions"] = () => sessions.listActive().pipe( Effect.map((activeSessions) => activeSessions.toSorted(bySessionPriority)), - Effect.mapError(toInternalError("Failed to list sessions.")), + Effect.mapError((cause) => new ServerAuthSessionsListError({ cause })), Effect.withSpan("EnvironmentAuth.listSessions"), ); - const revokeSession: EnvironmentAuthShape["revokeSession"] = (sessionId) => - sessions - .revoke(sessionId) - .pipe( - Effect.mapError(toInternalError("Failed to revoke session.")), - Effect.withSpan("EnvironmentAuth.revokeSession"), - ); + const revokeSession: EnvironmentAuth["Service"]["revokeSession"] = (sessionId) => + sessions.revoke(sessionId).pipe( + Effect.mapError((cause) => new ServerAuthSessionRevocationError({ cause })), + Effect.withSpan("EnvironmentAuth.revokeSession"), + ); - const revokeOtherSessionsExcept: EnvironmentAuthShape["revokeOtherSessionsExcept"] = ( + const revokeOtherSessionsExcept: EnvironmentAuth["Service"]["revokeOtherSessionsExcept"] = ( sessionId, ) => - sessions - .revokeAllExcept(sessionId) - .pipe( - Effect.mapError(toInternalError("Failed to revoke other sessions.")), - Effect.withSpan("EnvironmentAuth.revokeOtherSessionsExcept"), - ); + sessions.revokeAllExcept(sessionId).pipe( + Effect.mapError((cause) => new ServerAuthOtherSessionsRevocationError({ cause })), + Effect.withSpan("EnvironmentAuth.revokeOtherSessionsExcept"), + ); - const issuePairingCredential: EnvironmentAuthShape["issuePairingCredential"] = (input) => + 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: EnvironmentAuthShape["issueStartupPairingCredential"] = () => - issuePairingCredentialForSubject({ - scopes: AuthAdministrativeScopes, - subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT, - }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential")); + const issueStartupPairingCredential: EnvironmentAuth["Service"]["issueStartupPairingCredential"] = + () => + issuePairingCredentialForSubject({ + scopes: AuthAdministrativeScopes, + subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT, + }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential")); - const listClientSessions: EnvironmentAuthShape["listClientSessions"] = (currentSessionId) => + const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) => listSessions().pipe( Effect.map((clientSessions) => clientSessions.map( @@ -611,25 +887,23 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { Effect.withSpan("EnvironmentAuth.listClientSessions"), ); - const revokeClientSession: EnvironmentAuthShape["revokeClientSession"] = Effect.fn( + const revokeClientSession: EnvironmentAuth["Service"]["revokeClientSession"] = Effect.fn( "EnvironmentAuth.revokeClientSession", )(function* (currentSessionId, targetSessionId) { if (currentSessionId === targetSessionId) { - return yield* new ServerAuthForbiddenOperationError({ - reason: "current_session_revoke_not_allowed", - }); + return yield* new ServerAuthForbiddenOperationError({}); } return yield* revokeSession(targetSessionId); }); - const revokeOtherClientSessions: EnvironmentAuthShape["revokeOtherClientSessions"] = ( + const revokeOtherClientSessions: EnvironmentAuth["Service"]["revokeOtherClientSessions"] = ( currentSessionId, ) => revokeOtherSessionsExcept(currentSessionId).pipe( Effect.withSpan("EnvironmentAuth.revokeOtherClientSessions"), ); - const issueStartupPairingUrl: EnvironmentAuthShape["issueStartupPairingUrl"] = (baseUrl) => + const issueStartupPairingUrl: EnvironmentAuth["Service"]["issueStartupPairingUrl"] = (baseUrl) => issueStartupPairingCredential().pipe( Effect.map((issued) => { const url = new URL(baseUrl); @@ -641,15 +915,9 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { Effect.withSpan("EnvironmentAuth.issueStartupPairingUrl"), ); - const issueWebSocketTicket: EnvironmentAuthShape["issueWebSocketTicket"] = (session) => + const issueWebSocketTicket: EnvironmentAuth["Service"]["issueWebSocketTicket"] = (session) => sessions.issueWebSocketToken(session.sessionId).pipe( - Effect.mapError( - (cause) => - new ServerAuthInternalError({ - message: "Failed to issue websocket token.", - cause, - }), - ), + Effect.mapError((cause) => new ServerAuthWebSocketTokenIssueError({ cause })), Effect.map( (issued) => ({ @@ -660,10 +928,12 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { Effect.withSpan("EnvironmentAuth.issueWebSocketTicket"), ); - const authenticateHttpRequest: EnvironmentAuthShape["authenticateHttpRequest"] = (request) => + const authenticateHttpRequest: EnvironmentAuth["Service"]["authenticateHttpRequest"] = ( + request, + ) => authenticateRequest(request).pipe(Effect.withSpan("EnvironmentAuth.authenticateHttpRequest")); - const authenticateWebSocketUpgrade: EnvironmentAuthShape["authenticateWebSocketUpgrade"] = + const authenticateWebSocketUpgrade: EnvironmentAuth["Service"]["authenticateWebSocketUpgrade"] = Effect.fn("EnvironmentAuth.authenticateWebSocketUpgrade")(function* (request) { const requestUrl = HttpServerRequest.toURL(request); if (Option.isSome(requestUrl)) { @@ -685,7 +955,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { return yield* authenticateRequest(request); }); - return { + return EnvironmentAuth.of({ getDescriptor: () => Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuth.getDescriptor")), getSessionState, @@ -707,10 +977,10 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { authenticateWebSocketUpgrade, issueWebSocketTicket, issueStartupPairingUrl, - } satisfies EnvironmentAuthShape; + }); }); -export const layer = Layer.effect(EnvironmentAuth, make()).pipe( +export const layer = Layer.effect(EnvironmentAuth, make).pipe( Layer.provideMerge(PairingGrantStore.layer), Layer.provideMerge(SessionStore.layer), Layer.provideMerge(EnvironmentAuthPolicy.layer), diff --git a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts index 7dcc89761be..03009270e15 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -22,7 +22,11 @@ const makeServerConfigLayer = ( } satisfies ServerConfig.ServerConfig["Service"]; }), ).pipe( - Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-control-plane-test-" })), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-auth-control-plane-test-", + }), + ), ); const makeEnvironmentAuthLayer = ( diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 205c85b0234..7ffef0ff0a5 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -3,21 +3,19 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import { resolveSessionCookieName } from "./utils.ts"; import { isLoopbackHost, isWildcardHost } from "../startupAccess.ts"; -export interface EnvironmentAuthPolicyShape { - readonly getDescriptor: () => Effect.Effect; -} - export class EnvironmentAuthPolicy extends Context.Service< EnvironmentAuthPolicy, - EnvironmentAuthPolicyShape + { + readonly getDescriptor: () => Effect.Effect; + } >()("t3/auth/EnvironmentAuthPolicy") {} -export const make = Effect.fn("makeEnvironmentAuthPolicy")(function* () { - const config = yield* ServerConfig; +export const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host); const policy = @@ -46,10 +44,10 @@ export const make = Effect.fn("makeEnvironmentAuthPolicy")(function* () { }), }; - return { + return EnvironmentAuthPolicy.of({ getDescriptor: () => Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuthPolicy.getDescriptor")), - } satisfies EnvironmentAuthPolicyShape; + }); }); -export const layer = Layer.effect(EnvironmentAuthPolicy, make()); +export const layer = Layer.effect(EnvironmentAuthPolicy, make); diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 12b0060094a..b3c9b30f643 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -61,7 +61,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { expect(first.subject).toBe("one-time-token"); expect(first.label).toBe("Julius iPhone"); expect(issued.label).toBe("Julius iPhone"); - expect(second._tag).toBe("BootstrapCredentialInvalidError"); + expect(second._tag).toBe("UnknownBootstrapCredentialError"); expect(second.message).toContain("Unknown bootstrap credential"); }).pipe(Effect.provide(makePairingGrantStoreLayer())), ); @@ -85,7 +85,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { expect(successes).toHaveLength(1); expect(failures).toHaveLength(7); for (const failure of failures) { - expect(failure.failure._tag).toBe("BootstrapCredentialInvalidError"); + expect(failure.failure._tag).toBe("UnknownBootstrapCredentialError"); expect(failure.failure.message).toContain("Unknown bootstrap credential"); } }).pipe(Effect.provide(makePairingGrantStoreLayer())), @@ -132,7 +132,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { "relay:write", ]); expect(first.subject).toBe("desktop-bootstrap"); - expect(second._tag).toBe("BootstrapCredentialInvalidError"); + expect(second._tag).toBe("UnknownBootstrapCredentialError"); }).pipe( Effect.provide( makePairingGrantStoreLayer({ @@ -149,7 +149,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { yield* TestClock.adjust(Duration.minutes(6)); const expired = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); - expect(expired._tag).toBe("BootstrapCredentialInvalidError"); + expect(expired._tag).toBe("ExpiredBootstrapCredentialError"); expect(expired.message).toContain("Bootstrap credential expired"); }).pipe( Effect.provide( @@ -183,7 +183,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { expect(activeAfterRevoke.map((entry) => entry.id)).not.toContain(first.id); expect(activeAfterRevoke.map((entry) => entry.id)).toContain(second.id); expect(revokedConsume.message).toContain("no longer available"); - expect(revokedConsume._tag).toBe("BootstrapCredentialInvalidError"); + expect(revokedConsume._tag).toBe("UnavailableBootstrapCredentialError"); }).pipe(Effect.provide(makePairingGrantStoreLayer())), ); }); diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index c655a0f36b6..8a7a4d2e40f 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -7,17 +7,17 @@ import { } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; 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 PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import * as Option from "effect/Option"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; export interface BootstrapGrant { @@ -29,22 +29,110 @@ export interface BootstrapGrant { readonly expiresAt: DateTime.DateTime; } -export class BootstrapCredentialInvalidError extends Data.TaggedError( - "BootstrapCredentialInvalidError", -)<{ - readonly message: string; -}> {} +export class UnknownBootstrapCredentialError extends Schema.TaggedErrorClass()( + "UnknownBootstrapCredentialError", + {}, +) { + override get message(): string { + return "Unknown bootstrap credential."; + } +} + +export class ExpiredBootstrapCredentialError extends Schema.TaggedErrorClass()( + "ExpiredBootstrapCredentialError", + {}, +) { + override get message(): string { + return "Bootstrap credential expired."; + } +} + +export class BootstrapCredentialProofKeyMismatchError extends Schema.TaggedErrorClass()( + "BootstrapCredentialProofKeyMismatchError", + {}, +) { + override get message(): string { + return "Bootstrap credential proof key mismatch."; + } +} + +export class UnavailableBootstrapCredentialError extends Schema.TaggedErrorClass()( + "UnavailableBootstrapCredentialError", + {}, +) { + override get message(): string { + return "Bootstrap credential is no longer available."; + } +} + +export const BootstrapCredentialInvalidError = Schema.Union([ + UnknownBootstrapCredentialError, + ExpiredBootstrapCredentialError, + BootstrapCredentialProofKeyMismatchError, + UnavailableBootstrapCredentialError, +]); +export type BootstrapCredentialInvalidError = typeof BootstrapCredentialInvalidError.Type; +export const isBootstrapCredentialInvalidError = Schema.is(BootstrapCredentialInvalidError); + +export class ActivePairingLinksLoadError extends Schema.TaggedErrorClass()( + "ActivePairingLinksLoadError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to load active pairing links."; + } +} + +export class PairingLinkRevokeError extends Schema.TaggedErrorClass()( + "PairingLinkRevokeError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to revoke pairing link."; + } +} -export class BootstrapCredentialInternalError extends Data.TaggedError( - "BootstrapCredentialInternalError", -)<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class PairingCredentialIssueError extends Schema.TaggedErrorClass()( + "PairingCredentialIssueError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to issue pairing credential."; + } +} -export type BootstrapCredentialError = - | BootstrapCredentialInvalidError - | BootstrapCredentialInternalError; +export class BootstrapCredentialConsumeError extends Schema.TaggedErrorClass()( + "BootstrapCredentialConsumeError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to consume bootstrap credential."; + } +} + +export const BootstrapCredentialInternalError = Schema.Union([ + ActivePairingLinksLoadError, + PairingLinkRevokeError, + PairingCredentialIssueError, + BootstrapCredentialConsumeError, +]); +export type BootstrapCredentialInternalError = typeof BootstrapCredentialInternalError.Type; +export const isBootstrapCredentialInternalError = Schema.is(BootstrapCredentialInternalError); + +export const BootstrapCredentialError = Schema.Union([ + BootstrapCredentialInvalidError, + BootstrapCredentialInternalError, +]); +export type BootstrapCredentialError = typeof BootstrapCredentialError.Type; +export const isBootstrapCredentialError = Schema.is(BootstrapCredentialError); export interface IssuedBootstrapCredential { readonly id: string; @@ -64,31 +152,30 @@ export type BootstrapCredentialChange = readonly id: string; }; -export interface PairingGrantStoreShape { - readonly issueOneTimeToken: (input?: { - readonly ttl?: Duration.Duration; - readonly scopes?: ReadonlyArray; - readonly subject?: string; - readonly label?: string; - readonly proofKeyThumbprint?: string; - }) => Effect.Effect; - readonly listActive: () => Effect.Effect< - ReadonlyArray, - BootstrapCredentialInternalError - >; - readonly streamChanges: Stream.Stream; - readonly revoke: (id: string) => Effect.Effect; - readonly consume: ( - credential: string, - input?: { +export class PairingGrantStore extends Context.Service< + PairingGrantStore, + { + readonly issueOneTimeToken: (input?: { + readonly ttl?: Duration.Duration; + readonly scopes?: ReadonlyArray; + readonly subject?: string; + readonly label?: string; readonly proofKeyThumbprint?: string; - }, - ) => Effect.Effect; -} - -export class PairingGrantStore extends Context.Service()( - "t3/auth/PairingGrantStore", -) {} + }) => Effect.Effect; + readonly listActive: () => Effect.Effect< + ReadonlyArray, + BootstrapCredentialInternalError + >; + readonly streamChanges: Stream.Stream; + readonly revoke: (id: string) => Effect.Effect; + readonly consume: ( + credential: string, + input?: { + readonly proofKeyThumbprint?: string; + }, + ) => Effect.Effect; + } +>()("t3/auth/PairingGrantStore") {} interface StoredBootstrapGrant extends BootstrapGrant { readonly remainingUses: number | "unbounded"; @@ -111,20 +198,9 @@ const PAIRING_TOKEN_LENGTH = 12; const PAIRING_TOKEN_REJECTION_LIMIT = Math.floor(256 / PAIRING_TOKEN_ALPHABET.length) * PAIRING_TOKEN_ALPHABET.length; -const invalidBootstrapCredentialError = (message: string) => - new BootstrapCredentialInvalidError({ - message, - }); - -const internalBootstrapCredentialError = (message: string, cause: unknown) => - new BootstrapCredentialInternalError({ - message, - cause, - }); - -export const make = Effect.fn("makePairingGrantStore")(function* () { +export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; - const config = yield* ServerConfig; + const config = yield* ServerConfig.ServerConfig; const pairingLinks = yield* AuthPairingLinks.AuthPairingLinkRepository; const seededGrantsRef = yield* Ref.make(new Map()); const changesPubSub = yield* PubSub.unbounded(); @@ -177,10 +253,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { }); } - const toBootstrapCredentialError = (message: string) => (cause: unknown) => - internalBootstrapCredentialError(message, cause); - - const listActive: PairingGrantStoreShape["listActive"] = Effect.fn( + const listActive: PairingGrantStore["Service"]["listActive"] = Effect.fn( "PairingGrantStore.listActive", )( function* () { @@ -208,10 +281,10 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { } satisfies AuthPairingLink), ); }, - Effect.mapError(toBootstrapCredentialError("Failed to load active pairing links.")), + Effect.mapError((cause) => new ActivePairingLinksLoadError({ cause })), ); - const revoke: PairingGrantStoreShape["revoke"] = Effect.fn("PairingGrantStore.revoke")( + const revoke: PairingGrantStore["Service"]["revoke"] = Effect.fn("PairingGrantStore.revoke")( function* (id) { const revokedAt = yield* DateTime.now; const revoked = yield* pairingLinks.revoke({ @@ -223,10 +296,10 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { } return revoked; }, - Effect.mapError(toBootstrapCredentialError("Failed to revoke pairing link.")), + Effect.mapError((cause) => new PairingLinkRevokeError({ cause })), ); - const issueOneTimeToken: PairingGrantStoreShape["issueOneTimeToken"] = Effect.fn( + const issueOneTimeToken: PairingGrantStore["Service"]["issueOneTimeToken"] = Effect.fn( "PairingGrantStore.issueOneTimeToken", )( function* (input) { @@ -264,10 +337,10 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { }); return issued; }, - Effect.mapError(toBootstrapCredentialError("Failed to issue pairing credential.")), + Effect.mapError((cause) => new PairingCredentialIssueError({ cause })), ); - const consume: PairingGrantStoreShape["consume"] = Effect.fn("PairingGrantStore.consume")( + const consume: PairingGrantStore["Service"]["consume"] = Effect.fn("PairingGrantStore.consume")( function* (credential, input) { const now = yield* DateTime.now; const seededResult: ConsumeResult = yield* Ref.modify( @@ -279,7 +352,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { { _tag: "error", reason: "not-found", - error: invalidBootstrapCredentialError("Unknown bootstrap credential."), + error: new UnknownBootstrapCredentialError({}), }, current, ]; @@ -292,7 +365,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { { _tag: "error", reason: "expired", - error: invalidBootstrapCredentialError("Bootstrap credential expired."), + error: new ExpiredBootstrapCredentialError({}), }, next, ]; @@ -303,7 +376,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { { _tag: "error", reason: "not-found", - error: invalidBootstrapCredentialError("Bootstrap credential proof key mismatch."), + error: new BootstrapCredentialProofKeyMismatchError({}), }, next, ]; @@ -370,41 +443,36 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { const matching = yield* pairingLinks.getByCredential({ credential }); if (Option.isNone(matching)) { - return yield* invalidBootstrapCredentialError("Unknown bootstrap credential."); + return yield* new UnknownBootstrapCredentialError({}); } if (matching.value.revokedAt !== null) { - return yield* invalidBootstrapCredentialError( - "Bootstrap credential is no longer available.", - ); + return yield* new UnavailableBootstrapCredentialError({}); } if (matching.value.consumedAt !== null) { - return yield* invalidBootstrapCredentialError("Unknown bootstrap credential."); + return yield* new UnknownBootstrapCredentialError({}); } if (DateTime.isGreaterThanOrEqualTo(now, matching.value.expiresAt)) { - return yield* invalidBootstrapCredentialError("Bootstrap credential expired."); + return yield* new ExpiredBootstrapCredentialError({}); } if ( matching.value.proofKeyThumbprint !== null && matching.value.proofKeyThumbprint !== input?.proofKeyThumbprint ) { - return yield* invalidBootstrapCredentialError("Bootstrap credential proof key mismatch."); + return yield* new BootstrapCredentialProofKeyMismatchError({}); } - return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available."); + return yield* new UnavailableBootstrapCredentialError({}); }, Effect.mapError((cause) => - cause._tag === "BootstrapCredentialInvalidError" || - cause._tag === "BootstrapCredentialInternalError" - ? cause - : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause), + isBootstrapCredentialError(cause) ? cause : new BootstrapCredentialConsumeError({ cause }), ), ); - return { + return PairingGrantStore.of({ issueOneTimeToken, listActive, get streamChanges() { @@ -412,9 +480,9 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { }, revoke, consume, - } satisfies PairingGrantStoreShape; + }); }); -export const layer = Layer.effect(PairingGrantStore, make()).pipe( +export const layer = Layer.effect(PairingGrantStore, make).pipe( Layer.provideMerge(AuthPairingLinks.layer), ); diff --git a/apps/server/src/auth/ServerSecretStore.test.ts b/apps/server/src/auth/ServerSecretStore.test.ts index f18e59e6293..d4411fb9f3b 100644 --- a/apps/server/src/auth/ServerSecretStore.test.ts +++ b/apps/server/src/auth/ServerSecretStore.test.ts @@ -9,7 +9,7 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as PlatformError from "effect/PlatformError"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; const makeServerConfigLayer = () => @@ -231,7 +231,7 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32)); - assert.instanceOf(error, ServerSecretStore.SecretStoreError); + assert.instanceOf(error, ServerSecretStore.SecretStoreReadError); assert.include(error.message, "Failed to read secret session-signing-key."); assert.instanceOf(error.cause, PlatformError.PlatformError); assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); @@ -246,7 +246,7 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])), ); - assert.instanceOf(error, ServerSecretStore.SecretStoreError); + assert.instanceOf(error, ServerSecretStore.SecretStorePersistError); assert.include(error.message, "Failed to persist secret session-signing-key."); assert.instanceOf(error.cause, PlatformError.PlatformError); assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); @@ -259,7 +259,7 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { const error = yield* Effect.flip(secretStore.remove("session-signing-key")); - assert.instanceOf(error, ServerSecretStore.SecretStoreError); + assert.instanceOf(error, ServerSecretStore.SecretStoreRemoveError); assert.include(error.message, "Failed to remove secret session-signing-key."); assert.instanceOf(error.cause, PlatformError.PlatformError); assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); diff --git a/apps/server/src/auth/ServerSecretStore.ts b/apps/server/src/auth/ServerSecretStore.ts index 0dc4a6bb544..5e9890c1ea2 100644 --- a/apps/server/src/auth/ServerSecretStore.ts +++ b/apps/server/src/auth/ServerSecretStore.ts @@ -9,49 +9,158 @@ import * as Predicate from "effect/Predicate"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; -export class SecretStoreError extends Schema.TaggedErrorClass()( - "SecretStoreError", +const secretStoreErrorContext = { + resource: Schema.String, + cause: Schema.Defect(), +}; + +export class SecretStoreSecureError extends Schema.TaggedErrorClass()( + "SecretStoreSecureError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to secure ${this.resource}.`; + } +} + +export class SecretStoreReadError extends Schema.TaggedErrorClass()( + "SecretStoreReadError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), + ...secretStoreErrorContext, }, -) {} +) { + override get message(): string { + return `Failed to read ${this.resource}.`; + } +} + +export class SecretStoreTemporaryPathError extends Schema.TaggedErrorClass()( + "SecretStoreTemporaryPathError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to create temporary path for ${this.resource}.`; + } +} + +export class SecretStorePersistError extends Schema.TaggedErrorClass()( + "SecretStorePersistError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to persist ${this.resource}.`; + } +} + +export class SecretStoreRandomGenerationError extends Schema.TaggedErrorClass()( + "SecretStoreRandomGenerationError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to generate random bytes for ${this.resource}.`; + } +} + +export class SecretStoreConcurrentReadError extends Schema.TaggedErrorClass()( + "SecretStoreConcurrentReadError", + { + resource: Schema.String, + }, +) { + override get message(): string { + return `Failed to read ${this.resource} after concurrent creation.`; + } +} + +export class SecretStoreRemoveError extends Schema.TaggedErrorClass()( + "SecretStoreRemoveError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to remove ${this.resource}.`; + } +} + +export class SecretStoreDecodeError extends Schema.TaggedErrorClass()( + "SecretStoreDecodeError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to decode ${this.resource}.`; + } +} + +export class SecretStoreEncodeError extends Schema.TaggedErrorClass()( + "SecretStoreEncodeError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to encode ${this.resource}.`; + } +} + +export const SecretStoreError = Schema.Union([ + SecretStoreSecureError, + SecretStoreReadError, + SecretStoreTemporaryPathError, + SecretStorePersistError, + SecretStoreRandomGenerationError, + SecretStoreConcurrentReadError, + SecretStoreRemoveError, + SecretStoreDecodeError, + SecretStoreEncodeError, +]); +export type SecretStoreError = typeof SecretStoreError.Type; +export const isSecretStoreError = Schema.is(SecretStoreError); const isPlatformError = (value: unknown): value is PlatformError.PlatformError => Predicate.isTagged(value, "PlatformError"); export const isSecretAlreadyExistsError = (error: SecretStoreError): boolean => - isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists"; - -export interface ServerSecretStoreShape { - readonly get: (name: string) => Effect.Effect, SecretStoreError>; - readonly set: (name: string, value: Uint8Array) => Effect.Effect; - readonly create: (name: string, value: Uint8Array) => Effect.Effect; - readonly getOrCreateRandom: ( - name: string, - bytes: number, - ) => Effect.Effect; - readonly remove: (name: string) => Effect.Effect; -} + "cause" in error && isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists"; -export class ServerSecretStore extends Context.Service()( - "t3/auth/ServerSecretStore", -) {} +export class ServerSecretStore extends Context.Service< + ServerSecretStore, + { + readonly get: (name: string) => Effect.Effect, SecretStoreError>; + readonly set: (name: string, value: Uint8Array) => Effect.Effect; + readonly create: (name: string, value: Uint8Array) => Effect.Effect; + readonly getOrCreateRandom: ( + name: string, + bytes: number, + ) => Effect.Effect; + readonly remove: (name: string) => Effect.Effect; + } +>()("t3/auth/ServerSecretStore") {} -export const make = Effect.fn("makeServerSecretStore")(function* () { +export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const serverConfig = yield* ServerConfig; + const serverConfig = yield* ServerConfig.ServerConfig; yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true }); yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe( Effect.mapError( (cause) => - new SecretStoreError({ - message: `Failed to secure secrets directory ${serverConfig.secretsDir}.`, + new SecretStoreSecureError({ + resource: `secrets directory ${serverConfig.secretsDir}`, cause, }), ), @@ -59,15 +168,15 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); - const get: ServerSecretStoreShape["get"] = (name) => + const get: ServerSecretStore["Service"]["get"] = (name) => fileSystem.readFile(resolveSecretPath(name)).pipe( Effect.map((bytes) => Option.some(Uint8Array.from(bytes))), Effect.catch((cause) => cause.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail( - new SecretStoreError({ - message: `Failed to read secret ${name}.`, + new SecretStoreReadError({ + resource: `secret ${name}`, cause, }), ), @@ -75,13 +184,13 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { Effect.withSpan("ServerSecretStore.get"), ); - const set: ServerSecretStoreShape["set"] = (name, value) => { + const set: ServerSecretStore["Service"]["set"] = (name, value) => { const secretPath = resolveSecretPath(name); return crypto.randomUUIDv4.pipe( Effect.mapError( (cause) => - new SecretStoreError({ - message: `Failed to create temporary path for secret ${name}.`, + new SecretStoreTemporaryPathError({ + resource: `secret ${name}`, cause, }), ), @@ -98,8 +207,8 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { Effect.ignore, Effect.flatMap(() => Effect.fail( - new SecretStoreError({ - message: `Failed to persist secret ${name}.`, + new SecretStorePersistError({ + resource: `secret ${name}`, cause, }), ), @@ -112,7 +221,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { ); }; - const create: ServerSecretStoreShape["create"] = (name, value) => { + const create: ServerSecretStore["Service"]["create"] = (name, value) => { const secretPath = resolveSecretPath(name); return Effect.scoped( Effect.gen(function* () { @@ -127,15 +236,15 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { ).pipe( Effect.mapError( (cause) => - new SecretStoreError({ - message: `Failed to persist secret ${name}.`, + new SecretStorePersistError({ + resource: `secret ${name}`, cause, }), ), ); }; - const getOrCreateRandom: ServerSecretStoreShape["getOrCreateRandom"] = (name, bytes) => + const getOrCreateRandom: ServerSecretStore["Service"]["getOrCreateRandom"] = (name, bytes) => get(name).pipe( Effect.flatMap( Option.match({ @@ -144,15 +253,15 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { crypto.randomBytes(bytes).pipe( Effect.mapError( (cause) => - new SecretStoreError({ - message: `Failed to generate random bytes for secret ${name}.`, + new SecretStoreRandomGenerationError({ + resource: `secret ${name}`, cause, }), ), Effect.flatMap((generated) => create(name, generated).pipe( Effect.as(Uint8Array.from(generated)), - Effect.catchTag("SecretStoreError", (error) => + Effect.catchIf(isSecretStoreError, (error) => isSecretAlreadyExistsError(error) ? get(name).pipe( Effect.flatMap( @@ -160,8 +269,8 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { onSome: Effect.succeed, onNone: () => Effect.fail( - new SecretStoreError({ - message: `Failed to read secret ${name} after concurrent creation.`, + new SecretStoreConcurrentReadError({ + resource: `secret ${name}`, }), ), }), @@ -177,14 +286,14 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { Effect.withSpan("ServerSecretStore.getOrCreateRandom"), ); - const remove: ServerSecretStoreShape["remove"] = (name) => + const remove: ServerSecretStore["Service"]["remove"] = (name) => fileSystem.remove(resolveSecretPath(name)).pipe( Effect.catch((cause) => cause.reason._tag === "NotFound" ? Effect.void : Effect.fail( - new SecretStoreError({ - message: `Failed to remove secret ${name}.`, + new SecretStoreRemoveError({ + resource: `secret ${name}`, cause, }), ), @@ -192,13 +301,13 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { Effect.withSpan("ServerSecretStore.remove"), ); - return { + return ServerSecretStore.of({ get, set, create, getOrCreateRandom, remove, - } satisfies ServerSecretStoreShape; + }); }); -export const layer = Layer.effect(ServerSecretStore, make()); +export const layer = Layer.effect(ServerSecretStore, make); diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 130222408a6..0dd5d797d19 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -51,7 +51,7 @@ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessi const failingSessionLookupCredentialLayer = Layer.effect( SessionStore.SessionStore, - SessionStore.make(), + SessionStore.make, ).pipe( Layer.provide(failingSessionLookupRepositoryLayer), Layer.provide(ServerSecretStore.layer), @@ -89,7 +89,7 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { const sessions = yield* SessionStore.SessionStore; const error = yield* Effect.flip(sessions.verify("not-a-session-token")); - expect(error._tag).toBe("SessionCredentialInvalidError"); + expect(error._tag).toBe("MalformedSessionTokenError"); expect(error.message).toContain("Malformed session token"); }).pipe(Effect.provide(makeSessionStoreLayer())), ); @@ -105,8 +105,8 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { const sessionError = yield* Effect.flip(sessions.verify(issued.token)); const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token)); - expect(sessionError._tag).toBe("SessionCredentialInternalError"); - expect(websocketError._tag).toBe("SessionCredentialInternalError"); + expect(sessionError._tag).toBe("SessionCredentialVerificationError"); + expect(websocketError._tag).toBe("WebSocketTokenVerificationError"); expect(sessionError.cause).toBe(repositoryFailure); expect(websocketError.cause).toBe(repositoryFailure); }).pipe(Effect.provide(failingSessionLookupCredentialLayer)), diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index e1064c27904..18008a7d0a1 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -10,7 +10,6 @@ import { import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -21,7 +20,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as Option from "effect/Option"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import * as AuthSessions from "../persistence/AuthSessions.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; import { @@ -63,66 +62,311 @@ export type SessionCredentialChange = readonly sessionId: AuthSessionId; }; -export class SessionCredentialInvalidError extends Data.TaggedError( - "SessionCredentialInvalidError", -)<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -export class SessionCredentialInternalError extends Data.TaggedError( - "SessionCredentialInternalError", -)<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -export type SessionCredentialError = SessionCredentialInvalidError | SessionCredentialInternalError; - -export interface SessionStoreShape { - readonly cookieName: string; - readonly issue: (input?: { - readonly ttl?: Duration.Duration; - readonly subject?: string; - readonly method?: ServerAuthSessionMethod; - readonly scopes?: ReadonlyArray; - readonly client?: AuthClientMetadata; - readonly proofKeyThumbprint?: string; - }) => Effect.Effect; - readonly verify: (token: string) => Effect.Effect; - readonly issueWebSocketToken: ( - sessionId: AuthSessionId, - input?: { - readonly ttl?: Duration.Duration; - }, - ) => Effect.Effect< - { - readonly token: string; - readonly expiresAt: DateTime.DateTime; - }, - SessionCredentialInternalError - >; - readonly verifyWebSocketToken: ( - token: string, - ) => Effect.Effect; - readonly listActive: () => Effect.Effect< - ReadonlyArray, - SessionCredentialInternalError - >; - readonly streamChanges: Stream.Stream; - readonly revoke: ( - sessionId: AuthSessionId, - ) => Effect.Effect; - readonly revokeAllExcept: ( - sessionId: AuthSessionId, - ) => Effect.Effect; - readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect; - readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect; +export class MalformedSessionTokenError extends Schema.TaggedErrorClass()( + "MalformedSessionTokenError", + {}, +) { + override get message(): string { + return "Malformed session token."; + } +} + +export class InvalidSessionTokenSignatureError extends Schema.TaggedErrorClass()( + "InvalidSessionTokenSignatureError", + {}, +) { + override get message(): string { + return "Invalid session token signature."; + } } -export class SessionStore extends Context.Service()( - "t3/auth/SessionStore", -) {} +export class InvalidSessionTokenPayloadError extends Schema.TaggedErrorClass()( + "InvalidSessionTokenPayloadError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Invalid session token payload."; + } +} + +export class SessionTokenExpiredError extends Schema.TaggedErrorClass()( + "SessionTokenExpiredError", + {}, +) { + override get message(): string { + return "Session token expired."; + } +} + +export class UnknownSessionTokenError extends Schema.TaggedErrorClass()( + "UnknownSessionTokenError", + {}, +) { + override get message(): string { + return "Unknown session token."; + } +} + +export class SessionTokenRevokedError extends Schema.TaggedErrorClass()( + "SessionTokenRevokedError", + {}, +) { + override get message(): string { + return "Session token revoked."; + } +} + +export class InvalidSessionExpirationClaimError extends Schema.TaggedErrorClass()( + "InvalidSessionExpirationClaimError", + {}, +) { + override get message(): string { + return "Invalid `exp` claim"; + } +} + +export class MalformedWebSocketTokenError extends Schema.TaggedErrorClass()( + "MalformedWebSocketTokenError", + {}, +) { + override get message(): string { + return "Malformed websocket token."; + } +} + +export class InvalidWebSocketTokenSignatureError extends Schema.TaggedErrorClass()( + "InvalidWebSocketTokenSignatureError", + {}, +) { + override get message(): string { + return "Invalid websocket token signature."; + } +} + +export class InvalidWebSocketTokenPayloadError extends Schema.TaggedErrorClass()( + "InvalidWebSocketTokenPayloadError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Invalid websocket token payload."; + } +} + +export class WebSocketTokenExpiredError extends Schema.TaggedErrorClass()( + "WebSocketTokenExpiredError", + {}, +) { + override get message(): string { + return "Websocket token expired."; + } +} + +export class UnknownWebSocketSessionError extends Schema.TaggedErrorClass()( + "UnknownWebSocketSessionError", + {}, +) { + override get message(): string { + return "Unknown websocket session."; + } +} + +export class WebSocketSessionExpiredError extends Schema.TaggedErrorClass()( + "WebSocketSessionExpiredError", + {}, +) { + override get message(): string { + return "Websocket session expired."; + } +} + +export class WebSocketSessionRevokedError extends Schema.TaggedErrorClass()( + "WebSocketSessionRevokedError", + {}, +) { + override get message(): string { + return "Websocket session revoked."; + } +} + +export const SessionCredentialInvalidError = Schema.Union([ + MalformedSessionTokenError, + InvalidSessionTokenSignatureError, + InvalidSessionTokenPayloadError, + SessionTokenExpiredError, + UnknownSessionTokenError, + SessionTokenRevokedError, + InvalidSessionExpirationClaimError, + MalformedWebSocketTokenError, + InvalidWebSocketTokenSignatureError, + InvalidWebSocketTokenPayloadError, + WebSocketTokenExpiredError, + UnknownWebSocketSessionError, + WebSocketSessionExpiredError, + WebSocketSessionRevokedError, +]); +export type SessionCredentialInvalidError = typeof SessionCredentialInvalidError.Type; +export const isSessionCredentialInvalidError = Schema.is(SessionCredentialInvalidError); + +const sessionCredentialInternalErrorContext = { + cause: Schema.Defect(), +}; + +export class SessionClaimsEncodingError extends Schema.TaggedErrorClass()( + "SessionClaimsEncodingError", + { + operation: Schema.Literals(["encode_session_claims", "encode_websocket_claims"]), + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to encode claims"; + } +} + +export class SessionCredentialIssueError extends Schema.TaggedErrorClass()( + "SessionCredentialIssueError", + { + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue session credential."; + } +} + +export class SessionCredentialVerificationError extends Schema.TaggedErrorClass()( + "SessionCredentialVerificationError", + { + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to verify session credential."; + } +} + +export class WebSocketTokenIssueError extends Schema.TaggedErrorClass()( + "WebSocketTokenIssueError", + { + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue websocket token."; + } +} + +export class WebSocketTokenVerificationError extends Schema.TaggedErrorClass()( + "WebSocketTokenVerificationError", + { + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to verify websocket token."; + } +} + +export class ActiveSessionsListError extends Schema.TaggedErrorClass()( + "ActiveSessionsListError", + { + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to list active sessions."; + } +} + +export class SessionRevocationError extends Schema.TaggedErrorClass()( + "SessionRevocationError", + { + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to revoke session."; + } +} + +export class OtherSessionsRevocationError extends Schema.TaggedErrorClass()( + "OtherSessionsRevocationError", + { + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to revoke other sessions."; + } +} + +export const SessionCredentialInternalError = Schema.Union([ + SessionClaimsEncodingError, + SessionCredentialIssueError, + SessionCredentialVerificationError, + WebSocketTokenIssueError, + WebSocketTokenVerificationError, + ActiveSessionsListError, + SessionRevocationError, + OtherSessionsRevocationError, +]); +export type SessionCredentialInternalError = typeof SessionCredentialInternalError.Type; +export const isSessionCredentialInternalError = Schema.is(SessionCredentialInternalError); + +export const SessionCredentialError = Schema.Union([ + SessionCredentialInvalidError, + SessionCredentialInternalError, +]); +export type SessionCredentialError = typeof SessionCredentialError.Type; +export const isSessionCredentialError = Schema.is(SessionCredentialError); + +export class SessionStore extends Context.Service< + SessionStore, + { + readonly cookieName: string; + readonly issue: (input?: { + readonly ttl?: Duration.Duration; + readonly subject?: string; + readonly method?: ServerAuthSessionMethod; + readonly scopes?: ReadonlyArray; + readonly client?: AuthClientMetadata; + readonly proofKeyThumbprint?: string; + }) => Effect.Effect; + readonly verify: (token: string) => Effect.Effect; + readonly issueWebSocketToken: ( + sessionId: AuthSessionId, + input?: { + readonly ttl?: Duration.Duration; + }, + ) => Effect.Effect< + { + readonly token: string; + readonly expiresAt: DateTime.DateTime; + }, + SessionCredentialInternalError + >; + readonly verifyWebSocketToken: ( + token: string, + ) => Effect.Effect; + readonly listActive: () => Effect.Effect< + ReadonlyArray, + SessionCredentialInternalError + >; + readonly streamChanges: Stream.Stream; + readonly revoke: ( + sessionId: AuthSessionId, + ) => Effect.Effect; + readonly revokeAllExcept: ( + sessionId: AuthSessionId, + ) => Effect.Effect; + readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect; + readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect; + } +>()("t3/auth/SessionStore") {} const SIGNING_SECRET_NAME = "server-signing-key"; const DEFAULT_SESSION_TTL = Duration.days(30); @@ -184,15 +428,9 @@ function toAuthClientSession(input: Omit): AuthCli }; } -const toSessionCredentialInternalError = (message: string) => (cause: unknown) => - new SessionCredentialInternalError({ - message, - cause, - }); - -export const make = Effect.fn("makeSessionStore")(function* () { +export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; - const serverConfig = yield* ServerConfig; + const serverConfig = yield* ServerConfig.ServerConfig; const secretStore = yield* ServerSecretStore.ServerSecretStore; const authSessions = yield* AuthSessions.AuthSessionRepository; const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); @@ -238,7 +476,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { ); }); - const markConnected: SessionStoreShape["markConnected"] = (sessionId) => + const markConnected: SessionStore["Service"]["markConnected"] = (sessionId) => Ref.modify(connectedSessionsRef, (current) => { const next = new Map(current); const wasDisconnected = !next.has(sessionId); @@ -272,7 +510,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { Effect.withSpan("SessionStore.markConnected"), ); - const markDisconnected: SessionStoreShape["markDisconnected"] = (sessionId) => + const markDisconnected: SessionStore["Service"]["markDisconnected"] = (sessionId) => Ref.update(connectedSessionsRef, (current) => { const next = new Map(current); const remaining = (next.get(sessionId) ?? 0) - 1; @@ -299,7 +537,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { ); const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims)); - const issue: SessionStoreShape["issue"] = Effect.fn("SessionStore.issue")( + const issue: SessionStore["Service"]["issue"] = Effect.fn("SessionStore.issue")( function* (input) { const sessionId = AuthSessionId.make(yield* crypto.randomUUIDv4); const issuedAt = yield* DateTime.now; @@ -321,8 +559,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { const encodedPayload = yield* encodeClaims(claims).pipe( Effect.map(base64UrlEncode), Effect.mapError( - (cause) => - new SessionCredentialInternalError({ message: "Failed to encode claims", cause }), + (cause) => new SessionClaimsEncodingError({ operation: "encode_session_claims", cause }), ), ); const signature = signPayload(encodedPayload, signingSecret); @@ -367,59 +604,41 @@ export const make = Effect.fn("makeSessionStore")(function* () { ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), } satisfies IssuedSession; }, - Effect.mapError(toSessionCredentialInternalError("Failed to issue session credential.")), + Effect.mapError((cause) => new SessionCredentialIssueError({ cause })), ); - const verify: SessionStoreShape["verify"] = Effect.fn("SessionStore.verify")( + const verify: SessionStore["Service"]["verify"] = Effect.fn("SessionStore.verify")( function* (token) { const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) { - return yield* new SessionCredentialInvalidError({ - message: "Malformed session token.", - }); + return yield* new MalformedSessionTokenError({}); } const expectedSignature = signPayload(encodedPayload, signingSecret); if (!timingSafeEqualBase64Url(signature, expectedSignature)) { - return yield* new SessionCredentialInvalidError({ - message: "Invalid session token signature.", - }); + return yield* new InvalidSessionTokenSignatureError({}); } const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( - Effect.mapError( - (cause) => - new SessionCredentialInvalidError({ - message: "Invalid session token payload.", - cause, - }), - ), + Effect.mapError((cause) => new InvalidSessionTokenPayloadError({ cause })), ); const now = yield* Clock.currentTimeMillis; if (claims.exp <= now) { - return yield* new SessionCredentialInvalidError({ - message: "Session token expired.", - }); + return yield* new SessionTokenExpiredError({}); } const row = yield* authSessions.getById({ sessionId: claims.sid }); if (Option.isNone(row)) { - return yield* new SessionCredentialInvalidError({ - message: "Unknown session token.", - }); + return yield* new UnknownSessionTokenError({}); } if (row.value.revokedAt !== null) { - return yield* new SessionCredentialInvalidError({ - message: "Session token revoked.", - }); + return yield* new SessionTokenRevokedError({}); } const expiresAt = DateTime.make(claims.exp); if (Option.isNone(expiresAt)) { - return yield* new SessionCredentialInvalidError({ - message: "Invalid `exp` claim", - }); + return yield* new InvalidSessionExpirationClaimError({}); } return { @@ -434,17 +653,14 @@ export const make = Effect.fn("makeSessionStore")(function* () { } satisfies VerifiedSession; }, Effect.mapError((cause) => - cause._tag === "SessionCredentialInvalidError" + isSessionCredentialInvalidError(cause) ? cause - : new SessionCredentialInternalError({ - message: "Failed to verify session credential.", - cause, - }), + : new SessionCredentialVerificationError({ cause }), ), ); const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims)); - const issueWebSocketToken: SessionStoreShape["issueWebSocketToken"] = Effect.fn( + const issueWebSocketToken: SessionStore["Service"]["issueWebSocketToken"] = Effect.fn( "SessionStore.issueWebSocketToken", )( function* (sessionId, input) { @@ -463,7 +679,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { Effect.map(base64UrlEncode), Effect.mapError( (cause) => - new SessionCredentialInternalError({ message: "Failed to encode claims", cause }), + new SessionClaimsEncodingError({ operation: "encode_websocket_claims", cause }), ), ); const signature = signPayload(encodedPayload, signingSecret); @@ -472,59 +688,41 @@ export const make = Effect.fn("makeSessionStore")(function* () { expiresAt, }; }, - Effect.mapError(toSessionCredentialInternalError("Failed to issue websocket token.")), + Effect.mapError((cause) => new WebSocketTokenIssueError({ cause })), ); - const verifyWebSocketToken: SessionStoreShape["verifyWebSocketToken"] = Effect.fn( + const verifyWebSocketToken: SessionStore["Service"]["verifyWebSocketToken"] = Effect.fn( "SessionStore.verifyWebSocketToken", )( function* (token) { const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) { - return yield* new SessionCredentialInvalidError({ - message: "Malformed websocket token.", - }); + return yield* new MalformedWebSocketTokenError({}); } const expectedSignature = signPayload(encodedPayload, signingSecret); if (!timingSafeEqualBase64Url(signature, expectedSignature)) { - return yield* new SessionCredentialInvalidError({ - message: "Invalid websocket token signature.", - }); + return yield* new InvalidWebSocketTokenSignatureError({}); } const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( - Effect.mapError( - (cause) => - new SessionCredentialInvalidError({ - message: "Invalid websocket token payload.", - cause, - }), - ), + Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })), ); const now = yield* Clock.currentTimeMillis; if (claims.exp <= now) { - return yield* new SessionCredentialInvalidError({ - message: "Websocket token expired.", - }); + return yield* new WebSocketTokenExpiredError({}); } const row = yield* authSessions.getById({ sessionId: claims.sid }); if (Option.isNone(row)) { - return yield* new SessionCredentialInvalidError({ - message: "Unknown websocket session.", - }); + return yield* new UnknownWebSocketSessionError({}); } if (row.value.expiresAt.epochMilliseconds <= now) { - return yield* new SessionCredentialInvalidError({ - message: "Websocket session expired.", - }); + return yield* new WebSocketSessionExpiredError({}); } if (row.value.revokedAt !== null) { - return yield* new SessionCredentialInvalidError({ - message: "Websocket session revoked.", - }); + return yield* new WebSocketSessionRevokedError({}); } return { @@ -538,16 +736,13 @@ export const make = Effect.fn("makeSessionStore")(function* () { } satisfies VerifiedSession; }, Effect.mapError((cause) => - cause._tag === "SessionCredentialInvalidError" + isSessionCredentialInvalidError(cause) ? cause - : new SessionCredentialInternalError({ - message: "Failed to verify websocket token.", - cause, - }), + : new WebSocketTokenVerificationError({ cause }), ), ); - const listActive: SessionStoreShape["listActive"] = Effect.fn("SessionStore.listActive")( + const listActive: SessionStore["Service"]["listActive"] = Effect.fn("SessionStore.listActive")( function* () { const now = yield* DateTime.now; const connectedSessions = yield* Ref.get(connectedSessionsRef); @@ -567,10 +762,10 @@ export const make = Effect.fn("makeSessionStore")(function* () { }), ); }, - Effect.mapError(toSessionCredentialInternalError("Failed to list active sessions.")), + Effect.mapError((cause) => new ActiveSessionsListError({ cause })), ); - const revoke: SessionStoreShape["revoke"] = Effect.fn("SessionStore.revoke")( + const revoke: SessionStore["Service"]["revoke"] = Effect.fn("SessionStore.revoke")( function* (sessionId) { const revokedAt = yield* DateTime.now; const revoked = yield* authSessions.revoke({ @@ -587,10 +782,10 @@ export const make = Effect.fn("makeSessionStore")(function* () { } return revoked; }, - Effect.mapError(toSessionCredentialInternalError("Failed to revoke session.")), + Effect.mapError((cause) => new SessionRevocationError({ cause })), ); - const revokeAllExcept: SessionStoreShape["revokeAllExcept"] = Effect.fn( + const revokeAllExcept: SessionStore["Service"]["revokeAllExcept"] = Effect.fn( "SessionStore.revokeAllExcept", )( function* (sessionId) { @@ -618,10 +813,10 @@ export const make = Effect.fn("makeSessionStore")(function* () { } return revokedSessionIds.length; }, - Effect.mapError(toSessionCredentialInternalError("Failed to revoke other sessions.")), + Effect.mapError((cause) => new OtherSessionsRevocationError({ cause })), ); - return { + return SessionStore.of({ cookieName, issue, verify, @@ -635,9 +830,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { revokeAllExcept, markConnected, markDisconnected, - } satisfies SessionStoreShape; + }); }); -export const layer = Layer.effect(SessionStore, make()).pipe( - Layer.provideMerge(AuthSessions.layer), -); +export const layer = Layer.effect(SessionStore, make).pipe(Layer.provideMerge(AuthSessions.layer)); diff --git a/apps/server/src/auth/dpop.test.ts b/apps/server/src/auth/dpop.test.ts index 76898bc9463..fa75c407b0c 100644 --- a/apps/server/src/auth/dpop.test.ts +++ b/apps/server/src/auth/dpop.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; import * as PlatformError from "effect/PlatformError"; -import * as ServerSecretStore from "./ServerSecretStore.ts"; +import { SecretStorePersistError } from "./ServerSecretStore.ts"; import { mapDpopReplayStoreError } from "./dpop.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => - new ServerSecretStore.SecretStoreError({ - message: "Failed to persist DPoP proof.", + new SecretStorePersistError({ + resource: "DPoP proof", cause: PlatformError.systemError({ _tag: tag, module: "FileSystem", @@ -17,16 +17,20 @@ const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => describe("mapDpopReplayStoreError", () => { it("reports replay conflicts as invalid credentials", () => { - const error = mapDpopReplayStoreError(storeFailure("AlreadyExists")); + const cause = storeFailure("AlreadyExists"); + const error = mapDpopReplayStoreError(cause); expect(error._tag).toBe("ServerAuthInvalidCredentialError"); + if (error._tag === "ServerAuthInvalidCredentialError") { + expect(error.cause).toBe(cause); + } }); it("reports replay-store availability failures as internal errors", () => { const error = mapDpopReplayStoreError(storeFailure("PermissionDenied")); - expect(error._tag).toBe("ServerAuthInternalError"); - if (error._tag === "ServerAuthInternalError") { + expect(error._tag).toBe("ServerAuthDpopReplayStateRecordError"); + if (error._tag === "ServerAuthDpopReplayStateRecordError") { expect(error.message).toBe("Failed to record DPoP proof replay state."); } }); diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts index 66cd07f9e2e..87dc0c263e2 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -5,7 +5,12 @@ import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; -import * as EnvironmentAuth from "./EnvironmentAuth.ts"; +import { + ServerAuthDpopReplayKeyCalculationError, + ServerAuthDpopReplayStateRecordError, + ServerAuthInvalidCredentialError, + type ServerAuthInternalError, +} from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; function firstHeaderValue(value: string | undefined): string | undefined { @@ -26,14 +31,13 @@ export function requestAbsoluteUrl(request: HttpServerRequest.HttpServerRequest) export const mapDpopReplayStoreError = ( error: ServerSecretStore.SecretStoreError, -): EnvironmentAuth.ServerAuthInvalidCredentialError | EnvironmentAuth.ServerAuthInternalError => +): ServerAuthInvalidCredentialError | ServerAuthInternalError => ServerSecretStore.isSecretAlreadyExistsError(error) - ? new EnvironmentAuth.ServerAuthInvalidCredentialError({ - reason: "invalid_credential", - cause: "DPoP proof replayed.", + ? new ServerAuthInvalidCredentialError({ + diagnostic: "DPoP proof replayed.", + cause: error, }) - : new EnvironmentAuth.ServerAuthInternalError({ - message: "Failed to record DPoP proof replay state.", + : new ServerAuthDpopReplayStateRecordError({ cause: error, }); @@ -54,9 +58,8 @@ export const verifyRequestDpopProof = (input: { ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}), }); if (!result.ok) { - return yield* new EnvironmentAuth.ServerAuthInvalidCredentialError({ - reason: "invalid_credential", - cause: result.reason, + return yield* new ServerAuthInvalidCredentialError({ + diagnostic: result.reason, }); } const secretStore = yield* ServerSecretStore.ServerSecretStore; @@ -67,8 +70,7 @@ export const verifyRequestDpopProof = (input: { Effect.map(Encoding.encodeBase64Url), Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Failed to calculate DPoP replay key.", + new ServerAuthDpopReplayKeyCalculationError({ cause, }), ), @@ -86,7 +88,9 @@ export const verifyRequestDpopProof = (input: { ), ) .pipe( - Effect.catchTag("SecretStoreError", (error) => Effect.fail(mapDpopReplayStoreError(error))), + Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => + Effect.fail(mapDpopReplayStoreError(error)), + ), ); return result.thumbprint; }); diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 6e1be00209d..71fb00b970a 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -169,10 +169,12 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const session = yield* serverAuth.authenticateHttpRequest(request).pipe( - Effect.catchTags({ - ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), - ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error), - }), + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("internal_error", error), + ), ); return yield* httpEffect.pipe( Effect.provideService(EnvironmentAuthenticatedPrincipal, { @@ -201,7 +203,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( const request = yield* HttpServerRequest.HttpServerRequest; return yield* serverAuth.getSessionState(request); }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("internal_error", error), ), ), @@ -231,11 +233,12 @@ export const authHttpApiLayer = HttpApiBuilder.group( yield* appendCredentialResponseHeaders; return result.response; }, - Effect.catchTags({ - ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), - ServerAuthInternalError: (error) => - failEnvironmentInternal("browser_session_issuance_failed", error), - }), + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("browser_session_issuance_failed", error), + ), ), ) .handle( @@ -265,14 +268,14 @@ export const authHttpApiLayer = HttpApiBuilder.group( } const proofKeyThumbprint = args.headers.dpop ? yield* verifyRequestDpopProof({ request }).pipe( - Effect.catchTags({ - ServerAuthInvalidCredentialError: () => - appendDpopChallengeHeader.pipe( - Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")), - ), - ServerAuthInternalError: (error) => - failEnvironmentInternal("access_token_issuance_failed", error), - }), + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, () => + appendDpopChallengeHeader.pipe( + Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")), + ), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("access_token_issuance_failed", error), + ), ) : undefined; yield* appendCredentialResponseHeaders; @@ -293,12 +296,15 @@ export const authHttpApiLayer = HttpApiBuilder.group( ); }, traceRelayRequest, - Effect.catchTags({ - ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), - ServerAuthInvalidRequestError: (error) => failEnvironmentInvalidRequest(error.reason), - ServerAuthInternalError: (error) => - failEnvironmentInternal("access_token_issuance_failed", error), - }), + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInvalidRequestError, (error) => + failEnvironmentInvalidRequest(EnvironmentAuth.serverAuthInvalidRequestReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("access_token_issuance_failed", error), + ), ), ) .handle( @@ -310,7 +316,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( yield* appendCredentialResponseHeaders; return yield* serverAuth.issueWebSocketTicket(session); }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("websocket_ticket_issuance_failed", error), ), ), @@ -335,7 +341,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( } return yield* serverAuth.issuePairingCredential(args.payload); }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("pairing_credential_issuance_failed", error), ), ), @@ -348,7 +354,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( yield* requireEnvironmentScope(AuthAccessReadScope); return yield* serverAuth.listPairingLinks(); }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("pairing_links_load_failed", error), ), ), @@ -362,7 +368,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( const revoked = yield* serverAuth.revokePairingLink(args.payload.id); return { revoked }; }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("pairing_link_revoke_failed", error), ), ), @@ -375,7 +381,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( const session = yield* requireEnvironmentScope(AuthAccessReadScope); return yield* serverAuth.listClientSessions(session.sessionId); }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("client_sessions_load_failed", error), ), ), @@ -392,12 +398,12 @@ export const authHttpApiLayer = HttpApiBuilder.group( ); return { revoked }; }, - Effect.catchTags({ - ServerAuthForbiddenOperationError: (error) => - failEnvironmentOperationForbidden(error.reason), - ServerAuthInternalError: (error) => - failEnvironmentInternal("client_session_revoke_failed", error), - }), + Effect.catchTag("ServerAuthForbiddenOperationError", () => + failEnvironmentOperationForbidden("current_session_revoke_not_allowed"), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("client_session_revoke_failed", error), + ), ), ) .handle( @@ -409,7 +415,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( const revokedCount = yield* serverAuth.revokeOtherClientSessions(session.sessionId); return { revokedCount }; }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("client_session_revoke_failed", error), ), ), diff --git a/apps/server/src/cloud/environmentKeys.test.ts b/apps/server/src/cloud/environmentKeys.test.ts index 5c20cd64ed3..48c44ccc48a 100644 --- a/apps/server/src/cloud/environmentKeys.test.ts +++ b/apps/server/src/cloud/environmentKeys.test.ts @@ -6,7 +6,7 @@ import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; const makeServerSecretStoreLayer = () => @@ -65,8 +65,8 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it }).pipe( Effect.flatMap(() => Effect.fail( - new ServerSecretStore.SecretStoreError({ - message: "Concurrent keypair creation won.", + new ServerSecretStore.SecretStorePersistError({ + resource: "environment signing key pair", cause: PlatformError.systemError({ _tag: "AlreadyExists", module: "FileSystem", @@ -79,7 +79,7 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it ), getOrCreateRandom: unusedSecretStoreOperation, remove: unusedSecretStoreOperation, - } satisfies ServerSecretStore.ServerSecretStoreShape; + } satisfies ServerSecretStore.ServerSecretStore["Service"]; assert.deepEqual(yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore), { privateKey: "winner-private", diff --git a/apps/server/src/cloud/environmentKeys.ts b/apps/server/src/cloud/environmentKeys.ts index f051d8265cb..1d0cde91bf4 100644 --- a/apps/server/src/cloud/environmentKeys.ts +++ b/apps/server/src/cloud/environmentKeys.ts @@ -27,47 +27,46 @@ function stringToBytes(value: string): Uint8Array { return new TextEncoder().encode(value); } -const keyPairPersistenceError = (message: string, cause?: unknown) => - new ServerSecretStore.SecretStoreError({ message, cause }); +const KEY_PAIR_RESOURCE = "environment signing key pair"; + +const keyPairDecodeError = (cause: unknown): ServerSecretStore.SecretStoreDecodeError => + new ServerSecretStore.SecretStoreDecodeError({ resource: KEY_PAIR_RESOURCE, cause }); + +const keyPairEncodeError = (cause: unknown): ServerSecretStore.SecretStoreEncodeError => + new ServerSecretStore.SecretStoreEncodeError({ resource: KEY_PAIR_RESOURCE, cause }); + +const keyPairConcurrentReadError = (): ServerSecretStore.SecretStoreConcurrentReadError => + new ServerSecretStore.SecretStoreConcurrentReadError({ resource: KEY_PAIR_RESOURCE }); const readEnvironmentKeyPair = Effect.fn("readEnvironmentKeyPair")(function* ( - secrets: ServerSecretStore.ServerSecretStoreShape, + secrets: ServerSecretStore.ServerSecretStore["Service"], ) { const encoded = yield* secrets.get(CLOUD_LINK_KEY_PAIR); if (Option.isNone(encoded)) { return Option.none(); } const decoded = yield* decodeEnvironmentKeyPair(bytesToString(encoded.value)).pipe( - Effect.mapError((cause) => - keyPairPersistenceError("Failed to decode environment signing key pair.", cause), - ), + Effect.mapError(keyPairDecodeError), ); return Option.some(decoded); }); const persistEnvironmentKeyPair = Effect.fn("persistEnvironmentKeyPair")(function* ( - secrets: ServerSecretStore.ServerSecretStoreShape, + secrets: ServerSecretStore.ServerSecretStore["Service"], keyPair: EnvironmentKeyPair, ) { const encoded = yield* encodeEnvironmentKeyPair(keyPair).pipe( - Effect.mapError((cause) => - keyPairPersistenceError("Failed to encode environment signing key pair.", cause), - ), + Effect.mapError(keyPairEncodeError), ); return yield* secrets.create(CLOUD_LINK_KEY_PAIR, stringToBytes(encoded)).pipe( Effect.as(keyPair), - Effect.catchTag("SecretStoreError", (error) => + Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => ServerSecretStore.isSecretAlreadyExistsError(error) ? readEnvironmentKeyPair(secrets).pipe( Effect.flatMap( Option.match({ onSome: Effect.succeed, - onNone: () => - Effect.fail( - keyPairPersistenceError( - "Failed to read environment signing key pair after concurrent creation.", - ), - ), + onNone: () => Effect.fail(keyPairConcurrentReadError()), }), ), ) @@ -77,7 +76,7 @@ const persistEnvironmentKeyPair = Effect.fn("persistEnvironmentKeyPair")(functio }); export const getOrCreateEnvironmentKeyPairFromSecretStore = Effect.fn(function* ( - secrets: ServerSecretStore.ServerSecretStoreShape, + secrets: ServerSecretStore.ServerSecretStore["Service"], ) { const existing = yield* readEnvironmentKeyPair(secrets); if (Option.isSome(existing)) { diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 58274c9d708..ed2e5a4cf75 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -16,8 +16,8 @@ import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => - new ServerSecretStore.SecretStoreError({ - message: "Failed to persist cloud replay guard.", + new ServerSecretStore.SecretStorePersistError({ + resource: "cloud replay guard", cause: PlatformError.systemError({ _tag: tag, module: "FileSystem", @@ -40,6 +40,30 @@ function makeSecretStore( }; } +it("preserves messages surfaced by cloud 500 responses", () => { + const cause = new Error("cloud operation failed"); + + expect([ + new EnvironmentAuth.ServerAuthLinkedCloudAccountVerificationError({ cause }).message, + new EnvironmentAuth.ServerAuthLinkedCloudAccountReadError({ cause }).message, + new EnvironmentAuth.ServerAuthLinkedCloudAccountMissingError({}).message, + new EnvironmentAuth.ServerAuthCloudLinkJwtSigningError({ cause }).message, + new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({}).message, + new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({}).message, + new EnvironmentAuth.ServerAuthCloudHealthJwtSigningError({ cause }).message, + new EnvironmentAuth.ServerAuthCloudMintJwtSigningError({ cause }).message, + ]).toEqual([ + "Could not verify the linked cloud account.", + "Could not read the linked cloud account.", + "Cloud linked user is not installed for this environment.", + "Failed to sign cloud link JWT.", + "Cloud mint public key is not installed for this environment.", + "Cloud relay issuer is not installed for this environment.", + "Failed to sign cloud health JWT.", + "Failed to sign cloud mint JWT.", + ]); +}); + describe("consumeCloudReplayGuards", () => { it.effect("reports already-created guards as replay conflicts", () => Effect.gen(function* () { diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 71be9f376d8..fc2adca9fbc 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -68,7 +68,7 @@ import { RELAY_URL_SECRET, } from "./config.ts"; import { relayUrlConfig } from "./publicConfig.ts"; -import * as CliState from "./CliState.ts"; +import { setCliDesiredCloudLink } from "./CliState.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; import { traceRelayRequest } from "./traceRelayRequest.ts"; @@ -98,7 +98,7 @@ const failEnvironmentCloudInternalError = ); const failCloudCliTokenManagerError = (error: CliTokenManager.CloudCliTokenManagerError) => - failEnvironmentCloudInternalError(error.message)(error.cause); + failEnvironmentCloudInternalError(error.message)(error); const requireRelayUrl = relayUrlConfig.pipe( Effect.mapError( @@ -126,7 +126,7 @@ export function consumeCloudReplayGuards(input: { input.names.map((name) => input.secrets.create(name, input.value).pipe( Effect.as(true), - Effect.catchTag("SecretStoreError", (error) => + Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => ServerSecretStore.isSecretAlreadyExistsError(error) ? Effect.succeed(false) : Effect.fail(error), @@ -211,8 +211,7 @@ function validateLinkedCloudUser(input: { return input.secrets.get(CLOUD_LINKED_USER_ID).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Could not verify the linked cloud account.", + new EnvironmentAuth.ServerAuthLinkedCloudAccountVerificationError({ cause, }), ), @@ -239,19 +238,14 @@ function readInstalledCloudUserId( return secrets.get(CLOUD_LINKED_USER_ID).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Could not read the linked cloud account.", + new EnvironmentAuth.ServerAuthLinkedCloudAccountReadError({ cause, }), ), Effect.flatMap((bytes) => Option.isSome(bytes) ? Effect.succeed(bytesToString(bytes.value)) - : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud linked user is not installed for this environment.", - }), - ), + : Effect.fail(new EnvironmentAuth.ServerAuthLinkedCloudAccountMissingError({})), ), ); } @@ -394,8 +388,7 @@ const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function }).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Failed to sign cloud link JWT.", + new EnvironmentAuth.ServerAuthCloudLinkJwtSigningError({ cause, }), ), @@ -416,15 +409,17 @@ const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")( yield* appendCloudCredentialResponseHeaders; return proof satisfies RelayEnvironmentLinkProof; }, - Effect.catchTag("ServerAuthInternalError", (error) => - failEnvironmentCloudInternalError(error.message)(error.cause), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentCloudInternalError(error.message)(error), + ), + Effect.catchIf( + ServerSecretStore.isSecretStoreError, + failEnvironmentCloudInternalError("Could not generate environment link proof."), + ), + Effect.catchTag( + "PlatformError", + failEnvironmentCloudInternalError("Could not generate environment link proof."), ), - Effect.catchTags({ - PlatformError: failEnvironmentCloudInternalError("Could not generate environment link proof."), - SecretStoreError: failEnvironmentCloudInternalError( - "Could not generate environment link proof.", - ), - }), ); const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(function* ( @@ -477,17 +472,17 @@ const cloudRelayConfigHandler = Effect.fn("environment.cloud.relayConfig")( yield* requireEnvironmentScope(AuthRelayWriteScope); return yield* applyCloudRelayConfig(dependencies, payload); }, - Effect.catchTag("ServerAuthInternalError", (error) => - failEnvironmentCloudInternalError(error.message)(error.cause), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentCloudInternalError(error.message)(error), + ), + Effect.catchIf( + ServerSecretStore.isSecretStoreError, + failEnvironmentCloudInternalError("Could not persist environment relay configuration."), + ), + Effect.catchTag( + "SchemaError", + failEnvironmentCloudInternalError("Could not persist environment relay configuration."), ), - Effect.catchTags({ - SchemaError: failEnvironmentCloudInternalError( - "Could not persist environment relay configuration.", - ), - SecretStoreError: failEnvironmentCloudInternalError( - "Could not persist environment relay configuration.", - ), - }), ); const relayClientRequest = ( @@ -581,7 +576,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi }, schema: RelayEnvironmentLinkResponse, }); - yield* CliState.setCliDesiredCloudLink(true); + yield* setCliDesiredCloudLink(true); return yield* applyCloudRelayConfig(dependencies, { relayUrl, relayIssuer: link.relayIssuer, @@ -591,15 +586,16 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi endpointRuntime: link.endpointRuntime, }); }, + Effect.catchIf( + ServerSecretStore.isSecretStoreError, + failEnvironmentCloudInternalError("Could not persist desired T3 Connect link state."), + ), Effect.catchTags({ CloudCliCredentialRemovalError: failCloudCliTokenManagerError, CloudCliCredentialRefreshError: failCloudCliTokenManagerError, CloudCliCredentialReadError: failCloudCliTokenManagerError, CloudCliAuthorizationError: failCloudCliTokenManagerError, CloudCliAuthorizationTimeoutError: failCloudCliTokenManagerError, - SecretStoreError: failEnvironmentCloudInternalError( - "Could not persist desired T3 Connect link state.", - ), }), ); @@ -637,8 +633,8 @@ const cloudLinkStateHandler = Effect.fn("environment.cloud.linkState")( yield* requireEnvironmentScope(AuthRelayReadScope); return yield* readCloudLinkState(dependencies); }, - Effect.catchTag( - "SecretStoreError", + Effect.catchIf( + ServerSecretStore.isSecretStoreError, failEnvironmentCloudInternalError("Could not read environment relay configuration."), ), ); @@ -659,11 +655,11 @@ const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")( ], { concurrency: 7 }, ); - yield* CliState.setCliDesiredCloudLink(false); + yield* setCliDesiredCloudLink(false); return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; }, - Effect.catchTag( - "SecretStoreError", + Effect.catchIf( + ServerSecretStore.isSecretStoreError, failEnvironmentCloudInternalError("Could not remove environment relay configuration."), ), ); @@ -680,42 +676,40 @@ const cloudPreferencesHandler = Effect.fn("environment.cloud.preferences")( ); return yield* readCloudLinkState(dependencies); }, - Effect.catchTag( - "SecretStoreError", + Effect.catchIf( + ServerSecretStore.isSecretStoreError, failEnvironmentCloudInternalError("Could not persist environment cloud preferences."), ), ); const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( function* (dependencies: CloudHttpDependencies, request: RelayCloudEnvironmentHealthRequest) { - const cloudMintPublicKey = yield* dependencies.secrets.get(CLOUD_MINT_PUBLIC_KEY).pipe( - Effect.flatMap((bytes) => - Option.isSome(bytes) - ? Effect.succeed(bytesToString(bytes.value)) - : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud mint public key is not installed for this environment.", - }), - ), - ), - ); - const relayIssuer = yield* dependencies.secrets.get(RELAY_ISSUER_SECRET).pipe( - Effect.flatMap((bytes) => - Option.isSome(bytes) - ? Effect.succeed(bytesToString(bytes.value)) - : dependencies.secrets.get(RELAY_URL_SECRET).pipe( - Effect.flatMap((fallbackBytes) => - Option.isSome(fallbackBytes) - ? Effect.succeed(bytesToString(fallbackBytes.value)) - : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud relay issuer is not installed for this environment.", - }), - ), - ), - ), - ), - ); + const cloudMintPublicKey = yield* dependencies.secrets + .get(CLOUD_MINT_PUBLIC_KEY) + .pipe( + Effect.flatMap((bytes) => + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) + : Effect.fail(new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({})), + ), + ); + const relayIssuer = yield* dependencies.secrets + .get(RELAY_ISSUER_SECRET) + .pipe( + Effect.flatMap((bytes) => + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) + : dependencies.secrets + .get(RELAY_URL_SECRET) + .pipe( + Effect.flatMap((fallbackBytes) => + Option.isSome(fallbackBytes) + ? Effect.succeed(bytesToString(fallbackBytes.value)) + : Effect.fail(new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({})), + ), + ), + ), + ); const environmentId = yield* dependencies.environment.getEnvironmentId; const linkedCloudUserId = yield* readInstalledCloudUserId(dependencies.secrets); const now = yield* DateTime.now; @@ -777,8 +771,7 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( }).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Failed to sign cloud health JWT.", + new EnvironmentAuth.ServerAuthCloudHealthJwtSigningError({ cause, }), ), @@ -794,45 +787,47 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( yield* appendCloudCredentialResponseHeaders; return response; }, - Effect.catchTag("ServerAuthInternalError", (error) => - failEnvironmentCloudInternalError(error.message)(error.cause), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentCloudInternalError(error.message)(error), + ), + Effect.catchIf( + ServerSecretStore.isSecretStoreError, + failEnvironmentCloudInternalError("Could not answer cloud health request."), + ), + Effect.catchTag( + "PlatformError", + failEnvironmentCloudInternalError("Could not answer cloud health request."), ), - Effect.catchTags({ - PlatformError: failEnvironmentCloudInternalError("Could not answer cloud health request."), - SecretStoreError: failEnvironmentCloudInternalError("Could not answer cloud health request."), - }), ); const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential")( function* (dependencies: CloudHttpDependencies, request: RelayCloudMintCredentialRequest) { - const cloudMintPublicKey = yield* dependencies.secrets.get(CLOUD_MINT_PUBLIC_KEY).pipe( - Effect.flatMap((bytes) => - Option.isSome(bytes) - ? Effect.succeed(bytesToString(bytes.value)) - : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud mint public key is not installed for this environment.", - }), - ), - ), - ); - const relayIssuer = yield* dependencies.secrets.get(RELAY_ISSUER_SECRET).pipe( - Effect.flatMap((bytes) => - Option.isSome(bytes) - ? Effect.succeed(bytesToString(bytes.value)) - : dependencies.secrets.get(RELAY_URL_SECRET).pipe( - Effect.flatMap((fallbackBytes) => - Option.isSome(fallbackBytes) - ? Effect.succeed(bytesToString(fallbackBytes.value)) - : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud relay issuer is not installed for this environment.", - }), - ), - ), - ), - ), - ); + const cloudMintPublicKey = yield* dependencies.secrets + .get(CLOUD_MINT_PUBLIC_KEY) + .pipe( + Effect.flatMap((bytes) => + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) + : Effect.fail(new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({})), + ), + ); + const relayIssuer = yield* dependencies.secrets + .get(RELAY_ISSUER_SECRET) + .pipe( + Effect.flatMap((bytes) => + Option.isSome(bytes) + ? Effect.succeed(bytesToString(bytes.value)) + : dependencies.secrets + .get(RELAY_URL_SECRET) + .pipe( + Effect.flatMap((fallbackBytes) => + Option.isSome(fallbackBytes) + ? Effect.succeed(bytesToString(fallbackBytes.value)) + : Effect.fail(new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({})), + ), + ), + ), + ); const environmentId = yield* dependencies.environment.getEnvironmentId; const linkedCloudUserId = yield* readInstalledCloudUserId(dependencies.secrets); const now = yield* DateTime.now; @@ -899,8 +894,7 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") }).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Failed to sign cloud mint JWT.", + new EnvironmentAuth.ServerAuthCloudMintJwtSigningError({ cause, }), ), @@ -914,17 +908,17 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") yield* appendCloudCredentialResponseHeaders; return response; }, - Effect.catchTag("ServerAuthInternalError", (error) => - failEnvironmentCloudInternalError(error.message)(error.cause), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentCloudInternalError(error.message)(error), + ), + Effect.catchIf( + ServerSecretStore.isSecretStoreError, + failEnvironmentCloudInternalError("Could not issue cloud connection credential."), + ), + Effect.catchTag( + "PlatformError", + failEnvironmentCloudInternalError("Could not issue cloud connection credential."), ), - Effect.catchTags({ - PlatformError: failEnvironmentCloudInternalError( - "Could not issue cloud connection credential.", - ), - SecretStoreError: failEnvironmentCloudInternalError( - "Could not issue cloud connection credential.", - ), - }), ); export const connectHttpApiLayer = HttpApiBuilder.group( diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 0528d5e523d..ce9b498cb1f 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -81,10 +81,12 @@ const authenticateRawRouteWithScope = ( const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const session = yield* serverAuth.authenticateHttpRequest(request).pipe( - Effect.catchTags({ - ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), - ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error), - }), + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("internal_error", error), + ), ); if (!session.scopes.includes(scope)) { return yield* failEnvironmentScopeRequired(scope); diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index e6b26efb3ff..40ed694723d 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -67,15 +67,15 @@ function makeMemorySecretStore() { Effect.sync(() => { const value = values.get(name); return value === undefined ? Option.none() : Option.some(Uint8Array.from(value)); - })) satisfies ServerSecretStore.ServerSecretStoreShape["get"], + })) satisfies ServerSecretStore.ServerSecretStore["Service"]["get"], set: ((name, value) => Effect.sync(() => { values.set(name, Uint8Array.from(value)); - })) satisfies ServerSecretStore.ServerSecretStoreShape["set"], + })) satisfies ServerSecretStore.ServerSecretStore["Service"]["set"], create: ((name, value) => Effect.sync(() => { values.set(name, Uint8Array.from(value)); - })) satisfies ServerSecretStore.ServerSecretStoreShape["create"], + })) satisfies ServerSecretStore.ServerSecretStore["Service"]["create"], getOrCreateRandom: ((name, bytes) => Effect.sync(() => { const existing = values.get(name); @@ -85,12 +85,12 @@ function makeMemorySecretStore() { const generated = new Uint8Array(bytes); values.set(name, generated); return generated; - })) satisfies ServerSecretStore.ServerSecretStoreShape["getOrCreateRandom"], + })) satisfies ServerSecretStore.ServerSecretStore["Service"]["getOrCreateRandom"], remove: ((name) => Effect.sync(() => { values.delete(name); - })) satisfies ServerSecretStore.ServerSecretStoreShape["remove"], - } satisfies ServerSecretStore.ServerSecretStoreShape; + })) satisfies ServerSecretStore.ServerSecretStore["Service"]["remove"], + } satisfies ServerSecretStore.ServerSecretStore["Service"]; return { store, setString: (name: string, value: string) => store.set(name, encodeSecret(value)), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e76b3f63d7a..03b609ddcfe 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1726,10 +1726,12 @@ export const websocketRpcRouteLayer = Layer.unwrap( const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sessions = yield* SessionStore.SessionStore; const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( - Effect.catchTags({ - ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), - ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error), - }), + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("internal_error", error), + ), ); const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { disableTracing: true,