From 301d0fe3b81e59cdc525f4f0656af8dd7c0eee7b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 18:02:53 -0700 Subject: [PATCH 1/6] Refactor server auth Effect services Co-authored-by: codex --- apps/server/src/assets/AssetAccess.ts | 6 +- apps/server/src/auth/EnvironmentAuth.test.ts | 20 +- apps/server/src/auth/EnvironmentAuth.ts | 789 +++++++++++++----- .../src/auth/EnvironmentAuthAdmin.test.ts | 6 +- apps/server/src/auth/EnvironmentAuthPolicy.ts | 20 +- .../server/src/auth/PairingGrantStore.test.ts | 14 +- apps/server/src/auth/PairingGrantStore.ts | 275 ++++-- .../server/src/auth/ServerSecretStore.test.ts | 10 +- apps/server/src/auth/ServerSecretStore.ts | 220 +++-- apps/server/src/auth/SessionStore.test.ts | 14 +- apps/server/src/auth/SessionStore.ts | 612 ++++++++++---- apps/server/src/auth/dpop.test.ts | 9 +- apps/server/src/auth/dpop.ts | 12 +- apps/server/src/auth/http.ts | 78 +- apps/server/src/cliAuthFormat.ts | 6 +- apps/server/src/cloud/environmentKeys.test.ts | 13 +- apps/server/src/cloud/environmentKeys.ts | 42 +- apps/server/src/cloud/http.test.ts | 5 +- apps/server/src/cloud/http.ts | 127 +-- apps/server/src/http.ts | 10 +- .../src/relay/AgentAwarenessRelay.test.ts | 12 +- apps/server/src/ws.ts | 10 +- 22 files changed, 1653 insertions(+), 657 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index cf3c40f57c7..0679efcfa22 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -19,7 +19,7 @@ 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 ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; @@ -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)); diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index b917cadb980..54b5d1e850d 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -21,7 +21,11 @@ const makeServerConfigLayer = (overrides?: Partial) => EnvironmentAuth.layer.pipe( @@ -53,8 +57,8 @@ 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({ + reason: "unknown", }), ); @@ -68,13 +72,13 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { it.effect("maps unexpected bootstrap failures to 500", () => Effect.sync(() => { const error = EnvironmentAuth.toBootstrapExchangeError( - new PairingGrantStore.BootstrapCredentialInternalError({ - message: "Failed to consume bootstrap credential.", + new PairingGrantStore.BootstrapCredentialConsumeError({ + operation: "consume", cause: new Error("sqlite is unavailable"), }), ); - expect(error._tag).toBe("ServerAuthInternalError"); + expect(error._tag).toBe("ServerAuthBootstrapCredentialValidationError"); expect(error.message).toBe("Failed to validate bootstrap credential."); }), ); @@ -117,8 +121,8 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { ) .pipe(Effect.flip); - expect(error._tag).toBe("ServerAuthInvalidRequestError"); - if (error._tag === "ServerAuthInvalidRequestError") { + expect(error._tag).toBe("ServerAuthScopeNotGrantedError"); + if (error._tag === "ServerAuthScopeNotGrantedError") { expect(error.reason).toBe("scope_not_granted"); } }).pipe(Effect.provide(makeEnvironmentAuthLayer())), diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index d8c0079089f..13570ad8f81 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,488 @@ export interface AuthenticatedSession { readonly expiresAt?: DateTime.DateTime; } -export class ServerAuthInternalError extends Data.TaggedError("ServerAuthInternalError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +const ServerAuthInternalOperation = Schema.Literals([ + "validate_bootstrap_credential", + "validate_session_credential", + "issue_authenticated_session", + "issue_authenticated_access_token", + "create_pairing_link", + "list_pairing_links", + "revoke_pairing_link", + "issue_session_token", + "list_sessions", + "revoke_session", + "revoke_other_sessions", + "issue_websocket_token", + "record_dpop_replay_state", + "calculate_dpop_replay_key", + "verify_linked_cloud_account", + "read_linked_cloud_account", + "missing_linked_cloud_account", + "sign_cloud_link_jwt", + "missing_cloud_mint_public_key", + "missing_cloud_relay_issuer", + "sign_cloud_health_jwt", + "sign_cloud_mint_jwt", +]); +type ServerAuthInternalOperation = typeof ServerAuthInternalOperation.Type; -export class ServerAuthInvalidCredentialError extends Data.TaggedError( +const serverAuthInternalErrorContext = { + cause: Schema.optional(Schema.Defect()), +}; + +export class ServerAuthBootstrapCredentialValidationError extends Schema.TaggedErrorClass()( + "ServerAuthBootstrapCredentialValidationError", + { + operation: Schema.Literal("validate_bootstrap_credential"), + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to validate bootstrap credential."; + } +} + +export class ServerAuthSessionCredentialValidationError extends Schema.TaggedErrorClass()( + "ServerAuthSessionCredentialValidationError", + { + operation: Schema.Literal("validate_session_credential"), + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to validate session credential."; + } +} + +export class ServerAuthAuthenticatedSessionIssueError extends Schema.TaggedErrorClass()( + "ServerAuthAuthenticatedSessionIssueError", + { + operation: Schema.Literal("issue_authenticated_session"), + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue authenticated session."; + } +} + +export class ServerAuthAuthenticatedAccessTokenIssueError extends Schema.TaggedErrorClass()( + "ServerAuthAuthenticatedAccessTokenIssueError", + { + operation: Schema.Literal("issue_authenticated_access_token"), + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue authenticated access token."; + } +} + +export class ServerAuthPairingLinkCreationError extends Schema.TaggedErrorClass()( + "ServerAuthPairingLinkCreationError", + { + operation: Schema.Literal("create_pairing_link"), + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to create pairing link."; + } +} + +export class ServerAuthPairingLinksListError extends Schema.TaggedErrorClass()( + "ServerAuthPairingLinksListError", + { + operation: Schema.Literal("list_pairing_links"), + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to list pairing links."; + } +} + +export class ServerAuthPairingLinkRevocationError extends Schema.TaggedErrorClass()( + "ServerAuthPairingLinkRevocationError", + { + operation: Schema.Literal("revoke_pairing_link"), + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to revoke pairing link."; + } +} + +export class ServerAuthSessionTokenIssueError extends Schema.TaggedErrorClass()( + "ServerAuthSessionTokenIssueError", + { + operation: Schema.Literal("issue_session_token"), + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue session token."; + } +} + +export class ServerAuthSessionsListError extends Schema.TaggedErrorClass()( + "ServerAuthSessionsListError", + { + operation: Schema.Literal("list_sessions"), + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to list sessions."; + } +} + +export class ServerAuthSessionRevocationError extends Schema.TaggedErrorClass()( + "ServerAuthSessionRevocationError", + { + operation: Schema.Literal("revoke_session"), + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to revoke session."; + } +} + +export class ServerAuthOtherSessionsRevocationError extends Schema.TaggedErrorClass()( + "ServerAuthOtherSessionsRevocationError", + { + operation: Schema.Literal("revoke_other_sessions"), + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to revoke other sessions."; + } +} + +export class ServerAuthWebSocketTokenIssueError extends Schema.TaggedErrorClass()( + "ServerAuthWebSocketTokenIssueError", + { + operation: Schema.Literal("issue_websocket_token"), + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue websocket token."; + } +} + +export class ServerAuthDpopReplayStateRecordError extends Schema.TaggedErrorClass()( + "ServerAuthDpopReplayStateRecordError", + { + operation: Schema.Literal("record_dpop_replay_state"), + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to record DPoP proof replay state."; + } +} + +export class ServerAuthDpopReplayKeyCalculationError extends Schema.TaggedErrorClass()( + "ServerAuthDpopReplayKeyCalculationError", + { + operation: Schema.Literal("calculate_dpop_replay_key"), + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to calculate DPoP replay key."; + } +} + +export class ServerAuthLinkedCloudAccountVerificationError extends Schema.TaggedErrorClass()( + "ServerAuthLinkedCloudAccountVerificationError", + { + operation: Schema.Literal("verify_linked_cloud_account"), + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Could not verify the linked cloud account."; + } +} + +export class ServerAuthLinkedCloudAccountReadError extends Schema.TaggedErrorClass()( + "ServerAuthLinkedCloudAccountReadError", + { + operation: Schema.Literal("read_linked_cloud_account"), + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Could not read the linked cloud account."; + } +} + +export class ServerAuthLinkedCloudAccountMissingError extends Schema.TaggedErrorClass()( + "ServerAuthLinkedCloudAccountMissingError", + { + operation: Schema.Literal("missing_linked_cloud_account"), + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Cloud linked user is not installed for this environment."; + } +} + +export class ServerAuthCloudLinkJwtSigningError extends Schema.TaggedErrorClass()( + "ServerAuthCloudLinkJwtSigningError", + { + operation: Schema.Literal("sign_cloud_link_jwt"), + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to sign cloud link JWT."; + } +} + +export class ServerAuthCloudMintPublicKeyMissingError extends Schema.TaggedErrorClass()( + "ServerAuthCloudMintPublicKeyMissingError", + { + operation: Schema.Literal("missing_cloud_mint_public_key"), + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Cloud mint public key is not installed for this environment."; + } +} + +export class ServerAuthCloudRelayIssuerMissingError extends Schema.TaggedErrorClass()( + "ServerAuthCloudRelayIssuerMissingError", + { + operation: Schema.Literal("missing_cloud_relay_issuer"), + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Cloud relay issuer is not installed for this environment."; + } +} + +export class ServerAuthCloudHealthJwtSigningError extends Schema.TaggedErrorClass()( + "ServerAuthCloudHealthJwtSigningError", + { + operation: Schema.Literal("sign_cloud_health_jwt"), + ...serverAuthInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to sign cloud health JWT."; + } +} + +export class ServerAuthCloudMintJwtSigningError extends Schema.TaggedErrorClass()( + "ServerAuthCloudMintJwtSigningError", + { + operation: Schema.Literal("sign_cloud_mint_jwt"), + ...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); + +const serverAuthCredentialErrorContext = { + cause: Schema.optional(Schema.Defect()), +}; + +export class ServerAuthMissingCredentialError extends Schema.TaggedErrorClass()( + "ServerAuthMissingCredentialError", + { + reason: Schema.Literal("missing_credential"), + ...serverAuthCredentialErrorContext, + }, +) { + 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"; -}> {} + { + reason: Schema.Literal("invalid_credential"), + ...serverAuthCredentialErrorContext, + }, +) { + 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 class ServerAuthInvalidScopeError extends Schema.TaggedErrorClass()( + "ServerAuthInvalidScopeError", + { + reason: Schema.Literal("invalid_scope"), + }, +) { + 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", + { + reason: Schema.Literal("scope_not_granted"), + }, +) { + 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 class ServerAuthForbiddenOperationError extends Schema.TaggedErrorClass()( + "ServerAuthForbiddenOperationError", + { + reason: Schema.Literal("current_session_revoke_not_allowed"), + }, +) { + 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,19 +571,68 @@ const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => return right.issuedAt.epochMilliseconds - left.issuedAt.epochMilliseconds; }; +const serverAuthInternalError = ( + operation: ServerAuthInternalOperation, + cause?: unknown, +): ServerAuthInternalError => { + switch (operation) { + case "validate_bootstrap_credential": + return new ServerAuthBootstrapCredentialValidationError({ operation, cause }); + case "validate_session_credential": + return new ServerAuthSessionCredentialValidationError({ operation, cause }); + case "issue_authenticated_session": + return new ServerAuthAuthenticatedSessionIssueError({ operation, cause }); + case "issue_authenticated_access_token": + return new ServerAuthAuthenticatedAccessTokenIssueError({ operation, cause }); + case "create_pairing_link": + return new ServerAuthPairingLinkCreationError({ operation, cause }); + case "list_pairing_links": + return new ServerAuthPairingLinksListError({ operation, cause }); + case "revoke_pairing_link": + return new ServerAuthPairingLinkRevocationError({ operation, cause }); + case "issue_session_token": + return new ServerAuthSessionTokenIssueError({ operation, cause }); + case "list_sessions": + return new ServerAuthSessionsListError({ operation, cause }); + case "revoke_session": + return new ServerAuthSessionRevocationError({ operation, cause }); + case "revoke_other_sessions": + return new ServerAuthOtherSessionsRevocationError({ operation, cause }); + case "issue_websocket_token": + return new ServerAuthWebSocketTokenIssueError({ operation, cause }); + case "record_dpop_replay_state": + return new ServerAuthDpopReplayStateRecordError({ operation, cause }); + case "calculate_dpop_replay_key": + return new ServerAuthDpopReplayKeyCalculationError({ operation, cause }); + case "verify_linked_cloud_account": + return new ServerAuthLinkedCloudAccountVerificationError({ operation, cause }); + case "read_linked_cloud_account": + return new ServerAuthLinkedCloudAccountReadError({ operation, cause }); + case "missing_linked_cloud_account": + return new ServerAuthLinkedCloudAccountMissingError({ operation, cause }); + case "sign_cloud_link_jwt": + return new ServerAuthCloudLinkJwtSigningError({ operation, cause }); + case "missing_cloud_mint_public_key": + return new ServerAuthCloudMintPublicKeyMissingError({ operation, cause }); + case "missing_cloud_relay_issuer": + return new ServerAuthCloudRelayIssuerMissingError({ operation, cause }); + case "sign_cloud_health_jwt": + return new ServerAuthCloudHealthJwtSigningError({ operation, cause }); + case "sign_cloud_mint_jwt": + return new ServerAuthCloudMintJwtSigningError({ operation, cause }); + } +}; + const toInternalError = - (message: string) => + (operation: ServerAuthInternalOperation) => (cause: unknown): ServerAuthInternalError => - new ServerAuthInternalError({ message, cause }); + serverAuthInternalError(operation, 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 serverAuthInternalError("validate_bootstrap_credential", cause); } return new ServerAuthInvalidCredentialError({ @@ -231,17 +645,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({ reason: "invalid_credential", cause }) + : serverAuthInternalError("validate_session_credential", cause), + ), ); function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null { @@ -262,7 +670,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 +685,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 +705,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({ reason: "missing_credential" })); } return authenticateToken(credential).pipe( Effect.flatMap((session) => { @@ -337,7 +749,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 +761,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 +770,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { Effect.withSpan("EnvironmentAuth.getSessionState"), ); - const createBrowserSession: EnvironmentAuthShape["createBrowserSession"] = ( + const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = ( credential, requestMetadata, ) => @@ -376,12 +788,8 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { }, }) .pipe( - Effect.mapError( - (cause) => - new ServerAuthInternalError({ - message: "Failed to issue authenticated session.", - cause, - }), + Effect.mapError((cause) => + serverAuthInternalError("issue_authenticated_session", cause), ), ), ), @@ -400,7 +808,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,7 +816,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({ + return yield* new ServerAuthScopeNotGrantedError({ reason: "scope_not_granted", }); } @@ -429,12 +837,8 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { }, }) .pipe( - Effect.mapError( - (cause) => - new ServerAuthInternalError({ - message: "Failed to issue authenticated access token.", - cause, - }), + Effect.mapError((cause) => + serverAuthInternalError("issue_authenticated_access_token", cause), ), ); }), @@ -482,7 +886,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 +908,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(toInternalError("create_pairing_link")), ); - const listPairingLinks: EnvironmentAuthShape["listPairingLinks"] = (input) => + const listPairingLinks: EnvironmentAuth["Service"]["listPairingLinks"] = (input) => bootstrapCredentials.listActive().pipe( Effect.map((pairingLinks) => { const excludedSubjects = input?.excludeSubjects ?? [ @@ -519,19 +923,19 @@ 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(toInternalError("list_pairing_links")), Effect.withSpan("EnvironmentAuth.listPairingLinks"), ); - const revokePairingLink: EnvironmentAuthShape["revokePairingLink"] = (id) => + const revokePairingLink: EnvironmentAuth["Service"]["revokePairingLink"] = (id) => bootstrapCredentials .revoke(id) .pipe( - Effect.mapError(toInternalError("Failed to revoke pairing link.")), + Effect.mapError(toInternalError("revoke_pairing_link")), 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 +960,50 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { expiresAt: DateTime.toUtc(issued.expiresAt), }) satisfies IssuedBearerSession, ), - Effect.mapError(toInternalError("Failed to issue session token.")), + Effect.mapError(toInternalError("issue_session_token")), 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(toInternalError("list_sessions")), Effect.withSpan("EnvironmentAuth.listSessions"), ); - const revokeSession: EnvironmentAuthShape["revokeSession"] = (sessionId) => + const revokeSession: EnvironmentAuth["Service"]["revokeSession"] = (sessionId) => sessions .revoke(sessionId) .pipe( - Effect.mapError(toInternalError("Failed to revoke session.")), + Effect.mapError(toInternalError("revoke_session")), 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.mapError(toInternalError("revoke_other_sessions")), 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,7 +1016,7 @@ 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) { @@ -622,14 +1027,14 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { 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 +1046,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) => serverAuthInternalError("issue_websocket_token", cause)), Effect.map( (issued) => ({ @@ -660,10 +1059,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 +1086,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 +1108,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..eae3ce203a0 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.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..75ce69c7fb4 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -22,7 +22,9 @@ const makeServerConfigLayer = ( } satisfies ServerConfig.ServerConfig["Service"]; }), ).pipe( - Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-bootstrap-test-" })), + Layer.provide( + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-bootstrap-test-" }), + ), ); const makePairingGrantStoreLayer = ( @@ -61,7 +63,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 +87,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 +134,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 +151,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 +185,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..946a988320e 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,122 @@ export interface BootstrapGrant { readonly expiresAt: DateTime.DateTime; } -export class BootstrapCredentialInvalidError extends Data.TaggedError( - "BootstrapCredentialInvalidError", -)<{ - readonly message: string; -}> {} +export class UnknownBootstrapCredentialError extends Schema.TaggedErrorClass()( + "UnknownBootstrapCredentialError", + { + reason: Schema.Literal("unknown"), + }, +) { + override get message(): string { + return "Unknown bootstrap credential."; + } +} + +export class ExpiredBootstrapCredentialError extends Schema.TaggedErrorClass()( + "ExpiredBootstrapCredentialError", + { + reason: Schema.Literal("expired"), + }, +) { + override get message(): string { + return "Bootstrap credential expired."; + } +} + +export class BootstrapCredentialProofKeyMismatchError extends Schema.TaggedErrorClass()( + "BootstrapCredentialProofKeyMismatchError", + { + reason: Schema.Literal("proof_key_mismatch"), + }, +) { + override get message(): string { + return "Bootstrap credential proof key mismatch."; + } +} + +export class UnavailableBootstrapCredentialError extends Schema.TaggedErrorClass()( + "UnavailableBootstrapCredentialError", + { + reason: Schema.Literal("unavailable"), + }, +) { + 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", + { + operation: Schema.Literal("list_active"), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to load active pairing links."; + } +} + +export class PairingLinkRevokeError extends Schema.TaggedErrorClass()( + "PairingLinkRevokeError", + { + operation: Schema.Literal("revoke"), + 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", + { + operation: Schema.Literal("issue"), + 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", + { + operation: Schema.Literal("consume"), + 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 +164,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 +210,40 @@ 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, - }); +const invalidBootstrapCredentialError = ( + reason: BootstrapCredentialInvalidError["reason"], +): BootstrapCredentialInvalidError => { + switch (reason) { + case "unknown": + return new UnknownBootstrapCredentialError({ reason }); + case "expired": + return new ExpiredBootstrapCredentialError({ reason }); + case "proof_key_mismatch": + return new BootstrapCredentialProofKeyMismatchError({ reason }); + case "unavailable": + return new UnavailableBootstrapCredentialError({ reason }); + } +}; + +const internalBootstrapCredentialError = ( + operation: BootstrapCredentialInternalError["operation"], + cause: unknown, +): BootstrapCredentialInternalError => { + switch (operation) { + case "list_active": + return new ActivePairingLinksLoadError({ operation, cause }); + case "revoke": + return new PairingLinkRevokeError({ operation, cause }); + case "issue": + return new PairingCredentialIssueError({ operation, cause }); + case "consume": + return new BootstrapCredentialConsumeError({ operation, 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 +296,11 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { }); } - const toBootstrapCredentialError = (message: string) => (cause: unknown) => - internalBootstrapCredentialError(message, cause); + const toBootstrapCredentialError = + (operation: BootstrapCredentialInternalError["operation"]) => (cause: unknown) => + internalBootstrapCredentialError(operation, cause); - const listActive: PairingGrantStoreShape["listActive"] = Effect.fn( + const listActive: PairingGrantStore["Service"]["listActive"] = Effect.fn( "PairingGrantStore.listActive", )( function* () { @@ -208,10 +328,10 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { } satisfies AuthPairingLink), ); }, - Effect.mapError(toBootstrapCredentialError("Failed to load active pairing links.")), + Effect.mapError(toBootstrapCredentialError("list_active")), ); - 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 +343,10 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { } return revoked; }, - Effect.mapError(toBootstrapCredentialError("Failed to revoke pairing link.")), + Effect.mapError(toBootstrapCredentialError("revoke")), ); - const issueOneTimeToken: PairingGrantStoreShape["issueOneTimeToken"] = Effect.fn( + const issueOneTimeToken: PairingGrantStore["Service"]["issueOneTimeToken"] = Effect.fn( "PairingGrantStore.issueOneTimeToken", )( function* (input) { @@ -264,10 +384,10 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { }); return issued; }, - Effect.mapError(toBootstrapCredentialError("Failed to issue pairing credential.")), + Effect.mapError(toBootstrapCredentialError("issue")), ); - 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 +399,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { { _tag: "error", reason: "not-found", - error: invalidBootstrapCredentialError("Unknown bootstrap credential."), + error: invalidBootstrapCredentialError("unknown"), }, current, ]; @@ -292,7 +412,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { { _tag: "error", reason: "expired", - error: invalidBootstrapCredentialError("Bootstrap credential expired."), + error: invalidBootstrapCredentialError("expired"), }, next, ]; @@ -303,7 +423,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { { _tag: "error", reason: "not-found", - error: invalidBootstrapCredentialError("Bootstrap credential proof key mismatch."), + error: invalidBootstrapCredentialError("proof_key_mismatch"), }, next, ]; @@ -370,41 +490,38 @@ 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* invalidBootstrapCredentialError("unknown"); } if (matching.value.revokedAt !== null) { - return yield* invalidBootstrapCredentialError( - "Bootstrap credential is no longer available.", - ); + return yield* invalidBootstrapCredentialError("unavailable"); } if (matching.value.consumedAt !== null) { - return yield* invalidBootstrapCredentialError("Unknown bootstrap credential."); + return yield* invalidBootstrapCredentialError("unknown"); } if (DateTime.isGreaterThanOrEqualTo(now, matching.value.expiresAt)) { - return yield* invalidBootstrapCredentialError("Bootstrap credential expired."); + return yield* invalidBootstrapCredentialError("expired"); } if ( matching.value.proofKeyThumbprint !== null && matching.value.proofKeyThumbprint !== input?.proofKeyThumbprint ) { - return yield* invalidBootstrapCredentialError("Bootstrap credential proof key mismatch."); + return yield* invalidBootstrapCredentialError("proof_key_mismatch"); } - return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available."); + return yield* invalidBootstrapCredentialError("unavailable"); }, Effect.mapError((cause) => - cause._tag === "BootstrapCredentialInvalidError" || - cause._tag === "BootstrapCredentialInternalError" + isBootstrapCredentialError(cause) ? cause - : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause), + : internalBootstrapCredentialError("consume", cause), ), ); - return { + return PairingGrantStore.of({ issueOneTimeToken, listActive, get streamChanges() { @@ -412,9 +529,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..bb6ff855ffc 100644 --- a/apps/server/src/auth/ServerSecretStore.test.ts +++ b/apps/server/src/auth/ServerSecretStore.test.ts @@ -9,11 +9,11 @@ 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 = () => - ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-store-test-" }); + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-store-test-" }); const makeServerSecretStoreLayer = () => Layer.provide(ServerSecretStore.layer, 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..87e0d74f752 100644 --- a/apps/server/src/auth/ServerSecretStore.ts +++ b/apps/server/src/auth/ServerSecretStore.ts @@ -9,15 +9,134 @@ 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.optional(Schema.Defect()), +}; + +export class SecretStoreSecureError extends Schema.TaggedErrorClass()( + "SecretStoreSecureError", + { + operation: Schema.Literal("secure"), + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to secure ${this.resource}.`; + } +} + +export class SecretStoreReadError extends Schema.TaggedErrorClass()( + "SecretStoreReadError", + { + operation: Schema.Literal("read"), + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to read ${this.resource}.`; + } +} + +export class SecretStoreTemporaryPathError extends Schema.TaggedErrorClass()( + "SecretStoreTemporaryPathError", + { + operation: Schema.Literal("create_temporary_path"), + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to create temporary path for ${this.resource}.`; + } +} + +export class SecretStorePersistError extends Schema.TaggedErrorClass()( + "SecretStorePersistError", + { + operation: Schema.Literal("persist"), + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to persist ${this.resource}.`; + } +} + +export class SecretStoreRandomGenerationError extends Schema.TaggedErrorClass()( + "SecretStoreRandomGenerationError", + { + operation: Schema.Literal("generate_random"), + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to generate random bytes for ${this.resource}.`; + } +} + +export class SecretStoreConcurrentReadError extends Schema.TaggedErrorClass()( + "SecretStoreConcurrentReadError", + { + operation: Schema.Literal("read_after_concurrent_creation"), + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to read ${this.resource} after concurrent creation.`; + } +} + +export class SecretStoreRemoveError extends Schema.TaggedErrorClass()( + "SecretStoreRemoveError", { - message: Schema.String, - cause: Schema.optional(Schema.Defect()), + operation: Schema.Literal("remove"), + ...secretStoreErrorContext, }, -) {} +) { + override get message(): string { + return `Failed to remove ${this.resource}.`; + } +} + +export class SecretStoreDecodeError extends Schema.TaggedErrorClass()( + "SecretStoreDecodeError", + { + operation: Schema.Literal("decode"), + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to decode ${this.resource}.`; + } +} + +export class SecretStoreEncodeError extends Schema.TaggedErrorClass()( + "SecretStoreEncodeError", + { + operation: Schema.Literal("encode"), + ...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"); @@ -25,33 +144,33 @@ const isPlatformError = (value: unknown): value is PlatformError.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; -} - -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({ + operation: "secure", + resource: `secrets directory ${serverConfig.secretsDir}`, cause, }), ), @@ -59,15 +178,16 @@ 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({ + operation: "read", + resource: `secret ${name}`, cause, }), ), @@ -75,13 +195,14 @@ 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({ + operation: "create_temporary_path", + resource: `secret ${name}`, cause, }), ), @@ -98,8 +219,9 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { Effect.ignore, Effect.flatMap(() => Effect.fail( - new SecretStoreError({ - message: `Failed to persist secret ${name}.`, + new SecretStorePersistError({ + operation: "persist", + resource: `secret ${name}`, cause, }), ), @@ -112,7 +234,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 +249,16 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { ).pipe( Effect.mapError( (cause) => - new SecretStoreError({ - message: `Failed to persist secret ${name}.`, + new SecretStorePersistError({ + operation: "persist", + 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 +267,16 @@ 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({ + operation: "generate_random", + 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 +284,9 @@ 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({ + operation: "read_after_concurrent_creation", + resource: `secret ${name}`, }), ), }), @@ -177,14 +302,15 @@ 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({ + operation: "remove", + resource: `secret ${name}`, cause, }), ), @@ -192,13 +318,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..1c6583da315 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -24,7 +24,11 @@ const makeServerConfigLayer = ( ...overrides, } satisfies ServerConfig.ServerConfig["Service"]; }), - ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" }))); + ).pipe( + Layer.provide( + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" }), + ), + ); const makeSessionStoreLayer = ( overrides?: Partial>, @@ -51,7 +55,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 +93,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 +109,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..b70fa05c166 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,360 @@ 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; +const sessionCredentialInvalidErrorContext = { + cause: Schema.optional(Schema.Defect()), +}; + +export class MalformedSessionTokenError extends Schema.TaggedErrorClass()( + "MalformedSessionTokenError", + { + reason: Schema.Literal("malformed_session_token"), + ...sessionCredentialInvalidErrorContext, + }, +) { + override get message(): string { + return "Malformed session token."; + } +} + +export class InvalidSessionTokenSignatureError extends Schema.TaggedErrorClass()( + "InvalidSessionTokenSignatureError", + { + reason: Schema.Literal("invalid_session_token_signature"), + ...sessionCredentialInvalidErrorContext, + }, +) { + override get message(): string { + return "Invalid session token signature."; + } +} + +export class InvalidSessionTokenPayloadError extends Schema.TaggedErrorClass()( + "InvalidSessionTokenPayloadError", + { + reason: Schema.Literal("invalid_session_token_payload"), + ...sessionCredentialInvalidErrorContext, + }, +) { + override get message(): string { + return "Invalid session token payload."; + } +} + +export class SessionTokenExpiredError extends Schema.TaggedErrorClass()( + "SessionTokenExpiredError", + { + reason: Schema.Literal("session_token_expired"), + ...sessionCredentialInvalidErrorContext, + }, +) { + override get message(): string { + return "Session token expired."; + } +} + +export class UnknownSessionTokenError extends Schema.TaggedErrorClass()( + "UnknownSessionTokenError", + { + reason: Schema.Literal("unknown_session_token"), + ...sessionCredentialInvalidErrorContext, + }, +) { + override get message(): string { + return "Unknown session token."; + } +} + +export class SessionTokenRevokedError extends Schema.TaggedErrorClass()( + "SessionTokenRevokedError", + { + reason: Schema.Literal("session_token_revoked"), + ...sessionCredentialInvalidErrorContext, + }, +) { + override get message(): string { + return "Session token revoked."; + } +} + +export class InvalidSessionExpirationClaimError extends Schema.TaggedErrorClass()( + "InvalidSessionExpirationClaimError", + { + reason: Schema.Literal("invalid_session_exp_claim"), + ...sessionCredentialInvalidErrorContext, + }, +) { + override get message(): string { + return "Invalid `exp` claim"; + } +} + +export class MalformedWebSocketTokenError extends Schema.TaggedErrorClass()( + "MalformedWebSocketTokenError", + { + reason: Schema.Literal("malformed_websocket_token"), + ...sessionCredentialInvalidErrorContext, + }, +) { + override get message(): string { + return "Malformed websocket token."; + } } -export class SessionStore extends Context.Service()( - "t3/auth/SessionStore", -) {} +export class InvalidWebSocketTokenSignatureError extends Schema.TaggedErrorClass()( + "InvalidWebSocketTokenSignatureError", + { + reason: Schema.Literal("invalid_websocket_token_signature"), + ...sessionCredentialInvalidErrorContext, + }, +) { + override get message(): string { + return "Invalid websocket token signature."; + } +} + +export class InvalidWebSocketTokenPayloadError extends Schema.TaggedErrorClass()( + "InvalidWebSocketTokenPayloadError", + { + reason: Schema.Literal("invalid_websocket_token_payload"), + ...sessionCredentialInvalidErrorContext, + }, +) { + override get message(): string { + return "Invalid websocket token payload."; + } +} + +export class WebSocketTokenExpiredError extends Schema.TaggedErrorClass()( + "WebSocketTokenExpiredError", + { + reason: Schema.Literal("websocket_token_expired"), + ...sessionCredentialInvalidErrorContext, + }, +) { + override get message(): string { + return "Websocket token expired."; + } +} + +export class UnknownWebSocketSessionError extends Schema.TaggedErrorClass()( + "UnknownWebSocketSessionError", + { + reason: Schema.Literal("unknown_websocket_session"), + ...sessionCredentialInvalidErrorContext, + }, +) { + override get message(): string { + return "Unknown websocket session."; + } +} + +export class WebSocketSessionExpiredError extends Schema.TaggedErrorClass()( + "WebSocketSessionExpiredError", + { + reason: Schema.Literal("websocket_session_expired"), + ...sessionCredentialInvalidErrorContext, + }, +) { + override get message(): string { + return "Websocket session expired."; + } +} + +export class WebSocketSessionRevokedError extends Schema.TaggedErrorClass()( + "WebSocketSessionRevokedError", + { + reason: Schema.Literal("websocket_session_revoked"), + ...sessionCredentialInvalidErrorContext, + }, +) { + 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", + { + operation: Schema.Literal("issue_session_credential"), + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue session credential."; + } +} + +export class SessionCredentialVerificationError extends Schema.TaggedErrorClass()( + "SessionCredentialVerificationError", + { + operation: Schema.Literal("verify_session_credential"), + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to verify session credential."; + } +} + +export class WebSocketTokenIssueError extends Schema.TaggedErrorClass()( + "WebSocketTokenIssueError", + { + operation: Schema.Literal("issue_websocket_token"), + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to issue websocket token."; + } +} + +export class WebSocketTokenVerificationError extends Schema.TaggedErrorClass()( + "WebSocketTokenVerificationError", + { + operation: Schema.Literal("verify_websocket_token"), + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to verify websocket token."; + } +} + +export class ActiveSessionsListError extends Schema.TaggedErrorClass()( + "ActiveSessionsListError", + { + operation: Schema.Literal("list_active_sessions"), + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to list active sessions."; + } +} + +export class SessionRevocationError extends Schema.TaggedErrorClass()( + "SessionRevocationError", + { + operation: Schema.Literal("revoke_session"), + ...sessionCredentialInternalErrorContext, + }, +) { + override get message(): string { + return "Failed to revoke session."; + } +} + +export class OtherSessionsRevocationError extends Schema.TaggedErrorClass()( + "OtherSessionsRevocationError", + { + operation: Schema.Literal("revoke_other_sessions"), + ...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 +477,74 @@ function toAuthClientSession(input: Omit): AuthCli }; } -const toSessionCredentialInternalError = (message: string) => (cause: unknown) => - new SessionCredentialInternalError({ - message, - cause, - }); - -export const make = Effect.fn("makeSessionStore")(function* () { +const sessionCredentialInvalidError = ( + reason: SessionCredentialInvalidError["reason"], + cause?: unknown, +): SessionCredentialInvalidError => { + switch (reason) { + case "malformed_session_token": + return new MalformedSessionTokenError({ reason, cause }); + case "invalid_session_token_signature": + return new InvalidSessionTokenSignatureError({ reason, cause }); + case "invalid_session_token_payload": + return new InvalidSessionTokenPayloadError({ reason, cause }); + case "session_token_expired": + return new SessionTokenExpiredError({ reason, cause }); + case "unknown_session_token": + return new UnknownSessionTokenError({ reason, cause }); + case "session_token_revoked": + return new SessionTokenRevokedError({ reason, cause }); + case "invalid_session_exp_claim": + return new InvalidSessionExpirationClaimError({ reason, cause }); + case "malformed_websocket_token": + return new MalformedWebSocketTokenError({ reason, cause }); + case "invalid_websocket_token_signature": + return new InvalidWebSocketTokenSignatureError({ reason, cause }); + case "invalid_websocket_token_payload": + return new InvalidWebSocketTokenPayloadError({ reason, cause }); + case "websocket_token_expired": + return new WebSocketTokenExpiredError({ reason, cause }); + case "unknown_websocket_session": + return new UnknownWebSocketSessionError({ reason, cause }); + case "websocket_session_expired": + return new WebSocketSessionExpiredError({ reason, cause }); + case "websocket_session_revoked": + return new WebSocketSessionRevokedError({ reason, cause }); + } +}; + +const sessionCredentialInternalError = ( + operation: SessionCredentialInternalError["operation"], + cause: unknown, +): SessionCredentialInternalError => { + switch (operation) { + case "encode_session_claims": + case "encode_websocket_claims": + return new SessionClaimsEncodingError({ operation, cause }); + case "issue_session_credential": + return new SessionCredentialIssueError({ operation, cause }); + case "verify_session_credential": + return new SessionCredentialVerificationError({ operation, cause }); + case "issue_websocket_token": + return new WebSocketTokenIssueError({ operation, cause }); + case "verify_websocket_token": + return new WebSocketTokenVerificationError({ operation, cause }); + case "list_active_sessions": + return new ActiveSessionsListError({ operation, cause }); + case "revoke_session": + return new SessionRevocationError({ operation, cause }); + case "revoke_other_sessions": + return new OtherSessionsRevocationError({ operation, cause }); + } +}; + +const toSessionCredentialInternalError = + (operation: SessionCredentialInternalError["operation"]) => (cause: unknown) => + sessionCredentialInternalError(operation, cause); + +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 +590,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 +624,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 +651,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; @@ -320,10 +672,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 }), - ), + Effect.mapError((cause) => sessionCredentialInternalError("encode_session_claims", cause)), ); const signature = signPayload(encodedPayload, signingSecret); const client = input?.client ?? createDefaultClientMetadata(); @@ -367,59 +716,43 @@ export const make = Effect.fn("makeSessionStore")(function* () { ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), } satisfies IssuedSession; }, - Effect.mapError(toSessionCredentialInternalError("Failed to issue session credential.")), + Effect.mapError(toSessionCredentialInternalError("issue_session_credential")), ); - 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* sessionCredentialInvalidError("malformed_session_token"); } const expectedSignature = signPayload(encodedPayload, signingSecret); if (!timingSafeEqualBase64Url(signature, expectedSignature)) { - return yield* new SessionCredentialInvalidError({ - message: "Invalid session token signature.", - }); + return yield* sessionCredentialInvalidError("invalid_session_token_signature"); } const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( - Effect.mapError( - (cause) => - new SessionCredentialInvalidError({ - message: "Invalid session token payload.", - cause, - }), + Effect.mapError((cause) => + sessionCredentialInvalidError("invalid_session_token_payload", cause), ), ); const now = yield* Clock.currentTimeMillis; if (claims.exp <= now) { - return yield* new SessionCredentialInvalidError({ - message: "Session token expired.", - }); + return yield* sessionCredentialInvalidError("session_token_expired"); } const row = yield* authSessions.getById({ sessionId: claims.sid }); if (Option.isNone(row)) { - return yield* new SessionCredentialInvalidError({ - message: "Unknown session token.", - }); + return yield* sessionCredentialInvalidError("unknown_session_token"); } if (row.value.revokedAt !== null) { - return yield* new SessionCredentialInvalidError({ - message: "Session token revoked.", - }); + return yield* sessionCredentialInvalidError("session_token_revoked"); } const expiresAt = DateTime.make(claims.exp); if (Option.isNone(expiresAt)) { - return yield* new SessionCredentialInvalidError({ - message: "Invalid `exp` claim", - }); + return yield* sessionCredentialInvalidError("invalid_session_exp_claim"); } return { @@ -434,17 +767,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, - }), + : sessionCredentialInternalError("verify_session_credential", 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) { @@ -461,9 +791,8 @@ export const make = Effect.fn("makeSessionStore")(function* () { }; const encodedPayload = yield* encodeWsClaims(claims).pipe( Effect.map(base64UrlEncode), - Effect.mapError( - (cause) => - new SessionCredentialInternalError({ message: "Failed to encode claims", cause }), + Effect.mapError((cause) => + sessionCredentialInternalError("encode_websocket_claims", cause), ), ); const signature = signPayload(encodedPayload, signingSecret); @@ -472,59 +801,43 @@ export const make = Effect.fn("makeSessionStore")(function* () { expiresAt, }; }, - Effect.mapError(toSessionCredentialInternalError("Failed to issue websocket token.")), + Effect.mapError(toSessionCredentialInternalError("issue_websocket_token")), ); - 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* sessionCredentialInvalidError("malformed_websocket_token"); } const expectedSignature = signPayload(encodedPayload, signingSecret); if (!timingSafeEqualBase64Url(signature, expectedSignature)) { - return yield* new SessionCredentialInvalidError({ - message: "Invalid websocket token signature.", - }); + return yield* sessionCredentialInvalidError("invalid_websocket_token_signature"); } const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( - Effect.mapError( - (cause) => - new SessionCredentialInvalidError({ - message: "Invalid websocket token payload.", - cause, - }), + Effect.mapError((cause) => + sessionCredentialInvalidError("invalid_websocket_token_payload", cause), ), ); const now = yield* Clock.currentTimeMillis; if (claims.exp <= now) { - return yield* new SessionCredentialInvalidError({ - message: "Websocket token expired.", - }); + return yield* sessionCredentialInvalidError("websocket_token_expired"); } const row = yield* authSessions.getById({ sessionId: claims.sid }); if (Option.isNone(row)) { - return yield* new SessionCredentialInvalidError({ - message: "Unknown websocket session.", - }); + return yield* sessionCredentialInvalidError("unknown_websocket_session"); } if (row.value.expiresAt.epochMilliseconds <= now) { - return yield* new SessionCredentialInvalidError({ - message: "Websocket session expired.", - }); + return yield* sessionCredentialInvalidError("websocket_session_expired"); } if (row.value.revokedAt !== null) { - return yield* new SessionCredentialInvalidError({ - message: "Websocket session revoked.", - }); + return yield* sessionCredentialInvalidError("websocket_session_revoked"); } return { @@ -538,16 +851,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, - }), + : sessionCredentialInternalError("verify_websocket_token", 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 +877,10 @@ export const make = Effect.fn("makeSessionStore")(function* () { }), ); }, - Effect.mapError(toSessionCredentialInternalError("Failed to list active sessions.")), + Effect.mapError(toSessionCredentialInternalError("list_active_sessions")), ); - 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 +897,10 @@ export const make = Effect.fn("makeSessionStore")(function* () { } return revoked; }, - Effect.mapError(toSessionCredentialInternalError("Failed to revoke session.")), + Effect.mapError(toSessionCredentialInternalError("revoke_session")), ); - const revokeAllExcept: SessionStoreShape["revokeAllExcept"] = Effect.fn( + const revokeAllExcept: SessionStore["Service"]["revokeAllExcept"] = Effect.fn( "SessionStore.revokeAllExcept", )( function* (sessionId) { @@ -618,10 +928,10 @@ export const make = Effect.fn("makeSessionStore")(function* () { } return revokedSessionIds.length; }, - Effect.mapError(toSessionCredentialInternalError("Failed to revoke other sessions.")), + Effect.mapError(toSessionCredentialInternalError("revoke_other_sessions")), ); - return { + return SessionStore.of({ cookieName, issue, verify, @@ -635,9 +945,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..02d4f678f9f 100644 --- a/apps/server/src/auth/dpop.test.ts +++ b/apps/server/src/auth/dpop.test.ts @@ -5,8 +5,9 @@ import * as ServerSecretStore from "./ServerSecretStore.ts"; import { mapDpopReplayStoreError } from "./dpop.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => - new ServerSecretStore.SecretStoreError({ - message: "Failed to persist DPoP proof.", + new ServerSecretStore.SecretStorePersistError({ + operation: "persist", + resource: "DPoP proof", cause: PlatformError.systemError({ _tag: tag, module: "FileSystem", @@ -25,8 +26,8 @@ describe("mapDpopReplayStoreError", () => { 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..8d279466849 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -32,8 +32,8 @@ export const mapDpopReplayStoreError = ( reason: "invalid_credential", cause: "DPoP proof replayed.", }) - : new EnvironmentAuth.ServerAuthInternalError({ - message: "Failed to record DPoP proof replay state.", + : new EnvironmentAuth.ServerAuthDpopReplayStateRecordError({ + operation: "record_dpop_replay_state", cause: error, }); @@ -67,8 +67,8 @@ export const verifyRequestDpopProof = (input: { Effect.map(Encoding.encodeBase64Url), Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Failed to calculate DPoP replay key.", + new EnvironmentAuth.ServerAuthDpopReplayKeyCalculationError({ + operation: "calculate_dpop_replay_key", cause, }), ), @@ -86,7 +86,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..5dce0b67bb0 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(error.reason), + ), + 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(error.reason), + ), + 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(error.reason), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInvalidRequestError, (error) => + failEnvironmentInvalidRequest(error.reason), + ), + 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", (error) => + failEnvironmentOperationForbidden(error.reason), + ), + 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/cliAuthFormat.ts b/apps/server/src/cliAuthFormat.ts index 2ef5ba10a80..72a6e2629bc 100644 --- a/apps/server/src/cliAuthFormat.ts +++ b/apps/server/src/cliAuthFormat.ts @@ -1,7 +1,7 @@ import type { AuthClientMetadata, AuthClientSession, AuthPairingLink } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -import type { IssuedBearerSession, IssuedPairingLink } from "./auth/EnvironmentAuth.ts"; +import type * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; const newline = "\n"; @@ -25,7 +25,7 @@ function toIsoString(value: DateTime.DateTime | DateTime.Utc): string { } export function formatIssuedPairingCredential( - credential: IssuedPairingLink, + credential: EnvironmentAuth.IssuedPairingLink, options?: { readonly json?: boolean; readonly baseUrl?: string; @@ -105,7 +105,7 @@ export function formatPairingCredentialList( } export function formatIssuedSession( - session: IssuedBearerSession, + session: EnvironmentAuth.IssuedBearerSession, options?: { readonly json?: boolean; readonly tokenOnly?: boolean; diff --git a/apps/server/src/cloud/environmentKeys.test.ts b/apps/server/src/cloud/environmentKeys.test.ts index 5c20cd64ed3..b41d2904b1d 100644 --- a/apps/server/src/cloud/environmentKeys.test.ts +++ b/apps/server/src/cloud/environmentKeys.test.ts @@ -6,12 +6,14 @@ 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 = () => ServerSecretStore.layer.pipe( - Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-environment-keys-test-" })), + Layer.provide( + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-environment-keys-test-" }), + ), ); const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation"); @@ -65,8 +67,9 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it }).pipe( Effect.flatMap(() => Effect.fail( - new ServerSecretStore.SecretStoreError({ - message: "Concurrent keypair creation won.", + new ServerSecretStore.SecretStorePersistError({ + operation: "persist", + resource: "environment signing key pair", cause: PlatformError.systemError({ _tag: "AlreadyExists", module: "FileSystem", @@ -79,7 +82,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..fe997e34f66 100644 --- a/apps/server/src/cloud/environmentKeys.ts +++ b/apps/server/src/cloud/environmentKeys.ts @@ -27,47 +27,55 @@ function stringToBytes(value: string): Uint8Array { return new TextEncoder().encode(value); } -const keyPairPersistenceError = (message: string, cause?: unknown) => - new ServerSecretStore.SecretStoreError({ message, cause }); +const keyPairPersistenceError = ( + operation: "decode" | "encode" | "read_after_concurrent_creation", + cause?: unknown, +): ServerSecretStore.SecretStoreError => { + const resource = "environment signing key pair"; + switch (operation) { + case "decode": + return new ServerSecretStore.SecretStoreDecodeError({ operation, resource, cause }); + case "encode": + return new ServerSecretStore.SecretStoreEncodeError({ operation, resource, cause }); + case "read_after_concurrent_creation": + return new ServerSecretStore.SecretStoreConcurrentReadError({ + operation, + resource, + cause, + }); + } +}; 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((cause) => keyPairPersistenceError("decode", cause)), ); 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((cause) => keyPairPersistenceError("encode", cause)), ); 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.", - ), - ), + Effect.fail(keyPairPersistenceError("read_after_concurrent_creation")), }), ), ) @@ -77,7 +85,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..a016e7b08cd 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -16,8 +16,9 @@ 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({ + operation: "persist", + resource: "cloud replay guard", cause: PlatformError.systemError({ _tag: tag, module: "FileSystem", diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 71be9f376d8..279c6f404d5 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -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,8 @@ 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({ + operation: "verify_linked_cloud_account", cause, }), ), @@ -239,8 +239,8 @@ 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({ + operation: "read_linked_cloud_account", cause, }), ), @@ -248,8 +248,8 @@ function readInstalledCloudUserId( Option.isSome(bytes) ? Effect.succeed(bytesToString(bytes.value)) : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud linked user is not installed for this environment.", + new EnvironmentAuth.ServerAuthLinkedCloudAccountMissingError({ + operation: "missing_linked_cloud_account", }), ), ), @@ -394,8 +394,8 @@ 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({ + operation: "sign_cloud_link_jwt", cause, }), ), @@ -416,15 +416,17 @@ const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")( yield* appendCloudCredentialResponseHeaders; return proof satisfies RelayEnvironmentLinkProof; }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentCloudInternalError(error.message)(error.cause), ), - Effect.catchTags({ - PlatformError: failEnvironmentCloudInternalError("Could not generate environment link proof."), - SecretStoreError: failEnvironmentCloudInternalError( - "Could not generate environment link proof.", - ), - }), + Effect.catchIf( + ServerSecretStore.isSecretStoreError, + failEnvironmentCloudInternalError("Could not generate environment link proof."), + ), + Effect.catchTag( + "PlatformError", + failEnvironmentCloudInternalError("Could not generate environment link proof."), + ), ); const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(function* ( @@ -477,17 +479,17 @@ const cloudRelayConfigHandler = Effect.fn("environment.cloud.relayConfig")( yield* requireEnvironmentScope(AuthRelayWriteScope); return yield* applyCloudRelayConfig(dependencies, payload); }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentCloudInternalError(error.message)(error.cause), ), - Effect.catchTags({ - SchemaError: failEnvironmentCloudInternalError( - "Could not persist environment relay configuration.", - ), - SecretStoreError: failEnvironmentCloudInternalError( - "Could not persist environment relay configuration.", - ), - }), + Effect.catchIf( + ServerSecretStore.isSecretStoreError, + failEnvironmentCloudInternalError("Could not persist environment relay configuration."), + ), + Effect.catchTag( + "SchemaError", + failEnvironmentCloudInternalError("Could not persist environment relay configuration."), + ), ); const relayClientRequest = ( @@ -591,15 +593,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 +640,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."), ), ); @@ -662,8 +665,8 @@ const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")( yield* CliState.setCliDesiredCloudLink(false); return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; }, - Effect.catchTag( - "SecretStoreError", + Effect.catchIf( + ServerSecretStore.isSecretStoreError, failEnvironmentCloudInternalError("Could not remove environment relay configuration."), ), ); @@ -680,8 +683,8 @@ 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."), ), ); @@ -693,8 +696,8 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( Option.isSome(bytes) ? Effect.succeed(bytesToString(bytes.value)) : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud mint public key is not installed for this environment.", + new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({ + operation: "missing_cloud_mint_public_key", }), ), ), @@ -708,8 +711,8 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( Option.isSome(fallbackBytes) ? Effect.succeed(bytesToString(fallbackBytes.value)) : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud relay issuer is not installed for this environment.", + new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({ + operation: "missing_cloud_relay_issuer", }), ), ), @@ -777,8 +780,8 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( }).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Failed to sign cloud health JWT.", + new EnvironmentAuth.ServerAuthCloudHealthJwtSigningError({ + operation: "sign_cloud_health_jwt", cause, }), ), @@ -794,13 +797,17 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( yield* appendCloudCredentialResponseHeaders; return response; }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentCloudInternalError(error.message)(error.cause), ), - Effect.catchTags({ - PlatformError: failEnvironmentCloudInternalError("Could not answer cloud health request."), - SecretStoreError: failEnvironmentCloudInternalError("Could not answer cloud health request."), - }), + Effect.catchIf( + ServerSecretStore.isSecretStoreError, + failEnvironmentCloudInternalError("Could not answer cloud health request."), + ), + Effect.catchTag( + "PlatformError", + failEnvironmentCloudInternalError("Could not answer cloud health request."), + ), ); const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential")( @@ -810,8 +817,8 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") Option.isSome(bytes) ? Effect.succeed(bytesToString(bytes.value)) : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud mint public key is not installed for this environment.", + new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({ + operation: "missing_cloud_mint_public_key", }), ), ), @@ -825,8 +832,8 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") Option.isSome(fallbackBytes) ? Effect.succeed(bytesToString(fallbackBytes.value)) : Effect.fail( - new EnvironmentAuth.ServerAuthInternalError({ - message: "Cloud relay issuer is not installed for this environment.", + new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({ + operation: "missing_cloud_relay_issuer", }), ), ), @@ -899,8 +906,8 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") }).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthInternalError({ - message: "Failed to sign cloud mint JWT.", + new EnvironmentAuth.ServerAuthCloudMintJwtSigningError({ + operation: "sign_cloud_mint_jwt", cause, }), ), @@ -914,17 +921,17 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") yield* appendCloudCredentialResponseHeaders; return response; }, - Effect.catchTag("ServerAuthInternalError", (error) => + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentCloudInternalError(error.message)(error.cause), ), - Effect.catchTags({ - PlatformError: failEnvironmentCloudInternalError( - "Could not issue cloud connection credential.", - ), - SecretStoreError: failEnvironmentCloudInternalError( - "Could not issue cloud connection credential.", - ), - }), + Effect.catchIf( + ServerSecretStore.isSecretStoreError, + failEnvironmentCloudInternalError("Could not issue cloud connection credential."), + ), + Effect.catchTag( + "PlatformError", + 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..d8dba8adfb2 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(error.reason), + ), + 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..d886345fe64 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(error.reason), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("internal_error", error), + ), ); const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { disableTracing: true, From 0b3fe1ffb93aec08bfa80b3add4632b9680b65c7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 22:14:26 -0700 Subject: [PATCH 2/6] Use named imports for auth helpers Co-authored-by: codex --- apps/server/src/auth/dpop.test.ts | 4 ++-- apps/server/src/auth/dpop.ts | 17 +++++++++++------ apps/server/src/cliAuthFormat.ts | 6 +++--- apps/server/src/cloud/environmentKeys.ts | 4 ++-- apps/server/src/cloud/http.ts | 6 +++--- .../src/relay/AgentAwarenessRelay.test.ts | 2 +- 6 files changed, 22 insertions(+), 17 deletions(-) diff --git a/apps/server/src/auth/dpop.test.ts b/apps/server/src/auth/dpop.test.ts index 02d4f678f9f..8436d159a5f 100644 --- a/apps/server/src/auth/dpop.test.ts +++ b/apps/server/src/auth/dpop.test.ts @@ -1,11 +1,11 @@ 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.SecretStorePersistError({ + new SecretStorePersistError({ operation: "persist", resource: "DPoP proof", cause: PlatformError.systemError({ diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts index 8d279466849..3943d17f871 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,13 +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({ + ? new ServerAuthInvalidCredentialError({ reason: "invalid_credential", cause: "DPoP proof replayed.", }) - : new EnvironmentAuth.ServerAuthDpopReplayStateRecordError({ + : new ServerAuthDpopReplayStateRecordError({ operation: "record_dpop_replay_state", cause: error, }); @@ -54,7 +59,7 @@ export const verifyRequestDpopProof = (input: { ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}), }); if (!result.ok) { - return yield* new EnvironmentAuth.ServerAuthInvalidCredentialError({ + return yield* new ServerAuthInvalidCredentialError({ reason: "invalid_credential", cause: result.reason, }); @@ -67,7 +72,7 @@ export const verifyRequestDpopProof = (input: { Effect.map(Encoding.encodeBase64Url), Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthDpopReplayKeyCalculationError({ + new ServerAuthDpopReplayKeyCalculationError({ operation: "calculate_dpop_replay_key", cause, }), diff --git a/apps/server/src/cliAuthFormat.ts b/apps/server/src/cliAuthFormat.ts index 72a6e2629bc..2ef5ba10a80 100644 --- a/apps/server/src/cliAuthFormat.ts +++ b/apps/server/src/cliAuthFormat.ts @@ -1,7 +1,7 @@ import type { AuthClientMetadata, AuthClientSession, AuthPairingLink } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; -import type * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import type { IssuedBearerSession, IssuedPairingLink } from "./auth/EnvironmentAuth.ts"; const newline = "\n"; @@ -25,7 +25,7 @@ function toIsoString(value: DateTime.DateTime | DateTime.Utc): string { } export function formatIssuedPairingCredential( - credential: EnvironmentAuth.IssuedPairingLink, + credential: IssuedPairingLink, options?: { readonly json?: boolean; readonly baseUrl?: string; @@ -105,7 +105,7 @@ export function formatPairingCredentialList( } export function formatIssuedSession( - session: EnvironmentAuth.IssuedBearerSession, + session: IssuedBearerSession, options?: { readonly json?: boolean; readonly tokenOnly?: boolean; diff --git a/apps/server/src/cloud/environmentKeys.ts b/apps/server/src/cloud/environmentKeys.ts index fe997e34f66..31a2123c190 100644 --- a/apps/server/src/cloud/environmentKeys.ts +++ b/apps/server/src/cloud/environmentKeys.ts @@ -1,4 +1,4 @@ -import * as NodeCrypto from "node:crypto"; +import { generateKeyPairSync } from "node:crypto"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -101,7 +101,7 @@ export const getOrCreateEnvironmentKeyPairFromSecretStore = Effect.fn(function* }); } - const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { + const keyPair = generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 279c6f404d5..f176923ea8d 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"; @@ -583,7 +583,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, @@ -662,7 +662,7 @@ const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")( ], { concurrency: 7 }, ); - yield* CliState.setCliDesiredCloudLink(false); + yield* setCliDesiredCloudLink(false); return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; }, Effect.catchIf( diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 40ed694723d..4d6649fe0c3 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -1,4 +1,4 @@ -import * as NodeCrypto from "node:crypto"; +import { generateKeyPairSync } from "node:crypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { From fd55c4754e8215778392fe5130ad2809ab5acfe0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 23:34:31 -0700 Subject: [PATCH 3/6] Consolidate server auth error modeling Co-authored-by: codex --- apps/server/src/auth/EnvironmentAuth.test.ts | 17 +- apps/server/src/auth/EnvironmentAuth.ts | 371 ++---------------- apps/server/src/auth/PairingGrantStore.ts | 118 ++---- .../server/src/auth/ServerSecretStore.test.ts | 15 +- apps/server/src/auth/ServerSecretStore.ts | 150 ++----- apps/server/src/auth/SessionStore.test.ts | 10 +- apps/server/src/auth/SessionStore.ts | 279 +++---------- apps/server/src/auth/dpop.test.ts | 11 +- apps/server/src/auth/dpop.ts | 15 +- apps/server/src/auth/http.ts | 12 +- apps/server/src/cloud/environmentKeys.test.ts | 2 +- apps/server/src/cloud/environmentKeys.ts | 21 +- apps/server/src/cloud/http.test.ts | 2 +- apps/server/src/cloud/http.ts | 124 +++--- apps/server/src/http.ts | 2 +- apps/server/src/ws.ts | 2 +- 16 files changed, 237 insertions(+), 914 deletions(-) diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 54b5d1e850d..74f2cef1724 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -57,14 +57,12 @@ 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.UnknownBootstrapCredentialError({ - reason: "unknown", - }), + new PairingGrantStore.UnknownBootstrapCredentialError({}), ); expect(error._tag).toBe("ServerAuthInvalidCredentialError"); if (error._tag === "ServerAuthInvalidCredentialError") { - expect(error.reason).toBe("invalid_credential"); + expect(EnvironmentAuth.serverAuthCredentialReason(error)).toBe("invalid_credential"); } }), ); @@ -72,14 +70,17 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { it.effect("maps unexpected bootstrap failures to 500", () => Effect.sync(() => { const error = EnvironmentAuth.toBootstrapExchangeError( - new PairingGrantStore.BootstrapCredentialConsumeError({ + new PairingGrantStore.BootstrapCredentialInternalError({ operation: "consume", cause: new Error("sqlite is unavailable"), }), ); - expect(error._tag).toBe("ServerAuthBootstrapCredentialValidationError"); - expect(error.message).toBe("Failed to validate bootstrap credential."); + expect(error._tag).toBe("ServerAuthOperationError"); + if (error._tag === "ServerAuthOperationError") { + expect(error.operation).toBe("validate_bootstrap_credential"); + expect(error.message).toContain("validate_bootstrap_credential"); + } }), ); @@ -123,7 +124,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { expect(error._tag).toBe("ServerAuthScopeNotGrantedError"); if (error._tag === "ServerAuthScopeNotGrantedError") { - expect(error.reason).toBe("scope_not_granted"); + expect(EnvironmentAuth.serverAuthInvalidRequestReason(error)).toBe("scope_not_granted"); } }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 13570ad8f81..81b1757f1c0 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -84,241 +84,36 @@ const ServerAuthInternalOperation = Schema.Literals([ "calculate_dpop_replay_key", "verify_linked_cloud_account", "read_linked_cloud_account", - "missing_linked_cloud_account", "sign_cloud_link_jwt", - "missing_cloud_mint_public_key", - "missing_cloud_relay_issuer", "sign_cloud_health_jwt", "sign_cloud_mint_jwt", ]); type ServerAuthInternalOperation = typeof ServerAuthInternalOperation.Type; -const serverAuthInternalErrorContext = { - cause: Schema.optional(Schema.Defect()), -}; - -export class ServerAuthBootstrapCredentialValidationError extends Schema.TaggedErrorClass()( - "ServerAuthBootstrapCredentialValidationError", - { - operation: Schema.Literal("validate_bootstrap_credential"), - ...serverAuthInternalErrorContext, - }, -) { - override get message(): string { - return "Failed to validate bootstrap credential."; - } -} - -export class ServerAuthSessionCredentialValidationError extends Schema.TaggedErrorClass()( - "ServerAuthSessionCredentialValidationError", - { - operation: Schema.Literal("validate_session_credential"), - ...serverAuthInternalErrorContext, - }, -) { - override get message(): string { - return "Failed to validate session credential."; - } -} - -export class ServerAuthAuthenticatedSessionIssueError extends Schema.TaggedErrorClass()( - "ServerAuthAuthenticatedSessionIssueError", - { - operation: Schema.Literal("issue_authenticated_session"), - ...serverAuthInternalErrorContext, - }, -) { - override get message(): string { - return "Failed to issue authenticated session."; - } -} - -export class ServerAuthAuthenticatedAccessTokenIssueError extends Schema.TaggedErrorClass()( - "ServerAuthAuthenticatedAccessTokenIssueError", - { - operation: Schema.Literal("issue_authenticated_access_token"), - ...serverAuthInternalErrorContext, - }, -) { - override get message(): string { - return "Failed to issue authenticated access token."; - } -} - -export class ServerAuthPairingLinkCreationError extends Schema.TaggedErrorClass()( - "ServerAuthPairingLinkCreationError", - { - operation: Schema.Literal("create_pairing_link"), - ...serverAuthInternalErrorContext, - }, -) { - override get message(): string { - return "Failed to create pairing link."; - } -} - -export class ServerAuthPairingLinksListError extends Schema.TaggedErrorClass()( - "ServerAuthPairingLinksListError", +export class ServerAuthOperationError extends Schema.TaggedErrorClass()( + "ServerAuthOperationError", { - operation: Schema.Literal("list_pairing_links"), - ...serverAuthInternalErrorContext, + operation: ServerAuthInternalOperation, + cause: Schema.Defect(), }, ) { override get message(): string { - return "Failed to list pairing links."; - } -} - -export class ServerAuthPairingLinkRevocationError extends Schema.TaggedErrorClass()( - "ServerAuthPairingLinkRevocationError", - { - operation: Schema.Literal("revoke_pairing_link"), - ...serverAuthInternalErrorContext, - }, -) { - override get message(): string { - return "Failed to revoke pairing link."; - } -} - -export class ServerAuthSessionTokenIssueError extends Schema.TaggedErrorClass()( - "ServerAuthSessionTokenIssueError", - { - operation: Schema.Literal("issue_session_token"), - ...serverAuthInternalErrorContext, - }, -) { - override get message(): string { - return "Failed to issue session token."; - } -} - -export class ServerAuthSessionsListError extends Schema.TaggedErrorClass()( - "ServerAuthSessionsListError", - { - operation: Schema.Literal("list_sessions"), - ...serverAuthInternalErrorContext, - }, -) { - override get message(): string { - return "Failed to list sessions."; - } -} - -export class ServerAuthSessionRevocationError extends Schema.TaggedErrorClass()( - "ServerAuthSessionRevocationError", - { - operation: Schema.Literal("revoke_session"), - ...serverAuthInternalErrorContext, - }, -) { - override get message(): string { - return "Failed to revoke session."; - } -} - -export class ServerAuthOtherSessionsRevocationError extends Schema.TaggedErrorClass()( - "ServerAuthOtherSessionsRevocationError", - { - operation: Schema.Literal("revoke_other_sessions"), - ...serverAuthInternalErrorContext, - }, -) { - override get message(): string { - return "Failed to revoke other sessions."; - } -} - -export class ServerAuthWebSocketTokenIssueError extends Schema.TaggedErrorClass()( - "ServerAuthWebSocketTokenIssueError", - { - operation: Schema.Literal("issue_websocket_token"), - ...serverAuthInternalErrorContext, - }, -) { - override get message(): string { - return "Failed to issue websocket token."; - } -} - -export class ServerAuthDpopReplayStateRecordError extends Schema.TaggedErrorClass()( - "ServerAuthDpopReplayStateRecordError", - { - operation: Schema.Literal("record_dpop_replay_state"), - ...serverAuthInternalErrorContext, - }, -) { - override get message(): string { - return "Failed to record DPoP proof replay state."; - } -} - -export class ServerAuthDpopReplayKeyCalculationError extends Schema.TaggedErrorClass()( - "ServerAuthDpopReplayKeyCalculationError", - { - operation: Schema.Literal("calculate_dpop_replay_key"), - ...serverAuthInternalErrorContext, - }, -) { - override get message(): string { - return "Failed to calculate DPoP replay key."; - } -} - -export class ServerAuthLinkedCloudAccountVerificationError extends Schema.TaggedErrorClass()( - "ServerAuthLinkedCloudAccountVerificationError", - { - operation: Schema.Literal("verify_linked_cloud_account"), - ...serverAuthInternalErrorContext, - }, -) { - override get message(): string { - return "Could not verify the linked cloud account."; - } -} - -export class ServerAuthLinkedCloudAccountReadError extends Schema.TaggedErrorClass()( - "ServerAuthLinkedCloudAccountReadError", - { - operation: Schema.Literal("read_linked_cloud_account"), - ...serverAuthInternalErrorContext, - }, -) { - override get message(): string { - return "Could not read the linked cloud account."; + return `Server authentication operation '${this.operation}' failed.`; } } export class ServerAuthLinkedCloudAccountMissingError extends Schema.TaggedErrorClass()( "ServerAuthLinkedCloudAccountMissingError", - { - operation: Schema.Literal("missing_linked_cloud_account"), - ...serverAuthInternalErrorContext, - }, + {}, ) { override get message(): string { return "Cloud linked user is not installed for this environment."; } } -export class ServerAuthCloudLinkJwtSigningError extends Schema.TaggedErrorClass()( - "ServerAuthCloudLinkJwtSigningError", - { - operation: Schema.Literal("sign_cloud_link_jwt"), - ...serverAuthInternalErrorContext, - }, -) { - override get message(): string { - return "Failed to sign cloud link JWT."; - } -} - export class ServerAuthCloudMintPublicKeyMissingError extends Schema.TaggedErrorClass()( "ServerAuthCloudMintPublicKeyMissingError", - { - operation: Schema.Literal("missing_cloud_mint_public_key"), - ...serverAuthInternalErrorContext, - }, + {}, ) { override get message(): string { return "Cloud mint public key is not installed for this environment."; @@ -327,77 +122,25 @@ export class ServerAuthCloudMintPublicKeyMissingError extends Schema.TaggedError export class ServerAuthCloudRelayIssuerMissingError extends Schema.TaggedErrorClass()( "ServerAuthCloudRelayIssuerMissingError", - { - operation: Schema.Literal("missing_cloud_relay_issuer"), - ...serverAuthInternalErrorContext, - }, + {}, ) { override get message(): string { return "Cloud relay issuer is not installed for this environment."; } } -export class ServerAuthCloudHealthJwtSigningError extends Schema.TaggedErrorClass()( - "ServerAuthCloudHealthJwtSigningError", - { - operation: Schema.Literal("sign_cloud_health_jwt"), - ...serverAuthInternalErrorContext, - }, -) { - override get message(): string { - return "Failed to sign cloud health JWT."; - } -} - -export class ServerAuthCloudMintJwtSigningError extends Schema.TaggedErrorClass()( - "ServerAuthCloudMintJwtSigningError", - { - operation: Schema.Literal("sign_cloud_mint_jwt"), - ...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, + ServerAuthOperationError, ServerAuthLinkedCloudAccountMissingError, - ServerAuthCloudLinkJwtSigningError, ServerAuthCloudMintPublicKeyMissingError, ServerAuthCloudRelayIssuerMissingError, - ServerAuthCloudHealthJwtSigningError, - ServerAuthCloudMintJwtSigningError, ]); export type ServerAuthInternalError = typeof ServerAuthInternalError.Type; export const isServerAuthInternalError = Schema.is(ServerAuthInternalError); -const serverAuthCredentialErrorContext = { - cause: Schema.optional(Schema.Defect()), -}; - export class ServerAuthMissingCredentialError extends Schema.TaggedErrorClass()( "ServerAuthMissingCredentialError", - { - reason: Schema.Literal("missing_credential"), - ...serverAuthCredentialErrorContext, - }, + {}, ) { override get message(): string { return "Server authentication credential is missing."; @@ -407,8 +150,8 @@ export class ServerAuthMissingCredentialError extends Schema.TaggedErrorClass()( "ServerAuthInvalidCredentialError", { - reason: Schema.Literal("invalid_credential"), - ...serverAuthCredentialErrorContext, + diagnostic: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { @@ -422,12 +165,14 @@ export const ServerAuthCredentialError = Schema.Union([ ]); 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", - { - reason: Schema.Literal("invalid_scope"), - }, + {}, ) { override get message(): string { return "The requested authentication scope is invalid."; @@ -436,9 +181,7 @@ export class ServerAuthInvalidScopeError extends Schema.TaggedErrorClass()( "ServerAuthScopeNotGrantedError", - { - reason: Schema.Literal("scope_not_granted"), - }, + {}, ) { override get message(): string { return "The requested authentication scope was not granted."; @@ -451,12 +194,14 @@ export const ServerAuthInvalidRequestError = Schema.Union([ ]); 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", - { - reason: Schema.Literal("current_session_revoke_not_allowed"), - }, + {}, ) { override get message(): string { return "The current authentication session cannot revoke itself."; @@ -573,55 +318,8 @@ const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => const serverAuthInternalError = ( operation: ServerAuthInternalOperation, - cause?: unknown, -): ServerAuthInternalError => { - switch (operation) { - case "validate_bootstrap_credential": - return new ServerAuthBootstrapCredentialValidationError({ operation, cause }); - case "validate_session_credential": - return new ServerAuthSessionCredentialValidationError({ operation, cause }); - case "issue_authenticated_session": - return new ServerAuthAuthenticatedSessionIssueError({ operation, cause }); - case "issue_authenticated_access_token": - return new ServerAuthAuthenticatedAccessTokenIssueError({ operation, cause }); - case "create_pairing_link": - return new ServerAuthPairingLinkCreationError({ operation, cause }); - case "list_pairing_links": - return new ServerAuthPairingLinksListError({ operation, cause }); - case "revoke_pairing_link": - return new ServerAuthPairingLinkRevocationError({ operation, cause }); - case "issue_session_token": - return new ServerAuthSessionTokenIssueError({ operation, cause }); - case "list_sessions": - return new ServerAuthSessionsListError({ operation, cause }); - case "revoke_session": - return new ServerAuthSessionRevocationError({ operation, cause }); - case "revoke_other_sessions": - return new ServerAuthOtherSessionsRevocationError({ operation, cause }); - case "issue_websocket_token": - return new ServerAuthWebSocketTokenIssueError({ operation, cause }); - case "record_dpop_replay_state": - return new ServerAuthDpopReplayStateRecordError({ operation, cause }); - case "calculate_dpop_replay_key": - return new ServerAuthDpopReplayKeyCalculationError({ operation, cause }); - case "verify_linked_cloud_account": - return new ServerAuthLinkedCloudAccountVerificationError({ operation, cause }); - case "read_linked_cloud_account": - return new ServerAuthLinkedCloudAccountReadError({ operation, cause }); - case "missing_linked_cloud_account": - return new ServerAuthLinkedCloudAccountMissingError({ operation, cause }); - case "sign_cloud_link_jwt": - return new ServerAuthCloudLinkJwtSigningError({ operation, cause }); - case "missing_cloud_mint_public_key": - return new ServerAuthCloudMintPublicKeyMissingError({ operation, cause }); - case "missing_cloud_relay_issuer": - return new ServerAuthCloudRelayIssuerMissingError({ operation, cause }); - case "sign_cloud_health_jwt": - return new ServerAuthCloudHealthJwtSigningError({ operation, cause }); - case "sign_cloud_mint_jwt": - return new ServerAuthCloudMintJwtSigningError({ operation, cause }); - } -}; + cause: unknown, +): ServerAuthOperationError => new ServerAuthOperationError({ operation, cause }); const toInternalError = (operation: ServerAuthInternalOperation) => @@ -636,7 +334,6 @@ export function toBootstrapExchangeError( } return new ServerAuthInvalidCredentialError({ - reason: "invalid_credential", cause, }); } @@ -647,7 +344,7 @@ const mapSessionVerificationErrors = ( effect.pipe( Effect.mapError((cause) => SessionStore.isSessionCredentialInvalidError(cause) - ? new ServerAuthInvalidCredentialError({ reason: "invalid_credential", cause }) + ? new ServerAuthInvalidCredentialError({ cause }) : serverAuthInternalError("validate_session_credential", cause), ), ); @@ -713,7 +410,7 @@ export const make = Effect.gen(function* () { const dpopToken = parseDpopToken(request); const credential = cookieToken ?? bearerToken ?? dpopToken; if (!credential) { - return Effect.fail(new ServerAuthMissingCredentialError({ reason: "missing_credential" })); + return Effect.fail(new ServerAuthMissingCredentialError({})); } return authenticateToken(credential).pipe( Effect.flatMap((session) => { @@ -721,8 +418,7 @@ export const make = Effect.gen(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.", }), ); } @@ -739,8 +435,7 @@ export const make = Effect.gen(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.", }), ); } @@ -816,9 +511,7 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { const grantedScopes = requestedScopes ?? grant.scopes; if (!grantedScopes.every((scope) => grant.scopes.includes(scope))) { - return yield* new ServerAuthScopeNotGrantedError({ - reason: "scope_not_granted", - }); + return yield* new ServerAuthScopeNotGrantedError({}); } return yield* sessions .issue({ @@ -1020,9 +713,7 @@ export const make = Effect.gen(function* () { "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); }); diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 946a988320e..ffca6b2f47a 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -31,9 +31,7 @@ export interface BootstrapGrant { export class UnknownBootstrapCredentialError extends Schema.TaggedErrorClass()( "UnknownBootstrapCredentialError", - { - reason: Schema.Literal("unknown"), - }, + {}, ) { override get message(): string { return "Unknown bootstrap credential."; @@ -42,9 +40,7 @@ export class UnknownBootstrapCredentialError extends Schema.TaggedErrorClass()( "ExpiredBootstrapCredentialError", - { - reason: Schema.Literal("expired"), - }, + {}, ) { override get message(): string { return "Bootstrap credential expired."; @@ -53,9 +49,7 @@ export class ExpiredBootstrapCredentialError extends Schema.TaggedErrorClass()( "BootstrapCredentialProofKeyMismatchError", - { - reason: Schema.Literal("proof_key_mismatch"), - }, + {}, ) { override get message(): string { return "Bootstrap credential proof key mismatch."; @@ -64,9 +58,7 @@ export class BootstrapCredentialProofKeyMismatchError extends Schema.TaggedError export class UnavailableBootstrapCredentialError extends Schema.TaggedErrorClass()( "UnavailableBootstrapCredentialError", - { - reason: Schema.Literal("unavailable"), - }, + {}, ) { override get message(): string { return "Bootstrap credential is no longer available."; @@ -82,61 +74,25 @@ export const BootstrapCredentialInvalidError = Schema.Union([ export type BootstrapCredentialInvalidError = typeof BootstrapCredentialInvalidError.Type; export const isBootstrapCredentialInvalidError = Schema.is(BootstrapCredentialInvalidError); -export class ActivePairingLinksLoadError extends Schema.TaggedErrorClass()( - "ActivePairingLinksLoadError", - { - operation: Schema.Literal("list_active"), - cause: Schema.Defect(), - }, -) { - override get message(): string { - return "Failed to load active pairing links."; - } -} - -export class PairingLinkRevokeError extends Schema.TaggedErrorClass()( - "PairingLinkRevokeError", - { - operation: Schema.Literal("revoke"), - cause: Schema.Defect(), - }, -) { - override get message(): string { - return "Failed to revoke pairing link."; - } -} - -export class PairingCredentialIssueError extends Schema.TaggedErrorClass()( - "PairingCredentialIssueError", - { - operation: Schema.Literal("issue"), - cause: Schema.Defect(), - }, -) { - override get message(): string { - return "Failed to issue pairing credential."; - } -} +const BootstrapCredentialInternalOperation = Schema.Literals([ + "list_active", + "revoke", + "issue", + "consume", +]); -export class BootstrapCredentialConsumeError extends Schema.TaggedErrorClass()( - "BootstrapCredentialConsumeError", +export class BootstrapCredentialInternalError extends Schema.TaggedErrorClass()( + "BootstrapCredentialInternalError", { - operation: Schema.Literal("consume"), + operation: BootstrapCredentialInternalOperation, cause: Schema.Defect(), }, ) { override get message(): string { - return "Failed to consume bootstrap credential."; + return `Bootstrap credential operation '${this.operation}' failed.`; } } -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([ @@ -210,36 +166,10 @@ const PAIRING_TOKEN_LENGTH = 12; const PAIRING_TOKEN_REJECTION_LIMIT = Math.floor(256 / PAIRING_TOKEN_ALPHABET.length) * PAIRING_TOKEN_ALPHABET.length; -const invalidBootstrapCredentialError = ( - reason: BootstrapCredentialInvalidError["reason"], -): BootstrapCredentialInvalidError => { - switch (reason) { - case "unknown": - return new UnknownBootstrapCredentialError({ reason }); - case "expired": - return new ExpiredBootstrapCredentialError({ reason }); - case "proof_key_mismatch": - return new BootstrapCredentialProofKeyMismatchError({ reason }); - case "unavailable": - return new UnavailableBootstrapCredentialError({ reason }); - } -}; - const internalBootstrapCredentialError = ( operation: BootstrapCredentialInternalError["operation"], cause: unknown, -): BootstrapCredentialInternalError => { - switch (operation) { - case "list_active": - return new ActivePairingLinksLoadError({ operation, cause }); - case "revoke": - return new PairingLinkRevokeError({ operation, cause }); - case "issue": - return new PairingCredentialIssueError({ operation, cause }); - case "consume": - return new BootstrapCredentialConsumeError({ operation, cause }); - } -}; +): BootstrapCredentialInternalError => new BootstrapCredentialInternalError({ operation, cause }); export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; @@ -399,7 +329,7 @@ export const make = Effect.gen(function* () { { _tag: "error", reason: "not-found", - error: invalidBootstrapCredentialError("unknown"), + error: new UnknownBootstrapCredentialError({}), }, current, ]; @@ -412,7 +342,7 @@ export const make = Effect.gen(function* () { { _tag: "error", reason: "expired", - error: invalidBootstrapCredentialError("expired"), + error: new ExpiredBootstrapCredentialError({}), }, next, ]; @@ -423,7 +353,7 @@ export const make = Effect.gen(function* () { { _tag: "error", reason: "not-found", - error: invalidBootstrapCredentialError("proof_key_mismatch"), + error: new BootstrapCredentialProofKeyMismatchError({}), }, next, ]; @@ -490,29 +420,29 @@ export const make = Effect.gen(function* () { const matching = yield* pairingLinks.getByCredential({ credential }); if (Option.isNone(matching)) { - return yield* invalidBootstrapCredentialError("unknown"); + return yield* new UnknownBootstrapCredentialError({}); } if (matching.value.revokedAt !== null) { - return yield* invalidBootstrapCredentialError("unavailable"); + return yield* new UnavailableBootstrapCredentialError({}); } if (matching.value.consumedAt !== null) { - return yield* invalidBootstrapCredentialError("unknown"); + return yield* new UnknownBootstrapCredentialError({}); } if (DateTime.isGreaterThanOrEqualTo(now, matching.value.expiresAt)) { - return yield* invalidBootstrapCredentialError("expired"); + return yield* new ExpiredBootstrapCredentialError({}); } if ( matching.value.proofKeyThumbprint !== null && matching.value.proofKeyThumbprint !== input?.proofKeyThumbprint ) { - return yield* invalidBootstrapCredentialError("proof_key_mismatch"); + return yield* new BootstrapCredentialProofKeyMismatchError({}); } - return yield* invalidBootstrapCredentialError("unavailable"); + return yield* new UnavailableBootstrapCredentialError({}); }, Effect.mapError((cause) => isBootstrapCredentialError(cause) diff --git a/apps/server/src/auth/ServerSecretStore.test.ts b/apps/server/src/auth/ServerSecretStore.test.ts index bb6ff855ffc..94989346c7d 100644 --- a/apps/server/src/auth/ServerSecretStore.test.ts +++ b/apps/server/src/auth/ServerSecretStore.test.ts @@ -231,8 +231,9 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32)); - assert.instanceOf(error, ServerSecretStore.SecretStoreReadError); - assert.include(error.message, "Failed to read secret session-signing-key."); + assert.instanceOf(error, ServerSecretStore.SecretStoreError); + assert.equal(error.operation, "read"); + assert.include(error.message, "secret session-signing-key"); assert.instanceOf(error.cause, PlatformError.PlatformError); assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); }).pipe(Effect.provide(makePermissionDeniedSecretStoreLayer())), @@ -246,8 +247,9 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])), ); - assert.instanceOf(error, ServerSecretStore.SecretStorePersistError); - assert.include(error.message, "Failed to persist secret session-signing-key."); + assert.instanceOf(error, ServerSecretStore.SecretStoreError); + assert.equal(error.operation, "persist"); + assert.include(error.message, "secret session-signing-key"); assert.instanceOf(error.cause, PlatformError.PlatformError); assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); }).pipe(Effect.provide(makeRenameFailureSecretStoreLayer())), @@ -259,8 +261,9 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { const error = yield* Effect.flip(secretStore.remove("session-signing-key")); - assert.instanceOf(error, ServerSecretStore.SecretStoreRemoveError); - assert.include(error.message, "Failed to remove secret session-signing-key."); + assert.instanceOf(error, ServerSecretStore.SecretStoreError); + assert.equal(error.operation, "remove"); + assert.include(error.message, "secret session-signing-key"); assert.instanceOf(error.cause, PlatformError.PlatformError); assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); }).pipe(Effect.provide(makeRemoveFailureSecretStoreLayer())), diff --git a/apps/server/src/auth/ServerSecretStore.ts b/apps/server/src/auth/ServerSecretStore.ts index 87e0d74f752..84c3fe03243 100644 --- a/apps/server/src/auth/ServerSecretStore.ts +++ b/apps/server/src/auth/ServerSecretStore.ts @@ -11,131 +11,31 @@ import * as Schema from "effect/Schema"; import * as ServerConfig from "../config.ts"; -const secretStoreErrorContext = { - resource: Schema.String, - cause: Schema.optional(Schema.Defect()), -}; - -export class SecretStoreSecureError extends Schema.TaggedErrorClass()( - "SecretStoreSecureError", - { - operation: Schema.Literal("secure"), - ...secretStoreErrorContext, - }, -) { - override get message(): string { - return `Failed to secure ${this.resource}.`; - } -} - -export class SecretStoreReadError extends Schema.TaggedErrorClass()( - "SecretStoreReadError", - { - operation: Schema.Literal("read"), - ...secretStoreErrorContext, - }, -) { - override get message(): string { - return `Failed to read ${this.resource}.`; - } -} - -export class SecretStoreTemporaryPathError extends Schema.TaggedErrorClass()( - "SecretStoreTemporaryPathError", - { - operation: Schema.Literal("create_temporary_path"), - ...secretStoreErrorContext, - }, -) { - override get message(): string { - return `Failed to create temporary path for ${this.resource}.`; - } -} - -export class SecretStorePersistError extends Schema.TaggedErrorClass()( - "SecretStorePersistError", - { - operation: Schema.Literal("persist"), - ...secretStoreErrorContext, - }, -) { - override get message(): string { - return `Failed to persist ${this.resource}.`; - } -} - -export class SecretStoreRandomGenerationError extends Schema.TaggedErrorClass()( - "SecretStoreRandomGenerationError", - { - operation: Schema.Literal("generate_random"), - ...secretStoreErrorContext, - }, -) { - override get message(): string { - return `Failed to generate random bytes for ${this.resource}.`; - } -} - -export class SecretStoreConcurrentReadError extends Schema.TaggedErrorClass()( - "SecretStoreConcurrentReadError", - { - operation: Schema.Literal("read_after_concurrent_creation"), - ...secretStoreErrorContext, - }, -) { - override get message(): string { - return `Failed to read ${this.resource} after concurrent creation.`; - } -} - -export class SecretStoreRemoveError extends Schema.TaggedErrorClass()( - "SecretStoreRemoveError", - { - operation: Schema.Literal("remove"), - ...secretStoreErrorContext, - }, -) { - override get message(): string { - return `Failed to remove ${this.resource}.`; - } -} - -export class SecretStoreDecodeError extends Schema.TaggedErrorClass()( - "SecretStoreDecodeError", - { - operation: Schema.Literal("decode"), - ...secretStoreErrorContext, - }, -) { - override get message(): string { - return `Failed to decode ${this.resource}.`; - } -} +const SecretStoreOperation = Schema.Literals([ + "secure", + "read", + "create_temporary_path", + "persist", + "generate_random", + "read_after_concurrent_creation", + "remove", + "decode", + "encode", +]); -export class SecretStoreEncodeError extends Schema.TaggedErrorClass()( - "SecretStoreEncodeError", +export class SecretStoreError extends Schema.TaggedErrorClass()( + "SecretStoreError", { - operation: Schema.Literal("encode"), - ...secretStoreErrorContext, + operation: SecretStoreOperation, + resource: Schema.String, + cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { - return `Failed to encode ${this.resource}.`; + return `Secret store operation '${this.operation}' failed for ${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 => @@ -168,7 +68,7 @@ export const make = Effect.gen(function* () { yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe( Effect.mapError( (cause) => - new SecretStoreSecureError({ + new SecretStoreError({ operation: "secure", resource: `secrets directory ${serverConfig.secretsDir}`, cause, @@ -185,7 +85,7 @@ export const make = Effect.gen(function* () { cause.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail( - new SecretStoreReadError({ + new SecretStoreError({ operation: "read", resource: `secret ${name}`, cause, @@ -200,7 +100,7 @@ export const make = Effect.gen(function* () { return crypto.randomUUIDv4.pipe( Effect.mapError( (cause) => - new SecretStoreTemporaryPathError({ + new SecretStoreError({ operation: "create_temporary_path", resource: `secret ${name}`, cause, @@ -219,7 +119,7 @@ export const make = Effect.gen(function* () { Effect.ignore, Effect.flatMap(() => Effect.fail( - new SecretStorePersistError({ + new SecretStoreError({ operation: "persist", resource: `secret ${name}`, cause, @@ -249,7 +149,7 @@ export const make = Effect.gen(function* () { ).pipe( Effect.mapError( (cause) => - new SecretStorePersistError({ + new SecretStoreError({ operation: "persist", resource: `secret ${name}`, cause, @@ -267,7 +167,7 @@ export const make = Effect.gen(function* () { crypto.randomBytes(bytes).pipe( Effect.mapError( (cause) => - new SecretStoreRandomGenerationError({ + new SecretStoreError({ operation: "generate_random", resource: `secret ${name}`, cause, @@ -284,7 +184,7 @@ export const make = Effect.gen(function* () { onSome: Effect.succeed, onNone: () => Effect.fail( - new SecretStoreConcurrentReadError({ + new SecretStoreError({ operation: "read_after_concurrent_creation", resource: `secret ${name}`, }), @@ -308,7 +208,7 @@ export const make = Effect.gen(function* () { cause.reason._tag === "NotFound" ? Effect.void : Effect.fail( - new SecretStoreRemoveError({ + new SecretStoreError({ operation: "remove", resource: `secret ${name}`, cause, diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 1c6583da315..e6cdfa5930d 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -109,8 +109,14 @@ 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("SessionCredentialVerificationError"); - expect(websocketError._tag).toBe("WebSocketTokenVerificationError"); + expect(sessionError._tag).toBe("SessionCredentialInternalError"); + expect(websocketError._tag).toBe("SessionCredentialInternalError"); + if (sessionError._tag === "SessionCredentialInternalError") { + expect(sessionError.operation).toBe("verify_session_credential"); + } + if (websocketError._tag === "SessionCredentialInternalError") { + expect(websocketError.operation).toBe("verify_websocket_token"); + } 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 b70fa05c166..fc20677504b 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -62,16 +62,9 @@ export type SessionCredentialChange = readonly sessionId: AuthSessionId; }; -const sessionCredentialInvalidErrorContext = { - cause: Schema.optional(Schema.Defect()), -}; - export class MalformedSessionTokenError extends Schema.TaggedErrorClass()( "MalformedSessionTokenError", - { - reason: Schema.Literal("malformed_session_token"), - ...sessionCredentialInvalidErrorContext, - }, + {}, ) { override get message(): string { return "Malformed session token."; @@ -80,10 +73,7 @@ export class MalformedSessionTokenError extends Schema.TaggedErrorClass()( "InvalidSessionTokenSignatureError", - { - reason: Schema.Literal("invalid_session_token_signature"), - ...sessionCredentialInvalidErrorContext, - }, + {}, ) { override get message(): string { return "Invalid session token signature."; @@ -93,8 +83,7 @@ export class InvalidSessionTokenSignatureError extends Schema.TaggedErrorClass()( "InvalidSessionTokenPayloadError", { - reason: Schema.Literal("invalid_session_token_payload"), - ...sessionCredentialInvalidErrorContext, + cause: Schema.Defect(), }, ) { override get message(): string { @@ -104,10 +93,7 @@ export class InvalidSessionTokenPayloadError extends Schema.TaggedErrorClass()( "SessionTokenExpiredError", - { - reason: Schema.Literal("session_token_expired"), - ...sessionCredentialInvalidErrorContext, - }, + {}, ) { override get message(): string { return "Session token expired."; @@ -116,10 +102,7 @@ export class SessionTokenExpiredError extends Schema.TaggedErrorClass()( "UnknownSessionTokenError", - { - reason: Schema.Literal("unknown_session_token"), - ...sessionCredentialInvalidErrorContext, - }, + {}, ) { override get message(): string { return "Unknown session token."; @@ -128,10 +111,7 @@ export class UnknownSessionTokenError extends Schema.TaggedErrorClass()( "SessionTokenRevokedError", - { - reason: Schema.Literal("session_token_revoked"), - ...sessionCredentialInvalidErrorContext, - }, + {}, ) { override get message(): string { return "Session token revoked."; @@ -140,10 +120,7 @@ export class SessionTokenRevokedError extends Schema.TaggedErrorClass()( "InvalidSessionExpirationClaimError", - { - reason: Schema.Literal("invalid_session_exp_claim"), - ...sessionCredentialInvalidErrorContext, - }, + {}, ) { override get message(): string { return "Invalid `exp` claim"; @@ -152,10 +129,7 @@ export class InvalidSessionExpirationClaimError extends Schema.TaggedErrorClass< export class MalformedWebSocketTokenError extends Schema.TaggedErrorClass()( "MalformedWebSocketTokenError", - { - reason: Schema.Literal("malformed_websocket_token"), - ...sessionCredentialInvalidErrorContext, - }, + {}, ) { override get message(): string { return "Malformed websocket token."; @@ -164,10 +138,7 @@ export class MalformedWebSocketTokenError extends Schema.TaggedErrorClass()( "InvalidWebSocketTokenSignatureError", - { - reason: Schema.Literal("invalid_websocket_token_signature"), - ...sessionCredentialInvalidErrorContext, - }, + {}, ) { override get message(): string { return "Invalid websocket token signature."; @@ -177,8 +148,7 @@ export class InvalidWebSocketTokenSignatureError extends Schema.TaggedErrorClass export class InvalidWebSocketTokenPayloadError extends Schema.TaggedErrorClass()( "InvalidWebSocketTokenPayloadError", { - reason: Schema.Literal("invalid_websocket_token_payload"), - ...sessionCredentialInvalidErrorContext, + cause: Schema.Defect(), }, ) { override get message(): string { @@ -188,10 +158,7 @@ export class InvalidWebSocketTokenPayloadError extends Schema.TaggedErrorClass()( "WebSocketTokenExpiredError", - { - reason: Schema.Literal("websocket_token_expired"), - ...sessionCredentialInvalidErrorContext, - }, + {}, ) { override get message(): string { return "Websocket token expired."; @@ -200,10 +167,7 @@ export class WebSocketTokenExpiredError extends Schema.TaggedErrorClass()( "UnknownWebSocketSessionError", - { - reason: Schema.Literal("unknown_websocket_session"), - ...sessionCredentialInvalidErrorContext, - }, + {}, ) { override get message(): string { return "Unknown websocket session."; @@ -212,10 +176,7 @@ export class UnknownWebSocketSessionError extends Schema.TaggedErrorClass()( "WebSocketSessionExpiredError", - { - reason: Schema.Literal("websocket_session_expired"), - ...sessionCredentialInvalidErrorContext, - }, + {}, ) { override get message(): string { return "Websocket session expired."; @@ -224,10 +185,7 @@ export class WebSocketSessionExpiredError extends Schema.TaggedErrorClass()( "WebSocketSessionRevokedError", - { - reason: Schema.Literal("websocket_session_revoked"), - ...sessionCredentialInvalidErrorContext, - }, + {}, ) { override get message(): string { return "Websocket session revoked."; @@ -253,117 +211,30 @@ export const SessionCredentialInvalidError = Schema.Union([ 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", - { - operation: Schema.Literal("issue_session_credential"), - ...sessionCredentialInternalErrorContext, - }, -) { - override get message(): string { - return "Failed to issue session credential."; - } -} - -export class SessionCredentialVerificationError extends Schema.TaggedErrorClass()( - "SessionCredentialVerificationError", - { - operation: Schema.Literal("verify_session_credential"), - ...sessionCredentialInternalErrorContext, - }, -) { - override get message(): string { - return "Failed to verify session credential."; - } -} - -export class WebSocketTokenIssueError extends Schema.TaggedErrorClass()( - "WebSocketTokenIssueError", - { - operation: Schema.Literal("issue_websocket_token"), - ...sessionCredentialInternalErrorContext, - }, -) { - override get message(): string { - return "Failed to issue websocket token."; - } -} - -export class WebSocketTokenVerificationError extends Schema.TaggedErrorClass()( - "WebSocketTokenVerificationError", - { - operation: Schema.Literal("verify_websocket_token"), - ...sessionCredentialInternalErrorContext, - }, -) { - override get message(): string { - return "Failed to verify websocket token."; - } -} - -export class ActiveSessionsListError extends Schema.TaggedErrorClass()( - "ActiveSessionsListError", - { - operation: Schema.Literal("list_active_sessions"), - ...sessionCredentialInternalErrorContext, - }, -) { - override get message(): string { - return "Failed to list active sessions."; - } -} - -export class SessionRevocationError extends Schema.TaggedErrorClass()( - "SessionRevocationError", - { - operation: Schema.Literal("revoke_session"), - ...sessionCredentialInternalErrorContext, - }, -) { - override get message(): string { - return "Failed to revoke session."; - } -} +const SessionCredentialInternalOperation = Schema.Literals([ + "encode_session_claims", + "encode_websocket_claims", + "issue_session_credential", + "verify_session_credential", + "issue_websocket_token", + "verify_websocket_token", + "list_active_sessions", + "revoke_session", + "revoke_other_sessions", +]); -export class OtherSessionsRevocationError extends Schema.TaggedErrorClass()( - "OtherSessionsRevocationError", +export class SessionCredentialInternalError extends Schema.TaggedErrorClass()( + "SessionCredentialInternalError", { - operation: Schema.Literal("revoke_other_sessions"), - ...sessionCredentialInternalErrorContext, + operation: SessionCredentialInternalOperation, + cause: Schema.Defect(), }, ) { override get message(): string { - return "Failed to revoke other sessions."; + return `Session credential operation '${this.operation}' failed.`; } } -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([ @@ -477,66 +348,10 @@ function toAuthClientSession(input: Omit): AuthCli }; } -const sessionCredentialInvalidError = ( - reason: SessionCredentialInvalidError["reason"], - cause?: unknown, -): SessionCredentialInvalidError => { - switch (reason) { - case "malformed_session_token": - return new MalformedSessionTokenError({ reason, cause }); - case "invalid_session_token_signature": - return new InvalidSessionTokenSignatureError({ reason, cause }); - case "invalid_session_token_payload": - return new InvalidSessionTokenPayloadError({ reason, cause }); - case "session_token_expired": - return new SessionTokenExpiredError({ reason, cause }); - case "unknown_session_token": - return new UnknownSessionTokenError({ reason, cause }); - case "session_token_revoked": - return new SessionTokenRevokedError({ reason, cause }); - case "invalid_session_exp_claim": - return new InvalidSessionExpirationClaimError({ reason, cause }); - case "malformed_websocket_token": - return new MalformedWebSocketTokenError({ reason, cause }); - case "invalid_websocket_token_signature": - return new InvalidWebSocketTokenSignatureError({ reason, cause }); - case "invalid_websocket_token_payload": - return new InvalidWebSocketTokenPayloadError({ reason, cause }); - case "websocket_token_expired": - return new WebSocketTokenExpiredError({ reason, cause }); - case "unknown_websocket_session": - return new UnknownWebSocketSessionError({ reason, cause }); - case "websocket_session_expired": - return new WebSocketSessionExpiredError({ reason, cause }); - case "websocket_session_revoked": - return new WebSocketSessionRevokedError({ reason, cause }); - } -}; - const sessionCredentialInternalError = ( operation: SessionCredentialInternalError["operation"], cause: unknown, -): SessionCredentialInternalError => { - switch (operation) { - case "encode_session_claims": - case "encode_websocket_claims": - return new SessionClaimsEncodingError({ operation, cause }); - case "issue_session_credential": - return new SessionCredentialIssueError({ operation, cause }); - case "verify_session_credential": - return new SessionCredentialVerificationError({ operation, cause }); - case "issue_websocket_token": - return new WebSocketTokenIssueError({ operation, cause }); - case "verify_websocket_token": - return new WebSocketTokenVerificationError({ operation, cause }); - case "list_active_sessions": - return new ActiveSessionsListError({ operation, cause }); - case "revoke_session": - return new SessionRevocationError({ operation, cause }); - case "revoke_other_sessions": - return new OtherSessionsRevocationError({ operation, cause }); - } -}; +): SessionCredentialInternalError => new SessionCredentialInternalError({ operation, cause }); const toSessionCredentialInternalError = (operation: SessionCredentialInternalError["operation"]) => (cause: unknown) => @@ -723,36 +538,34 @@ export const make = Effect.gen(function* () { function* (token) { const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) { - return yield* sessionCredentialInvalidError("malformed_session_token"); + return yield* new MalformedSessionTokenError({}); } const expectedSignature = signPayload(encodedPayload, signingSecret); if (!timingSafeEqualBase64Url(signature, expectedSignature)) { - return yield* sessionCredentialInvalidError("invalid_session_token_signature"); + return yield* new InvalidSessionTokenSignatureError({}); } const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( - Effect.mapError((cause) => - sessionCredentialInvalidError("invalid_session_token_payload", cause), - ), + Effect.mapError((cause) => new InvalidSessionTokenPayloadError({ cause })), ); const now = yield* Clock.currentTimeMillis; if (claims.exp <= now) { - return yield* sessionCredentialInvalidError("session_token_expired"); + return yield* new SessionTokenExpiredError({}); } const row = yield* authSessions.getById({ sessionId: claims.sid }); if (Option.isNone(row)) { - return yield* sessionCredentialInvalidError("unknown_session_token"); + return yield* new UnknownSessionTokenError({}); } if (row.value.revokedAt !== null) { - return yield* sessionCredentialInvalidError("session_token_revoked"); + return yield* new SessionTokenRevokedError({}); } const expiresAt = DateTime.make(claims.exp); if (Option.isNone(expiresAt)) { - return yield* sessionCredentialInvalidError("invalid_session_exp_claim"); + return yield* new InvalidSessionExpirationClaimError({}); } return { @@ -810,34 +623,32 @@ export const make = Effect.gen(function* () { function* (token) { const [encodedPayload, signature] = token.split("."); if (!encodedPayload || !signature) { - return yield* sessionCredentialInvalidError("malformed_websocket_token"); + return yield* new MalformedWebSocketTokenError({}); } const expectedSignature = signPayload(encodedPayload, signingSecret); if (!timingSafeEqualBase64Url(signature, expectedSignature)) { - return yield* sessionCredentialInvalidError("invalid_websocket_token_signature"); + return yield* new InvalidWebSocketTokenSignatureError({}); } const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( - Effect.mapError((cause) => - sessionCredentialInvalidError("invalid_websocket_token_payload", cause), - ), + Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })), ); const now = yield* Clock.currentTimeMillis; if (claims.exp <= now) { - return yield* sessionCredentialInvalidError("websocket_token_expired"); + return yield* new WebSocketTokenExpiredError({}); } const row = yield* authSessions.getById({ sessionId: claims.sid }); if (Option.isNone(row)) { - return yield* sessionCredentialInvalidError("unknown_websocket_session"); + return yield* new UnknownWebSocketSessionError({}); } if (row.value.expiresAt.epochMilliseconds <= now) { - return yield* sessionCredentialInvalidError("websocket_session_expired"); + return yield* new WebSocketSessionExpiredError({}); } if (row.value.revokedAt !== null) { - return yield* sessionCredentialInvalidError("websocket_session_revoked"); + return yield* new WebSocketSessionRevokedError({}); } return { diff --git a/apps/server/src/auth/dpop.test.ts b/apps/server/src/auth/dpop.test.ts index 8436d159a5f..5717017d62a 100644 --- a/apps/server/src/auth/dpop.test.ts +++ b/apps/server/src/auth/dpop.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; import * as PlatformError from "effect/PlatformError"; -import { SecretStorePersistError } from "./ServerSecretStore.ts"; +import { SecretStoreError } from "./ServerSecretStore.ts"; import { mapDpopReplayStoreError } from "./dpop.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => - new SecretStorePersistError({ + new SecretStoreError({ operation: "persist", resource: "DPoP proof", cause: PlatformError.systemError({ @@ -26,9 +26,10 @@ describe("mapDpopReplayStoreError", () => { it("reports replay-store availability failures as internal errors", () => { const error = mapDpopReplayStoreError(storeFailure("PermissionDenied")); - expect(error._tag).toBe("ServerAuthDpopReplayStateRecordError"); - if (error._tag === "ServerAuthDpopReplayStateRecordError") { - expect(error.message).toBe("Failed to record DPoP proof replay state."); + expect(error._tag).toBe("ServerAuthOperationError"); + if (error._tag === "ServerAuthOperationError") { + expect(error.operation).toBe("record_dpop_replay_state"); + expect(error.message).toContain("record_dpop_replay_state"); } }); }); diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts index 3943d17f871..72dfdf1375b 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -6,10 +6,9 @@ import * as Encoding from "effect/Encoding"; import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import { - ServerAuthDpopReplayKeyCalculationError, - ServerAuthDpopReplayStateRecordError, ServerAuthInvalidCredentialError, - type ServerAuthInternalError, + ServerAuthInternalError, + ServerAuthOperationError, } from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; @@ -34,10 +33,9 @@ export const mapDpopReplayStoreError = ( ): ServerAuthInvalidCredentialError | ServerAuthInternalError => ServerSecretStore.isSecretAlreadyExistsError(error) ? new ServerAuthInvalidCredentialError({ - reason: "invalid_credential", - cause: "DPoP proof replayed.", + cause: error, }) - : new ServerAuthDpopReplayStateRecordError({ + : new ServerAuthOperationError({ operation: "record_dpop_replay_state", cause: error, }); @@ -60,8 +58,7 @@ export const verifyRequestDpopProof = (input: { }); if (!result.ok) { return yield* new ServerAuthInvalidCredentialError({ - reason: "invalid_credential", - cause: result.reason, + diagnostic: result.reason, }); } const secretStore = yield* ServerSecretStore.ServerSecretStore; @@ -72,7 +69,7 @@ export const verifyRequestDpopProof = (input: { Effect.map(Encoding.encodeBase64Url), Effect.mapError( (cause) => - new ServerAuthDpopReplayKeyCalculationError({ + new ServerAuthOperationError({ operation: "calculate_dpop_replay_key", cause, }), diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 5dce0b67bb0..71fb00b970a 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -170,7 +170,7 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( const request = yield* HttpServerRequest.HttpServerRequest; const session = yield* serverAuth.authenticateHttpRequest(request).pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(error.reason), + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), ), Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("internal_error", error), @@ -234,7 +234,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( return result.response; }, Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(error.reason), + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), ), Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("browser_session_issuance_failed", error), @@ -297,10 +297,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( }, traceRelayRequest, Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(error.reason), + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), ), Effect.catchIf(EnvironmentAuth.isServerAuthInvalidRequestError, (error) => - failEnvironmentInvalidRequest(error.reason), + failEnvironmentInvalidRequest(EnvironmentAuth.serverAuthInvalidRequestReason(error)), ), Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("access_token_issuance_failed", error), @@ -398,8 +398,8 @@ export const authHttpApiLayer = HttpApiBuilder.group( ); return { revoked }; }, - Effect.catchTag("ServerAuthForbiddenOperationError", (error) => - failEnvironmentOperationForbidden(error.reason), + Effect.catchTag("ServerAuthForbiddenOperationError", () => + failEnvironmentOperationForbidden("current_session_revoke_not_allowed"), ), 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 b41d2904b1d..c5af0adedd9 100644 --- a/apps/server/src/cloud/environmentKeys.test.ts +++ b/apps/server/src/cloud/environmentKeys.test.ts @@ -67,7 +67,7 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it }).pipe( Effect.flatMap(() => Effect.fail( - new ServerSecretStore.SecretStorePersistError({ + new ServerSecretStore.SecretStoreError({ operation: "persist", resource: "environment signing key pair", cause: PlatformError.systemError({ diff --git a/apps/server/src/cloud/environmentKeys.ts b/apps/server/src/cloud/environmentKeys.ts index 31a2123c190..155b5848ee6 100644 --- a/apps/server/src/cloud/environmentKeys.ts +++ b/apps/server/src/cloud/environmentKeys.ts @@ -30,21 +30,12 @@ function stringToBytes(value: string): Uint8Array { const keyPairPersistenceError = ( operation: "decode" | "encode" | "read_after_concurrent_creation", cause?: unknown, -): ServerSecretStore.SecretStoreError => { - const resource = "environment signing key pair"; - switch (operation) { - case "decode": - return new ServerSecretStore.SecretStoreDecodeError({ operation, resource, cause }); - case "encode": - return new ServerSecretStore.SecretStoreEncodeError({ operation, resource, cause }); - case "read_after_concurrent_creation": - return new ServerSecretStore.SecretStoreConcurrentReadError({ - operation, - resource, - cause, - }); - } -}; +): ServerSecretStore.SecretStoreError => + new ServerSecretStore.SecretStoreError({ + operation, + resource: "environment signing key pair", + cause, + }); const readEnvironmentKeyPair = Effect.fn("readEnvironmentKeyPair")(function* ( secrets: ServerSecretStore.ServerSecretStore["Service"], diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index a016e7b08cd..5d26cae8605 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -16,7 +16,7 @@ import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => - new ServerSecretStore.SecretStorePersistError({ + new ServerSecretStore.SecretStoreError({ operation: "persist", resource: "cloud replay guard", cause: PlatformError.systemError({ diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index f176923ea8d..7ff6fccecc4 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -211,7 +211,7 @@ function validateLinkedCloudUser(input: { return input.secrets.get(CLOUD_LINKED_USER_ID).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthLinkedCloudAccountVerificationError({ + new EnvironmentAuth.ServerAuthOperationError({ operation: "verify_linked_cloud_account", cause, }), @@ -239,7 +239,7 @@ function readInstalledCloudUserId( return secrets.get(CLOUD_LINKED_USER_ID).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthLinkedCloudAccountReadError({ + new EnvironmentAuth.ServerAuthOperationError({ operation: "read_linked_cloud_account", cause, }), @@ -247,11 +247,7 @@ function readInstalledCloudUserId( Effect.flatMap((bytes) => Option.isSome(bytes) ? Effect.succeed(bytesToString(bytes.value)) - : Effect.fail( - new EnvironmentAuth.ServerAuthLinkedCloudAccountMissingError({ - operation: "missing_linked_cloud_account", - }), - ), + : Effect.fail(new EnvironmentAuth.ServerAuthLinkedCloudAccountMissingError({})), ), ); } @@ -394,7 +390,7 @@ const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function }).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthCloudLinkJwtSigningError({ + new EnvironmentAuth.ServerAuthOperationError({ operation: "sign_cloud_link_jwt", cause, }), @@ -691,34 +687,32 @@ const cloudPreferencesHandler = Effect.fn("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.ServerAuthCloudMintPublicKeyMissingError({ - operation: "missing_cloud_mint_public_key", - }), - ), - ), - ); - 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({ - operation: "missing_cloud_relay_issuer", - }), - ), - ), - ), - ), - ); + 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; @@ -780,7 +774,7 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( }).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthCloudHealthJwtSigningError({ + new EnvironmentAuth.ServerAuthOperationError({ operation: "sign_cloud_health_jwt", cause, }), @@ -812,34 +806,32 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( 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.ServerAuthCloudMintPublicKeyMissingError({ - operation: "missing_cloud_mint_public_key", - }), - ), - ), - ); - 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({ - operation: "missing_cloud_relay_issuer", - }), - ), - ), - ), - ), - ); + 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; @@ -906,7 +898,7 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") }).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthCloudMintJwtSigningError({ + new EnvironmentAuth.ServerAuthOperationError({ operation: "sign_cloud_mint_jwt", cause, }), diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index d8dba8adfb2..ce9b498cb1f 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -82,7 +82,7 @@ const authenticateRawRouteWithScope = ( const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const session = yield* serverAuth.authenticateHttpRequest(request).pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(error.reason), + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), ), Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("internal_error", error), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index d886345fe64..03b609ddcfe 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1727,7 +1727,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( const sessions = yield* SessionStore.SessionStore; const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(error.reason), + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), ), Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("internal_error", error), From 3abd97229ddf2f0693c26dee9dcda245aac290f1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 23:50:55 -0700 Subject: [PATCH 4/6] Restore specific server auth failures Co-authored-by: codex --- apps/server/src/auth/EnvironmentAuth.test.ts | 24 +- apps/server/src/auth/EnvironmentAuth.ts | 314 ++++++++++++++---- apps/server/src/auth/PairingGrantStore.ts | 71 ++-- .../server/src/auth/ServerSecretStore.test.ts | 15 +- apps/server/src/auth/ServerSecretStore.ts | 149 +++++++-- apps/server/src/auth/SessionStore.test.ts | 10 +- apps/server/src/auth/SessionStore.ts | 144 ++++++-- apps/server/src/auth/dpop.test.ts | 18 +- apps/server/src/auth/dpop.ts | 12 +- apps/server/src/cloud/environmentKeys.test.ts | 3 +- apps/server/src/cloud/environmentKeys.ts | 26 +- apps/server/src/cloud/http.test.ts | 27 +- apps/server/src/cloud/http.ts | 25 +- 13 files changed, 598 insertions(+), 240 deletions(-) diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 74f2cef1724..9d6bfc71335 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -61,25 +61,20 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { ); expect(error._tag).toBe("ServerAuthInvalidCredentialError"); - if (error._tag === "ServerAuthInvalidCredentialError") { - expect(EnvironmentAuth.serverAuthCredentialReason(error)).toBe("invalid_credential"); - } }), ); it.effect("maps unexpected bootstrap failures to 500", () => Effect.sync(() => { - const error = EnvironmentAuth.toBootstrapExchangeError( - new PairingGrantStore.BootstrapCredentialInternalError({ - operation: "consume", - 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("ServerAuthOperationError"); - if (error._tag === "ServerAuthOperationError") { - expect(error.operation).toBe("validate_bootstrap_credential"); - expect(error.message).toContain("validate_bootstrap_credential"); + expect(error._tag).toBe("ServerAuthBootstrapCredentialValidationError"); + expect(error.message).toBe("Failed to validate bootstrap credential."); + if (error._tag === "ServerAuthBootstrapCredentialValidationError") { + expect(error.cause).toBe(cause); } }), ); @@ -123,9 +118,6 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { .pipe(Effect.flip); expect(error._tag).toBe("ServerAuthScopeNotGrantedError"); - if (error._tag === "ServerAuthScopeNotGrantedError") { - expect(EnvironmentAuth.serverAuthInvalidRequestReason(error)).toBe("scope_not_granted"); - } }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 81b1757f1c0..dd53a83ca95 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -67,38 +67,183 @@ export interface AuthenticatedSession { readonly expiresAt?: DateTime.DateTime; } -const ServerAuthInternalOperation = Schema.Literals([ - "validate_bootstrap_credential", - "validate_session_credential", - "issue_authenticated_session", - "issue_authenticated_access_token", - "create_pairing_link", - "list_pairing_links", - "revoke_pairing_link", - "issue_session_token", - "list_sessions", - "revoke_session", - "revoke_other_sessions", - "issue_websocket_token", - "record_dpop_replay_state", - "calculate_dpop_replay_key", - "verify_linked_cloud_account", - "read_linked_cloud_account", - "sign_cloud_link_jwt", - "sign_cloud_health_jwt", - "sign_cloud_mint_jwt", -]); -type ServerAuthInternalOperation = typeof ServerAuthInternalOperation.Type; +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 ServerAuthOperationError extends Schema.TaggedErrorClass()( - "ServerAuthOperationError", +export class ServerAuthLinkedCloudAccountReadError extends Schema.TaggedErrorClass()( + "ServerAuthLinkedCloudAccountReadError", { - operation: ServerAuthInternalOperation, - cause: Schema.Defect(), + ...serverAuthInternalErrorContext, }, ) { override get message(): string { - return `Server authentication operation '${this.operation}' failed.`; + return "Could not read the linked cloud account."; } } @@ -111,6 +256,17 @@ export class ServerAuthLinkedCloudAccountMissingError extends Schema.TaggedError } } +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", {}, @@ -129,11 +285,51 @@ export class ServerAuthCloudRelayIssuerMissingError extends Schema.TaggedErrorCl } } +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([ - ServerAuthOperationError, + 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); @@ -316,21 +512,11 @@ const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => return right.issuedAt.epochMilliseconds - left.issuedAt.epochMilliseconds; }; -const serverAuthInternalError = ( - operation: ServerAuthInternalOperation, - cause: unknown, -): ServerAuthOperationError => new ServerAuthOperationError({ operation, cause }); - -const toInternalError = - (operation: ServerAuthInternalOperation) => - (cause: unknown): ServerAuthInternalError => - serverAuthInternalError(operation, cause); - export function toBootstrapExchangeError( cause: PairingGrantStore.BootstrapCredentialError, ): ServerAuthInvalidCredentialError | ServerAuthInternalError { if (PairingGrantStore.isBootstrapCredentialInternalError(cause)) { - return serverAuthInternalError("validate_bootstrap_credential", cause); + return new ServerAuthBootstrapCredentialValidationError({ cause }); } return new ServerAuthInvalidCredentialError({ @@ -345,7 +531,7 @@ const mapSessionVerificationErrors = ( Effect.mapError((cause) => SessionStore.isSessionCredentialInvalidError(cause) ? new ServerAuthInvalidCredentialError({ cause }) - : serverAuthInternalError("validate_session_credential", cause), + : new ServerAuthSessionCredentialValidationError({ cause }), ), ); @@ -483,9 +669,7 @@ export const make = Effect.gen(function* () { }, }) .pipe( - Effect.mapError((cause) => - serverAuthInternalError("issue_authenticated_session", cause), - ), + Effect.mapError((cause) => new ServerAuthAuthenticatedSessionIssueError({ cause })), ), ), Effect.map( @@ -530,8 +714,8 @@ export const make = Effect.gen(function* () { }, }) .pipe( - Effect.mapError((cause) => - serverAuthInternalError("issue_authenticated_access_token", cause), + Effect.mapError( + (cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause }), ), ); }), @@ -601,7 +785,7 @@ export const make = Effect.gen(function* () { expiresAt: DateTime.toUtc(issued.expiresAt), } satisfies IssuedPairingLink; }, - Effect.mapError(toInternalError("create_pairing_link")), + Effect.mapError((cause) => new ServerAuthPairingLinkCreationError({ cause })), ); const listPairingLinks: EnvironmentAuth["Service"]["listPairingLinks"] = (input) => @@ -616,17 +800,15 @@ export const make = Effect.gen(function* () { (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds, ); }), - Effect.mapError(toInternalError("list_pairing_links")), + Effect.mapError((cause) => new ServerAuthPairingLinksListError({ cause })), Effect.withSpan("EnvironmentAuth.listPairingLinks"), ); const revokePairingLink: EnvironmentAuth["Service"]["revokePairingLink"] = (id) => - bootstrapCredentials - .revoke(id) - .pipe( - Effect.mapError(toInternalError("revoke_pairing_link")), - Effect.withSpan("EnvironmentAuth.revokePairingLink"), - ); + bootstrapCredentials.revoke(id).pipe( + Effect.mapError((cause) => new ServerAuthPairingLinkRevocationError({ cause })), + Effect.withSpan("EnvironmentAuth.revokePairingLink"), + ); const issueSession: EnvironmentAuth["Service"]["issueSession"] = (input) => sessions @@ -653,34 +835,30 @@ export const make = Effect.gen(function* () { expiresAt: DateTime.toUtc(issued.expiresAt), }) satisfies IssuedBearerSession, ), - Effect.mapError(toInternalError("issue_session_token")), + Effect.mapError((cause) => new ServerAuthSessionTokenIssueError({ cause })), Effect.withSpan("EnvironmentAuth.issueSession"), ); const listSessions: EnvironmentAuth["Service"]["listSessions"] = () => sessions.listActive().pipe( Effect.map((activeSessions) => activeSessions.toSorted(bySessionPriority)), - Effect.mapError(toInternalError("list_sessions")), + Effect.mapError((cause) => new ServerAuthSessionsListError({ cause })), Effect.withSpan("EnvironmentAuth.listSessions"), ); const revokeSession: EnvironmentAuth["Service"]["revokeSession"] = (sessionId) => - sessions - .revoke(sessionId) - .pipe( - Effect.mapError(toInternalError("revoke_session")), - Effect.withSpan("EnvironmentAuth.revokeSession"), - ); + sessions.revoke(sessionId).pipe( + Effect.mapError((cause) => new ServerAuthSessionRevocationError({ cause })), + Effect.withSpan("EnvironmentAuth.revokeSession"), + ); const revokeOtherSessionsExcept: EnvironmentAuth["Service"]["revokeOtherSessionsExcept"] = ( sessionId, ) => - sessions - .revokeAllExcept(sessionId) - .pipe( - Effect.mapError(toInternalError("revoke_other_sessions")), - Effect.withSpan("EnvironmentAuth.revokeOtherSessionsExcept"), - ); + sessions.revokeAllExcept(sessionId).pipe( + Effect.mapError((cause) => new ServerAuthOtherSessionsRevocationError({ cause })), + Effect.withSpan("EnvironmentAuth.revokeOtherSessionsExcept"), + ); const issuePairingCredential: EnvironmentAuth["Service"]["issuePairingCredential"] = (input) => issuePairingCredentialForSubject({ @@ -739,7 +917,7 @@ export const make = Effect.gen(function* () { const issueWebSocketTicket: EnvironmentAuth["Service"]["issueWebSocketTicket"] = (session) => sessions.issueWebSocketToken(session.sessionId).pipe( - Effect.mapError((cause) => serverAuthInternalError("issue_websocket_token", cause)), + Effect.mapError((cause) => new ServerAuthWebSocketTokenIssueError({ cause })), Effect.map( (issued) => ({ diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index ffca6b2f47a..8a7a4d2e40f 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -74,25 +74,57 @@ export const BootstrapCredentialInvalidError = Schema.Union([ export type BootstrapCredentialInvalidError = typeof BootstrapCredentialInvalidError.Type; export const isBootstrapCredentialInvalidError = Schema.is(BootstrapCredentialInvalidError); -const BootstrapCredentialInternalOperation = Schema.Literals([ - "list_active", - "revoke", - "issue", - "consume", -]); +export class ActivePairingLinksLoadError extends Schema.TaggedErrorClass()( + "ActivePairingLinksLoadError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to load active pairing links."; + } +} -export class BootstrapCredentialInternalError extends Schema.TaggedErrorClass()( - "BootstrapCredentialInternalError", +export class PairingLinkRevokeError extends Schema.TaggedErrorClass()( + "PairingLinkRevokeError", { - operation: BootstrapCredentialInternalOperation, cause: Schema.Defect(), }, ) { override get message(): string { - return `Bootstrap credential operation '${this.operation}' failed.`; + return "Failed to revoke pairing link."; } } +export class PairingCredentialIssueError extends Schema.TaggedErrorClass()( + "PairingCredentialIssueError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to issue pairing credential."; + } +} + +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([ @@ -166,11 +198,6 @@ const PAIRING_TOKEN_LENGTH = 12; const PAIRING_TOKEN_REJECTION_LIMIT = Math.floor(256 / PAIRING_TOKEN_ALPHABET.length) * PAIRING_TOKEN_ALPHABET.length; -const internalBootstrapCredentialError = ( - operation: BootstrapCredentialInternalError["operation"], - cause: unknown, -): BootstrapCredentialInternalError => new BootstrapCredentialInternalError({ operation, cause }); - export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const config = yield* ServerConfig.ServerConfig; @@ -226,10 +253,6 @@ export const make = Effect.gen(function* () { }); } - const toBootstrapCredentialError = - (operation: BootstrapCredentialInternalError["operation"]) => (cause: unknown) => - internalBootstrapCredentialError(operation, cause); - const listActive: PairingGrantStore["Service"]["listActive"] = Effect.fn( "PairingGrantStore.listActive", )( @@ -258,7 +281,7 @@ export const make = Effect.gen(function* () { } satisfies AuthPairingLink), ); }, - Effect.mapError(toBootstrapCredentialError("list_active")), + Effect.mapError((cause) => new ActivePairingLinksLoadError({ cause })), ); const revoke: PairingGrantStore["Service"]["revoke"] = Effect.fn("PairingGrantStore.revoke")( @@ -273,7 +296,7 @@ export const make = Effect.gen(function* () { } return revoked; }, - Effect.mapError(toBootstrapCredentialError("revoke")), + Effect.mapError((cause) => new PairingLinkRevokeError({ cause })), ); const issueOneTimeToken: PairingGrantStore["Service"]["issueOneTimeToken"] = Effect.fn( @@ -314,7 +337,7 @@ export const make = Effect.gen(function* () { }); return issued; }, - Effect.mapError(toBootstrapCredentialError("issue")), + Effect.mapError((cause) => new PairingCredentialIssueError({ cause })), ); const consume: PairingGrantStore["Service"]["consume"] = Effect.fn("PairingGrantStore.consume")( @@ -445,9 +468,7 @@ export const make = Effect.gen(function* () { return yield* new UnavailableBootstrapCredentialError({}); }, Effect.mapError((cause) => - isBootstrapCredentialError(cause) - ? cause - : internalBootstrapCredentialError("consume", cause), + isBootstrapCredentialError(cause) ? cause : new BootstrapCredentialConsumeError({ cause }), ), ); diff --git a/apps/server/src/auth/ServerSecretStore.test.ts b/apps/server/src/auth/ServerSecretStore.test.ts index 94989346c7d..bb6ff855ffc 100644 --- a/apps/server/src/auth/ServerSecretStore.test.ts +++ b/apps/server/src/auth/ServerSecretStore.test.ts @@ -231,9 +231,8 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32)); - assert.instanceOf(error, ServerSecretStore.SecretStoreError); - assert.equal(error.operation, "read"); - assert.include(error.message, "secret session-signing-key"); + 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"); }).pipe(Effect.provide(makePermissionDeniedSecretStoreLayer())), @@ -247,9 +246,8 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])), ); - assert.instanceOf(error, ServerSecretStore.SecretStoreError); - assert.equal(error.operation, "persist"); - assert.include(error.message, "secret session-signing-key"); + 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"); }).pipe(Effect.provide(makeRenameFailureSecretStoreLayer())), @@ -261,9 +259,8 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { const error = yield* Effect.flip(secretStore.remove("session-signing-key")); - assert.instanceOf(error, ServerSecretStore.SecretStoreError); - assert.equal(error.operation, "remove"); - assert.include(error.message, "secret session-signing-key"); + 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"); }).pipe(Effect.provide(makeRemoveFailureSecretStoreLayer())), diff --git a/apps/server/src/auth/ServerSecretStore.ts b/apps/server/src/auth/ServerSecretStore.ts index 84c3fe03243..5e9890c1ea2 100644 --- a/apps/server/src/auth/ServerSecretStore.ts +++ b/apps/server/src/auth/ServerSecretStore.ts @@ -11,38 +11,129 @@ import * as Schema from "effect/Schema"; import * as ServerConfig from "../config.ts"; -const SecretStoreOperation = Schema.Literals([ - "secure", - "read", - "create_temporary_path", - "persist", - "generate_random", - "read_after_concurrent_creation", - "remove", - "decode", - "encode", -]); +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", + { + ...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 SecretStoreError extends Schema.TaggedErrorClass()( - "SecretStoreError", +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", { - operation: SecretStoreOperation, resource: Schema.String, - cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { - return `Secret store operation '${this.operation}' failed for ${this.resource}.`; + 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"; + "cause" in error && isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists"; export class ServerSecretStore extends Context.Service< ServerSecretStore, @@ -68,8 +159,7 @@ export const make = Effect.gen(function* () { yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe( Effect.mapError( (cause) => - new SecretStoreError({ - operation: "secure", + new SecretStoreSecureError({ resource: `secrets directory ${serverConfig.secretsDir}`, cause, }), @@ -85,8 +175,7 @@ export const make = Effect.gen(function* () { cause.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail( - new SecretStoreError({ - operation: "read", + new SecretStoreReadError({ resource: `secret ${name}`, cause, }), @@ -100,8 +189,7 @@ export const make = Effect.gen(function* () { return crypto.randomUUIDv4.pipe( Effect.mapError( (cause) => - new SecretStoreError({ - operation: "create_temporary_path", + new SecretStoreTemporaryPathError({ resource: `secret ${name}`, cause, }), @@ -119,8 +207,7 @@ export const make = Effect.gen(function* () { Effect.ignore, Effect.flatMap(() => Effect.fail( - new SecretStoreError({ - operation: "persist", + new SecretStorePersistError({ resource: `secret ${name}`, cause, }), @@ -149,8 +236,7 @@ export const make = Effect.gen(function* () { ).pipe( Effect.mapError( (cause) => - new SecretStoreError({ - operation: "persist", + new SecretStorePersistError({ resource: `secret ${name}`, cause, }), @@ -167,8 +253,7 @@ export const make = Effect.gen(function* () { crypto.randomBytes(bytes).pipe( Effect.mapError( (cause) => - new SecretStoreError({ - operation: "generate_random", + new SecretStoreRandomGenerationError({ resource: `secret ${name}`, cause, }), @@ -184,8 +269,7 @@ export const make = Effect.gen(function* () { onSome: Effect.succeed, onNone: () => Effect.fail( - new SecretStoreError({ - operation: "read_after_concurrent_creation", + new SecretStoreConcurrentReadError({ resource: `secret ${name}`, }), ), @@ -208,8 +292,7 @@ export const make = Effect.gen(function* () { cause.reason._tag === "NotFound" ? Effect.void : Effect.fail( - new SecretStoreError({ - operation: "remove", + new SecretStoreRemoveError({ resource: `secret ${name}`, cause, }), diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index e6cdfa5930d..1c6583da315 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -109,14 +109,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"); - if (sessionError._tag === "SessionCredentialInternalError") { - expect(sessionError.operation).toBe("verify_session_credential"); - } - if (websocketError._tag === "SessionCredentialInternalError") { - expect(websocketError.operation).toBe("verify_websocket_token"); - } + 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 fc20677504b..18008a7d0a1 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -211,30 +211,110 @@ export const SessionCredentialInvalidError = Schema.Union([ export type SessionCredentialInvalidError = typeof SessionCredentialInvalidError.Type; export const isSessionCredentialInvalidError = Schema.is(SessionCredentialInvalidError); -const SessionCredentialInternalOperation = Schema.Literals([ - "encode_session_claims", - "encode_websocket_claims", - "issue_session_credential", - "verify_session_credential", - "issue_websocket_token", - "verify_websocket_token", - "list_active_sessions", - "revoke_session", - "revoke_other_sessions", -]); +const sessionCredentialInternalErrorContext = { + cause: Schema.Defect(), +}; -export class SessionCredentialInternalError extends Schema.TaggedErrorClass()( - "SessionCredentialInternalError", +export class SessionClaimsEncodingError extends Schema.TaggedErrorClass()( + "SessionClaimsEncodingError", { - operation: SessionCredentialInternalOperation, - cause: Schema.Defect(), + 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 `Session credential operation '${this.operation}' failed.`; + 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([ @@ -348,15 +428,6 @@ function toAuthClientSession(input: Omit): AuthCli }; } -const sessionCredentialInternalError = ( - operation: SessionCredentialInternalError["operation"], - cause: unknown, -): SessionCredentialInternalError => new SessionCredentialInternalError({ operation, cause }); - -const toSessionCredentialInternalError = - (operation: SessionCredentialInternalError["operation"]) => (cause: unknown) => - sessionCredentialInternalError(operation, cause); - export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const serverConfig = yield* ServerConfig.ServerConfig; @@ -487,7 +558,9 @@ export const make = Effect.gen(function* () { const encodedPayload = yield* encodeClaims(claims).pipe( Effect.map(base64UrlEncode), - Effect.mapError((cause) => sessionCredentialInternalError("encode_session_claims", cause)), + Effect.mapError( + (cause) => new SessionClaimsEncodingError({ operation: "encode_session_claims", cause }), + ), ); const signature = signPayload(encodedPayload, signingSecret); const client = input?.client ?? createDefaultClientMetadata(); @@ -531,7 +604,7 @@ export const make = Effect.gen(function* () { ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), } satisfies IssuedSession; }, - Effect.mapError(toSessionCredentialInternalError("issue_session_credential")), + Effect.mapError((cause) => new SessionCredentialIssueError({ cause })), ); const verify: SessionStore["Service"]["verify"] = Effect.fn("SessionStore.verify")( @@ -582,7 +655,7 @@ export const make = Effect.gen(function* () { Effect.mapError((cause) => isSessionCredentialInvalidError(cause) ? cause - : sessionCredentialInternalError("verify_session_credential", cause), + : new SessionCredentialVerificationError({ cause }), ), ); @@ -604,8 +677,9 @@ export const make = Effect.gen(function* () { }; const encodedPayload = yield* encodeWsClaims(claims).pipe( Effect.map(base64UrlEncode), - Effect.mapError((cause) => - sessionCredentialInternalError("encode_websocket_claims", cause), + Effect.mapError( + (cause) => + new SessionClaimsEncodingError({ operation: "encode_websocket_claims", cause }), ), ); const signature = signPayload(encodedPayload, signingSecret); @@ -614,7 +688,7 @@ export const make = Effect.gen(function* () { expiresAt, }; }, - Effect.mapError(toSessionCredentialInternalError("issue_websocket_token")), + Effect.mapError((cause) => new WebSocketTokenIssueError({ cause })), ); const verifyWebSocketToken: SessionStore["Service"]["verifyWebSocketToken"] = Effect.fn( @@ -664,7 +738,7 @@ export const make = Effect.gen(function* () { Effect.mapError((cause) => isSessionCredentialInvalidError(cause) ? cause - : sessionCredentialInternalError("verify_websocket_token", cause), + : new WebSocketTokenVerificationError({ cause }), ), ); @@ -688,7 +762,7 @@ export const make = Effect.gen(function* () { }), ); }, - Effect.mapError(toSessionCredentialInternalError("list_active_sessions")), + Effect.mapError((cause) => new ActiveSessionsListError({ cause })), ); const revoke: SessionStore["Service"]["revoke"] = Effect.fn("SessionStore.revoke")( @@ -708,7 +782,7 @@ export const make = Effect.gen(function* () { } return revoked; }, - Effect.mapError(toSessionCredentialInternalError("revoke_session")), + Effect.mapError((cause) => new SessionRevocationError({ cause })), ); const revokeAllExcept: SessionStore["Service"]["revokeAllExcept"] = Effect.fn( @@ -739,7 +813,7 @@ export const make = Effect.gen(function* () { } return revokedSessionIds.length; }, - Effect.mapError(toSessionCredentialInternalError("revoke_other_sessions")), + Effect.mapError((cause) => new OtherSessionsRevocationError({ cause })), ); return SessionStore.of({ diff --git a/apps/server/src/auth/dpop.test.ts b/apps/server/src/auth/dpop.test.ts index 5717017d62a..fa75c407b0c 100644 --- a/apps/server/src/auth/dpop.test.ts +++ b/apps/server/src/auth/dpop.test.ts @@ -1,12 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; import * as PlatformError from "effect/PlatformError"; -import { SecretStoreError } from "./ServerSecretStore.ts"; +import { SecretStorePersistError } from "./ServerSecretStore.ts"; import { mapDpopReplayStoreError } from "./dpop.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => - new SecretStoreError({ - operation: "persist", + new SecretStorePersistError({ resource: "DPoP proof", cause: PlatformError.systemError({ _tag: tag, @@ -18,18 +17,21 @@ 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("ServerAuthOperationError"); - if (error._tag === "ServerAuthOperationError") { - expect(error.operation).toBe("record_dpop_replay_state"); - expect(error.message).toContain("record_dpop_replay_state"); + 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 72dfdf1375b..87dc0c263e2 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -6,9 +6,10 @@ import * as Encoding from "effect/Encoding"; import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import { + ServerAuthDpopReplayKeyCalculationError, + ServerAuthDpopReplayStateRecordError, ServerAuthInvalidCredentialError, - ServerAuthInternalError, - ServerAuthOperationError, + type ServerAuthInternalError, } from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; @@ -33,10 +34,10 @@ export const mapDpopReplayStoreError = ( ): ServerAuthInvalidCredentialError | ServerAuthInternalError => ServerSecretStore.isSecretAlreadyExistsError(error) ? new ServerAuthInvalidCredentialError({ + diagnostic: "DPoP proof replayed.", cause: error, }) - : new ServerAuthOperationError({ - operation: "record_dpop_replay_state", + : new ServerAuthDpopReplayStateRecordError({ cause: error, }); @@ -69,8 +70,7 @@ export const verifyRequestDpopProof = (input: { Effect.map(Encoding.encodeBase64Url), Effect.mapError( (cause) => - new ServerAuthOperationError({ - operation: "calculate_dpop_replay_key", + new ServerAuthDpopReplayKeyCalculationError({ cause, }), ), diff --git a/apps/server/src/cloud/environmentKeys.test.ts b/apps/server/src/cloud/environmentKeys.test.ts index c5af0adedd9..05fdad6efd2 100644 --- a/apps/server/src/cloud/environmentKeys.test.ts +++ b/apps/server/src/cloud/environmentKeys.test.ts @@ -67,8 +67,7 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it }).pipe( Effect.flatMap(() => Effect.fail( - new ServerSecretStore.SecretStoreError({ - operation: "persist", + new ServerSecretStore.SecretStorePersistError({ resource: "environment signing key pair", cause: PlatformError.systemError({ _tag: "AlreadyExists", diff --git a/apps/server/src/cloud/environmentKeys.ts b/apps/server/src/cloud/environmentKeys.ts index 155b5848ee6..2b93486398f 100644 --- a/apps/server/src/cloud/environmentKeys.ts +++ b/apps/server/src/cloud/environmentKeys.ts @@ -27,15 +27,16 @@ function stringToBytes(value: string): Uint8Array { return new TextEncoder().encode(value); } -const keyPairPersistenceError = ( - operation: "decode" | "encode" | "read_after_concurrent_creation", - cause?: unknown, -): ServerSecretStore.SecretStoreError => - new ServerSecretStore.SecretStoreError({ - operation, - resource: "environment signing key pair", - 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.ServerSecretStore["Service"], @@ -45,7 +46,7 @@ const readEnvironmentKeyPair = Effect.fn("readEnvironmentKeyPair")(function* ( return Option.none(); } const decoded = yield* decodeEnvironmentKeyPair(bytesToString(encoded.value)).pipe( - Effect.mapError((cause) => keyPairPersistenceError("decode", cause)), + Effect.mapError(keyPairDecodeError), ); return Option.some(decoded); }); @@ -55,7 +56,7 @@ const persistEnvironmentKeyPair = Effect.fn("persistEnvironmentKeyPair")(functio keyPair: EnvironmentKeyPair, ) { const encoded = yield* encodeEnvironmentKeyPair(keyPair).pipe( - Effect.mapError((cause) => keyPairPersistenceError("encode", cause)), + Effect.mapError(keyPairEncodeError), ); return yield* secrets.create(CLOUD_LINK_KEY_PAIR, stringToBytes(encoded)).pipe( Effect.as(keyPair), @@ -65,8 +66,7 @@ const persistEnvironmentKeyPair = Effect.fn("persistEnvironmentKeyPair")(functio Effect.flatMap( Option.match({ onSome: Effect.succeed, - onNone: () => - Effect.fail(keyPairPersistenceError("read_after_concurrent_creation")), + onNone: () => Effect.fail(keyPairConcurrentReadError()), }), ), ) diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index 5d26cae8605..ed2e5a4cf75 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -16,8 +16,7 @@ import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => - new ServerSecretStore.SecretStoreError({ - operation: "persist", + new ServerSecretStore.SecretStorePersistError({ resource: "cloud replay guard", cause: PlatformError.systemError({ _tag: tag, @@ -41,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 7ff6fccecc4..fc2adca9fbc 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.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( @@ -211,8 +211,7 @@ function validateLinkedCloudUser(input: { return input.secrets.get(CLOUD_LINKED_USER_ID).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthOperationError({ - operation: "verify_linked_cloud_account", + new EnvironmentAuth.ServerAuthLinkedCloudAccountVerificationError({ cause, }), ), @@ -239,8 +238,7 @@ function readInstalledCloudUserId( return secrets.get(CLOUD_LINKED_USER_ID).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthOperationError({ - operation: "read_linked_cloud_account", + new EnvironmentAuth.ServerAuthLinkedCloudAccountReadError({ cause, }), ), @@ -390,8 +388,7 @@ const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function }).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthOperationError({ - operation: "sign_cloud_link_jwt", + new EnvironmentAuth.ServerAuthCloudLinkJwtSigningError({ cause, }), ), @@ -413,7 +410,7 @@ const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")( return proof satisfies RelayEnvironmentLinkProof; }, Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentCloudInternalError(error.message)(error.cause), + failEnvironmentCloudInternalError(error.message)(error), ), Effect.catchIf( ServerSecretStore.isSecretStoreError, @@ -476,7 +473,7 @@ const cloudRelayConfigHandler = Effect.fn("environment.cloud.relayConfig")( return yield* applyCloudRelayConfig(dependencies, payload); }, Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentCloudInternalError(error.message)(error.cause), + failEnvironmentCloudInternalError(error.message)(error), ), Effect.catchIf( ServerSecretStore.isSecretStoreError, @@ -774,8 +771,7 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( }).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthOperationError({ - operation: "sign_cloud_health_jwt", + new EnvironmentAuth.ServerAuthCloudHealthJwtSigningError({ cause, }), ), @@ -792,7 +788,7 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( return response; }, Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentCloudInternalError(error.message)(error.cause), + failEnvironmentCloudInternalError(error.message)(error), ), Effect.catchIf( ServerSecretStore.isSecretStoreError, @@ -898,8 +894,7 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") }).pipe( Effect.mapError( (cause) => - new EnvironmentAuth.ServerAuthOperationError({ - operation: "sign_cloud_mint_jwt", + new EnvironmentAuth.ServerAuthCloudMintJwtSigningError({ cause, }), ), @@ -914,7 +909,7 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") return response; }, Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentCloudInternalError(error.message)(error.cause), + failEnvironmentCloudInternalError(error.message)(error), ), Effect.catchIf( ServerSecretStore.isSecretStoreError, From ae4f52e08ad0d06e044908b3e45b62affec3fcf3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 00:45:28 -0700 Subject: [PATCH 5/6] Use service module public exports Co-authored-by: codex --- apps/server/src/assets/AssetAccess.ts | 6 +++--- apps/server/src/auth/EnvironmentAuth.test.ts | 6 +----- apps/server/src/auth/EnvironmentAuthAdmin.test.ts | 2 +- apps/server/src/auth/PairingGrantStore.test.ts | 4 +--- apps/server/src/auth/ServerSecretStore.test.ts | 2 +- apps/server/src/auth/SessionStore.test.ts | 6 +----- apps/server/src/cloud/environmentKeys.test.ts | 4 +--- 7 files changed, 9 insertions(+), 21 deletions(-) diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 0679efcfa22..873e9fc3d37 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -21,7 +21,7 @@ import { } from "../auth/utils.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, @@ -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 9d6bfc71335..335e0685197 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -21,11 +21,7 @@ const makeServerConfigLayer = (overrides?: Partial) => EnvironmentAuth.layer.pipe( diff --git a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts index eae3ce203a0..03009270e15 100644 --- a/apps/server/src/auth/EnvironmentAuthAdmin.test.ts +++ b/apps/server/src/auth/EnvironmentAuthAdmin.test.ts @@ -23,7 +23,7 @@ const makeServerConfigLayer = ( }), ).pipe( Layer.provide( - ServerConfig.ServerConfig.layerTest(process.cwd(), { + ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-control-plane-test-", }), ), diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 75ce69c7fb4..b3c9b30f643 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -22,9 +22,7 @@ const makeServerConfigLayer = ( } satisfies ServerConfig.ServerConfig["Service"]; }), ).pipe( - Layer.provide( - ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-bootstrap-test-" }), - ), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-bootstrap-test-" })), ); const makePairingGrantStoreLayer = ( diff --git a/apps/server/src/auth/ServerSecretStore.test.ts b/apps/server/src/auth/ServerSecretStore.test.ts index bb6ff855ffc..d4411fb9f3b 100644 --- a/apps/server/src/auth/ServerSecretStore.test.ts +++ b/apps/server/src/auth/ServerSecretStore.test.ts @@ -13,7 +13,7 @@ import * as ServerConfig from "../config.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; const makeServerConfigLayer = () => - ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-store-test-" }); + ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-store-test-" }); const makeServerSecretStoreLayer = () => Layer.provide(ServerSecretStore.layer, makeServerConfigLayer()); diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 1c6583da315..0dd5d797d19 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -24,11 +24,7 @@ const makeServerConfigLayer = ( ...overrides, } satisfies ServerConfig.ServerConfig["Service"]; }), - ).pipe( - Layer.provide( - ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" }), - ), - ); + ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" }))); const makeSessionStoreLayer = ( overrides?: Partial>, diff --git a/apps/server/src/cloud/environmentKeys.test.ts b/apps/server/src/cloud/environmentKeys.test.ts index 05fdad6efd2..48c44ccc48a 100644 --- a/apps/server/src/cloud/environmentKeys.test.ts +++ b/apps/server/src/cloud/environmentKeys.test.ts @@ -11,9 +11,7 @@ import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys. const makeServerSecretStoreLayer = () => ServerSecretStore.layer.pipe( - Layer.provide( - ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-environment-keys-test-" }), - ), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-environment-keys-test-" })), ); const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation"); From daba9c6ae113585f0b1ecaa6749391b3e1ab4a81 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 01:31:29 -0700 Subject: [PATCH 6/6] Restore canonical Node namespace imports Co-authored-by: codex --- apps/desktop/src/main.ts | 4 ++-- apps/server/src/cloud/environmentKeys.ts | 4 ++-- apps/server/src/relay/AgentAwarenessRelay.test.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) 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/cloud/environmentKeys.ts b/apps/server/src/cloud/environmentKeys.ts index 2b93486398f..1d0cde91bf4 100644 --- a/apps/server/src/cloud/environmentKeys.ts +++ b/apps/server/src/cloud/environmentKeys.ts @@ -1,4 +1,4 @@ -import { generateKeyPairSync } from "node:crypto"; +import * as NodeCrypto from "node:crypto"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -92,7 +92,7 @@ export const getOrCreateEnvironmentKeyPairFromSecretStore = Effect.fn(function* }); } - const keyPair = generateKeyPairSync("ed25519", { + const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { privateKeyEncoding: { format: "pem", type: "pkcs8" }, publicKeyEncoding: { format: "pem", type: "spki" }, }); diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 4d6649fe0c3..40ed694723d 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -1,4 +1,4 @@ -import { generateKeyPairSync } from "node:crypto"; +import * as NodeCrypto from "node:crypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type {