diff --git a/apps/server/src/auth/EnvironmentAuth.test.ts b/apps/server/src/auth/EnvironmentAuth.test.ts index 335e0685197..ae90389d38f 100644 --- a/apps/server/src/auth/EnvironmentAuth.test.ts +++ b/apps/server/src/auth/EnvironmentAuth.test.ts @@ -114,6 +114,16 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { .pipe(Effect.flip); expect(error._tag).toBe("ServerAuthScopeNotGrantedError"); + if (error._tag === "ServerAuthScopeNotGrantedError") { + expect(error.requestedScopes).toEqual(["orchestration:read", "access:write"]); + expect(error.grantedScopes).toEqual([ + "orchestration:read", + "orchestration:operate", + "terminal:operate", + "review:write", + "relay:read", + ]); + } }).pipe(Effect.provide(makeEnvironmentAuthLayer())), ); @@ -252,4 +262,20 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { ), ), ); + + it.effect("retains both session ids when rejecting self-revocation", () => + Effect.gen(function* () { + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const issued = yield* serverAuth.issueSession(); + const error = yield* serverAuth + .revokeClientSession(issued.sessionId, issued.sessionId) + .pipe(Effect.flip); + + expect(error._tag).toBe("ServerAuthForbiddenOperationError"); + if (error._tag === "ServerAuthForbiddenOperationError") { + expect(error.currentSessionId).toBe(issued.sessionId); + expect(error.targetSessionId).toBe(issued.sessionId); + } + }).pipe(Effect.provide(makeEnvironmentAuthLayer())), + ); }); diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index dd53a83ca95..fdc228a10a3 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -85,44 +85,50 @@ export class ServerAuthBootstrapCredentialValidationError extends Schema.TaggedE export class ServerAuthSessionCredentialValidationError extends Schema.TaggedErrorClass()( "ServerAuthSessionCredentialValidationError", { + credentialKind: Schema.Literals(["session", "websocket-ticket"]), ...serverAuthInternalErrorContext, }, ) { override get message(): string { - return "Failed to validate session credential."; + return `Failed to validate ${this.credentialKind} credential.`; } } export class ServerAuthAuthenticatedSessionIssueError extends Schema.TaggedErrorClass()( "ServerAuthAuthenticatedSessionIssueError", { + subject: Schema.String, ...serverAuthInternalErrorContext, }, ) { override get message(): string { - return "Failed to issue authenticated session."; + return `Failed to issue authenticated session for ${this.subject}.`; } } export class ServerAuthAuthenticatedAccessTokenIssueError extends Schema.TaggedErrorClass()( "ServerAuthAuthenticatedAccessTokenIssueError", { + subject: Schema.String, + scopes: Schema.Array(Schema.String), ...serverAuthInternalErrorContext, }, ) { override get message(): string { - return "Failed to issue authenticated access token."; + return `Failed to issue authenticated access token for ${this.subject} with scopes [${this.scopes.join(", ")}].`; } } export class ServerAuthPairingLinkCreationError extends Schema.TaggedErrorClass()( "ServerAuthPairingLinkCreationError", { + subject: Schema.String, + scopes: Schema.Array(Schema.String), ...serverAuthInternalErrorContext, }, ) { override get message(): string { - return "Failed to create pairing link."; + return `Failed to create pairing link for ${this.subject} with scopes [${this.scopes.join(", ")}].`; } } @@ -140,22 +146,25 @@ export class ServerAuthPairingLinksListError extends Schema.TaggedErrorClass()( "ServerAuthPairingLinkRevocationError", { + pairingLinkId: Schema.String, ...serverAuthInternalErrorContext, }, ) { override get message(): string { - return "Failed to revoke pairing link."; + return `Failed to revoke pairing link ${this.pairingLinkId}.`; } } export class ServerAuthSessionTokenIssueError extends Schema.TaggedErrorClass()( "ServerAuthSessionTokenIssueError", { + subject: Schema.String, + scopes: Schema.Array(Schema.String), ...serverAuthInternalErrorContext, }, ) { override get message(): string { - return "Failed to issue session token."; + return `Failed to issue session token for ${this.subject} with scopes [${this.scopes.join(", ")}].`; } } @@ -173,55 +182,63 @@ export class ServerAuthSessionsListError extends Schema.TaggedErrorClass()( "ServerAuthSessionRevocationError", { + sessionId: Schema.String, ...serverAuthInternalErrorContext, }, ) { override get message(): string { - return "Failed to revoke session."; + return `Failed to revoke session ${this.sessionId}.`; } } export class ServerAuthOtherSessionsRevocationError extends Schema.TaggedErrorClass()( "ServerAuthOtherSessionsRevocationError", { + excludedSessionId: Schema.String, ...serverAuthInternalErrorContext, }, ) { override get message(): string { - return "Failed to revoke other sessions."; + return `Failed to revoke sessions other than ${this.excludedSessionId}.`; } } export class ServerAuthWebSocketTokenIssueError extends Schema.TaggedErrorClass()( "ServerAuthWebSocketTokenIssueError", { + sessionId: Schema.String, ...serverAuthInternalErrorContext, }, ) { override get message(): string { - return "Failed to issue websocket token."; + return `Failed to issue websocket token for session ${this.sessionId}.`; } } export class ServerAuthDpopReplayStateRecordError extends Schema.TaggedErrorClass()( "ServerAuthDpopReplayStateRecordError", { + proofKeyThumbprint: Schema.String, + proofId: Schema.String, + replayKey: Schema.String, ...serverAuthInternalErrorContext, }, ) { override get message(): string { - return "Failed to record DPoP proof replay state."; + return `Failed to record replay state for DPoP proof ${this.proofId} (${this.proofKeyThumbprint}).`; } } export class ServerAuthDpopReplayKeyCalculationError extends Schema.TaggedErrorClass()( "ServerAuthDpopReplayKeyCalculationError", { + proofKeyThumbprint: Schema.String, + proofId: Schema.String, ...serverAuthInternalErrorContext, }, ) { override get message(): string { - return "Failed to calculate DPoP replay key."; + return `Failed to calculate replay key for DPoP proof ${this.proofId} (${this.proofKeyThumbprint}).`; } } @@ -360,11 +377,6 @@ export const ServerAuthCredentialError = Schema.Union([ ServerAuthInvalidCredentialError, ]); export type ServerAuthCredentialError = typeof ServerAuthCredentialError.Type; -export const isServerAuthCredentialError = Schema.is(ServerAuthCredentialError); -export const serverAuthCredentialReason = ( - error: ServerAuthCredentialError, -): "missing_credential" | "invalid_credential" => - error._tag === "ServerAuthMissingCredentialError" ? "missing_credential" : "invalid_credential"; export class ServerAuthInvalidScopeError extends Schema.TaggedErrorClass()( "ServerAuthInvalidScopeError", @@ -377,10 +389,13 @@ export class ServerAuthInvalidScopeError extends Schema.TaggedErrorClass()( "ServerAuthScopeNotGrantedError", - {}, + { + requestedScopes: Schema.Array(Schema.String), + grantedScopes: Schema.Array(Schema.String), + }, ) { override get message(): string { - return "The requested authentication scope was not granted."; + return `Requested scopes [${this.requestedScopes.join(", ")}] exceed granted scopes [${this.grantedScopes.join(", ")}].`; } } @@ -389,28 +404,35 @@ export const ServerAuthInvalidRequestError = Schema.Union([ ServerAuthScopeNotGrantedError, ]); export type ServerAuthInvalidRequestError = typeof ServerAuthInvalidRequestError.Type; -export const isServerAuthInvalidRequestError = Schema.is(ServerAuthInvalidRequestError); -export const serverAuthInvalidRequestReason = ( - error: ServerAuthInvalidRequestError, -): "invalid_scope" | "scope_not_granted" => - error._tag === "ServerAuthInvalidScopeError" ? "invalid_scope" : "scope_not_granted"; export class ServerAuthForbiddenOperationError extends Schema.TaggedErrorClass()( "ServerAuthForbiddenOperationError", - {}, + { + currentSessionId: Schema.String, + targetSessionId: Schema.String, + }, ) { override get message(): string { - return "The current authentication session cannot revoke itself."; + return `Authentication session ${this.currentSessionId} cannot revoke itself.`; } } +export type ServerAuthAuthenticationInternalError = + | ServerAuthSessionCredentialValidationError + | ServerAuthDpopReplayStateRecordError + | ServerAuthDpopReplayKeyCalculationError; + +export type ServerAuthAuthenticationError = + | ServerAuthCredentialError + | ServerAuthAuthenticationInternalError; + export class EnvironmentAuth extends Context.Service< EnvironmentAuth, { readonly getDescriptor: () => Effect.Effect; readonly getSessionState: ( request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect; + ) => Effect.Effect; readonly createBrowserSession: ( credential: string, requestMetadata: AuthClientMetadata, @@ -419,7 +441,9 @@ export class EnvironmentAuth extends Context.Service< readonly response: AuthBrowserSessionResult; readonly sessionToken: string; }, - ServerAuthInvalidCredentialError | ServerAuthInternalError + | ServerAuthInvalidCredentialError + | ServerAuthBootstrapCredentialValidationError + | ServerAuthAuthenticatedSessionIssueError >; readonly exchangeBootstrapCredentialForAccessToken: ( credential: string, @@ -430,7 +454,10 @@ export class EnvironmentAuth extends Context.Service< }, ) => Effect.Effect< AuthAccessTokenResult, - ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError + | ServerAuthInvalidCredentialError + | ServerAuthScopeNotGrantedError + | ServerAuthBootstrapCredentialValidationError + | ServerAuthAuthenticatedAccessTokenIssueError >; readonly createPairingLink: (input?: { readonly ttl?: Duration.Duration; @@ -438,56 +465,61 @@ export class EnvironmentAuth extends Context.Service< readonly scopes?: ReadonlyArray; readonly subject?: string; readonly proofKeyThumbprint?: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly issuePairingCredential: ( input?: AuthCreatePairingCredentialInput, - ) => Effect.Effect; + ) => Effect.Effect; readonly issueStartupPairingCredential: () => Effect.Effect< AuthPairingCredentialResult, - ServerAuthInternalError + ServerAuthPairingLinkCreationError >; readonly listPairingLinks: (input?: { readonly excludeSubjects?: ReadonlyArray; - }) => Effect.Effect, ServerAuthInternalError>; - readonly revokePairingLink: (id: string) => Effect.Effect; + }) => Effect.Effect, ServerAuthPairingLinksListError>; + readonly revokePairingLink: ( + id: string, + ) => Effect.Effect; readonly issueSession: (input?: { readonly ttl?: Duration.Duration; readonly subject?: string; readonly scopes?: ReadonlyArray; readonly label?: string; - }) => Effect.Effect; + }) => Effect.Effect; readonly listSessions: () => Effect.Effect< ReadonlyArray, - ServerAuthInternalError + ServerAuthSessionsListError >; readonly revokeSession: ( sessionId: AuthSessionId, - ) => Effect.Effect; + ) => Effect.Effect; readonly revokeOtherSessionsExcept: ( sessionId: AuthSessionId, - ) => Effect.Effect; + ) => Effect.Effect; readonly listClientSessions: ( currentSessionId: AuthSessionId, - ) => Effect.Effect, ServerAuthInternalError>; + ) => Effect.Effect, ServerAuthSessionsListError>; readonly revokeClientSession: ( currentSessionId: AuthSessionId, targetSessionId: AuthSessionId, - ) => Effect.Effect; + ) => Effect.Effect< + boolean, + ServerAuthForbiddenOperationError | ServerAuthSessionRevocationError + >; readonly revokeOtherClientSessions: ( currentSessionId: AuthSessionId, - ) => Effect.Effect; + ) => Effect.Effect; readonly authenticateHttpRequest: ( request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect; + ) => Effect.Effect; readonly authenticateWebSocketUpgrade: ( request: HttpServerRequest.HttpServerRequest, - ) => Effect.Effect; + ) => Effect.Effect; readonly issueWebSocketTicket: ( session: Pick, - ) => Effect.Effect; + ) => Effect.Effect; readonly issueStartupPairingUrl: ( baseUrl: string, - ) => Effect.Effect; + ) => Effect.Effect; } >()("t3/auth/EnvironmentAuth") {} @@ -514,7 +546,7 @@ const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => export function toBootstrapExchangeError( cause: PairingGrantStore.BootstrapCredentialError, -): ServerAuthInvalidCredentialError | ServerAuthInternalError { +): ServerAuthInvalidCredentialError | ServerAuthBootstrapCredentialValidationError { if (PairingGrantStore.isBootstrapCredentialInternalError(cause)) { return new ServerAuthBootstrapCredentialValidationError({ cause }); } @@ -526,12 +558,17 @@ export function toBootstrapExchangeError( const mapSessionVerificationErrors = ( effect: Effect.Effect, -): Effect.Effect => + credentialKind: ServerAuthSessionCredentialValidationError["credentialKind"], +): Effect.Effect< + A, + ServerAuthInvalidCredentialError | ServerAuthSessionCredentialValidationError, + R +> => effect.pipe( Effect.mapError((cause) => SessionStore.isSessionCredentialInvalidError(cause) ? new ServerAuthInvalidCredentialError({ cause }) - : new ServerAuthSessionCredentialValidationError({ cause }), + : new ServerAuthSessionCredentialValidationError({ credentialKind, cause }), ), ); @@ -565,7 +602,7 @@ export const make = Effect.gen(function* () { token: string, ): Effect.Effect< AuthenticatedSession, - ServerAuthInvalidCredentialError | ServerAuthInternalError + ServerAuthInvalidCredentialError | ServerAuthSessionCredentialValidationError > => sessions.verify(token).pipe( Effect.tapError((cause) => @@ -585,12 +622,12 @@ export const make = Effect.gen(function* () { ...(session.proofKeyThumbprint ? { proofKeyThumbprint: session.proofKeyThumbprint } : {}), ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), })), - mapSessionVerificationErrors, + (effect) => mapSessionVerificationErrors(effect, "session"), ); const authenticateRequest = ( request: HttpServerRequest.HttpServerRequest, - ): Effect.Effect => { + ): Effect.Effect => { const cookieToken = request.cookies[sessions.cookieName]; const bearerToken = parseBearerToken(request); const dpopToken = parseDpopToken(request); @@ -642,12 +679,18 @@ export const make = Effect.gen(function* () { ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}), }) satisfies AuthSessionState, ), - Effect.catchIf(isServerAuthCredentialError, () => - Effect.succeed({ - authenticated: false, - auth: descriptor, - } satisfies AuthSessionState), - ), + Effect.catchTags({ + ServerAuthMissingCredentialError: () => + Effect.succeed({ + authenticated: false, + auth: descriptor, + } satisfies AuthSessionState), + ServerAuthInvalidCredentialError: () => + Effect.succeed({ + authenticated: false, + auth: descriptor, + } satisfies AuthSessionState), + }), Effect.withSpan("EnvironmentAuth.getSessionState"), ); @@ -669,7 +712,13 @@ export const make = Effect.gen(function* () { }, }) .pipe( - Effect.mapError((cause) => new ServerAuthAuthenticatedSessionIssueError({ cause })), + Effect.mapError( + (cause) => + new ServerAuthAuthenticatedSessionIssueError({ + subject: grant.subject, + cause, + }), + ), ), ), Effect.map( @@ -695,7 +744,10 @@ 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({}); + return yield* new ServerAuthScopeNotGrantedError({ + requestedScopes: grantedScopes, + grantedScopes: grant.scopes, + }); } return yield* sessions .issue({ @@ -715,7 +767,12 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.mapError( - (cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause }), + (cause) => + new ServerAuthAuthenticatedAccessTokenIssueError({ + subject: grant.subject, + scopes: grantedScopes, + cause, + }), ), ); }), @@ -765,28 +822,33 @@ export const make = Effect.gen(function* () { const createPairingLink: EnvironmentAuth["Service"]["createPairingLink"] = Effect.fn( "EnvironmentAuth.createPairingLink", - )( - function* (input) { - const createdAt = yield* DateTime.now; - const issued = yield* bootstrapCredentials.issueOneTimeToken({ - scopes: input?.scopes ?? AuthStandardClientScopes, - subject: input?.subject ?? "one-time-token", + )(function* (input) { + const scopes = input?.scopes ?? AuthStandardClientScopes; + const subject = input?.subject ?? "one-time-token"; + const createdAt = yield* DateTime.now; + const issued = yield* bootstrapCredentials + .issueOneTimeToken({ + scopes, + subject, ...(input?.ttl ? { ttl: input.ttl } : {}), ...(input?.label ? { label: input.label } : {}), ...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}), - }); - return { - id: issued.id, - credential: issued.credential, - scopes: input?.scopes ?? AuthStandardClientScopes, - subject: input?.subject ?? "one-time-token", - ...(issued.label ? { label: issued.label } : {}), - createdAt: DateTime.toUtc(createdAt), - expiresAt: DateTime.toUtc(issued.expiresAt), - } satisfies IssuedPairingLink; - }, - Effect.mapError((cause) => new ServerAuthPairingLinkCreationError({ cause })), - ); + }) + .pipe( + Effect.mapError( + (cause) => new ServerAuthPairingLinkCreationError({ subject, scopes, cause }), + ), + ); + return { + id: issued.id, + credential: issued.credential, + scopes, + subject, + ...(issued.label ? { label: issued.label } : {}), + createdAt: DateTime.toUtc(createdAt), + expiresAt: DateTime.toUtc(issued.expiresAt), + } satisfies IssuedPairingLink; + }); const listPairingLinks: EnvironmentAuth["Service"]["listPairingLinks"] = (input) => bootstrapCredentials.listActive().pipe( @@ -806,16 +868,20 @@ export const make = Effect.gen(function* () { const revokePairingLink: EnvironmentAuth["Service"]["revokePairingLink"] = (id) => bootstrapCredentials.revoke(id).pipe( - Effect.mapError((cause) => new ServerAuthPairingLinkRevocationError({ cause })), + Effect.mapError( + (cause) => new ServerAuthPairingLinkRevocationError({ pairingLinkId: id, cause }), + ), Effect.withSpan("EnvironmentAuth.revokePairingLink"), ); - const issueSession: EnvironmentAuth["Service"]["issueSession"] = (input) => - sessions + const issueSession: EnvironmentAuth["Service"]["issueSession"] = (input) => { + const subject = input?.subject ?? DEFAULT_SESSION_SUBJECT; + const scopes = input?.scopes ?? AuthAdministrativeScopes; + return sessions .issue({ - subject: input?.subject ?? DEFAULT_SESSION_SUBJECT, + subject, method: "bearer-access-token", - scopes: input?.scopes ?? AuthAdministrativeScopes, + scopes, client: { ...(input?.label ? { label: input.label } : {}), deviceType: "bot", @@ -830,14 +896,17 @@ export const make = Effect.gen(function* () { token: issued.token, method: "bearer-access-token", scopes: issued.scopes, - subject: input?.subject ?? DEFAULT_SESSION_SUBJECT, + subject, client: issued.client, expiresAt: DateTime.toUtc(issued.expiresAt), }) satisfies IssuedBearerSession, ), - Effect.mapError((cause) => new ServerAuthSessionTokenIssueError({ cause })), + Effect.mapError( + (cause) => new ServerAuthSessionTokenIssueError({ subject, scopes, cause }), + ), Effect.withSpan("EnvironmentAuth.issueSession"), ); + }; const listSessions: EnvironmentAuth["Service"]["listSessions"] = () => sessions.listActive().pipe( @@ -848,7 +917,7 @@ export const make = Effect.gen(function* () { const revokeSession: EnvironmentAuth["Service"]["revokeSession"] = (sessionId) => sessions.revoke(sessionId).pipe( - Effect.mapError((cause) => new ServerAuthSessionRevocationError({ cause })), + Effect.mapError((cause) => new ServerAuthSessionRevocationError({ sessionId, cause })), Effect.withSpan("EnvironmentAuth.revokeSession"), ); @@ -856,7 +925,10 @@ export const make = Effect.gen(function* () { sessionId, ) => sessions.revokeAllExcept(sessionId).pipe( - Effect.mapError((cause) => new ServerAuthOtherSessionsRevocationError({ cause })), + Effect.mapError( + (cause) => + new ServerAuthOtherSessionsRevocationError({ excludedSessionId: sessionId, cause }), + ), Effect.withSpan("EnvironmentAuth.revokeOtherSessionsExcept"), ); @@ -891,7 +963,10 @@ export const make = Effect.gen(function* () { "EnvironmentAuth.revokeClientSession", )(function* (currentSessionId, targetSessionId) { if (currentSessionId === targetSessionId) { - return yield* new ServerAuthForbiddenOperationError({}); + return yield* new ServerAuthForbiddenOperationError({ + currentSessionId, + targetSessionId, + }); } return yield* revokeSession(targetSessionId); }); @@ -917,7 +992,9 @@ export const make = Effect.gen(function* () { const issueWebSocketTicket: EnvironmentAuth["Service"]["issueWebSocketTicket"] = (session) => sessions.issueWebSocketToken(session.sessionId).pipe( - Effect.mapError((cause) => new ServerAuthWebSocketTokenIssueError({ cause })), + Effect.mapError( + (cause) => new ServerAuthWebSocketTokenIssueError({ sessionId: session.sessionId, cause }), + ), Effect.map( (issued) => ({ @@ -947,7 +1024,7 @@ export const make = Effect.gen(function* () { scopes: session.scopes, ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}), })), - mapSessionVerificationErrors, + (effect) => mapSessionVerificationErrors(effect, "websocket-ticket"), ); } } diff --git a/apps/server/src/auth/ServerSecretStore.test.ts b/apps/server/src/auth/ServerSecretStore.test.ts index d4411fb9f3b..50fca258a0b 100644 --- a/apps/server/src/auth/ServerSecretStore.test.ts +++ b/apps/server/src/auth/ServerSecretStore.test.ts @@ -45,6 +45,31 @@ const makePermissionDeniedSecretStoreLayer = () => Layer.provideMerge(PermissionDeniedFileSystemLayer), ); +const DirectoryCreateFailureFileSystemLayer = Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return { + ...fileSystem, + makeDirectory: (path) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "makeDirectory", + pathOrDescriptor: String(path), + }), + ), + } satisfies FileSystem.FileSystem; + }), +).pipe(Layer.provide(NodeServices.layer)); + +const makeDirectoryCreateFailureSecretStoreLayer = (config: ServerConfig.ServerConfig["Service"]) => + ServerSecretStore.layer.pipe( + Layer.provide(Layer.succeed(ServerConfig.ServerConfig, config)), + Layer.provideMerge(DirectoryCreateFailureFileSystemLayer), + ); + const RenameFailureFileSystemLayer = Layer.effect( FileSystem.FileSystem, Effect.gen(function* () { @@ -146,6 +171,20 @@ const makeConcurrentCreateSecretStoreLayer = () => ); it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { + it.effect("preserves directory context when secret-store initialization fails", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig.pipe(Effect.provide(makeServerConfigLayer())); + const error = yield* Layer.build(makeDirectoryCreateFailureSecretStoreLayer(config)).pipe( + Effect.scoped, + Effect.flip, + ); + + assert.instanceOf(error, ServerSecretStore.SecretStoreDirectoryCreateError); + assert.match(error.directoryPath, /secrets$/u); + assert.instanceOf(error.cause, PlatformError.PlatformError); + }), + ); + it.effect("returns Option.none when a secret file does not exist", () => Effect.gen(function* () { const secretStore = yield* ServerSecretStore.ServerSecretStore; @@ -232,7 +271,8 @@ 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.equal(error.secretName, "session-signing-key"); + assert.match(error.secretPath, /session-signing-key\.bin$/u); assert.instanceOf(error.cause, PlatformError.PlatformError); assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); }).pipe(Effect.provide(makePermissionDeniedSecretStoreLayer())), @@ -247,7 +287,9 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { ); assert.instanceOf(error, ServerSecretStore.SecretStorePersistError); - assert.include(error.message, "Failed to persist secret session-signing-key."); + assert.equal(error.operation, "set"); + assert.equal(error.secretName, "session-signing-key"); + assert.match(error.secretPath, /session-signing-key\.bin$/u); assert.instanceOf(error.cause, PlatformError.PlatformError); assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); }).pipe(Effect.provide(makeRenameFailureSecretStoreLayer())), @@ -260,7 +302,8 @@ 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.equal(error.secretName, "session-signing-key"); + assert.match(error.secretPath, /session-signing-key\.bin$/u); 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 5e9890c1ea2..bbd567a9416 100644 --- a/apps/server/src/auth/ServerSecretStore.ts +++ b/apps/server/src/auth/ServerSecretStore.ts @@ -11,114 +11,136 @@ import * as Schema from "effect/Schema"; import * as ServerConfig from "../config.ts"; -const secretStoreErrorContext = { - resource: Schema.String, +const storedSecretErrorContext = { + secretName: Schema.String, + secretPath: Schema.String, cause: Schema.Defect(), }; -export class SecretStoreSecureError extends Schema.TaggedErrorClass()( - "SecretStoreSecureError", +export class SecretStoreDirectoryCreateError extends Schema.TaggedErrorClass()( + "SecretStoreDirectoryCreateError", { - ...secretStoreErrorContext, + directoryPath: Schema.String, + cause: Schema.Defect(), }, ) { override get message(): string { - return `Failed to secure ${this.resource}.`; + return `Failed to create secret store directory ${this.directoryPath}.`; + } +} + +export class SecretStoreDirectorySecureError extends Schema.TaggedErrorClass()( + "SecretStoreDirectorySecureError", + { + directoryPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to secure secret store directory ${this.directoryPath}.`; } } export class SecretStoreReadError extends Schema.TaggedErrorClass()( "SecretStoreReadError", { - ...secretStoreErrorContext, + ...storedSecretErrorContext, }, ) { override get message(): string { - return `Failed to read ${this.resource}.`; + return `Failed to read secret ${this.secretName} at ${this.secretPath}.`; } } -export class SecretStoreTemporaryPathError extends Schema.TaggedErrorClass()( - "SecretStoreTemporaryPathError", +export class SecretStoreTemporaryPathGenerationError extends Schema.TaggedErrorClass()( + "SecretStoreTemporaryPathGenerationError", { - ...secretStoreErrorContext, + secretName: Schema.String, + secretPath: Schema.String, + cause: Schema.Defect(), }, ) { override get message(): string { - return `Failed to create temporary path for ${this.resource}.`; + return `Failed to generate a temporary path for secret ${this.secretName} at ${this.secretPath}.`; } } export class SecretStorePersistError extends Schema.TaggedErrorClass()( "SecretStorePersistError", { - ...secretStoreErrorContext, + operation: Schema.Literals(["create", "set"]), + ...storedSecretErrorContext, }, ) { override get message(): string { - return `Failed to persist ${this.resource}.`; + return `Failed to ${this.operation} secret ${this.secretName} at ${this.secretPath}.`; } } export class SecretStoreRandomGenerationError extends Schema.TaggedErrorClass()( "SecretStoreRandomGenerationError", { - ...secretStoreErrorContext, + secretName: Schema.String, + byteCount: Schema.Number, + cause: Schema.Defect(), }, ) { override get message(): string { - return `Failed to generate random bytes for ${this.resource}.`; + return `Failed to generate ${this.byteCount} random bytes for secret ${this.secretName}.`; } } export class SecretStoreConcurrentReadError extends Schema.TaggedErrorClass()( "SecretStoreConcurrentReadError", { - resource: Schema.String, + secretName: Schema.String, }, ) { override get message(): string { - return `Failed to read ${this.resource} after concurrent creation.`; + return `Failed to read secret ${this.secretName} after concurrent creation.`; } } export class SecretStoreRemoveError extends Schema.TaggedErrorClass()( "SecretStoreRemoveError", { - ...secretStoreErrorContext, + ...storedSecretErrorContext, }, ) { override get message(): string { - return `Failed to remove ${this.resource}.`; + return `Failed to remove secret ${this.secretName} at ${this.secretPath}.`; } } export class SecretStoreDecodeError extends Schema.TaggedErrorClass()( "SecretStoreDecodeError", { - ...secretStoreErrorContext, + secretName: Schema.String, + cause: Schema.Defect(), }, ) { override get message(): string { - return `Failed to decode ${this.resource}.`; + return `Failed to decode secret ${this.secretName}.`; } } export class SecretStoreEncodeError extends Schema.TaggedErrorClass()( "SecretStoreEncodeError", { - ...secretStoreErrorContext, + secretName: Schema.String, + cause: Schema.Defect(), }, ) { override get message(): string { - return `Failed to encode ${this.resource}.`; + return `Failed to encode secret ${this.secretName}.`; } } export const SecretStoreError = Schema.Union([ - SecretStoreSecureError, + SecretStoreDirectoryCreateError, + SecretStoreDirectorySecureError, SecretStoreReadError, - SecretStoreTemporaryPathError, + SecretStoreTemporaryPathGenerationError, SecretStorePersistError, SecretStoreRandomGenerationError, SecretStoreConcurrentReadError, @@ -138,14 +160,26 @@ export const isSecretAlreadyExistsError = (error: SecretStoreError): boolean => 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 get: (name: string) => Effect.Effect, SecretStoreReadError>; + 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; + ) => Effect.Effect< + Uint8Array, + | SecretStoreReadError + | SecretStoreRandomGenerationError + | SecretStorePersistError + | SecretStoreConcurrentReadError + >; + readonly remove: (name: string) => Effect.Effect; } >()("t3/auth/ServerSecretStore") {} @@ -155,12 +189,20 @@ export const make = Effect.gen(function* () { const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; - yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true }); + yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new SecretStoreDirectoryCreateError({ + directoryPath: serverConfig.secretsDir, + cause, + }), + ), + ); yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe( Effect.mapError( (cause) => - new SecretStoreSecureError({ - resource: `secrets directory ${serverConfig.secretsDir}`, + new SecretStoreDirectorySecureError({ + directoryPath: serverConfig.secretsDir, cause, }), ), @@ -168,29 +210,33 @@ export const make = Effect.gen(function* () { const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); - const get: ServerSecretStore["Service"]["get"] = (name) => - fileSystem.readFile(resolveSecretPath(name)).pipe( + const get: ServerSecretStore["Service"]["get"] = (name) => { + const secretPath = resolveSecretPath(name); + return fileSystem.readFile(secretPath).pipe( Effect.map((bytes) => Option.some(Uint8Array.from(bytes))), Effect.catch((cause) => cause.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail( new SecretStoreReadError({ - resource: `secret ${name}`, + secretName: name, + secretPath, cause, }), ), ), Effect.withSpan("ServerSecretStore.get"), ); + }; const set: ServerSecretStore["Service"]["set"] = (name, value) => { const secretPath = resolveSecretPath(name); return crypto.randomUUIDv4.pipe( Effect.mapError( (cause) => - new SecretStoreTemporaryPathError({ - resource: `secret ${name}`, + new SecretStoreTemporaryPathGenerationError({ + secretName: name, + secretPath, cause, }), ), @@ -208,7 +254,9 @@ export const make = Effect.gen(function* () { Effect.flatMap(() => Effect.fail( new SecretStorePersistError({ - resource: `secret ${name}`, + operation: "set", + secretName: name, + secretPath, cause, }), ), @@ -237,7 +285,9 @@ export const make = Effect.gen(function* () { Effect.mapError( (cause) => new SecretStorePersistError({ - resource: `secret ${name}`, + operation: "create", + secretName: name, + secretPath, cause, }), ), @@ -254,30 +304,32 @@ export const make = Effect.gen(function* () { Effect.mapError( (cause) => new SecretStoreRandomGenerationError({ - resource: `secret ${name}`, + secretName: name, + byteCount: bytes, cause, }), ), Effect.flatMap((generated) => create(name, generated).pipe( Effect.as(Uint8Array.from(generated)), - Effect.catchIf(isSecretStoreError, (error) => - isSecretAlreadyExistsError(error) - ? get(name).pipe( - Effect.flatMap( - Option.match({ - onSome: Effect.succeed, - onNone: () => - Effect.fail( - new SecretStoreConcurrentReadError({ - resource: `secret ${name}`, - }), - ), - }), - ), - ) - : Effect.fail(error), - ), + Effect.catchTags({ + SecretStorePersistError: (error) => + isSecretAlreadyExistsError(error) + ? get(name).pipe( + Effect.flatMap( + Option.match({ + onSome: Effect.succeed, + onNone: () => + Effect.fail( + new SecretStoreConcurrentReadError({ + secretName: name, + }), + ), + }), + ), + ) + : Effect.fail(error), + }), ), ), ), @@ -286,20 +338,23 @@ export const make = Effect.gen(function* () { Effect.withSpan("ServerSecretStore.getOrCreateRandom"), ); - const remove: ServerSecretStore["Service"]["remove"] = (name) => - fileSystem.remove(resolveSecretPath(name)).pipe( + const remove: ServerSecretStore["Service"]["remove"] = (name) => { + const secretPath = resolveSecretPath(name); + return fileSystem.remove(secretPath).pipe( Effect.catch((cause) => cause.reason._tag === "NotFound" ? Effect.void : Effect.fail( new SecretStoreRemoveError({ - resource: `secret ${name}`, + secretName: name, + secretPath, cause, }), ), ), Effect.withSpan("ServerSecretStore.remove"), ); + }; return ServerSecretStore.of({ get, diff --git a/apps/server/src/auth/dpop.test.ts b/apps/server/src/auth/dpop.test.ts index fa75c407b0c..d01417cebfc 100644 --- a/apps/server/src/auth/dpop.test.ts +++ b/apps/server/src/auth/dpop.test.ts @@ -6,7 +6,9 @@ import { mapDpopReplayStoreError } from "./dpop.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => new SecretStorePersistError({ - resource: "DPoP proof", + operation: "create", + secretName: "DPoP proof", + secretPath: "dpop-proof.bin", cause: PlatformError.systemError({ _tag: tag, module: "FileSystem", @@ -15,10 +17,16 @@ const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => }), }); +const replayContext = { + proofKeyThumbprint: "proof-key-thumbprint", + proofId: "proof-id", + replayKey: "replay-key", +}; + describe("mapDpopReplayStoreError", () => { it("reports replay conflicts as invalid credentials", () => { const cause = storeFailure("AlreadyExists"); - const error = mapDpopReplayStoreError(cause); + const error = mapDpopReplayStoreError(cause, replayContext); expect(error._tag).toBe("ServerAuthInvalidCredentialError"); if (error._tag === "ServerAuthInvalidCredentialError") { @@ -27,11 +35,18 @@ describe("mapDpopReplayStoreError", () => { }); it("reports replay-store availability failures as internal errors", () => { - const error = mapDpopReplayStoreError(storeFailure("PermissionDenied")); + const cause = storeFailure("PermissionDenied"); + const error = mapDpopReplayStoreError(cause, replayContext); expect(error._tag).toBe("ServerAuthDpopReplayStateRecordError"); if (error._tag === "ServerAuthDpopReplayStateRecordError") { - expect(error.message).toBe("Failed to record DPoP proof replay state."); + expect(error.message).toBe( + "Failed to record replay state for DPoP proof proof-id (proof-key-thumbprint).", + ); + expect(error.proofKeyThumbprint).toBe(replayContext.proofKeyThumbprint); + expect(error.proofId).toBe(replayContext.proofId); + expect(error.replayKey).toBe(replayContext.replayKey); + expect(error.cause).toBe(cause); } }); }); diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts index 87dc0c263e2..f5da796e747 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -9,7 +9,6 @@ import { ServerAuthDpopReplayKeyCalculationError, ServerAuthDpopReplayStateRecordError, ServerAuthInvalidCredentialError, - type ServerAuthInternalError, } from "./EnvironmentAuth.ts"; import * as ServerSecretStore from "./ServerSecretStore.ts"; @@ -31,13 +30,19 @@ export function requestAbsoluteUrl(request: HttpServerRequest.HttpServerRequest) export const mapDpopReplayStoreError = ( error: ServerSecretStore.SecretStoreError, -): ServerAuthInvalidCredentialError | ServerAuthInternalError => + context: { + readonly proofKeyThumbprint: string; + readonly proofId: string; + readonly replayKey: string; + }, +): ServerAuthInvalidCredentialError | ServerAuthDpopReplayStateRecordError => ServerSecretStore.isSecretAlreadyExistsError(error) ? new ServerAuthInvalidCredentialError({ diagnostic: "DPoP proof replayed.", cause: error, }) : new ServerAuthDpopReplayStateRecordError({ + ...context, cause: error, }); @@ -71,6 +76,8 @@ export const verifyRequestDpopProof = (input: { Effect.mapError( (cause) => new ServerAuthDpopReplayKeyCalculationError({ + proofKeyThumbprint: result.thumbprint, + proofId: result.jti, cause, }), ), @@ -88,8 +95,12 @@ export const verifyRequestDpopProof = (input: { ), ) .pipe( - Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => - Effect.fail(mapDpopReplayStoreError(error)), + Effect.mapError((error) => + mapDpopReplayStoreError(error, { + proofKeyThumbprint: result.thumbprint, + proofId: result.jti, + replayKey, + }), ), ); return result.thumbprint; diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 71fb00b970a..03977cf9ffb 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -151,6 +151,22 @@ export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, }); } +const failAuthenticationInternal = (error: EnvironmentAuth.ServerAuthAuthenticationInternalError) => + failEnvironmentInternal("internal_error", error); + +export const catchEnvironmentAuthenticationErrors = ( + effect: Effect.Effect, +) => + effect.pipe( + Effect.catchTags({ + ServerAuthMissingCredentialError: () => failEnvironmentAuthInvalid("missing_credential"), + ServerAuthInvalidCredentialError: () => failEnvironmentAuthInvalid("invalid_credential"), + ServerAuthSessionCredentialValidationError: failAuthenticationInternal, + ServerAuthDpopReplayStateRecordError: failAuthenticationInternal, + ServerAuthDpopReplayKeyCalculationError: failAuthenticationInternal, + }), + ); + export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope")(function* ( scope: AuthEnvironmentScope, ) { @@ -168,13 +184,8 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( return (httpEffect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; - const session = yield* serverAuth.authenticateHttpRequest(request).pipe( - Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), - ), - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("internal_error", error), - ), + const session = yield* catchEnvironmentAuthenticationErrors( + serverAuth.authenticateHttpRequest(request), ); return yield* httpEffect.pipe( Effect.provideService(EnvironmentAuthenticatedPrincipal, { @@ -183,7 +194,11 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( }), session.subject === "cloud-connect" ? traceAuthenticatedRelayRequest : identity, ); - }).pipe(Effect.catchTag("EnvironmentAuthInvalidError", appendDpopChallengeOnUnauthorized)); + }).pipe( + Effect.catchTags({ + EnvironmentAuthInvalidError: appendDpopChallengeOnUnauthorized, + }), + ); }), ); @@ -203,9 +218,11 @@ export const authHttpApiLayer = HttpApiBuilder.group( const request = yield* HttpServerRequest.HttpServerRequest; return yield* serverAuth.getSessionState(request); }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("internal_error", error), - ), + Effect.catchTags({ + ServerAuthSessionCredentialValidationError: failAuthenticationInternal, + ServerAuthDpopReplayStateRecordError: failAuthenticationInternal, + ServerAuthDpopReplayKeyCalculationError: failAuthenticationInternal, + }), ), ) .handle( @@ -233,12 +250,14 @@ export const authHttpApiLayer = HttpApiBuilder.group( yield* appendCredentialResponseHeaders; return result.response; }, - Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), - ), - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("browser_session_issuance_failed", error), - ), + Effect.catchTags({ + ServerAuthInvalidCredentialError: () => + failEnvironmentAuthInvalid("invalid_credential"), + ServerAuthBootstrapCredentialValidationError: (error) => + failEnvironmentInternal("browser_session_issuance_failed", error), + ServerAuthAuthenticatedSessionIssueError: (error) => + failEnvironmentInternal("browser_session_issuance_failed", error), + }), ), ) .handle( @@ -268,14 +287,16 @@ export const authHttpApiLayer = HttpApiBuilder.group( } const proofKeyThumbprint = args.headers.dpop ? yield* verifyRequestDpopProof({ request }).pipe( - Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, () => - appendDpopChallengeHeader.pipe( - Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")), - ), - ), - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("access_token_issuance_failed", error), - ), + Effect.catchTags({ + ServerAuthInvalidCredentialError: () => + appendDpopChallengeHeader.pipe( + Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")), + ), + ServerAuthDpopReplayStateRecordError: (error) => + failEnvironmentInternal("access_token_issuance_failed", error), + ServerAuthDpopReplayKeyCalculationError: (error) => + failEnvironmentInternal("access_token_issuance_failed", error), + }), ) : undefined; yield* appendCredentialResponseHeaders; @@ -296,15 +317,16 @@ export const authHttpApiLayer = HttpApiBuilder.group( ); }, traceRelayRequest, - Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), - ), - Effect.catchIf(EnvironmentAuth.isServerAuthInvalidRequestError, (error) => - failEnvironmentInvalidRequest(EnvironmentAuth.serverAuthInvalidRequestReason(error)), - ), - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("access_token_issuance_failed", error), - ), + Effect.catchTags({ + ServerAuthInvalidCredentialError: () => + failEnvironmentAuthInvalid("invalid_credential"), + ServerAuthScopeNotGrantedError: () => + failEnvironmentInvalidRequest("scope_not_granted"), + ServerAuthBootstrapCredentialValidationError: (error) => + failEnvironmentInternal("access_token_issuance_failed", error), + ServerAuthAuthenticatedAccessTokenIssueError: (error) => + failEnvironmentInternal("access_token_issuance_failed", error), + }), ), ) .handle( @@ -316,9 +338,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( yield* appendCredentialResponseHeaders; return yield* serverAuth.issueWebSocketTicket(session); }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("websocket_ticket_issuance_failed", error), - ), + Effect.catchTags({ + ServerAuthWebSocketTokenIssueError: (error) => + failEnvironmentInternal("websocket_ticket_issuance_failed", error), + }), ), ) .handle( @@ -341,9 +364,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( } return yield* serverAuth.issuePairingCredential(args.payload); }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("pairing_credential_issuance_failed", error), - ), + Effect.catchTags({ + ServerAuthPairingLinkCreationError: (error) => + failEnvironmentInternal("pairing_credential_issuance_failed", error), + }), ), ) .handle( @@ -354,9 +378,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( yield* requireEnvironmentScope(AuthAccessReadScope); return yield* serverAuth.listPairingLinks(); }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("pairing_links_load_failed", error), - ), + Effect.catchTags({ + ServerAuthPairingLinksListError: (error) => + failEnvironmentInternal("pairing_links_load_failed", error), + }), ), ) .handle( @@ -368,9 +393,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( const revoked = yield* serverAuth.revokePairingLink(args.payload.id); return { revoked }; }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("pairing_link_revoke_failed", error), - ), + Effect.catchTags({ + ServerAuthPairingLinkRevocationError: (error) => + failEnvironmentInternal("pairing_link_revoke_failed", error), + }), ), ) .handle( @@ -381,9 +407,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( const session = yield* requireEnvironmentScope(AuthAccessReadScope); return yield* serverAuth.listClientSessions(session.sessionId); }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("client_sessions_load_failed", error), - ), + Effect.catchTags({ + ServerAuthSessionsListError: (error) => + failEnvironmentInternal("client_sessions_load_failed", error), + }), ), ) .handle( @@ -398,12 +425,12 @@ export const authHttpApiLayer = HttpApiBuilder.group( ); return { revoked }; }, - Effect.catchTag("ServerAuthForbiddenOperationError", () => - failEnvironmentOperationForbidden("current_session_revoke_not_allowed"), - ), - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("client_session_revoke_failed", error), - ), + Effect.catchTags({ + ServerAuthForbiddenOperationError: () => + failEnvironmentOperationForbidden("current_session_revoke_not_allowed"), + ServerAuthSessionRevocationError: (error) => + failEnvironmentInternal("client_session_revoke_failed", error), + }), ), ) .handle( @@ -415,9 +442,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( const revokedCount = yield* serverAuth.revokeOtherClientSessions(session.sessionId); return { revokedCount }; }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("client_session_revoke_failed", error), - ), + Effect.catchTags({ + ServerAuthOtherSessionsRevocationError: (error) => + failEnvironmentInternal("client_session_revoke_failed", error), + }), ), ); }), diff --git a/apps/server/src/cloud/CliTokenManager.test.ts b/apps/server/src/cloud/CliTokenManager.test.ts new file mode 100644 index 00000000000..0cf08e92955 --- /dev/null +++ b/apps/server/src/cloud/CliTokenManager.test.ts @@ -0,0 +1,121 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as CliTokenManager from "./CliTokenManager.ts"; + +const unusedSecretStoreOperation = () => Effect.die("unused secret-store operation"); + +function makeSecretStore( + overrides: Partial, +): ServerSecretStore.ServerSecretStore["Service"] { + return { + get: unusedSecretStoreOperation, + set: unusedSecretStoreOperation, + create: unusedSecretStoreOperation, + getOrCreateRandom: unusedSecretStoreOperation, + remove: unusedSecretStoreOperation, + ...overrides, + }; +} + +function makeTokenManager(secretStore: ServerSecretStore.ServerSecretStore["Service"]) { + return CliTokenManager.make.pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.succeed(ServerSecretStore.ServerSecretStore, secretStore), + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unused HTTP client")), + ), + ), + ), + ); +} + +describe("CloudCliTokenManager", () => { + it.effect("retains secret context and cause when credential removal fails", () => { + const failure = new ServerSecretStore.SecretStoreRemoveError({ + secretName: "cloud-cli-oauth-token", + secretPath: "/tmp/secrets/cloud-cli-oauth-token.bin", + cause: PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "remove", + pathOrDescriptor: "/tmp/secrets/cloud-cli-oauth-token.bin", + }), + }); + + return Effect.gen(function* () { + const tokens = yield* makeTokenManager( + makeSecretStore({ remove: () => Effect.fail(failure) }), + ); + const error = yield* Effect.flip(tokens.clear); + + expect(error).toMatchObject({ + _tag: "CloudCliCredentialRemovalError", + secretName: "cloud-cli-oauth-token", + cause: failure, + }); + expect(error.message).toBe( + "Could not remove the stored T3 Connect CLI credential cloud-cli-oauth-token.", + ); + }); + }); + + it.effect("classifies credential read failures without replacing the cause", () => { + const failure = new ServerSecretStore.SecretStoreReadError({ + secretName: "cloud-cli-oauth-token", + secretPath: "/tmp/secrets/cloud-cli-oauth-token.bin", + cause: PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: "/tmp/secrets/cloud-cli-oauth-token.bin", + }), + }); + + return Effect.gen(function* () { + const tokens = yield* makeTokenManager(makeSecretStore({ get: () => Effect.fail(failure) })); + const error = yield* Effect.flip(tokens.hasCredential); + + expect(error).toMatchObject({ + _tag: "CloudCliCredentialReadError", + stage: "read-credential", + secretName: "cloud-cli-oauth-token", + cause: failure, + }); + expect(error.message).toBe( + "Could not inspect the stored T3 Connect CLI credential cloud-cli-oauth-token during read-credential.", + ); + }); + }); + + it.effect("classifies malformed persisted credentials as refresh decode failures", () => + Effect.gen(function* () { + const tokens = yield* makeTokenManager( + makeSecretStore({ + get: () => + Effect.succeed(Option.some(new TextEncoder().encode("not valid credential JSON"))), + }), + ); + const error = yield* Effect.flip(tokens.getExisting); + + expect(error).toMatchObject({ + _tag: "CloudCliCredentialRefreshError", + stage: "decode-credential", + secretName: "cloud-cli-oauth-token", + cause: { _tag: "SchemaError" }, + }); + expect(error.message).toBe( + "Could not refresh the T3 Connect CLI credential cloud-cli-oauth-token during decode-credential.", + ); + }), + ); +}); diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index 00709370b26..8ea2f76c54e 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -27,6 +27,8 @@ import { cloudCliOAuthConfig, type CloudCliOAuthConfig } from "./publicConfig.ts const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token"; const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10); const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5)); +const CLOUD_CLI_OAUTH_CALLBACK_HOST = "127.0.0.1"; +const CLOUD_CLI_OAUTH_CALLBACK_PORT = 34338; const PersistedToken = Schema.Struct({ accessToken: Schema.String, @@ -46,48 +48,136 @@ const OAuthTokenResponse = Schema.Struct({ token_type: Schema.String, }); +type CredentialReadFailure = ServerSecretStore.SecretStoreReadError | Schema.SchemaError; + +type CredentialPersistFailure = + | Schema.SchemaError + | ServerSecretStore.SecretStoreTemporaryPathGenerationError + | ServerSecretStore.SecretStorePersistError; + +const CloudCliCredentialRefreshStage = Schema.Literals([ + "read-credential", + "decode-credential", + "load-oauth-config", + "exchange-token", + "encode-credential", + "persist-credential", +]); + +const CloudCliAuthorizationStage = Schema.Literals([ + "load-oauth-config", + "prepare-pkce", + "start-callback-server", + "exchange-token", + "encode-credential", + "persist-credential", +]); + export class CloudCliCredentialRemovalError extends Schema.TaggedErrorClass()( "CloudCliCredentialRemovalError", - { cause: Schema.Defect() }, + { + secretName: Schema.Literal(CLOUD_CLI_OAUTH_TOKEN_SECRET), + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Could not remove the stored T3 Connect CLI credential."; + return `Could not remove the stored T3 Connect CLI credential ${this.secretName}.`; } } export class CloudCliCredentialRefreshError extends Schema.TaggedErrorClass()( "CloudCliCredentialRefreshError", - { cause: Schema.Defect() }, + { + stage: CloudCliCredentialRefreshStage, + secretName: Schema.Literal(CLOUD_CLI_OAUTH_TOKEN_SECRET), + tokenEndpoint: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, ) { + static fromCredentialRead(cause: CredentialReadFailure): CloudCliCredentialRefreshError { + return new CloudCliCredentialRefreshError({ + stage: cause._tag === "SecretStoreReadError" ? "read-credential" : "decode-credential", + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, + cause, + }); + } + + static fromCredentialPersist(cause: CredentialPersistFailure): CloudCliCredentialRefreshError { + return new CloudCliCredentialRefreshError({ + stage: cause._tag === "SchemaError" ? "encode-credential" : "persist-credential", + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, + cause, + }); + } + override get message(): string { - return "Could not refresh the T3 Connect CLI credential."; + const tokenEndpoint = this.tokenEndpoint ? ` using ${this.tokenEndpoint}` : ""; + return `Could not refresh the T3 Connect CLI credential ${this.secretName} during ${this.stage}${tokenEndpoint}.`; } } export class CloudCliCredentialReadError extends Schema.TaggedErrorClass()( "CloudCliCredentialReadError", - { cause: Schema.Defect() }, + { + stage: Schema.Literals(["read-credential", "decode-credential"]), + secretName: Schema.Literal(CLOUD_CLI_OAUTH_TOKEN_SECRET), + cause: Schema.Defect(), + }, ) { + static fromCredentialRead(cause: CredentialReadFailure): CloudCliCredentialReadError { + return new CloudCliCredentialReadError({ + stage: cause._tag === "SecretStoreReadError" ? "read-credential" : "decode-credential", + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, + cause, + }); + } + override get message(): string { - return "Could not read the stored T3 Connect CLI credential."; + return `Could not inspect the stored T3 Connect CLI credential ${this.secretName} during ${this.stage}.`; } } export class CloudCliAuthorizationError extends Schema.TaggedErrorClass()( "CloudCliAuthorizationError", - { cause: Schema.Defect() }, + { + stage: CloudCliAuthorizationStage, + secretName: Schema.Literal(CLOUD_CLI_OAUTH_TOKEN_SECRET), + tokenEndpoint: Schema.optional(Schema.String), + redirectUri: Schema.optional(Schema.String), + callbackHost: Schema.optional(Schema.String), + callbackPort: Schema.optional(Schema.Number), + cause: Schema.Defect(), + }, ) { + static fromCredentialPersist(cause: CredentialPersistFailure): CloudCliAuthorizationError { + return new CloudCliAuthorizationError({ + stage: cause._tag === "SchemaError" ? "encode-credential" : "persist-credential", + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, + cause, + }); + } + override get message(): string { - return "Could not authorize the T3 Connect CLI."; + const tokenEndpoint = this.tokenEndpoint ? ` using ${this.tokenEndpoint}` : ""; + const redirectUri = this.redirectUri ? ` with callback ${this.redirectUri}` : ""; + const callbackAddress = + this.callbackHost && this.callbackPort !== undefined + ? ` on ${this.callbackHost}:${this.callbackPort}` + : ""; + return `Could not authorize the T3 Connect CLI credential ${this.secretName} during ${this.stage}${tokenEndpoint}${redirectUri}${callbackAddress}.`; } } export class CloudCliAuthorizationTimeoutError extends Schema.TaggedErrorClass()( "CloudCliAuthorizationTimeoutError", - { cause: Schema.Defect() }, + { + redirectUri: Schema.String, + timeoutMillis: Schema.Number, + cause: Schema.Defect(), + }, ) { override get message(): string { - return "Timed out waiting for T3 Connect authorization."; + return `Timed out after ${this.timeoutMillis}ms waiting for T3 Connect authorization at ${this.redirectUri}.`; } } @@ -103,18 +193,21 @@ export type CloudCliTokenManagerError = typeof CloudCliTokenManagerError.Type; export class CloudCliTokenManager extends Context.Service< CloudCliTokenManager, { - readonly get: Effect.Effect; - readonly getExisting: Effect.Effect, CloudCliTokenManagerError>; - readonly hasCredential: Effect.Effect; - readonly clear: Effect.Effect; + readonly get: Effect.Effect< + PersistedToken, + | CloudCliCredentialRefreshError + | CloudCliAuthorizationError + | CloudCliAuthorizationTimeoutError + >; + readonly getExisting: Effect.Effect< + Option.Option, + CloudCliCredentialRefreshError + >; + readonly hasCredential: Effect.Effect; + readonly clear: Effect.Effect; } >()("t3/cloud/CliTokenManager/CloudCliTokenManager") {} -const wrapError = - (makeError: (cause: unknown) => WrappedError) => - (effect: Effect.Effect): Effect.Effect => - effect.pipe(Effect.mapError(makeError)); - function stringToBytes(value: string): Uint8Array { return new TextEncoder().encode(value); } @@ -134,9 +227,15 @@ export const make = Effect.gen(function* () { return token; }); - const clear = secrets - .remove(CLOUD_CLI_OAUTH_TOKEN_SECRET) - .pipe(wrapError((cause) => new CloudCliCredentialRemovalError({ cause }))); + const clear = secrets.remove(CLOUD_CLI_OAUTH_TOKEN_SECRET).pipe( + Effect.mapError( + (cause) => + new CloudCliCredentialRemovalError({ + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, + cause, + }), + ), + ); const read = Effect.fn("cloud.cli_token.read")(function* () { const encoded = yield* secrets.get(CLOUD_CLI_OAUTH_TOKEN_SECRET); @@ -162,21 +261,62 @@ export const make = Effect.gen(function* () { }); const refresh = Effect.fn("cloud.cli_token.refresh")(function* (token: PersistedToken) { - const metadata = yield* cloudCliOAuthConfig; + const metadata = yield* cloudCliOAuthConfig.pipe( + Effect.mapError( + (cause) => + new CloudCliCredentialRefreshError({ + stage: "load-oauth-config", + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, + cause, + }), + ), + ); return yield* exchangeToken(metadata, { grant_type: "refresh_token", refresh_token: token.refreshToken, client_id: metadata.clientId, - }); + }).pipe( + Effect.mapError( + (cause) => + new CloudCliCredentialRefreshError({ + stage: "exchange-token", + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, + tokenEndpoint: metadata.tokenEndpoint, + cause, + }), + ), + ); }); const login = Effect.fn("cloud.cli_token.login")(function* () { - const metadata = yield* cloudCliOAuthConfig; - const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32)); - const challenge = Encoding.encodeBase64Url( - yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)), + const metadata = yield* cloudCliOAuthConfig.pipe( + Effect.mapError( + (cause) => + new CloudCliAuthorizationError({ + stage: "load-oauth-config", + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, + cause, + }), + ), + ); + const { challenge, state, verifier } = yield* Effect.gen(function* () { + const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32)); + const challenge = Encoding.encodeBase64Url( + yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)), + ); + const state = yield* crypto.randomUUIDv4; + return { challenge, state, verifier }; + }).pipe( + Effect.mapError( + (cause) => + new CloudCliAuthorizationError({ + stage: "prepare-pkce", + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, + redirectUri: metadata.redirectUri, + cause, + }), + ), ); - const state = yield* crypto.randomUUIDv4; const callback = yield* Deferred.make(); const callbackRoute = HttpRouter.add( "GET", @@ -207,12 +347,23 @@ export const make = Effect.gen(function* () { }).pipe( Layer.provide( NodeHttpServer.layer(NodeHttp.createServer, { - host: "127.0.0.1", - port: 34338, + host: CLOUD_CLI_OAUTH_CALLBACK_HOST, + port: CLOUD_CLI_OAUTH_CALLBACK_PORT, disablePreemptiveShutdown: true, }), ), Layer.build, + Effect.mapError( + (cause) => + new CloudCliAuthorizationError({ + stage: "start-callback-server", + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, + redirectUri: metadata.redirectUri, + callbackHost: CLOUD_CLI_OAUTH_CALLBACK_HOST, + callbackPort: CLOUD_CLI_OAUTH_CALLBACK_PORT, + cause, + }), + ), ); const authorizationUrl = new URL(metadata.authorizationEndpoint); authorizationUrl.searchParams.set("client_id", metadata.clientId); @@ -225,13 +376,16 @@ export const make = Effect.gen(function* () { yield* Console.log(`Open this URL to authorize T3 Connect:\n${authorizationUrl.toString()}\n`); const code = yield* Deferred.await(callback).pipe( Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT), - Effect.catchTag("TimeoutError", (cause) => - Effect.fail( - new CloudCliAuthorizationTimeoutError({ - cause, - }), - ), - ), + Effect.catchTags({ + TimeoutError: (cause) => + Effect.fail( + new CloudCliAuthorizationTimeoutError({ + redirectUri: metadata.redirectUri, + timeoutMillis: Duration.toMillis(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT), + cause, + }), + ), + }), ); return yield* exchangeToken(metadata, { grant_type: "authorization_code", @@ -239,26 +393,45 @@ export const make = Effect.gen(function* () { redirect_uri: metadata.redirectUri, client_id: metadata.clientId, code_verifier: verifier, - }); + }).pipe( + Effect.mapError( + (cause) => + new CloudCliAuthorizationError({ + stage: "exchange-token", + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, + tokenEndpoint: metadata.tokenEndpoint, + redirectUri: metadata.redirectUri, + cause, + }), + ), + ); }); const getExistingNoLock = Effect.fn("cloud.cli_token.get_existing_no_lock")(function* () { - const token = yield* read(); + const token = yield* read().pipe( + Effect.mapError(CloudCliCredentialRefreshError.fromCredentialRead), + ); if (Option.isNone(token)) return token; const now = yield* Clock.currentTimeMillis; if (token.value.expiresAtEpochMs - CLOUD_CLI_OAUTH_REFRESH_EARLY_MS > now) { return token; } - return Option.some(yield* refresh(token.value).pipe(Effect.flatMap(persist))); + return Option.some( + yield* refresh(token.value).pipe( + Effect.flatMap((refreshed) => + persist(refreshed).pipe( + Effect.mapError(CloudCliCredentialRefreshError.fromCredentialPersist), + ), + ), + ), + ); }); - const getExisting = semaphore.withPermits(1)( - getExistingNoLock().pipe(wrapError((cause) => new CloudCliCredentialRefreshError({ cause }))), - ); + const getExisting = semaphore.withPermits(1)(getExistingNoLock()); const hasCredential = semaphore.withPermits(1)( read().pipe( Effect.map(Option.isSome), - wrapError((cause) => new CloudCliCredentialReadError({ cause })), + Effect.mapError(CloudCliCredentialReadError.fromCredentialRead), ), ); const get = semaphore.withPermits(1)( @@ -266,8 +439,14 @@ export const make = Effect.gen(function* () { const token = yield* getExistingNoLock(); return Option.isSome(token) ? token.value - : yield* Effect.scoped(login()).pipe(Effect.flatMap(persist)); - }).pipe(wrapError((cause) => new CloudCliAuthorizationError({ cause }))), + : yield* Effect.scoped(login()).pipe( + Effect.flatMap((authorized) => + persist(authorized).pipe( + Effect.mapError(CloudCliAuthorizationError.fromCredentialPersist), + ), + ), + ); + }), ); return CloudCliTokenManager.of({ get, getExisting, hasCredential, clear }); diff --git a/apps/server/src/cloud/environmentKeys.test.ts b/apps/server/src/cloud/environmentKeys.test.ts index 48c44ccc48a..a2fef8c8b0b 100644 --- a/apps/server/src/cloud/environmentKeys.test.ts +++ b/apps/server/src/cloud/environmentKeys.test.ts @@ -66,7 +66,9 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it Effect.flatMap(() => Effect.fail( new ServerSecretStore.SecretStorePersistError({ - resource: "environment signing key pair", + operation: "create", + secretName: "cloud-link-ed25519-key-pair", + secretPath: "cloud-link-ed25519-key-pair.bin", cause: PlatformError.systemError({ _tag: "AlreadyExists", module: "FileSystem", @@ -87,4 +89,24 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it }); }), ); + + it.effect("retains the secret name and decode cause for malformed keypairs", () => + Effect.gen(function* () { + const secretStore = { + get: () => Effect.succeed(Option.some(new TextEncoder().encode("not-json"))), + set: unusedSecretStoreOperation, + create: unusedSecretStoreOperation, + getOrCreateRandom: unusedSecretStoreOperation, + remove: unusedSecretStoreOperation, + } satisfies ServerSecretStore.ServerSecretStore["Service"]; + + const error = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore).pipe( + Effect.flip, + ); + + assert.instanceOf(error, ServerSecretStore.SecretStoreDecodeError); + assert.equal(error.secretName, "cloud-link-ed25519-key-pair"); + assert.exists(error.cause); + }), + ); }); diff --git a/apps/server/src/cloud/environmentKeys.ts b/apps/server/src/cloud/environmentKeys.ts index 1d0cde91bf4..3e20983aef1 100644 --- a/apps/server/src/cloud/environmentKeys.ts +++ b/apps/server/src/cloud/environmentKeys.ts @@ -27,17 +27,6 @@ function stringToBytes(value: string): Uint8Array { return new TextEncoder().encode(value); } -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"], ) { @@ -46,7 +35,13 @@ const readEnvironmentKeyPair = Effect.fn("readEnvironmentKeyPair")(function* ( return Option.none(); } const decoded = yield* decodeEnvironmentKeyPair(bytesToString(encoded.value)).pipe( - Effect.mapError(keyPairDecodeError), + Effect.mapError( + (cause) => + new ServerSecretStore.SecretStoreDecodeError({ + secretName: CLOUD_LINK_KEY_PAIR, + cause, + }), + ), ); return Option.some(decoded); }); @@ -56,22 +51,34 @@ const persistEnvironmentKeyPair = Effect.fn("persistEnvironmentKeyPair")(functio keyPair: EnvironmentKeyPair, ) { const encoded = yield* encodeEnvironmentKeyPair(keyPair).pipe( - Effect.mapError(keyPairEncodeError), + Effect.mapError( + (cause) => + new ServerSecretStore.SecretStoreEncodeError({ + secretName: CLOUD_LINK_KEY_PAIR, + cause, + }), + ), ); return yield* secrets.create(CLOUD_LINK_KEY_PAIR, stringToBytes(encoded)).pipe( Effect.as(keyPair), - Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => - ServerSecretStore.isSecretAlreadyExistsError(error) - ? readEnvironmentKeyPair(secrets).pipe( - Effect.flatMap( - Option.match({ - onSome: Effect.succeed, - onNone: () => Effect.fail(keyPairConcurrentReadError()), - }), - ), - ) - : Effect.fail(error), - ), + Effect.catchTags({ + SecretStorePersistError: (error) => + ServerSecretStore.isSecretAlreadyExistsError(error) + ? readEnvironmentKeyPair(secrets).pipe( + Effect.flatMap( + Option.match({ + onSome: Effect.succeed, + onNone: () => + Effect.fail( + new ServerSecretStore.SecretStoreConcurrentReadError({ + secretName: CLOUD_LINK_KEY_PAIR, + }), + ), + }), + ), + ) + : Effect.fail(error), + }), ); }); diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index ed2e5a4cf75..561418173a6 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -1,23 +1,35 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Tracer from "effect/Tracer"; -import { HttpClient, HttpServerRequest } from "effect/unstable/http"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; import { RelayClientTracer } from "@t3tools/shared/relayTracing"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; -import { consumeCloudReplayGuards, reconcileDesiredCloudLink } from "./http.ts"; +import { + CloudRelayRequestError, + consumeCloudReplayGuards, + reconcileDesiredCloudLink, +} from "./http.ts"; import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts"; const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => new ServerSecretStore.SecretStorePersistError({ - resource: "cloud replay guard", + operation: "create", + secretName: "cloud replay guard", + secretPath: "cloud-replay-guard.bin", cause: PlatformError.systemError({ _tag: tag, module: "FileSystem", @@ -40,6 +52,55 @@ function makeSecretStore( }; } +function reconcileWith(input: { + readonly getExisting: CliTokenManager.CloudCliTokenManager["Service"]["getExisting"]; + readonly httpClient?: HttpClient.HttpClient; + readonly env?: Readonly>; +}) { + return reconcileDesiredCloudLink("http://127.0.0.1:3774").pipe( + Effect.provideService( + ServerSecretStore.ServerSecretStore, + makeSecretStore(unusedSecretStoreOperation), + ), + Effect.provideService( + ServerEnvironment.ServerEnvironment, + ServerEnvironment.ServerEnvironment.of({ + getEnvironmentId: unusedSecretStoreOperation(), + getDescriptor: unusedSecretStoreOperation(), + }), + ), + Effect.provideService( + ManagedEndpointRuntime.CloudManagedEndpointRuntime, + ManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ + applyConfig: unusedSecretStoreOperation, + } satisfies ManagedEndpointRuntime.CloudManagedEndpointRuntime["Service"]), + ), + Effect.provideService( + EnvironmentAuth.EnvironmentAuth, + EnvironmentAuth.EnvironmentAuth.of({} as EnvironmentAuth.EnvironmentAuth["Service"]), + ), + Effect.provideService( + CliTokenManager.CloudCliTokenManager, + CliTokenManager.CloudCliTokenManager.of({ + get: unusedSecretStoreOperation(), + getExisting: input.getExisting, + hasCredential: unusedSecretStoreOperation(), + clear: unusedSecretStoreOperation(), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + input.httpClient ?? HttpClient.make(() => unusedSecretStoreOperation()), + ), + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + ConfigProvider.layer(ConfigProvider.fromEnv({ env: input.env ?? {} })), + ), + ), + ); +} + it("preserves messages surfaced by cloud 500 responses", () => { const cause = new Error("cloud operation failed"); @@ -93,6 +154,45 @@ describe("consumeCloudReplayGuards", () => { ); }); +describe("CloudRelayRequestError", () => { + it("classifies response failures without deriving its message from the cause", () => { + const request = HttpClientRequest.post( + "https://relay.example.test/v1/client/environment-links", + ); + const response = HttpClientResponse.fromWeb( + request, + new Response("sensitive upstream response", { status: 502 }), + ); + const upstreamCause = new Error("sensitive upstream response details"); + const cause = new HttpClientError.HttpClientError({ + reason: new HttpClientError.StatusCodeError({ + request, + response, + cause: upstreamCause, + }), + }); + + const error = CloudRelayRequestError.fromClientFailure({ + operation: "create-environment-link", + url: request.url, + cause, + }); + + expect(error).toMatchObject({ + operation: "create-environment-link", + phase: "check-response-status", + method: "POST", + url: request.url, + responseStatus: 502, + cause, + }); + expect(error.message).toBe( + "T3 Connect relay create-environment-link failed during check-response-status with response status 502.", + ); + expect(error.message).not.toContain(upstreamCause.message); + }); +}); + describe("relay request tracing", () => { it.effect("does not accept an unauthenticated request trace parent", () => Effect.gen(function* () { @@ -160,48 +260,47 @@ describe("relay request tracing", () => { describe("reconcileDesiredCloudLink", () => { it.effect("requires stored CLI authorization without exposing an HTTP endpoint", () => Effect.gen(function* () { - const error = yield* Effect.flip(reconcileDesiredCloudLink("http://127.0.0.1:3774")); + const error = yield* Effect.flip( + reconcileWith({ getExisting: Effect.succeed(Option.none()) }), + ); expect(error).toMatchObject({ _tag: "EnvironmentHttpUnauthorizedError", message: "Run `t3 connect link` to authorize this environment.", }); - }).pipe( - Effect.provideService( - ServerSecretStore.ServerSecretStore, - makeSecretStore(unusedSecretStoreOperation), - ), - Effect.provideService( - ServerEnvironment.ServerEnvironment, - ServerEnvironment.ServerEnvironment.of({ - getEnvironmentId: unusedSecretStoreOperation(), - getDescriptor: unusedSecretStoreOperation(), + }), + ); + + it.effect("redacts relay transport failures behind a stable structural message", () => { + const transportCause = new Error("upstream included a sensitive database password"); + const httpClient = HttpClient.make((request) => + Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ request, cause: transportCause }), }), ), - Effect.provideService( - ManagedEndpointRuntime.CloudManagedEndpointRuntime, - ManagedEndpointRuntime.CloudManagedEndpointRuntime.of({ - applyConfig: unusedSecretStoreOperation, - } satisfies ManagedEndpointRuntime.CloudManagedEndpointRuntime["Service"]), - ), - Effect.provideService( - EnvironmentAuth.EnvironmentAuth, - EnvironmentAuth.EnvironmentAuth.of({} as EnvironmentAuth.EnvironmentAuth["Service"]), - ), - Effect.provideService( - CliTokenManager.CloudCliTokenManager, - CliTokenManager.CloudCliTokenManager.of({ - get: unusedSecretStoreOperation(), - getExisting: Effect.succeed(Option.none()), - hasCredential: unusedSecretStoreOperation(), - clear: unusedSecretStoreOperation(), + ); + + return Effect.gen(function* () { + const error = yield* Effect.flip( + reconcileWith({ + getExisting: Effect.succeed( + Option.some({ + accessToken: "access-token", + refreshToken: "refresh-token", + expiresAtEpochMs: Number.MAX_SAFE_INTEGER, + }), + ), + httpClient, + env: { T3CODE_RELAY_URL: "https://relay.example.test" }, }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => unusedSecretStoreOperation()), - ), - Effect.provide(NodeServices.layer), - ), - ); + ); + + expect(error).toMatchObject({ + _tag: "EnvironmentHttpInternalServerError", + message: "T3 Connect relay create-link-challenge failed during send-request.", + }); + expect(error.message).not.toContain(transportCause.message); + }); + }); }); diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index fc2adca9fbc..f12cf48a83f 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -48,8 +48,13 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as HttpEffect from "effect/unstable/http/HttpEffect"; -import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import * as HttpBody from "effect/unstable/http/HttpBody"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientError from "effect/unstable/http/HttpClientError"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; @@ -85,6 +90,104 @@ const CLOUD_CREDENTIAL_RESPONSE_HEADERS = { pragma: "no-cache", } as const; +const CloudRelayRequestOperation = Schema.Literals([ + "create-link-challenge", + "create-environment-link", +]); + +const CloudRelayRequestPhase = Schema.Literals([ + "encode-request", + "send-request", + "check-response-status", + "decode-response", +]); + +export class CloudRelayConfigurationError extends Schema.TaggedErrorClass()( + "CloudRelayConfigurationError", + { + configKey: Schema.Literal("T3CODE_RELAY_URL"), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `${this.configKey} must be configured as a secure absolute HTTPS origin.`; + } +} + +export class CloudRelayRequestError extends Schema.TaggedErrorClass()( + "CloudRelayRequestError", + { + operation: CloudRelayRequestOperation, + phase: CloudRelayRequestPhase, + method: Schema.Literal("POST"), + url: Schema.String, + responseStatus: Schema.optional(Schema.Number), + cause: Schema.Defect(), + }, +) { + static fromClientFailure(input: { + readonly operation: CloudRelayRequestError["operation"]; + readonly url: string; + readonly cause: HttpBody.HttpBodyError | HttpClientError.HttpClientError | Schema.SchemaError; + readonly responseStatus?: number; + }): CloudRelayRequestError { + if (input.cause._tag === "SchemaError") { + return new CloudRelayRequestError({ + operation: input.operation, + phase: "decode-response", + method: "POST", + url: input.url, + ...(input.responseStatus === undefined ? {} : { responseStatus: input.responseStatus }), + cause: input.cause, + }); + } + + if (!HttpClientError.isHttpClientError(input.cause)) { + return new CloudRelayRequestError({ + operation: input.operation, + phase: "encode-request", + method: "POST", + url: input.url, + cause: input.cause, + }); + } + + const phase: CloudRelayRequestError["phase"] = (() => { + switch (input.cause.reason._tag) { + case "EncodeError": + return "encode-request"; + case "TransportError": + case "InvalidUrlError": + return "send-request"; + case "StatusCodeError": + return "check-response-status"; + case "DecodeError": + case "EmptyBodyError": + return "decode-response"; + } + })(); + + return new CloudRelayRequestError({ + operation: input.operation, + phase, + method: "POST", + url: input.url, + ...(input.cause.response === undefined + ? input.responseStatus === undefined + ? {} + : { responseStatus: input.responseStatus } + : { responseStatus: input.cause.response.status }), + cause: input.cause, + }); + } + + override get message(): string { + const responseStatus = + this.responseStatus === undefined ? "" : ` with response status ${this.responseStatus}`; + return `T3 Connect relay ${this.operation} failed during ${this.phase}${responseStatus}.`; + } +} + const appendCloudCredentialResponseHeaders = HttpEffect.appendPreResponseHandler( (_request, response) => Effect.succeed(HttpServerResponse.setHeaders(response, CLOUD_CREDENTIAL_RESPONSE_HEADERS)), @@ -97,14 +200,12 @@ const failEnvironmentCloudInternalError = Effect.flatMap(() => Effect.fail(new EnvironmentHttpInternalServerError({ message }))), ); -const failCloudCliTokenManagerError = (error: CliTokenManager.CloudCliTokenManagerError) => - failEnvironmentCloudInternalError(error.message)(error); - const requireRelayUrl = relayUrlConfig.pipe( Effect.mapError( - () => - new EnvironmentHttpInternalServerError({ - message: "T3CODE_RELAY_URL must be configured as a secure absolute HTTPS origin.", + (cause) => + new CloudRelayConfigurationError({ + configKey: "T3CODE_RELAY_URL", + cause, }), ), ); @@ -126,11 +227,12 @@ export function consumeCloudReplayGuards(input: { input.names.map((name) => input.secrets.create(name, input.value).pipe( Effect.as(true), - Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => - ServerSecretStore.isSecretAlreadyExistsError(error) - ? Effect.succeed(false) - : Effect.fail(error), - ), + Effect.catchTags({ + SecretStorePersistError: (error) => + ServerSecretStore.isSecretAlreadyExistsError(error) + ? Effect.succeed(false) + : Effect.fail(error), + }), ), ), { concurrency: input.names.length }, @@ -207,7 +309,7 @@ function validateRelayConfigPayload( function validateLinkedCloudUser(input: { readonly secrets: ServerSecretStore.ServerSecretStore["Service"]; readonly cloudUserId: string; -}): Effect.Effect { +}) { return input.secrets.get(CLOUD_LINKED_USER_ID).pipe( Effect.mapError( (cause) => @@ -232,9 +334,7 @@ function validateLinkedCloudUser(input: { ); } -function readInstalledCloudUserId( - secrets: ServerSecretStore.ServerSecretStore["Service"], -): Effect.Effect { +function readInstalledCloudUserId(secrets: ServerSecretStore.ServerSecretStore["Service"]) { return secrets.get(CLOUD_LINKED_USER_ID).pipe( Effect.mapError( (cause) => @@ -409,17 +509,26 @@ const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")( yield* appendCloudCredentialResponseHeaders; return proof satisfies RelayEnvironmentLinkProof; }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentCloudInternalError(error.message)(error), - ), - Effect.catchIf( - ServerSecretStore.isSecretStoreError, - failEnvironmentCloudInternalError("Could not generate environment link proof."), - ), - Effect.catchTag( - "PlatformError", - failEnvironmentCloudInternalError("Could not generate environment link proof."), - ), + Effect.catchTags({ + ServerAuthCloudLinkJwtSigningError: (error) => + failEnvironmentCloudInternalError(error.message)(error), + SecretStoreReadError: failEnvironmentCloudInternalError( + "Could not generate environment link proof.", + ), + SecretStoreDecodeError: failEnvironmentCloudInternalError( + "Could not generate environment link proof.", + ), + SecretStoreEncodeError: failEnvironmentCloudInternalError( + "Could not generate environment link proof.", + ), + SecretStorePersistError: failEnvironmentCloudInternalError( + "Could not generate environment link proof.", + ), + SecretStoreConcurrentReadError: failEnvironmentCloudInternalError( + "Could not generate environment link proof.", + ), + PlatformError: failEnvironmentCloudInternalError("Could not generate environment link proof."), + }), ); const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(function* ( @@ -472,22 +581,28 @@ const cloudRelayConfigHandler = Effect.fn("environment.cloud.relayConfig")( yield* requireEnvironmentScope(AuthRelayWriteScope); return yield* applyCloudRelayConfig(dependencies, payload); }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentCloudInternalError(error.message)(error), - ), - Effect.catchIf( - ServerSecretStore.isSecretStoreError, - failEnvironmentCloudInternalError("Could not persist environment relay configuration."), - ), - Effect.catchTag( - "SchemaError", - failEnvironmentCloudInternalError("Could not persist environment relay configuration."), - ), + Effect.catchTags({ + ServerAuthLinkedCloudAccountVerificationError: (error) => + failEnvironmentCloudInternalError(error.message)(error), + SecretStoreTemporaryPathGenerationError: failEnvironmentCloudInternalError( + "Could not persist environment relay configuration.", + ), + SecretStorePersistError: failEnvironmentCloudInternalError( + "Could not persist environment relay configuration.", + ), + SecretStoreRemoveError: failEnvironmentCloudInternalError( + "Could not persist environment relay configuration.", + ), + SchemaError: failEnvironmentCloudInternalError( + "Could not persist environment relay configuration.", + ), + }), ); const relayClientRequest = ( dependencies: CloudHttpDependencies, input: { + readonly operation: CloudRelayRequestError["operation"]; readonly url: string; readonly token: string; readonly payload: unknown; @@ -497,14 +612,46 @@ const relayClientRequest = ( HttpClientRequest.post(input.url).pipe( HttpClientRequest.bearerToken(input.token), HttpClientRequest.bodyJson(input.payload), - Effect.flatMap(dependencies.httpClient.execute), - Effect.flatMap(HttpClientResponse.filterStatusOk), - Effect.flatMap(HttpClientResponse.schemaBodyJson(input.schema)), - Effect.mapError( - (cause) => - new EnvironmentHttpInternalServerError({ - message: `T3 Connect relay request failed: ${String(cause)}`, - }), + Effect.mapError((cause) => + CloudRelayRequestError.fromClientFailure({ + operation: input.operation, + url: input.url, + cause, + }), + ), + Effect.flatMap((request) => + dependencies.httpClient.execute(request).pipe( + Effect.mapError((cause) => + CloudRelayRequestError.fromClientFailure({ + operation: input.operation, + url: input.url, + cause, + }), + ), + ), + ), + Effect.flatMap((response) => + HttpClientResponse.filterStatusOk(response).pipe( + Effect.mapError((cause) => + CloudRelayRequestError.fromClientFailure({ + operation: input.operation, + url: input.url, + cause, + }), + ), + ), + ), + Effect.flatMap((response) => + HttpClientResponse.schemaBodyJson(input.schema)(response).pipe( + Effect.mapError((cause) => + CloudRelayRequestError.fromClientFailure({ + operation: input.operation, + url: input.url, + responseStatus: response.status, + cause, + }), + ), + ), ), withRelayClientTracing, ); @@ -539,6 +686,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi ); const relayUrl = yield* requireRelayUrl; const challenge = yield* relayClientRequest(dependencies, { + operation: "create-link-challenge", url: `${relayUrl}/v1/client/environment-link-challenges`, token: token.accessToken, payload: { @@ -566,6 +714,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi localOrigin, ); const link = yield* relayClientRequest(dependencies, { + operation: "create-environment-link", url: `${relayUrl}/v1/client/environment-links`, token: token.accessToken, payload: { @@ -586,16 +735,43 @@ 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, + ServerAuthLinkedCloudAccountVerificationError: (error) => + failEnvironmentCloudInternalError(error.message)(error), + ServerAuthCloudLinkJwtSigningError: (error) => + failEnvironmentCloudInternalError(error.message)(error), + SecretStoreReadError: failEnvironmentCloudInternalError( + "Could not persist desired T3 Connect link state.", + ), + SecretStoreTemporaryPathGenerationError: failEnvironmentCloudInternalError( + "Could not persist desired T3 Connect link state.", + ), + SecretStorePersistError: failEnvironmentCloudInternalError( + "Could not persist desired T3 Connect link state.", + ), + SecretStoreRemoveError: failEnvironmentCloudInternalError( + "Could not persist desired T3 Connect link state.", + ), + SecretStoreDecodeError: failEnvironmentCloudInternalError( + "Could not persist desired T3 Connect link state.", + ), + SecretStoreEncodeError: failEnvironmentCloudInternalError( + "Could not persist desired T3 Connect link state.", + ), + SecretStoreConcurrentReadError: failEnvironmentCloudInternalError( + "Could not persist desired T3 Connect link state.", + ), + SchemaError: failEnvironmentCloudInternalError( + "Could not persist desired T3 Connect link state.", + ), + PlatformError: failEnvironmentCloudInternalError( + "Could not persist desired T3 Connect link state.", + ), + CloudRelayConfigurationError: (error) => + failEnvironmentCloudInternalError(error.message)(error), + CloudRelayRequestError: (error) => failEnvironmentCloudInternalError(error.message)(error), + CloudCliCredentialRefreshError: (error) => + failEnvironmentCloudInternalError(error.message)(error), }), ); @@ -633,10 +809,11 @@ const cloudLinkStateHandler = Effect.fn("environment.cloud.linkState")( yield* requireEnvironmentScope(AuthRelayReadScope); return yield* readCloudLinkState(dependencies); }, - Effect.catchIf( - ServerSecretStore.isSecretStoreError, - failEnvironmentCloudInternalError("Could not read environment relay configuration."), - ), + Effect.catchTags({ + SecretStoreReadError: failEnvironmentCloudInternalError( + "Could not read environment relay configuration.", + ), + }), ); const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")( @@ -658,10 +835,17 @@ const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")( yield* setCliDesiredCloudLink(false); return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; }, - Effect.catchIf( - ServerSecretStore.isSecretStoreError, - failEnvironmentCloudInternalError("Could not remove environment relay configuration."), - ), + Effect.catchTags({ + SecretStoreTemporaryPathGenerationError: failEnvironmentCloudInternalError( + "Could not remove environment relay configuration.", + ), + SecretStorePersistError: failEnvironmentCloudInternalError( + "Could not remove environment relay configuration.", + ), + SecretStoreRemoveError: failEnvironmentCloudInternalError( + "Could not remove environment relay configuration.", + ), + }), ); const cloudPreferencesHandler = Effect.fn("environment.cloud.preferences")( @@ -676,10 +860,17 @@ const cloudPreferencesHandler = Effect.fn("environment.cloud.preferences")( ); return yield* readCloudLinkState(dependencies); }, - Effect.catchIf( - ServerSecretStore.isSecretStoreError, - failEnvironmentCloudInternalError("Could not persist environment cloud preferences."), - ), + Effect.catchTags({ + SecretStoreReadError: failEnvironmentCloudInternalError( + "Could not persist environment cloud preferences.", + ), + SecretStoreTemporaryPathGenerationError: failEnvironmentCloudInternalError( + "Could not persist environment cloud preferences.", + ), + SecretStorePersistError: failEnvironmentCloudInternalError( + "Could not persist environment cloud preferences.", + ), + }), ); const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( @@ -787,17 +978,34 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( yield* appendCloudCredentialResponseHeaders; return response; }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentCloudInternalError(error.message)(error), - ), - Effect.catchIf( - ServerSecretStore.isSecretStoreError, - failEnvironmentCloudInternalError("Could not answer cloud health request."), - ), - Effect.catchTag( - "PlatformError", - failEnvironmentCloudInternalError("Could not answer cloud health request."), - ), + Effect.catchTags({ + ServerAuthLinkedCloudAccountReadError: (error) => + failEnvironmentCloudInternalError(error.message)(error), + ServerAuthLinkedCloudAccountMissingError: (error) => + failEnvironmentCloudInternalError(error.message)(error), + ServerAuthCloudMintPublicKeyMissingError: (error) => + failEnvironmentCloudInternalError(error.message)(error), + ServerAuthCloudRelayIssuerMissingError: (error) => + failEnvironmentCloudInternalError(error.message)(error), + ServerAuthCloudHealthJwtSigningError: (error) => + failEnvironmentCloudInternalError(error.message)(error), + SecretStoreReadError: failEnvironmentCloudInternalError( + "Could not answer cloud health request.", + ), + SecretStorePersistError: failEnvironmentCloudInternalError( + "Could not answer cloud health request.", + ), + SecretStoreDecodeError: failEnvironmentCloudInternalError( + "Could not answer cloud health request.", + ), + SecretStoreEncodeError: failEnvironmentCloudInternalError( + "Could not answer cloud health request.", + ), + SecretStoreConcurrentReadError: failEnvironmentCloudInternalError( + "Could not answer cloud health request.", + ), + PlatformError: failEnvironmentCloudInternalError("Could not answer cloud health request."), + }), ); const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential")( @@ -908,17 +1116,38 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") yield* appendCloudCredentialResponseHeaders; return response; }, - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentCloudInternalError(error.message)(error), - ), - Effect.catchIf( - ServerSecretStore.isSecretStoreError, - failEnvironmentCloudInternalError("Could not issue cloud connection credential."), - ), - Effect.catchTag( - "PlatformError", - failEnvironmentCloudInternalError("Could not issue cloud connection credential."), - ), + Effect.catchTags({ + ServerAuthLinkedCloudAccountReadError: (error) => + failEnvironmentCloudInternalError(error.message)(error), + ServerAuthLinkedCloudAccountMissingError: (error) => + failEnvironmentCloudInternalError(error.message)(error), + ServerAuthCloudMintPublicKeyMissingError: (error) => + failEnvironmentCloudInternalError(error.message)(error), + ServerAuthCloudRelayIssuerMissingError: (error) => + failEnvironmentCloudInternalError(error.message)(error), + ServerAuthPairingLinkCreationError: (error) => + failEnvironmentCloudInternalError(error.message)(error), + ServerAuthCloudMintJwtSigningError: (error) => + failEnvironmentCloudInternalError(error.message)(error), + SecretStoreReadError: failEnvironmentCloudInternalError( + "Could not issue cloud connection credential.", + ), + SecretStorePersistError: failEnvironmentCloudInternalError( + "Could not issue cloud connection credential.", + ), + SecretStoreDecodeError: failEnvironmentCloudInternalError( + "Could not issue cloud connection credential.", + ), + SecretStoreEncodeError: failEnvironmentCloudInternalError( + "Could not issue cloud connection credential.", + ), + SecretStoreConcurrentReadError: failEnvironmentCloudInternalError( + "Could not issue cloud connection credential.", + ), + 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 ce9b498cb1f..67ddf22dd7b 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -35,9 +35,8 @@ import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; import { annotateEnvironmentRequest, + catchEnvironmentAuthenticationErrors, failEnvironmentScopeRequired, - failEnvironmentAuthInvalid, - failEnvironmentInternal, } from "./auth/http.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts"; @@ -80,13 +79,8 @@ const authenticateRawRouteWithScope = ( Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; - const session = yield* serverAuth.authenticateHttpRequest(request).pipe( - Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), - ), - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("internal_error", error), - ), + const session = yield* catchEnvironmentAuthenticationErrors( + serverAuth.authenticateHttpRequest(request), ); if (!session.scopes.includes(scope)) { return yield* failEnvironmentScopeRequired(scope); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 03b609ddcfe..6071b6b5826 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -105,7 +105,7 @@ import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; -import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; +import { catchEnvironmentAuthenticationErrors } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const isWorkspacePathOutsideRootError = Schema.is(WorkspacePaths.WorkspacePathOutsideRootError); @@ -1725,13 +1725,8 @@ export const websocketRpcRouteLayer = Layer.unwrap( const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sessions = yield* SessionStore.SessionStore; - const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( - Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), - ), - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("internal_error", error), - ), + const session = yield* catchEnvironmentAuthenticationErrors( + serverAuth.authenticateWebSocketUpgrade(request), ); const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { disableTracing: true,