| author | |
| committer | |
| log | 97e5cd3bf7bbee427a177d5017aa2d250429bbf6 |
| tree | af3da5b7dc8ca22415e555cf1ae1ff8ff9268c4e |
| parent | b19fc1b87b22a3c923e60c2ef2a1dae336279924 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>22 files changed, 1434 insertions(+), 764 deletions(-)
apps/desktop/src/main.ts+2-2| ... | @@ -1,7 +1,7 @@ | ... | @@ -1,7 +1,7 @@ |
| 1 | import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; | 1 | import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; |
| 2 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | 2 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import { homedir } from "node:os"; | 4 | import * as NodeOS from "node:os"; |
| 5 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| ... | @@ -60,7 +60,7 @@ const desktopEnvironmentLayer = Layer.unwrap( | ... | @@ -60,7 +60,7 @@ const desktopEnvironmentLayer = Layer.unwrap( |
| 60 | const processArch = yield* HostProcessArchitecture; | 60 | const processArch = yield* HostProcessArchitecture; |
| 61 | return DesktopEnvironment.layer({ | 61 | return DesktopEnvironment.layer({ |
| 62 | dirname: __dirname, | 62 | dirname: __dirname, |
| 63 | homeDirectory: homedir(), | 63 | homeDirectory: NodeOS.homedir(), |
| 64 | platform, | 64 | platform, |
| 65 | processArch, | 65 | processArch, |
| 66 | ...metadata, | 66 | ...metadata, |
apps/server/src/assets/AssetAccess.ts+6-6| ... | @@ -19,9 +19,9 @@ import { | ... | @@ -19,9 +19,9 @@ import { |
| 19 | signPayload, | 19 | signPayload, |
| 20 | timingSafeEqualBase64Url, | 20 | timingSafeEqualBase64Url, |
| 21 | } from "../auth/utils.ts"; | 21 | } from "../auth/utils.ts"; |
| 22 | import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; | 22 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 23 | import { resolveAttachmentPathById } from "../attachmentStore.ts"; | 23 | import { resolveAttachmentPathById } from "../attachmentStore.ts"; |
| 24 | import { ServerConfig } from "../config.ts"; | 24 | import * as ServerConfig from "../config.ts"; |
| 25 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; | 25 | import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts"; |
| 26 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; | 26 | import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; |
| 27 | 27 | ||
| ... | @@ -181,7 +181,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i | ... | @@ -181,7 +181,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i |
| 181 | break; | 181 | break; |
| 182 | } | 182 | } |
| 183 | case "attachment": { | 183 | case "attachment": { |
| 184 | const config = yield* ServerConfig; | 184 | const config = yield* ServerConfig.ServerConfig; |
| 185 | const attachmentPath = resolveAttachmentPathById({ | 185 | const attachmentPath = resolveAttachmentPathById({ |
| 186 | attachmentsDir: config.attachmentsDir, | 186 | attachmentsDir: config.attachmentsDir, |
| 187 | attachmentId: input.resource.attachmentId, | 187 | attachmentId: input.resource.attachmentId, |
| ... | @@ -225,7 +225,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i | ... | @@ -225,7 +225,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i |
| 225 | } | 225 | } |
| 226 | } | 226 | } |
| 227 | 227 | ||
| 228 | const secretStore = yield* ServerSecretStore; | 228 | const secretStore = yield* ServerSecretStore.ServerSecretStore; |
| 229 | const signingSecret = yield* secretStore | 229 | const signingSecret = yield* secretStore |
| 230 | .getOrCreateRandom(SIGNING_SECRET_NAME, 32) | 230 | .getOrCreateRandom(SIGNING_SECRET_NAME, 32) |
| 231 | .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); | 231 | .pipe(Effect.mapError((cause) => failAccess(cause.message, cause))); |
| ... | @@ -244,7 +244,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( | ... | @@ -244,7 +244,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( |
| 244 | const [encodedPayload, signature] = token.split("."); | 244 | const [encodedPayload, signature] = token.split("."); |
| 245 | if (!encodedPayload || !signature) return null; | 245 | if (!encodedPayload || !signature) return null; |
| 246 | 246 | ||
| 247 | const secretStore = yield* ServerSecretStore; | 247 | const secretStore = yield* ServerSecretStore.ServerSecretStore; |
| 248 | const signingSecret = yield* secretStore | 248 | const signingSecret = yield* secretStore |
| 249 | .getOrCreateRandom(SIGNING_SECRET_NAME, 32) | 249 | .getOrCreateRandom(SIGNING_SECRET_NAME, 32) |
| 250 | .pipe(Effect.orElseSucceed(() => null)); | 250 | .pipe(Effect.orElseSucceed(() => null)); |
| ... | @@ -255,7 +255,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( | ... | @@ -255,7 +255,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( |
| 255 | if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; | 255 | if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null; |
| 256 | 256 | ||
| 257 | if (claims.kind === "attachment") { | 257 | if (claims.kind === "attachment") { |
| 258 | const config = yield* ServerConfig; | 258 | const config = yield* ServerConfig.ServerConfig; |
| 259 | const attachmentPath = resolveAttachmentPathById({ | 259 | const attachmentPath = resolveAttachmentPathById({ |
| 260 | attachmentsDir: config.attachmentsDir, | 260 | attachmentsDir: config.attachmentsDir, |
| 261 | attachmentId: claims.attachmentId, | 261 | attachmentId: claims.attachmentId, |
apps/server/src/auth/EnvironmentAuth.test.ts+10-17| ... | @@ -53,29 +53,25 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { | ... | @@ -53,29 +53,25 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 53 | it.effect("classifies invalid bootstrap credential failures for the HTTP boundary", () => | 53 | it.effect("classifies invalid bootstrap credential failures for the HTTP boundary", () => |
| 54 | Effect.sync(() => { | 54 | Effect.sync(() => { |
| 55 | const error = EnvironmentAuth.toBootstrapExchangeError( | 55 | const error = EnvironmentAuth.toBootstrapExchangeError( |
| 56 | new PairingGrantStore.BootstrapCredentialInvalidError({ | 56 | new PairingGrantStore.UnknownBootstrapCredentialError({}), |
| 57 | message: "Unknown bootstrap credential.", | ||
| 58 | }), | ||
| 59 | ); | 57 | ); |
| 60 | 58 | ||
| 61 | expect(error._tag).toBe("ServerAuthInvalidCredentialError"); | 59 | expect(error._tag).toBe("ServerAuthInvalidCredentialError"); |
| 62 | if (error._tag === "ServerAuthInvalidCredentialError") { | ||
| 63 | expect(error.reason).toBe("invalid_credential"); | ||
| 64 | } | ||
| 65 | }), | 60 | }), |
| 66 | ); | 61 | ); |
| 67 | 62 | ||
| 68 | it.effect("maps unexpected bootstrap failures to 500", () => | 63 | it.effect("maps unexpected bootstrap failures to 500", () => |
| 69 | Effect.sync(() => { | 64 | Effect.sync(() => { |
| 70 | const error = EnvironmentAuth.toBootstrapExchangeError( | 65 | const cause = new PairingGrantStore.BootstrapCredentialConsumeError({ |
| 71 | new PairingGrantStore.BootstrapCredentialInternalError({ | 66 | cause: new Error("sqlite is unavailable"), |
| 72 | message: "Failed to consume bootstrap credential.", | 67 | }); |
| 73 | cause: new Error("sqlite is unavailable"), | 68 | const error = EnvironmentAuth.toBootstrapExchangeError(cause); |
| 74 | }), | ||
| 75 | ); | ||
| 76 | 69 | ||
| 77 | expect(error._tag).toBe("ServerAuthInternalError"); | 70 | expect(error._tag).toBe("ServerAuthBootstrapCredentialValidationError"); |
| 78 | expect(error.message).toBe("Failed to validate bootstrap credential."); | 71 | expect(error.message).toBe("Failed to validate bootstrap credential."); |
| 72 | if (error._tag === "ServerAuthBootstrapCredentialValidationError") { | ||
| 73 | expect(error.cause).toBe(cause); | ||
| 74 | } | ||
| 79 | }), | 75 | }), |
| 80 | ); | 76 | ); |
| 81 | 77 | ||
| ... | @@ -117,10 +113,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { | ... | @@ -117,10 +113,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 117 | ) | 113 | ) |
| 118 | .pipe(Effect.flip); | 114 | .pipe(Effect.flip); |
| 119 | 115 | ||
| 120 | expect(error._tag).toBe("ServerAuthInvalidRequestError"); | 116 | expect(error._tag).toBe("ServerAuthScopeNotGrantedError"); |
| 121 | if (error._tag === "ServerAuthInvalidRequestError") { | ||
| 122 | expect(error.reason).toBe("scope_not_granted"); | ||
| 123 | } | ||
| 124 | }).pipe(Effect.provide(makeEnvironmentAuthLayer())), | 117 | }).pipe(Effect.provide(makeEnvironmentAuthLayer())), |
| 125 | ); | 118 | ); |
| 126 | 119 |
apps/server/src/auth/EnvironmentAuth.ts+492-222| ... | @@ -20,12 +20,12 @@ import { | ... | @@ -20,12 +20,12 @@ import { |
| 20 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; | 20 | import { encodeOAuthScope } from "@t3tools/shared/oauthScope"; |
| 21 | import * as Context from "effect/Context"; | 21 | import * as Context from "effect/Context"; |
| 22 | import * as Crypto from "effect/Crypto"; | 22 | import * as Crypto from "effect/Crypto"; |
| 23 | import * as Data from "effect/Data"; | ||
| 24 | import * as DateTime from "effect/DateTime"; | 23 | import * as DateTime from "effect/DateTime"; |
| 25 | import * as Duration from "effect/Duration"; | 24 | import * as Duration from "effect/Duration"; |
| 26 | import * as Effect from "effect/Effect"; | 25 | import * as Effect from "effect/Effect"; |
| 27 | import * as Layer from "effect/Layer"; | 26 | import * as Layer from "effect/Layer"; |
| 28 | import * as Option from "effect/Option"; | 27 | import * as Option from "effect/Option"; |
| 28 | import * as Schema from "effect/Schema"; | ||
| 29 | import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; | 29 | import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; |
| 30 | 30 | ||
| 31 | import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; | 31 | import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts"; |
| ... | @@ -67,123 +67,429 @@ export interface AuthenticatedSession { | ... | @@ -67,123 +67,429 @@ export interface AuthenticatedSession { |
| 67 | readonly expiresAt?: DateTime.DateTime; | 67 | readonly expiresAt?: DateTime.DateTime; |
| 68 | } | 68 | } |
| 69 | 69 | ||
| 70 | export class ServerAuthInternalError extends Data.TaggedError("ServerAuthInternalError")<{ | 70 | const serverAuthInternalErrorContext = { |
| 71 | readonly message: string; | 71 | cause: Schema.Defect(), |
| 72 | readonly cause?: unknown; | 72 | }; |
| 73 | }> {} | 73 | |
| 74 | export class ServerAuthBootstrapCredentialValidationError extends Schema.TaggedErrorClass<ServerAuthBootstrapCredentialValidationError>()( | ||
| 75 | "ServerAuthBootstrapCredentialValidationError", | ||
| 76 | { | ||
| 77 | ...serverAuthInternalErrorContext, | ||
| 78 | }, | ||
| 79 | ) { | ||
| 80 | override get message(): string { | ||
| 81 | return "Failed to validate bootstrap credential."; | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 85 | export class ServerAuthSessionCredentialValidationError extends Schema.TaggedErrorClass<ServerAuthSessionCredentialValidationError>()( | ||
| 86 | "ServerAuthSessionCredentialValidationError", | ||
| 87 | { | ||
| 88 | ...serverAuthInternalErrorContext, | ||
| 89 | }, | ||
| 90 | ) { | ||
| 91 | override get message(): string { | ||
| 92 | return "Failed to validate session credential."; | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | export class ServerAuthAuthenticatedSessionIssueError extends Schema.TaggedErrorClass<ServerAuthAuthenticatedSessionIssueError>()( | ||
| 97 | "ServerAuthAuthenticatedSessionIssueError", | ||
| 98 | { | ||
| 99 | ...serverAuthInternalErrorContext, | ||
| 100 | }, | ||
| 101 | ) { | ||
| 102 | override get message(): string { | ||
| 103 | return "Failed to issue authenticated session."; | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | export class ServerAuthAuthenticatedAccessTokenIssueError extends Schema.TaggedErrorClass<ServerAuthAuthenticatedAccessTokenIssueError>()( | ||
| 108 | "ServerAuthAuthenticatedAccessTokenIssueError", | ||
| 109 | { | ||
| 110 | ...serverAuthInternalErrorContext, | ||
| 111 | }, | ||
| 112 | ) { | ||
| 113 | override get message(): string { | ||
| 114 | return "Failed to issue authenticated access token."; | ||
| 115 | } | ||
| 116 | } | ||
| 117 | |||
| 118 | export class ServerAuthPairingLinkCreationError extends Schema.TaggedErrorClass<ServerAuthPairingLinkCreationError>()( | ||
| 119 | "ServerAuthPairingLinkCreationError", | ||
| 120 | { | ||
| 121 | ...serverAuthInternalErrorContext, | ||
| 122 | }, | ||
| 123 | ) { | ||
| 124 | override get message(): string { | ||
| 125 | return "Failed to create pairing link."; | ||
| 126 | } | ||
| 127 | } | ||
| 128 | |||
| 129 | export class ServerAuthPairingLinksListError extends Schema.TaggedErrorClass<ServerAuthPairingLinksListError>()( | ||
| 130 | "ServerAuthPairingLinksListError", | ||
| 131 | { | ||
| 132 | ...serverAuthInternalErrorContext, | ||
| 133 | }, | ||
| 134 | ) { | ||
| 135 | override get message(): string { | ||
| 136 | return "Failed to list pairing links."; | ||
| 137 | } | ||
| 138 | } | ||
| 139 | |||
| 140 | export class ServerAuthPairingLinkRevocationError extends Schema.TaggedErrorClass<ServerAuthPairingLinkRevocationError>()( | ||
| 141 | "ServerAuthPairingLinkRevocationError", | ||
| 142 | { | ||
| 143 | ...serverAuthInternalErrorContext, | ||
| 144 | }, | ||
| 145 | ) { | ||
| 146 | override get message(): string { | ||
| 147 | return "Failed to revoke pairing link."; | ||
| 148 | } | ||
| 149 | } | ||
| 150 | |||
| 151 | export class ServerAuthSessionTokenIssueError extends Schema.TaggedErrorClass<ServerAuthSessionTokenIssueError>()( | ||
| 152 | "ServerAuthSessionTokenIssueError", | ||
| 153 | { | ||
| 154 | ...serverAuthInternalErrorContext, | ||
| 155 | }, | ||
| 156 | ) { | ||
| 157 | override get message(): string { | ||
| 158 | return "Failed to issue session token."; | ||
| 159 | } | ||
| 160 | } | ||
| 161 | |||
| 162 | export class ServerAuthSessionsListError extends Schema.TaggedErrorClass<ServerAuthSessionsListError>()( | ||
| 163 | "ServerAuthSessionsListError", | ||
| 164 | { | ||
| 165 | ...serverAuthInternalErrorContext, | ||
| 166 | }, | ||
| 167 | ) { | ||
| 168 | override get message(): string { | ||
| 169 | return "Failed to list sessions."; | ||
| 170 | } | ||
| 171 | } | ||
| 172 | |||
| 173 | export class ServerAuthSessionRevocationError extends Schema.TaggedErrorClass<ServerAuthSessionRevocationError>()( | ||
| 174 | "ServerAuthSessionRevocationError", | ||
| 175 | { | ||
| 176 | ...serverAuthInternalErrorContext, | ||
| 177 | }, | ||
| 178 | ) { | ||
| 179 | override get message(): string { | ||
| 180 | return "Failed to revoke session."; | ||
| 181 | } | ||
| 182 | } | ||
| 183 | |||
| 184 | export class ServerAuthOtherSessionsRevocationError extends Schema.TaggedErrorClass<ServerAuthOtherSessionsRevocationError>()( | ||
| 185 | "ServerAuthOtherSessionsRevocationError", | ||
| 186 | { | ||
| 187 | ...serverAuthInternalErrorContext, | ||
| 188 | }, | ||
| 189 | ) { | ||
| 190 | override get message(): string { | ||
| 191 | return "Failed to revoke other sessions."; | ||
| 192 | } | ||
| 193 | } | ||
| 194 | |||
| 195 | export class ServerAuthWebSocketTokenIssueError extends Schema.TaggedErrorClass<ServerAuthWebSocketTokenIssueError>()( | ||
| 196 | "ServerAuthWebSocketTokenIssueError", | ||
| 197 | { | ||
| 198 | ...serverAuthInternalErrorContext, | ||
| 199 | }, | ||
| 200 | ) { | ||
| 201 | override get message(): string { | ||
| 202 | return "Failed to issue websocket token."; | ||
| 203 | } | ||
| 204 | } | ||
| 205 | |||
| 206 | export class ServerAuthDpopReplayStateRecordError extends Schema.TaggedErrorClass<ServerAuthDpopReplayStateRecordError>()( | ||
| 207 | "ServerAuthDpopReplayStateRecordError", | ||
| 208 | { | ||
| 209 | ...serverAuthInternalErrorContext, | ||
| 210 | }, | ||
| 211 | ) { | ||
| 212 | override get message(): string { | ||
| 213 | return "Failed to record DPoP proof replay state."; | ||
| 214 | } | ||
| 215 | } | ||
| 216 | |||
| 217 | export class ServerAuthDpopReplayKeyCalculationError extends Schema.TaggedErrorClass<ServerAuthDpopReplayKeyCalculationError>()( | ||
| 218 | "ServerAuthDpopReplayKeyCalculationError", | ||
| 219 | { | ||
| 220 | ...serverAuthInternalErrorContext, | ||
| 221 | }, | ||
| 222 | ) { | ||
| 223 | override get message(): string { | ||
| 224 | return "Failed to calculate DPoP replay key."; | ||
| 225 | } | ||
| 226 | } | ||
| 227 | |||
| 228 | export class ServerAuthLinkedCloudAccountVerificationError extends Schema.TaggedErrorClass<ServerAuthLinkedCloudAccountVerificationError>()( | ||
| 229 | "ServerAuthLinkedCloudAccountVerificationError", | ||
| 230 | { | ||
| 231 | ...serverAuthInternalErrorContext, | ||
| 232 | }, | ||
| 233 | ) { | ||
| 234 | override get message(): string { | ||
| 235 | return "Could not verify the linked cloud account."; | ||
| 236 | } | ||
| 237 | } | ||
| 238 | |||
| 239 | export class ServerAuthLinkedCloudAccountReadError extends Schema.TaggedErrorClass<ServerAuthLinkedCloudAccountReadError>()( | ||
| 240 | "ServerAuthLinkedCloudAccountReadError", | ||
| 241 | { | ||
| 242 | ...serverAuthInternalErrorContext, | ||
| 243 | }, | ||
| 244 | ) { | ||
| 245 | override get message(): string { | ||
| 246 | return "Could not read the linked cloud account."; | ||
| 247 | } | ||
| 248 | } | ||
| 249 | |||
| 250 | export class ServerAuthLinkedCloudAccountMissingError extends Schema.TaggedErrorClass<ServerAuthLinkedCloudAccountMissingError>()( | ||
| 251 | "ServerAuthLinkedCloudAccountMissingError", | ||
| 252 | {}, | ||
| 253 | ) { | ||
| 254 | override get message(): string { | ||
| 255 | return "Cloud linked user is not installed for this environment."; | ||
| 256 | } | ||
| 257 | } | ||
| 258 | |||
| 259 | export class ServerAuthCloudLinkJwtSigningError extends Schema.TaggedErrorClass<ServerAuthCloudLinkJwtSigningError>()( | ||
| 260 | "ServerAuthCloudLinkJwtSigningError", | ||
| 261 | { | ||
| 262 | ...serverAuthInternalErrorContext, | ||
| 263 | }, | ||
| 264 | ) { | ||
| 265 | override get message(): string { | ||
| 266 | return "Failed to sign cloud link JWT."; | ||
| 267 | } | ||
| 268 | } | ||
| 269 | |||
| 270 | export class ServerAuthCloudMintPublicKeyMissingError extends Schema.TaggedErrorClass<ServerAuthCloudMintPublicKeyMissingError>()( | ||
| 271 | "ServerAuthCloudMintPublicKeyMissingError", | ||
| 272 | {}, | ||
| 273 | ) { | ||
| 274 | override get message(): string { | ||
| 275 | return "Cloud mint public key is not installed for this environment."; | ||
| 276 | } | ||
| 277 | } | ||
| 278 | |||
| 279 | export class ServerAuthCloudRelayIssuerMissingError extends Schema.TaggedErrorClass<ServerAuthCloudRelayIssuerMissingError>()( | ||
| 280 | "ServerAuthCloudRelayIssuerMissingError", | ||
| 281 | {}, | ||
| 282 | ) { | ||
| 283 | override get message(): string { | ||
| 284 | return "Cloud relay issuer is not installed for this environment."; | ||
| 285 | } | ||
| 286 | } | ||
| 74 | 287 | ||
| 75 | export class ServerAuthInvalidCredentialError extends Data.TaggedError( | 288 | export class ServerAuthCloudHealthJwtSigningError extends Schema.TaggedErrorClass<ServerAuthCloudHealthJwtSigningError>()( |
| 289 | "ServerAuthCloudHealthJwtSigningError", | ||
| 290 | { | ||
| 291 | ...serverAuthInternalErrorContext, | ||
| 292 | }, | ||
| 293 | ) { | ||
| 294 | override get message(): string { | ||
| 295 | return "Failed to sign cloud health JWT."; | ||
| 296 | } | ||
| 297 | } | ||
| 298 | |||
| 299 | export class ServerAuthCloudMintJwtSigningError extends Schema.TaggedErrorClass<ServerAuthCloudMintJwtSigningError>()( | ||
| 300 | "ServerAuthCloudMintJwtSigningError", | ||
| 301 | { | ||
| 302 | ...serverAuthInternalErrorContext, | ||
| 303 | }, | ||
| 304 | ) { | ||
| 305 | override get message(): string { | ||
| 306 | return "Failed to sign cloud mint JWT."; | ||
| 307 | } | ||
| 308 | } | ||
| 309 | |||
| 310 | export const ServerAuthInternalError = Schema.Union([ | ||
| 311 | ServerAuthBootstrapCredentialValidationError, | ||
| 312 | ServerAuthSessionCredentialValidationError, | ||
| 313 | ServerAuthAuthenticatedSessionIssueError, | ||
| 314 | ServerAuthAuthenticatedAccessTokenIssueError, | ||
| 315 | ServerAuthPairingLinkCreationError, | ||
| 316 | ServerAuthPairingLinksListError, | ||
| 317 | ServerAuthPairingLinkRevocationError, | ||
| 318 | ServerAuthSessionTokenIssueError, | ||
| 319 | ServerAuthSessionsListError, | ||
| 320 | ServerAuthSessionRevocationError, | ||
| 321 | ServerAuthOtherSessionsRevocationError, | ||
| 322 | ServerAuthWebSocketTokenIssueError, | ||
| 323 | ServerAuthDpopReplayStateRecordError, | ||
| 324 | ServerAuthDpopReplayKeyCalculationError, | ||
| 325 | ServerAuthLinkedCloudAccountVerificationError, | ||
| 326 | ServerAuthLinkedCloudAccountReadError, | ||
| 327 | ServerAuthLinkedCloudAccountMissingError, | ||
| 328 | ServerAuthCloudLinkJwtSigningError, | ||
| 329 | ServerAuthCloudMintPublicKeyMissingError, | ||
| 330 | ServerAuthCloudRelayIssuerMissingError, | ||
| 331 | ServerAuthCloudHealthJwtSigningError, | ||
| 332 | ServerAuthCloudMintJwtSigningError, | ||
| 333 | ]); | ||
| 334 | export type ServerAuthInternalError = typeof ServerAuthInternalError.Type; | ||
| 335 | export const isServerAuthInternalError = Schema.is(ServerAuthInternalError); | ||
| 336 | |||
| 337 | export class ServerAuthMissingCredentialError extends Schema.TaggedErrorClass<ServerAuthMissingCredentialError>()( | ||
| 338 | "ServerAuthMissingCredentialError", | ||
| 339 | {}, | ||
| 340 | ) { | ||
| 341 | override get message(): string { | ||
| 342 | return "Server authentication credential is missing."; | ||
| 343 | } | ||
| 344 | } | ||
| 345 | |||
| 346 | export class ServerAuthInvalidCredentialError extends Schema.TaggedErrorClass<ServerAuthInvalidCredentialError>()( | ||
| 76 | "ServerAuthInvalidCredentialError", | 347 | "ServerAuthInvalidCredentialError", |
| 77 | )<{ | 348 | { |
| 78 | readonly reason: "missing_credential" | "invalid_credential"; | 349 | diagnostic: Schema.optional(Schema.String), |
| 79 | readonly cause?: unknown; | 350 | cause: Schema.optional(Schema.Defect()), |
| 80 | }> {} | 351 | }, |
| 81 | 352 | ) { | |
| 82 | export class ServerAuthInvalidRequestError extends Data.TaggedError( | 353 | override get message(): string { |
| 83 | "ServerAuthInvalidRequestError", | 354 | return "Server authentication credential is invalid."; |
| 84 | )<{ | 355 | } |
| 85 | readonly reason: "invalid_scope" | "scope_not_granted"; | 356 | } |
| 86 | }> {} | ||
| 87 | |||
| 88 | export class ServerAuthForbiddenOperationError extends Data.TaggedError( | ||
| 89 | "ServerAuthForbiddenOperationError", | ||
| 90 | )<{ | ||
| 91 | readonly reason: "current_session_revoke_not_allowed"; | ||
| 92 | }> {} | ||
| 93 | 357 | ||
| 94 | export interface EnvironmentAuthShape { | 358 | export const ServerAuthCredentialError = Schema.Union([ |
| 95 | readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>; | 359 | ServerAuthMissingCredentialError, |
| 96 | readonly getSessionState: ( | 360 | ServerAuthInvalidCredentialError, |
| 97 | request: HttpServerRequest.HttpServerRequest, | 361 | ]); |
| 98 | ) => Effect.Effect<AuthSessionState, ServerAuthInternalError>; | 362 | export type ServerAuthCredentialError = typeof ServerAuthCredentialError.Type; |
| 99 | readonly createBrowserSession: ( | 363 | export const isServerAuthCredentialError = Schema.is(ServerAuthCredentialError); |
| 100 | credential: string, | 364 | export const serverAuthCredentialReason = ( |
| 101 | requestMetadata: AuthClientMetadata, | 365 | error: ServerAuthCredentialError, |
| 102 | ) => Effect.Effect< | 366 | ): "missing_credential" | "invalid_credential" => |
| 103 | { | 367 | error._tag === "ServerAuthMissingCredentialError" ? "missing_credential" : "invalid_credential"; |
| 104 | readonly response: AuthBrowserSessionResult; | 368 | |
| 105 | readonly sessionToken: string; | 369 | export class ServerAuthInvalidScopeError extends Schema.TaggedErrorClass<ServerAuthInvalidScopeError>()( |
| 106 | }, | 370 | "ServerAuthInvalidScopeError", |
| 107 | ServerAuthInvalidCredentialError | ServerAuthInternalError | 371 | {}, |
| 108 | >; | 372 | ) { |
| 109 | readonly exchangeBootstrapCredentialForAccessToken: ( | 373 | override get message(): string { |
| 110 | credential: string, | 374 | return "The requested authentication scope is invalid."; |
| 111 | requestedScopes: ReadonlyArray<AuthEnvironmentScope> | undefined, | 375 | } |
| 112 | requestMetadata: AuthClientMetadata, | ||
| 113 | input?: { | ||
| 114 | readonly proofKeyThumbprint?: string; | ||
| 115 | }, | ||
| 116 | ) => Effect.Effect< | ||
| 117 | AuthAccessTokenResult, | ||
| 118 | ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError | ||
| 119 | >; | ||
| 120 | readonly createPairingLink: (input?: { | ||
| 121 | readonly ttl?: Duration.Duration; | ||
| 122 | readonly label?: string; | ||
| 123 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | ||
| 124 | readonly subject?: string; | ||
| 125 | readonly proofKeyThumbprint?: string; | ||
| 126 | }) => Effect.Effect<IssuedPairingLink, ServerAuthInternalError>; | ||
| 127 | readonly issuePairingCredential: ( | ||
| 128 | input?: AuthCreatePairingCredentialInput, | ||
| 129 | ) => Effect.Effect<AuthPairingCredentialResult, ServerAuthInternalError>; | ||
| 130 | readonly issueStartupPairingCredential: () => Effect.Effect< | ||
| 131 | AuthPairingCredentialResult, | ||
| 132 | ServerAuthInternalError | ||
| 133 | >; | ||
| 134 | readonly listPairingLinks: (input?: { | ||
| 135 | readonly excludeSubjects?: ReadonlyArray<string>; | ||
| 136 | }) => Effect.Effect<ReadonlyArray<AuthPairingLink>, ServerAuthInternalError>; | ||
| 137 | readonly revokePairingLink: (id: string) => Effect.Effect<boolean, ServerAuthInternalError>; | ||
| 138 | readonly issueSession: (input?: { | ||
| 139 | readonly ttl?: Duration.Duration; | ||
| 140 | readonly subject?: string; | ||
| 141 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | ||
| 142 | readonly label?: string; | ||
| 143 | }) => Effect.Effect<IssuedBearerSession, ServerAuthInternalError>; | ||
| 144 | readonly listSessions: () => Effect.Effect< | ||
| 145 | ReadonlyArray<AuthClientSession>, | ||
| 146 | ServerAuthInternalError | ||
| 147 | >; | ||
| 148 | readonly revokeSession: ( | ||
| 149 | sessionId: AuthSessionId, | ||
| 150 | ) => Effect.Effect<boolean, ServerAuthInternalError>; | ||
| 151 | readonly revokeOtherSessionsExcept: ( | ||
| 152 | sessionId: AuthSessionId, | ||
| 153 | ) => Effect.Effect<number, ServerAuthInternalError>; | ||
| 154 | readonly listClientSessions: ( | ||
| 155 | currentSessionId: AuthSessionId, | ||
| 156 | ) => Effect.Effect<ReadonlyArray<AuthClientSession>, ServerAuthInternalError>; | ||
| 157 | readonly revokeClientSession: ( | ||
| 158 | currentSessionId: AuthSessionId, | ||
| 159 | targetSessionId: AuthSessionId, | ||
| 160 | ) => Effect.Effect<boolean, ServerAuthForbiddenOperationError | ServerAuthInternalError>; | ||
| 161 | readonly revokeOtherClientSessions: ( | ||
| 162 | currentSessionId: AuthSessionId, | ||
| 163 | ) => Effect.Effect<number, ServerAuthInternalError>; | ||
| 164 | readonly authenticateHttpRequest: ( | ||
| 165 | request: HttpServerRequest.HttpServerRequest, | ||
| 166 | ) => Effect.Effect< | ||
| 167 | AuthenticatedSession, | ||
| 168 | ServerAuthInvalidCredentialError | ServerAuthInternalError | ||
| 169 | >; | ||
| 170 | readonly authenticateWebSocketUpgrade: ( | ||
| 171 | request: HttpServerRequest.HttpServerRequest, | ||
| 172 | ) => Effect.Effect< | ||
| 173 | AuthenticatedSession, | ||
| 174 | ServerAuthInvalidCredentialError | ServerAuthInternalError | ||
| 175 | >; | ||
| 176 | readonly issueWebSocketTicket: ( | ||
| 177 | session: Pick<AuthenticatedSession, "sessionId">, | ||
| 178 | ) => Effect.Effect<AuthWebSocketTicketResult, ServerAuthInternalError>; | ||
| 179 | readonly issueStartupPairingUrl: ( | ||
| 180 | baseUrl: string, | ||
| 181 | ) => Effect.Effect<string, ServerAuthInternalError>; | ||
| 182 | } | 376 | } |
| 183 | 377 | ||
| 184 | export class EnvironmentAuth extends Context.Service<EnvironmentAuth, EnvironmentAuthShape>()( | 378 | export class ServerAuthScopeNotGrantedError extends Schema.TaggedErrorClass<ServerAuthScopeNotGrantedError>()( |
| 185 | "t3/auth/EnvironmentAuth", | 379 | "ServerAuthScopeNotGrantedError", |
| 186 | ) {} | 380 | {}, |
| 381 | ) { | ||
| 382 | override get message(): string { | ||
| 383 | return "The requested authentication scope was not granted."; | ||
| 384 | } | ||
| 385 | } | ||
| 386 | |||
| 387 | export const ServerAuthInvalidRequestError = Schema.Union([ | ||
| 388 | ServerAuthInvalidScopeError, | ||
| 389 | ServerAuthScopeNotGrantedError, | ||
| 390 | ]); | ||
| 391 | export type ServerAuthInvalidRequestError = typeof ServerAuthInvalidRequestError.Type; | ||
| 392 | export const isServerAuthInvalidRequestError = Schema.is(ServerAuthInvalidRequestError); | ||
| 393 | export const serverAuthInvalidRequestReason = ( | ||
| 394 | error: ServerAuthInvalidRequestError, | ||
| 395 | ): "invalid_scope" | "scope_not_granted" => | ||
| 396 | error._tag === "ServerAuthInvalidScopeError" ? "invalid_scope" : "scope_not_granted"; | ||
| 397 | |||
| 398 | export class ServerAuthForbiddenOperationError extends Schema.TaggedErrorClass<ServerAuthForbiddenOperationError>()( | ||
| 399 | "ServerAuthForbiddenOperationError", | ||
| 400 | {}, | ||
| 401 | ) { | ||
| 402 | override get message(): string { | ||
| 403 | return "The current authentication session cannot revoke itself."; | ||
| 404 | } | ||
| 405 | } | ||
| 406 | |||
| 407 | export class EnvironmentAuth extends Context.Service< | ||
| 408 | EnvironmentAuth, | ||
| 409 | { | ||
| 410 | readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>; | ||
| 411 | readonly getSessionState: ( | ||
| 412 | request: HttpServerRequest.HttpServerRequest, | ||
| 413 | ) => Effect.Effect<AuthSessionState, ServerAuthInternalError>; | ||
| 414 | readonly createBrowserSession: ( | ||
| 415 | credential: string, | ||
| 416 | requestMetadata: AuthClientMetadata, | ||
| 417 | ) => Effect.Effect< | ||
| 418 | { | ||
| 419 | readonly response: AuthBrowserSessionResult; | ||
| 420 | readonly sessionToken: string; | ||
| 421 | }, | ||
| 422 | ServerAuthInvalidCredentialError | ServerAuthInternalError | ||
| 423 | >; | ||
| 424 | readonly exchangeBootstrapCredentialForAccessToken: ( | ||
| 425 | credential: string, | ||
| 426 | requestedScopes: ReadonlyArray<AuthEnvironmentScope> | undefined, | ||
| 427 | requestMetadata: AuthClientMetadata, | ||
| 428 | input?: { | ||
| 429 | readonly proofKeyThumbprint?: string; | ||
| 430 | }, | ||
| 431 | ) => Effect.Effect< | ||
| 432 | AuthAccessTokenResult, | ||
| 433 | ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError | ||
| 434 | >; | ||
| 435 | readonly createPairingLink: (input?: { | ||
| 436 | readonly ttl?: Duration.Duration; | ||
| 437 | readonly label?: string; | ||
| 438 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | ||
| 439 | readonly subject?: string; | ||
| 440 | readonly proofKeyThumbprint?: string; | ||
| 441 | }) => Effect.Effect<IssuedPairingLink, ServerAuthInternalError>; | ||
| 442 | readonly issuePairingCredential: ( | ||
| 443 | input?: AuthCreatePairingCredentialInput, | ||
| 444 | ) => Effect.Effect<AuthPairingCredentialResult, ServerAuthInternalError>; | ||
| 445 | readonly issueStartupPairingCredential: () => Effect.Effect< | ||
| 446 | AuthPairingCredentialResult, | ||
| 447 | ServerAuthInternalError | ||
| 448 | >; | ||
| 449 | readonly listPairingLinks: (input?: { | ||
| 450 | readonly excludeSubjects?: ReadonlyArray<string>; | ||
| 451 | }) => Effect.Effect<ReadonlyArray<AuthPairingLink>, ServerAuthInternalError>; | ||
| 452 | readonly revokePairingLink: (id: string) => Effect.Effect<boolean, ServerAuthInternalError>; | ||
| 453 | readonly issueSession: (input?: { | ||
| 454 | readonly ttl?: Duration.Duration; | ||
| 455 | readonly subject?: string; | ||
| 456 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | ||
| 457 | readonly label?: string; | ||
| 458 | }) => Effect.Effect<IssuedBearerSession, ServerAuthInternalError>; | ||
| 459 | readonly listSessions: () => Effect.Effect< | ||
| 460 | ReadonlyArray<AuthClientSession>, | ||
| 461 | ServerAuthInternalError | ||
| 462 | >; | ||
| 463 | readonly revokeSession: ( | ||
| 464 | sessionId: AuthSessionId, | ||
| 465 | ) => Effect.Effect<boolean, ServerAuthInternalError>; | ||
| 466 | readonly revokeOtherSessionsExcept: ( | ||
| 467 | sessionId: AuthSessionId, | ||
| 468 | ) => Effect.Effect<number, ServerAuthInternalError>; | ||
| 469 | readonly listClientSessions: ( | ||
| 470 | currentSessionId: AuthSessionId, | ||
| 471 | ) => Effect.Effect<ReadonlyArray<AuthClientSession>, ServerAuthInternalError>; | ||
| 472 | readonly revokeClientSession: ( | ||
| 473 | currentSessionId: AuthSessionId, | ||
| 474 | targetSessionId: AuthSessionId, | ||
| 475 | ) => Effect.Effect<boolean, ServerAuthForbiddenOperationError | ServerAuthInternalError>; | ||
| 476 | readonly revokeOtherClientSessions: ( | ||
| 477 | currentSessionId: AuthSessionId, | ||
| 478 | ) => Effect.Effect<number, ServerAuthInternalError>; | ||
| 479 | readonly authenticateHttpRequest: ( | ||
| 480 | request: HttpServerRequest.HttpServerRequest, | ||
| 481 | ) => Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError>; | ||
| 482 | readonly authenticateWebSocketUpgrade: ( | ||
| 483 | request: HttpServerRequest.HttpServerRequest, | ||
| 484 | ) => Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError>; | ||
| 485 | readonly issueWebSocketTicket: ( | ||
| 486 | session: Pick<AuthenticatedSession, "sessionId">, | ||
| 487 | ) => Effect.Effect<AuthWebSocketTicketResult, ServerAuthInternalError>; | ||
| 488 | readonly issueStartupPairingUrl: ( | ||
| 489 | baseUrl: string, | ||
| 490 | ) => Effect.Effect<string, ServerAuthInternalError>; | ||
| 491 | } | ||
| 492 | >()("t3/auth/EnvironmentAuth") {} | ||
| 187 | 493 | ||
| 188 | type BootstrapExchangeResult = { | 494 | type BootstrapExchangeResult = { |
| 189 | readonly response: AuthBrowserSessionResult; | 495 | readonly response: AuthBrowserSessionResult; |
| ... | @@ -206,23 +512,14 @@ const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => | ... | @@ -206,23 +512,14 @@ const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => |
| 206 | return right.issuedAt.epochMilliseconds - left.issuedAt.epochMilliseconds; | 512 | return right.issuedAt.epochMilliseconds - left.issuedAt.epochMilliseconds; |
| 207 | }; | 513 | }; |
| 208 | 514 | ||
| 209 | const toInternalError = | ||
| 210 | (message: string) => | ||
| 211 | (cause: unknown): ServerAuthInternalError => | ||
| 212 | new ServerAuthInternalError({ message, cause }); | ||
| 213 | |||
| 214 | export function toBootstrapExchangeError( | 515 | export function toBootstrapExchangeError( |
| 215 | cause: PairingGrantStore.BootstrapCredentialError, | 516 | cause: PairingGrantStore.BootstrapCredentialError, |
| 216 | ): ServerAuthInvalidCredentialError | ServerAuthInternalError { | 517 | ): ServerAuthInvalidCredentialError | ServerAuthInternalError { |
| 217 | if (cause._tag === "BootstrapCredentialInternalError") { | 518 | if (PairingGrantStore.isBootstrapCredentialInternalError(cause)) { |
| 218 | return new ServerAuthInternalError({ | 519 | return new ServerAuthBootstrapCredentialValidationError({ cause }); |
| 219 | message: "Failed to validate bootstrap credential.", | ||
| 220 | cause, | ||
| 221 | }); | ||
| 222 | } | 520 | } |
| 223 | 521 | ||
| 224 | return new ServerAuthInvalidCredentialError({ | 522 | return new ServerAuthInvalidCredentialError({ |
| 225 | reason: "invalid_credential", | ||
| 226 | cause, | 523 | cause, |
| 227 | }); | 524 | }); |
| 228 | } | 525 | } |
| ... | @@ -231,17 +528,11 @@ const mapSessionVerificationErrors = <A, R>( | ... | @@ -231,17 +528,11 @@ const mapSessionVerificationErrors = <A, R>( |
| 231 | effect: Effect.Effect<A, SessionStore.SessionCredentialError, R>, | 528 | effect: Effect.Effect<A, SessionStore.SessionCredentialError, R>, |
| 232 | ): Effect.Effect<A, ServerAuthInvalidCredentialError | ServerAuthInternalError, R> => | 529 | ): Effect.Effect<A, ServerAuthInvalidCredentialError | ServerAuthInternalError, R> => |
| 233 | effect.pipe( | 530 | effect.pipe( |
| 234 | Effect.catchTags({ | 531 | Effect.mapError((cause) => |
| 235 | SessionCredentialInvalidError: (cause) => | 532 | SessionStore.isSessionCredentialInvalidError(cause) |
| 236 | Effect.fail(new ServerAuthInvalidCredentialError({ reason: "invalid_credential", cause })), | 533 | ? new ServerAuthInvalidCredentialError({ cause }) |
| 237 | SessionCredentialInternalError: (cause) => | 534 | : new ServerAuthSessionCredentialValidationError({ cause }), |
| 238 | Effect.fail( | 535 | ), |
| 239 | new ServerAuthInternalError({ | ||
| 240 | message: "Failed to validate session credential.", | ||
| 241 | cause, | ||
| 242 | }), | ||
| 243 | ), | ||
| 244 | }), | ||
| 245 | ); | 536 | ); |
| 246 | 537 | ||
| 247 | function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null { | 538 | function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null { |
| ... | @@ -262,7 +553,7 @@ function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string | | ... | @@ -262,7 +553,7 @@ function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string | |
| 262 | return token.length > 0 ? token : null; | 553 | return token.length > 0 ? token : null; |
| 263 | } | 554 | } |
| 264 | 555 | ||
| 265 | export const make = Effect.fn("makeEnvironmentAuth")(function* () { | 556 | export const make = Effect.gen(function* () { |
| 266 | const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; | 557 | const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; |
| 267 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; | 558 | const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; |
| 268 | const sessions = yield* SessionStore.SessionStore; | 559 | const sessions = yield* SessionStore.SessionStore; |
| ... | @@ -277,12 +568,14 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -277,12 +568,14 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 277 | ServerAuthInvalidCredentialError | ServerAuthInternalError | 568 | ServerAuthInvalidCredentialError | ServerAuthInternalError |
| 278 | > => | 569 | > => |
| 279 | sessions.verify(token).pipe( | 570 | sessions.verify(token).pipe( |
| 280 | Effect.tapErrorTag("SessionCredentialInvalidError", (cause) => | 571 | Effect.tapError((cause) => |
| 281 | Effect.logWarning("Rejected authenticated session credential.").pipe( | 572 | SessionStore.isSessionCredentialInvalidError(cause) |
| 282 | Effect.annotateLogs({ | 573 | ? Effect.logWarning("Rejected authenticated session credential.").pipe( |
| 283 | reason: cause.message, | 574 | Effect.annotateLogs({ |
| 284 | }), | 575 | reason: cause.message, |
| 285 | ), | 576 | }), |
| 577 | ) | ||
| 578 | : Effect.void, | ||
| 286 | ), | 579 | ), |
| 287 | Effect.map((session) => ({ | 580 | Effect.map((session) => ({ |
| 288 | sessionId: session.sessionId, | 581 | sessionId: session.sessionId, |
| ... | @@ -295,13 +588,15 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -295,13 +588,15 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 295 | mapSessionVerificationErrors, | 588 | mapSessionVerificationErrors, |
| 296 | ); | 589 | ); |
| 297 | 590 | ||
| 298 | const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => { | 591 | const authenticateRequest = ( |
| 592 | request: HttpServerRequest.HttpServerRequest, | ||
| 593 | ): Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError> => { | ||
| 299 | const cookieToken = request.cookies[sessions.cookieName]; | 594 | const cookieToken = request.cookies[sessions.cookieName]; |
| 300 | const bearerToken = parseBearerToken(request); | 595 | const bearerToken = parseBearerToken(request); |
| 301 | const dpopToken = parseDpopToken(request); | 596 | const dpopToken = parseDpopToken(request); |
| 302 | const credential = cookieToken ?? bearerToken ?? dpopToken; | 597 | const credential = cookieToken ?? bearerToken ?? dpopToken; |
| 303 | if (!credential) { | 598 | if (!credential) { |
| 304 | return Effect.fail(new ServerAuthInvalidCredentialError({ reason: "missing_credential" })); | 599 | return Effect.fail(new ServerAuthMissingCredentialError({})); |
| 305 | } | 600 | } |
| 306 | return authenticateToken(credential).pipe( | 601 | return authenticateToken(credential).pipe( |
| 307 | Effect.flatMap((session) => { | 602 | Effect.flatMap((session) => { |
| ... | @@ -309,8 +604,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -309,8 +604,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 309 | if (!dpopToken || dpopToken !== credential) { | 604 | if (!dpopToken || dpopToken !== credential) { |
| 310 | return Effect.fail( | 605 | return Effect.fail( |
| 311 | new ServerAuthInvalidCredentialError({ | 606 | new ServerAuthInvalidCredentialError({ |
| 312 | reason: "invalid_credential", | 607 | diagnostic: "DPoP-bound access token requires DPoP authorization.", |
| 313 | cause: "DPoP-bound access token requires DPoP authorization.", | ||
| 314 | }), | 608 | }), |
| 315 | ); | 609 | ); |
| 316 | } | 610 | } |
| ... | @@ -327,8 +621,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -327,8 +621,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 327 | if (dpopToken) { | 621 | if (dpopToken) { |
| 328 | return Effect.fail( | 622 | return Effect.fail( |
| 329 | new ServerAuthInvalidCredentialError({ | 623 | new ServerAuthInvalidCredentialError({ |
| 330 | reason: "invalid_credential", | 624 | diagnostic: "DPoP authorization requires a proof-bound access token.", |
| 331 | cause: "DPoP authorization requires a proof-bound access token.", | ||
| 332 | }), | 625 | }), |
| 333 | ); | 626 | ); |
| 334 | } | 627 | } |
| ... | @@ -337,7 +630,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -337,7 +630,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 337 | ); | 630 | ); |
| 338 | }; | 631 | }; |
| 339 | 632 | ||
| 340 | const getSessionState: EnvironmentAuthShape["getSessionState"] = (request) => | 633 | const getSessionState: EnvironmentAuth["Service"]["getSessionState"] = (request) => |
| 341 | authenticateRequest(request).pipe( | 634 | authenticateRequest(request).pipe( |
| 342 | Effect.map( | 635 | Effect.map( |
| 343 | (session) => | 636 | (session) => |
| ... | @@ -349,7 +642,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -349,7 +642,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 349 | ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}), | 642 | ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}), |
| 350 | }) satisfies AuthSessionState, | 643 | }) satisfies AuthSessionState, |
| 351 | ), | 644 | ), |
| 352 | Effect.catchTag("ServerAuthInvalidCredentialError", () => | 645 | Effect.catchIf(isServerAuthCredentialError, () => |
| 353 | Effect.succeed({ | 646 | Effect.succeed({ |
| 354 | authenticated: false, | 647 | authenticated: false, |
| 355 | auth: descriptor, | 648 | auth: descriptor, |
| ... | @@ -358,7 +651,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -358,7 +651,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 358 | Effect.withSpan("EnvironmentAuth.getSessionState"), | 651 | Effect.withSpan("EnvironmentAuth.getSessionState"), |
| 359 | ); | 652 | ); |
| 360 | 653 | ||
| 361 | const createBrowserSession: EnvironmentAuthShape["createBrowserSession"] = ( | 654 | const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = ( |
| 362 | credential, | 655 | credential, |
| 363 | requestMetadata, | 656 | requestMetadata, |
| 364 | ) => | 657 | ) => |
| ... | @@ -376,13 +669,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -376,13 +669,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 376 | }, | 669 | }, |
| 377 | }) | 670 | }) |
| 378 | .pipe( | 671 | .pipe( |
| 379 | Effect.mapError( | 672 | Effect.mapError((cause) => new ServerAuthAuthenticatedSessionIssueError({ cause })), |
| 380 | (cause) => | ||
| 381 | new ServerAuthInternalError({ | ||
| 382 | message: "Failed to issue authenticated session.", | ||
| 383 | cause, | ||
| 384 | }), | ||
| 385 | ), | ||
| 386 | ), | 673 | ), |
| 387 | ), | 674 | ), |
| 388 | Effect.map( | 675 | Effect.map( |
| ... | @@ -400,7 +687,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -400,7 +687,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 400 | Effect.withSpan("EnvironmentAuth.createBrowserSession"), | 687 | Effect.withSpan("EnvironmentAuth.createBrowserSession"), |
| 401 | ); | 688 | ); |
| 402 | 689 | ||
| 403 | const exchangeBootstrapCredentialForAccessToken: EnvironmentAuthShape["exchangeBootstrapCredentialForAccessToken"] = | 690 | const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = |
| 404 | (credential, requestedScopes, requestMetadata, input) => | 691 | (credential, requestedScopes, requestMetadata, input) => |
| 405 | bootstrapCredentials.consume(credential, input).pipe( | 692 | bootstrapCredentials.consume(credential, input).pipe( |
| 406 | Effect.mapError(toBootstrapExchangeError), | 693 | Effect.mapError(toBootstrapExchangeError), |
| ... | @@ -408,9 +695,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -408,9 +695,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 408 | Effect.gen(function* () { | 695 | Effect.gen(function* () { |
| 409 | const grantedScopes = requestedScopes ?? grant.scopes; | 696 | const grantedScopes = requestedScopes ?? grant.scopes; |
| 410 | if (!grantedScopes.every((scope) => grant.scopes.includes(scope))) { | 697 | if (!grantedScopes.every((scope) => grant.scopes.includes(scope))) { |
| 411 | return yield* new ServerAuthInvalidRequestError({ | 698 | return yield* new ServerAuthScopeNotGrantedError({}); |
| 412 | reason: "scope_not_granted", | ||
| 413 | }); | ||
| 414 | } | 699 | } |
| 415 | return yield* sessions | 700 | return yield* sessions |
| 416 | .issue({ | 701 | .issue({ |
| ... | @@ -430,11 +715,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -430,11 +715,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 430 | }) | 715 | }) |
| 431 | .pipe( | 716 | .pipe( |
| 432 | Effect.mapError( | 717 | Effect.mapError( |
| 433 | (cause) => | 718 | (cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause }), |
| 434 | new ServerAuthInternalError({ | ||
| 435 | message: "Failed to issue authenticated access token.", | ||
| 436 | cause, | ||
| 437 | }), | ||
| 438 | ), | 719 | ), |
| 439 | ); | 720 | ); |
| 440 | }), | 721 | }), |
| ... | @@ -482,7 +763,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -482,7 +763,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 482 | ), | 763 | ), |
| 483 | ); | 764 | ); |
| 484 | 765 | ||
| 485 | const createPairingLink: EnvironmentAuthShape["createPairingLink"] = Effect.fn( | 766 | const createPairingLink: EnvironmentAuth["Service"]["createPairingLink"] = Effect.fn( |
| 486 | "EnvironmentAuth.createPairingLink", | 767 | "EnvironmentAuth.createPairingLink", |
| 487 | )( | 768 | )( |
| 488 | function* (input) { | 769 | function* (input) { |
| ... | @@ -504,10 +785,10 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -504,10 +785,10 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 504 | expiresAt: DateTime.toUtc(issued.expiresAt), | 785 | expiresAt: DateTime.toUtc(issued.expiresAt), |
| 505 | } satisfies IssuedPairingLink; | 786 | } satisfies IssuedPairingLink; |
| 506 | }, | 787 | }, |
| 507 | Effect.mapError(toInternalError("Failed to create pairing link.")), | 788 | Effect.mapError((cause) => new ServerAuthPairingLinkCreationError({ cause })), |
| 508 | ); | 789 | ); |
| 509 | 790 | ||
| 510 | const listPairingLinks: EnvironmentAuthShape["listPairingLinks"] = (input) => | 791 | const listPairingLinks: EnvironmentAuth["Service"]["listPairingLinks"] = (input) => |
| 511 | bootstrapCredentials.listActive().pipe( | 792 | bootstrapCredentials.listActive().pipe( |
| 512 | Effect.map((pairingLinks) => { | 793 | Effect.map((pairingLinks) => { |
| 513 | const excludedSubjects = input?.excludeSubjects ?? [ | 794 | const excludedSubjects = input?.excludeSubjects ?? [ |
| ... | @@ -519,19 +800,17 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -519,19 +800,17 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 519 | (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds, | 800 | (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds, |
| 520 | ); | 801 | ); |
| 521 | }), | 802 | }), |
| 522 | Effect.mapError(toInternalError("Failed to list pairing links.")), | 803 | Effect.mapError((cause) => new ServerAuthPairingLinksListError({ cause })), |
| 523 | Effect.withSpan("EnvironmentAuth.listPairingLinks"), | 804 | Effect.withSpan("EnvironmentAuth.listPairingLinks"), |
| 524 | ); | 805 | ); |
| 525 | 806 | ||
| 526 | const revokePairingLink: EnvironmentAuthShape["revokePairingLink"] = (id) => | 807 | const revokePairingLink: EnvironmentAuth["Service"]["revokePairingLink"] = (id) => |
| 527 | bootstrapCredentials | 808 | bootstrapCredentials.revoke(id).pipe( |
| 528 | .revoke(id) | 809 | Effect.mapError((cause) => new ServerAuthPairingLinkRevocationError({ cause })), |
| 529 | .pipe( | 810 | Effect.withSpan("EnvironmentAuth.revokePairingLink"), |
| 530 | Effect.mapError(toInternalError("Failed to revoke pairing link.")), | 811 | ); |
| 531 | Effect.withSpan("EnvironmentAuth.revokePairingLink"), | ||
| 532 | ); | ||
| 533 | 812 | ||
| 534 | const issueSession: EnvironmentAuthShape["issueSession"] = (input) => | 813 | const issueSession: EnvironmentAuth["Service"]["issueSession"] = (input) => |
| 535 | sessions | 814 | sessions |
| 536 | .issue({ | 815 | .issue({ |
| 537 | subject: input?.subject ?? DEFAULT_SESSION_SUBJECT, | 816 | subject: input?.subject ?? DEFAULT_SESSION_SUBJECT, |
| ... | @@ -556,49 +835,46 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -556,49 +835,46 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 556 | expiresAt: DateTime.toUtc(issued.expiresAt), | 835 | expiresAt: DateTime.toUtc(issued.expiresAt), |
| 557 | }) satisfies IssuedBearerSession, | 836 | }) satisfies IssuedBearerSession, |
| 558 | ), | 837 | ), |
| 559 | Effect.mapError(toInternalError("Failed to issue session token.")), | 838 | Effect.mapError((cause) => new ServerAuthSessionTokenIssueError({ cause })), |
| 560 | Effect.withSpan("EnvironmentAuth.issueSession"), | 839 | Effect.withSpan("EnvironmentAuth.issueSession"), |
| 561 | ); | 840 | ); |
| 562 | 841 | ||
| 563 | const listSessions: EnvironmentAuthShape["listSessions"] = () => | 842 | const listSessions: EnvironmentAuth["Service"]["listSessions"] = () => |
| 564 | sessions.listActive().pipe( | 843 | sessions.listActive().pipe( |
| 565 | Effect.map((activeSessions) => activeSessions.toSorted(bySessionPriority)), | 844 | Effect.map((activeSessions) => activeSessions.toSorted(bySessionPriority)), |
| 566 | Effect.mapError(toInternalError("Failed to list sessions.")), | 845 | Effect.mapError((cause) => new ServerAuthSessionsListError({ cause })), |
| 567 | Effect.withSpan("EnvironmentAuth.listSessions"), | 846 | Effect.withSpan("EnvironmentAuth.listSessions"), |
| 568 | ); | 847 | ); |
| 569 | 848 | ||
| 570 | const revokeSession: EnvironmentAuthShape["revokeSession"] = (sessionId) => | 849 | const revokeSession: EnvironmentAuth["Service"]["revokeSession"] = (sessionId) => |
| 571 | sessions | 850 | sessions.revoke(sessionId).pipe( |
| 572 | .revoke(sessionId) | 851 | Effect.mapError((cause) => new ServerAuthSessionRevocationError({ cause })), |
| 573 | .pipe( | 852 | Effect.withSpan("EnvironmentAuth.revokeSession"), |
| 574 | Effect.mapError(toInternalError("Failed to revoke session.")), | 853 | ); |
| 575 | Effect.withSpan("EnvironmentAuth.revokeSession"), | ||
| 576 | ); | ||
| 577 | 854 | ||
| 578 | const revokeOtherSessionsExcept: EnvironmentAuthShape["revokeOtherSessionsExcept"] = ( | 855 | const revokeOtherSessionsExcept: EnvironmentAuth["Service"]["revokeOtherSessionsExcept"] = ( |
| 579 | sessionId, | 856 | sessionId, |
| 580 | ) => | 857 | ) => |
| 581 | sessions | 858 | sessions.revokeAllExcept(sessionId).pipe( |
| 582 | .revokeAllExcept(sessionId) | 859 | Effect.mapError((cause) => new ServerAuthOtherSessionsRevocationError({ cause })), |
| 583 | .pipe( | 860 | Effect.withSpan("EnvironmentAuth.revokeOtherSessionsExcept"), |
| 584 | Effect.mapError(toInternalError("Failed to revoke other sessions.")), | 861 | ); |
| 585 | Effect.withSpan("EnvironmentAuth.revokeOtherSessionsExcept"), | ||
| 586 | ); | ||
| 587 | 862 | ||
| 588 | const issuePairingCredential: EnvironmentAuthShape["issuePairingCredential"] = (input) => | 863 | const issuePairingCredential: EnvironmentAuth["Service"]["issuePairingCredential"] = (input) => |
| 589 | issuePairingCredentialForSubject({ | 864 | issuePairingCredentialForSubject({ |
| 590 | scopes: input?.scopes ?? AuthStandardClientScopes, | 865 | scopes: input?.scopes ?? AuthStandardClientScopes, |
| 591 | subject: "one-time-token", | 866 | subject: "one-time-token", |
| 592 | ...(input?.label ? { label: input.label } : {}), | 867 | ...(input?.label ? { label: input.label } : {}), |
| 593 | }).pipe(Effect.withSpan("EnvironmentAuth.issuePairingCredential")); | 868 | }).pipe(Effect.withSpan("EnvironmentAuth.issuePairingCredential")); |
| 594 | 869 | ||
| 595 | const issueStartupPairingCredential: EnvironmentAuthShape["issueStartupPairingCredential"] = () => | 870 | const issueStartupPairingCredential: EnvironmentAuth["Service"]["issueStartupPairingCredential"] = |
| 596 | issuePairingCredentialForSubject({ | 871 | () => |
| 597 | scopes: AuthAdministrativeScopes, | 872 | issuePairingCredentialForSubject({ |
| 598 | subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT, | 873 | scopes: AuthAdministrativeScopes, |
| 599 | }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential")); | 874 | subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT, |
| 875 | }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential")); | ||
| 600 | 876 | ||
| 601 | const listClientSessions: EnvironmentAuthShape["listClientSessions"] = (currentSessionId) => | 877 | const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) => |
| 602 | listSessions().pipe( | 878 | listSessions().pipe( |
| 603 | Effect.map((clientSessions) => | 879 | Effect.map((clientSessions) => |
| 604 | clientSessions.map( | 880 | clientSessions.map( |
| ... | @@ -611,25 +887,23 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -611,25 +887,23 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 611 | Effect.withSpan("EnvironmentAuth.listClientSessions"), | 887 | Effect.withSpan("EnvironmentAuth.listClientSessions"), |
| 612 | ); | 888 | ); |
| 613 | 889 | ||
| 614 | const revokeClientSession: EnvironmentAuthShape["revokeClientSession"] = Effect.fn( | 890 | const revokeClientSession: EnvironmentAuth["Service"]["revokeClientSession"] = Effect.fn( |
| 615 | "EnvironmentAuth.revokeClientSession", | 891 | "EnvironmentAuth.revokeClientSession", |
| 616 | )(function* (currentSessionId, targetSessionId) { | 892 | )(function* (currentSessionId, targetSessionId) { |
| 617 | if (currentSessionId === targetSessionId) { | 893 | if (currentSessionId === targetSessionId) { |
| 618 | return yield* new ServerAuthForbiddenOperationError({ | 894 | return yield* new ServerAuthForbiddenOperationError({}); |
| 619 | reason: "current_session_revoke_not_allowed", | ||
| 620 | }); | ||
| 621 | } | 895 | } |
| 622 | return yield* revokeSession(targetSessionId); | 896 | return yield* revokeSession(targetSessionId); |
| 623 | }); | 897 | }); |
| 624 | 898 | ||
| 625 | const revokeOtherClientSessions: EnvironmentAuthShape["revokeOtherClientSessions"] = ( | 899 | const revokeOtherClientSessions: EnvironmentAuth["Service"]["revokeOtherClientSessions"] = ( |
| 626 | currentSessionId, | 900 | currentSessionId, |
| 627 | ) => | 901 | ) => |
| 628 | revokeOtherSessionsExcept(currentSessionId).pipe( | 902 | revokeOtherSessionsExcept(currentSessionId).pipe( |
| 629 | Effect.withSpan("EnvironmentAuth.revokeOtherClientSessions"), | 903 | Effect.withSpan("EnvironmentAuth.revokeOtherClientSessions"), |
| 630 | ); | 904 | ); |
| 631 | 905 | ||
| 632 | const issueStartupPairingUrl: EnvironmentAuthShape["issueStartupPairingUrl"] = (baseUrl) => | 906 | const issueStartupPairingUrl: EnvironmentAuth["Service"]["issueStartupPairingUrl"] = (baseUrl) => |
| 633 | issueStartupPairingCredential().pipe( | 907 | issueStartupPairingCredential().pipe( |
| 634 | Effect.map((issued) => { | 908 | Effect.map((issued) => { |
| 635 | const url = new URL(baseUrl); | 909 | const url = new URL(baseUrl); |
| ... | @@ -641,15 +915,9 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -641,15 +915,9 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 641 | Effect.withSpan("EnvironmentAuth.issueStartupPairingUrl"), | 915 | Effect.withSpan("EnvironmentAuth.issueStartupPairingUrl"), |
| 642 | ); | 916 | ); |
| 643 | 917 | ||
| 644 | const issueWebSocketTicket: EnvironmentAuthShape["issueWebSocketTicket"] = (session) => | 918 | const issueWebSocketTicket: EnvironmentAuth["Service"]["issueWebSocketTicket"] = (session) => |
| 645 | sessions.issueWebSocketToken(session.sessionId).pipe( | 919 | sessions.issueWebSocketToken(session.sessionId).pipe( |
| 646 | Effect.mapError( | 920 | Effect.mapError((cause) => new ServerAuthWebSocketTokenIssueError({ cause })), |
| 647 | (cause) => | ||
| 648 | new ServerAuthInternalError({ | ||
| 649 | message: "Failed to issue websocket token.", | ||
| 650 | cause, | ||
| 651 | }), | ||
| 652 | ), | ||
| 653 | Effect.map( | 921 | Effect.map( |
| 654 | (issued) => | 922 | (issued) => |
| 655 | ({ | 923 | ({ |
| ... | @@ -660,10 +928,12 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -660,10 +928,12 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 660 | Effect.withSpan("EnvironmentAuth.issueWebSocketTicket"), | 928 | Effect.withSpan("EnvironmentAuth.issueWebSocketTicket"), |
| 661 | ); | 929 | ); |
| 662 | 930 | ||
| 663 | const authenticateHttpRequest: EnvironmentAuthShape["authenticateHttpRequest"] = (request) => | 931 | const authenticateHttpRequest: EnvironmentAuth["Service"]["authenticateHttpRequest"] = ( |
| 932 | request, | ||
| 933 | ) => | ||
| 664 | authenticateRequest(request).pipe(Effect.withSpan("EnvironmentAuth.authenticateHttpRequest")); | 934 | authenticateRequest(request).pipe(Effect.withSpan("EnvironmentAuth.authenticateHttpRequest")); |
| 665 | 935 | ||
| 666 | const authenticateWebSocketUpgrade: EnvironmentAuthShape["authenticateWebSocketUpgrade"] = | 936 | const authenticateWebSocketUpgrade: EnvironmentAuth["Service"]["authenticateWebSocketUpgrade"] = |
| 667 | Effect.fn("EnvironmentAuth.authenticateWebSocketUpgrade")(function* (request) { | 937 | Effect.fn("EnvironmentAuth.authenticateWebSocketUpgrade")(function* (request) { |
| 668 | const requestUrl = HttpServerRequest.toURL(request); | 938 | const requestUrl = HttpServerRequest.toURL(request); |
| 669 | if (Option.isSome(requestUrl)) { | 939 | if (Option.isSome(requestUrl)) { |
| ... | @@ -685,7 +955,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -685,7 +955,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 685 | return yield* authenticateRequest(request); | 955 | return yield* authenticateRequest(request); |
| 686 | }); | 956 | }); |
| 687 | 957 | ||
| 688 | return { | 958 | return EnvironmentAuth.of({ |
| 689 | getDescriptor: () => | 959 | getDescriptor: () => |
| 690 | Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuth.getDescriptor")), | 960 | Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuth.getDescriptor")), |
| 691 | getSessionState, | 961 | getSessionState, |
| ... | @@ -707,10 +977,10 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { | ... | @@ -707,10 +977,10 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () { |
| 707 | authenticateWebSocketUpgrade, | 977 | authenticateWebSocketUpgrade, |
| 708 | issueWebSocketTicket, | 978 | issueWebSocketTicket, |
| 709 | issueStartupPairingUrl, | 979 | issueStartupPairingUrl, |
| 710 | } satisfies EnvironmentAuthShape; | 980 | }); |
| 711 | }); | 981 | }); |
| 712 | 982 | ||
| 713 | export const layer = Layer.effect(EnvironmentAuth, make()).pipe( | 983 | export const layer = Layer.effect(EnvironmentAuth, make).pipe( |
| 714 | Layer.provideMerge(PairingGrantStore.layer), | 984 | Layer.provideMerge(PairingGrantStore.layer), |
| 715 | Layer.provideMerge(SessionStore.layer), | 985 | Layer.provideMerge(SessionStore.layer), |
| 716 | Layer.provideMerge(EnvironmentAuthPolicy.layer), | 986 | Layer.provideMerge(EnvironmentAuthPolicy.layer), |
apps/server/src/auth/EnvironmentAuthAdmin.test.ts+5-1| ... | @@ -22,7 +22,11 @@ const makeServerConfigLayer = ( | ... | @@ -22,7 +22,11 @@ const makeServerConfigLayer = ( |
| 22 | } satisfies ServerConfig.ServerConfig["Service"]; | 22 | } satisfies ServerConfig.ServerConfig["Service"]; |
| 23 | }), | 23 | }), |
| 24 | ).pipe( | 24 | ).pipe( |
| 25 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-control-plane-test-" })), | 25 | Layer.provide( |
| 26 | ServerConfig.layerTest(process.cwd(), { | ||
| 27 | prefix: "t3-auth-control-plane-test-", | ||
| 28 | }), | ||
| 29 | ), | ||
| 26 | ); | 30 | ); |
| 27 | 31 | ||
| 28 | const makeEnvironmentAuthLayer = ( | 32 | const makeEnvironmentAuthLayer = ( |
apps/server/src/auth/EnvironmentAuthPolicy.ts+9-11| ... | @@ -3,21 +3,19 @@ import * as Context from "effect/Context"; | ... | @@ -3,21 +3,19 @@ import * as Context from "effect/Context"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 5 | 5 | ||
| 6 | import { ServerConfig } from "../config.ts"; | 6 | import * as ServerConfig from "../config.ts"; |
| 7 | import { resolveSessionCookieName } from "./utils.ts"; | 7 | import { resolveSessionCookieName } from "./utils.ts"; |
| 8 | import { isLoopbackHost, isWildcardHost } from "../startupAccess.ts"; | 8 | import { isLoopbackHost, isWildcardHost } from "../startupAccess.ts"; |
| 9 | 9 | ||
| 10 | export interface EnvironmentAuthPolicyShape { | ||
| 11 | readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>; | ||
| 12 | } | ||
| 13 | |||
| 14 | export class EnvironmentAuthPolicy extends Context.Service< | 10 | export class EnvironmentAuthPolicy extends Context.Service< |
| 15 | EnvironmentAuthPolicy, | 11 | EnvironmentAuthPolicy, |
| 16 | EnvironmentAuthPolicyShape | 12 | { |
| 13 | readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>; | ||
| 14 | } | ||
| 17 | >()("t3/auth/EnvironmentAuthPolicy") {} | 15 | >()("t3/auth/EnvironmentAuthPolicy") {} |
| 18 | 16 | ||
| 19 | export const make = Effect.fn("makeEnvironmentAuthPolicy")(function* () { | 17 | export const make = Effect.gen(function* () { |
| 20 | const config = yield* ServerConfig; | 18 | const config = yield* ServerConfig.ServerConfig; |
| 21 | const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host); | 19 | const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host); |
| 22 | 20 | ||
| 23 | const policy = | 21 | const policy = |
| ... | @@ -46,10 +44,10 @@ export const make = Effect.fn("makeEnvironmentAuthPolicy")(function* () { | ... | @@ -46,10 +44,10 @@ export const make = Effect.fn("makeEnvironmentAuthPolicy")(function* () { |
| 46 | }), | 44 | }), |
| 47 | }; | 45 | }; |
| 48 | 46 | ||
| 49 | return { | 47 | return EnvironmentAuthPolicy.of({ |
| 50 | getDescriptor: () => | 48 | getDescriptor: () => |
| 51 | Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuthPolicy.getDescriptor")), | 49 | Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuthPolicy.getDescriptor")), |
| 52 | } satisfies EnvironmentAuthPolicyShape; | 50 | }); |
| 53 | }); | 51 | }); |
| 54 | 52 | ||
| 55 | export const layer = Layer.effect(EnvironmentAuthPolicy, make()); | 53 | export const layer = Layer.effect(EnvironmentAuthPolicy, make); |
apps/server/src/auth/PairingGrantStore.test.ts+5-5| ... | @@ -61,7 +61,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { | ... | @@ -61,7 +61,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { |
| 61 | expect(first.subject).toBe("one-time-token"); | 61 | expect(first.subject).toBe("one-time-token"); |
| 62 | expect(first.label).toBe("Julius iPhone"); | 62 | expect(first.label).toBe("Julius iPhone"); |
| 63 | expect(issued.label).toBe("Julius iPhone"); | 63 | expect(issued.label).toBe("Julius iPhone"); |
| 64 | expect(second._tag).toBe("BootstrapCredentialInvalidError"); | 64 | expect(second._tag).toBe("UnknownBootstrapCredentialError"); |
| 65 | expect(second.message).toContain("Unknown bootstrap credential"); | 65 | expect(second.message).toContain("Unknown bootstrap credential"); |
| 66 | }).pipe(Effect.provide(makePairingGrantStoreLayer())), | 66 | }).pipe(Effect.provide(makePairingGrantStoreLayer())), |
| 67 | ); | 67 | ); |
| ... | @@ -85,7 +85,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { | ... | @@ -85,7 +85,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { |
| 85 | expect(successes).toHaveLength(1); | 85 | expect(successes).toHaveLength(1); |
| 86 | expect(failures).toHaveLength(7); | 86 | expect(failures).toHaveLength(7); |
| 87 | for (const failure of failures) { | 87 | for (const failure of failures) { |
| 88 | expect(failure.failure._tag).toBe("BootstrapCredentialInvalidError"); | 88 | expect(failure.failure._tag).toBe("UnknownBootstrapCredentialError"); |
| 89 | expect(failure.failure.message).toContain("Unknown bootstrap credential"); | 89 | expect(failure.failure.message).toContain("Unknown bootstrap credential"); |
| 90 | } | 90 | } |
| 91 | }).pipe(Effect.provide(makePairingGrantStoreLayer())), | 91 | }).pipe(Effect.provide(makePairingGrantStoreLayer())), |
| ... | @@ -132,7 +132,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { | ... | @@ -132,7 +132,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { |
| 132 | "relay:write", | 132 | "relay:write", |
| 133 | ]); | 133 | ]); |
| 134 | expect(first.subject).toBe("desktop-bootstrap"); | 134 | expect(first.subject).toBe("desktop-bootstrap"); |
| 135 | expect(second._tag).toBe("BootstrapCredentialInvalidError"); | 135 | expect(second._tag).toBe("UnknownBootstrapCredentialError"); |
| 136 | }).pipe( | 136 | }).pipe( |
| 137 | Effect.provide( | 137 | Effect.provide( |
| 138 | makePairingGrantStoreLayer({ | 138 | makePairingGrantStoreLayer({ |
| ... | @@ -149,7 +149,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { | ... | @@ -149,7 +149,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { |
| 149 | yield* TestClock.adjust(Duration.minutes(6)); | 149 | yield* TestClock.adjust(Duration.minutes(6)); |
| 150 | const expired = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); | 150 | const expired = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token")); |
| 151 | 151 | ||
| 152 | expect(expired._tag).toBe("BootstrapCredentialInvalidError"); | 152 | expect(expired._tag).toBe("ExpiredBootstrapCredentialError"); |
| 153 | expect(expired.message).toContain("Bootstrap credential expired"); | 153 | expect(expired.message).toContain("Bootstrap credential expired"); |
| 154 | }).pipe( | 154 | }).pipe( |
| 155 | Effect.provide( | 155 | Effect.provide( |
| ... | @@ -183,7 +183,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { | ... | @@ -183,7 +183,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { |
| 183 | expect(activeAfterRevoke.map((entry) => entry.id)).not.toContain(first.id); | 183 | expect(activeAfterRevoke.map((entry) => entry.id)).not.toContain(first.id); |
| 184 | expect(activeAfterRevoke.map((entry) => entry.id)).toContain(second.id); | 184 | expect(activeAfterRevoke.map((entry) => entry.id)).toContain(second.id); |
| 185 | expect(revokedConsume.message).toContain("no longer available"); | 185 | expect(revokedConsume.message).toContain("no longer available"); |
| 186 | expect(revokedConsume._tag).toBe("BootstrapCredentialInvalidError"); | 186 | expect(revokedConsume._tag).toBe("UnavailableBootstrapCredentialError"); |
| 187 | }).pipe(Effect.provide(makePairingGrantStoreLayer())), | 187 | }).pipe(Effect.provide(makePairingGrantStoreLayer())), |
| 188 | ); | 188 | ); |
| 189 | }); | 189 | }); |
apps/server/src/auth/PairingGrantStore.ts+150-82| ... | @@ -7,17 +7,17 @@ import { | ... | @@ -7,17 +7,17 @@ import { |
| 7 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import * as Context from "effect/Context"; | 8 | import * as Context from "effect/Context"; |
| 9 | import * as Crypto from "effect/Crypto"; | 9 | import * as Crypto from "effect/Crypto"; |
| 10 | import * as Data from "effect/Data"; | ||
| 11 | import * as DateTime from "effect/DateTime"; | 10 | import * as DateTime from "effect/DateTime"; |
| 12 | import * as Duration from "effect/Duration"; | 11 | import * as Duration from "effect/Duration"; |
| 13 | import * as Effect from "effect/Effect"; | 12 | import * as Effect from "effect/Effect"; |
| 14 | import * as Layer from "effect/Layer"; | 13 | import * as Layer from "effect/Layer"; |
| 14 | import * as Option from "effect/Option"; | ||
| 15 | import * as PubSub from "effect/PubSub"; | 15 | import * as PubSub from "effect/PubSub"; |
| 16 | import * as Ref from "effect/Ref"; | 16 | import * as Ref from "effect/Ref"; |
| 17 | import * as Schema from "effect/Schema"; | ||
| 17 | import * as Stream from "effect/Stream"; | 18 | import * as Stream from "effect/Stream"; |
| 18 | import * as Option from "effect/Option"; | ||
| 19 | 19 | ||
| 20 | import { ServerConfig } from "../config.ts"; | 20 | import * as ServerConfig from "../config.ts"; |
| 21 | import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; | 21 | import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; |
| 22 | 22 | ||
| 23 | export interface BootstrapGrant { | 23 | export interface BootstrapGrant { |
| ... | @@ -29,22 +29,110 @@ export interface BootstrapGrant { | ... | @@ -29,22 +29,110 @@ export interface BootstrapGrant { |
| 29 | readonly expiresAt: DateTime.DateTime; | 29 | readonly expiresAt: DateTime.DateTime; |
| 30 | } | 30 | } |
| 31 | 31 | ||
| 32 | export class BootstrapCredentialInvalidError extends Data.TaggedError( | 32 | export class UnknownBootstrapCredentialError extends Schema.TaggedErrorClass<UnknownBootstrapCredentialError>()( |
| 33 | "BootstrapCredentialInvalidError", | 33 | "UnknownBootstrapCredentialError", |
| 34 | )<{ | 34 | {}, |
| 35 | readonly message: string; | 35 | ) { |
| 36 | }> {} | 36 | override get message(): string { |
| 37 | return "Unknown bootstrap credential."; | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | export class ExpiredBootstrapCredentialError extends Schema.TaggedErrorClass<ExpiredBootstrapCredentialError>()( | ||
| 42 | "ExpiredBootstrapCredentialError", | ||
| 43 | {}, | ||
| 44 | ) { | ||
| 45 | override get message(): string { | ||
| 46 | return "Bootstrap credential expired."; | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | export class BootstrapCredentialProofKeyMismatchError extends Schema.TaggedErrorClass<BootstrapCredentialProofKeyMismatchError>()( | ||
| 51 | "BootstrapCredentialProofKeyMismatchError", | ||
| 52 | {}, | ||
| 53 | ) { | ||
| 54 | override get message(): string { | ||
| 55 | return "Bootstrap credential proof key mismatch."; | ||
| 56 | } | ||
| 57 | } | ||
| 58 | |||
| 59 | export class UnavailableBootstrapCredentialError extends Schema.TaggedErrorClass<UnavailableBootstrapCredentialError>()( | ||
| 60 | "UnavailableBootstrapCredentialError", | ||
| 61 | {}, | ||
| 62 | ) { | ||
| 63 | override get message(): string { | ||
| 64 | return "Bootstrap credential is no longer available."; | ||
| 65 | } | ||
| 66 | } | ||
| 67 | |||
| 68 | export const BootstrapCredentialInvalidError = Schema.Union([ | ||
| 69 | UnknownBootstrapCredentialError, | ||
| 70 | ExpiredBootstrapCredentialError, | ||
| 71 | BootstrapCredentialProofKeyMismatchError, | ||
| 72 | UnavailableBootstrapCredentialError, | ||
| 73 | ]); | ||
| 74 | export type BootstrapCredentialInvalidError = typeof BootstrapCredentialInvalidError.Type; | ||
| 75 | export const isBootstrapCredentialInvalidError = Schema.is(BootstrapCredentialInvalidError); | ||
| 76 | |||
| 77 | export class ActivePairingLinksLoadError extends Schema.TaggedErrorClass<ActivePairingLinksLoadError>()( | ||
| 78 | "ActivePairingLinksLoadError", | ||
| 79 | { | ||
| 80 | cause: Schema.Defect(), | ||
| 81 | }, | ||
| 82 | ) { | ||
| 83 | override get message(): string { | ||
| 84 | return "Failed to load active pairing links."; | ||
| 85 | } | ||
| 86 | } | ||
| 87 | |||
| 88 | export class PairingLinkRevokeError extends Schema.TaggedErrorClass<PairingLinkRevokeError>()( | ||
| 89 | "PairingLinkRevokeError", | ||
| 90 | { | ||
| 91 | cause: Schema.Defect(), | ||
| 92 | }, | ||
| 93 | ) { | ||
| 94 | override get message(): string { | ||
| 95 | return "Failed to revoke pairing link."; | ||
| 96 | } | ||
| 97 | } | ||
| 37 | 98 | ||
| 38 | export class BootstrapCredentialInternalError extends Data.TaggedError( | 99 | export class PairingCredentialIssueError extends Schema.TaggedErrorClass<PairingCredentialIssueError>()( |
| 39 | "BootstrapCredentialInternalError", | 100 | "PairingCredentialIssueError", |
| 40 | )<{ | 101 | { |
| 41 | readonly message: string; | 102 | cause: Schema.Defect(), |
| 42 | readonly cause?: unknown; | 103 | }, |
| 43 | }> {} | 104 | ) { |
| 105 | override get message(): string { | ||
| 106 | return "Failed to issue pairing credential."; | ||
| 107 | } | ||
| 108 | } | ||
| 44 | 109 | ||
| 45 | export type BootstrapCredentialError = | 110 | export class BootstrapCredentialConsumeError extends Schema.TaggedErrorClass<BootstrapCredentialConsumeError>()( |
| 46 | | BootstrapCredentialInvalidError | 111 | "BootstrapCredentialConsumeError", |
| 47 | | BootstrapCredentialInternalError; | 112 | { |
| 113 | cause: Schema.Defect(), | ||
| 114 | }, | ||
| 115 | ) { | ||
| 116 | override get message(): string { | ||
| 117 | return "Failed to consume bootstrap credential."; | ||
| 118 | } | ||
| 119 | } | ||
| 120 | |||
| 121 | export const BootstrapCredentialInternalError = Schema.Union([ | ||
| 122 | ActivePairingLinksLoadError, | ||
| 123 | PairingLinkRevokeError, | ||
| 124 | PairingCredentialIssueError, | ||
| 125 | BootstrapCredentialConsumeError, | ||
| 126 | ]); | ||
| 127 | export type BootstrapCredentialInternalError = typeof BootstrapCredentialInternalError.Type; | ||
| 128 | export const isBootstrapCredentialInternalError = Schema.is(BootstrapCredentialInternalError); | ||
| 129 | |||
| 130 | export const BootstrapCredentialError = Schema.Union([ | ||
| 131 | BootstrapCredentialInvalidError, | ||
| 132 | BootstrapCredentialInternalError, | ||
| 133 | ]); | ||
| 134 | export type BootstrapCredentialError = typeof BootstrapCredentialError.Type; | ||
| 135 | export const isBootstrapCredentialError = Schema.is(BootstrapCredentialError); | ||
| 48 | 136 | ||
| 49 | export interface IssuedBootstrapCredential { | 137 | export interface IssuedBootstrapCredential { |
| 50 | readonly id: string; | 138 | readonly id: string; |
| ... | @@ -64,31 +152,30 @@ export type BootstrapCredentialChange = | ... | @@ -64,31 +152,30 @@ export type BootstrapCredentialChange = |
| 64 | readonly id: string; | 152 | readonly id: string; |
| 65 | }; | 153 | }; |
| 66 | 154 | ||
| 67 | export interface PairingGrantStoreShape { | 155 | export class PairingGrantStore extends Context.Service< |
| 68 | readonly issueOneTimeToken: (input?: { | 156 | PairingGrantStore, |
| 69 | readonly ttl?: Duration.Duration; | 157 | { |
| 70 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | 158 | readonly issueOneTimeToken: (input?: { |
| 71 | readonly subject?: string; | 159 | readonly ttl?: Duration.Duration; |
| 72 | readonly label?: string; | 160 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; |
| 73 | readonly proofKeyThumbprint?: string; | 161 | readonly subject?: string; |
| 74 | }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialInternalError>; | 162 | readonly label?: string; |
| 75 | readonly listActive: () => Effect.Effect< | ||
| 76 | ReadonlyArray<AuthPairingLink>, | ||
| 77 | BootstrapCredentialInternalError | ||
| 78 | >; | ||
| 79 | readonly streamChanges: Stream.Stream<BootstrapCredentialChange>; | ||
| 80 | readonly revoke: (id: string) => Effect.Effect<boolean, BootstrapCredentialInternalError>; | ||
| 81 | readonly consume: ( | ||
| 82 | credential: string, | ||
| 83 | input?: { | ||
| 84 | readonly proofKeyThumbprint?: string; | 163 | readonly proofKeyThumbprint?: string; |
| 85 | }, | 164 | }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialInternalError>; |
| 86 | ) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>; | 165 | readonly listActive: () => Effect.Effect< |
| 87 | } | 166 | ReadonlyArray<AuthPairingLink>, |
| 88 | 167 | BootstrapCredentialInternalError | |
| 89 | export class PairingGrantStore extends Context.Service<PairingGrantStore, PairingGrantStoreShape>()( | 168 | >; |
| 90 | "t3/auth/PairingGrantStore", | 169 | readonly streamChanges: Stream.Stream<BootstrapCredentialChange>; |
| 91 | ) {} | 170 | readonly revoke: (id: string) => Effect.Effect<boolean, BootstrapCredentialInternalError>; |
| 171 | readonly consume: ( | ||
| 172 | credential: string, | ||
| 173 | input?: { | ||
| 174 | readonly proofKeyThumbprint?: string; | ||
| 175 | }, | ||
| 176 | ) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>; | ||
| 177 | } | ||
| 178 | >()("t3/auth/PairingGrantStore") {} | ||
| 92 | 179 | ||
| 93 | interface StoredBootstrapGrant extends BootstrapGrant { | 180 | interface StoredBootstrapGrant extends BootstrapGrant { |
| 94 | readonly remainingUses: number | "unbounded"; | 181 | readonly remainingUses: number | "unbounded"; |
| ... | @@ -111,20 +198,9 @@ const PAIRING_TOKEN_LENGTH = 12; | ... | @@ -111,20 +198,9 @@ const PAIRING_TOKEN_LENGTH = 12; |
| 111 | const PAIRING_TOKEN_REJECTION_LIMIT = | 198 | const PAIRING_TOKEN_REJECTION_LIMIT = |
| 112 | Math.floor(256 / PAIRING_TOKEN_ALPHABET.length) * PAIRING_TOKEN_ALPHABET.length; | 199 | Math.floor(256 / PAIRING_TOKEN_ALPHABET.length) * PAIRING_TOKEN_ALPHABET.length; |
| 113 | 200 | ||
| 114 | const invalidBootstrapCredentialError = (message: string) => | 201 | export const make = Effect.gen(function* () { |
| 115 | new BootstrapCredentialInvalidError({ | ||
| 116 | message, | ||
| 117 | }); | ||
| 118 | |||
| 119 | const internalBootstrapCredentialError = (message: string, cause: unknown) => | ||
| 120 | new BootstrapCredentialInternalError({ | ||
| 121 | message, | ||
| 122 | cause, | ||
| 123 | }); | ||
| 124 | |||
| 125 | export const make = Effect.fn("makePairingGrantStore")(function* () { | ||
| 126 | const crypto = yield* Crypto.Crypto; | 202 | const crypto = yield* Crypto.Crypto; |
| 127 | const config = yield* ServerConfig; | 203 | const config = yield* ServerConfig.ServerConfig; |
| 128 | const pairingLinks = yield* AuthPairingLinks.AuthPairingLinkRepository; | 204 | const pairingLinks = yield* AuthPairingLinks.AuthPairingLinkRepository; |
| 129 | const seededGrantsRef = yield* Ref.make(new Map<string, StoredBootstrapGrant>()); | 205 | const seededGrantsRef = yield* Ref.make(new Map<string, StoredBootstrapGrant>()); |
| 130 | const changesPubSub = yield* PubSub.unbounded<BootstrapCredentialChange>(); | 206 | const changesPubSub = yield* PubSub.unbounded<BootstrapCredentialChange>(); |
| ... | @@ -177,10 +253,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { | ... | @@ -177,10 +253,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { |
| 177 | }); | 253 | }); |
| 178 | } | 254 | } |
| 179 | 255 | ||
| 180 | const toBootstrapCredentialError = (message: string) => (cause: unknown) => | 256 | const listActive: PairingGrantStore["Service"]["listActive"] = Effect.fn( |
| 181 | internalBootstrapCredentialError(message, cause); | ||
| 182 | |||
| 183 | const listActive: PairingGrantStoreShape["listActive"] = Effect.fn( | ||
| 184 | "PairingGrantStore.listActive", | 257 | "PairingGrantStore.listActive", |
| 185 | )( | 258 | )( |
| 186 | function* () { | 259 | function* () { |
| ... | @@ -208,10 +281,10 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { | ... | @@ -208,10 +281,10 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { |
| 208 | } satisfies AuthPairingLink), | 281 | } satisfies AuthPairingLink), |
| 209 | ); | 282 | ); |
| 210 | }, | 283 | }, |
| 211 | Effect.mapError(toBootstrapCredentialError("Failed to load active pairing links.")), | 284 | Effect.mapError((cause) => new ActivePairingLinksLoadError({ cause })), |
| 212 | ); | 285 | ); |
| 213 | 286 | ||
| 214 | const revoke: PairingGrantStoreShape["revoke"] = Effect.fn("PairingGrantStore.revoke")( | 287 | const revoke: PairingGrantStore["Service"]["revoke"] = Effect.fn("PairingGrantStore.revoke")( |
| 215 | function* (id) { | 288 | function* (id) { |
| 216 | const revokedAt = yield* DateTime.now; | 289 | const revokedAt = yield* DateTime.now; |
| 217 | const revoked = yield* pairingLinks.revoke({ | 290 | const revoked = yield* pairingLinks.revoke({ |
| ... | @@ -223,10 +296,10 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { | ... | @@ -223,10 +296,10 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { |
| 223 | } | 296 | } |
| 224 | return revoked; | 297 | return revoked; |
| 225 | }, | 298 | }, |
| 226 | Effect.mapError(toBootstrapCredentialError("Failed to revoke pairing link.")), | 299 | Effect.mapError((cause) => new PairingLinkRevokeError({ cause })), |
| 227 | ); | 300 | ); |
| 228 | 301 | ||
| 229 | const issueOneTimeToken: PairingGrantStoreShape["issueOneTimeToken"] = Effect.fn( | 302 | const issueOneTimeToken: PairingGrantStore["Service"]["issueOneTimeToken"] = Effect.fn( |
| 230 | "PairingGrantStore.issueOneTimeToken", | 303 | "PairingGrantStore.issueOneTimeToken", |
| 231 | )( | 304 | )( |
| 232 | function* (input) { | 305 | function* (input) { |
| ... | @@ -264,10 +337,10 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { | ... | @@ -264,10 +337,10 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { |
| 264 | }); | 337 | }); |
| 265 | return issued; | 338 | return issued; |
| 266 | }, | 339 | }, |
| 267 | Effect.mapError(toBootstrapCredentialError("Failed to issue pairing credential.")), | 340 | Effect.mapError((cause) => new PairingCredentialIssueError({ cause })), |
| 268 | ); | 341 | ); |
| 269 | 342 | ||
| 270 | const consume: PairingGrantStoreShape["consume"] = Effect.fn("PairingGrantStore.consume")( | 343 | const consume: PairingGrantStore["Service"]["consume"] = Effect.fn("PairingGrantStore.consume")( |
| 271 | function* (credential, input) { | 344 | function* (credential, input) { |
| 272 | const now = yield* DateTime.now; | 345 | const now = yield* DateTime.now; |
| 273 | const seededResult: ConsumeResult = yield* Ref.modify( | 346 | const seededResult: ConsumeResult = yield* Ref.modify( |
| ... | @@ -279,7 +352,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { | ... | @@ -279,7 +352,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { |
| 279 | { | 352 | { |
| 280 | _tag: "error", | 353 | _tag: "error", |
| 281 | reason: "not-found", | 354 | reason: "not-found", |
| 282 | error: invalidBootstrapCredentialError("Unknown bootstrap credential."), | 355 | error: new UnknownBootstrapCredentialError({}), |
| 283 | }, | 356 | }, |
| 284 | current, | 357 | current, |
| 285 | ]; | 358 | ]; |
| ... | @@ -292,7 +365,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { | ... | @@ -292,7 +365,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { |
| 292 | { | 365 | { |
| 293 | _tag: "error", | 366 | _tag: "error", |
| 294 | reason: "expired", | 367 | reason: "expired", |
| 295 | error: invalidBootstrapCredentialError("Bootstrap credential expired."), | 368 | error: new ExpiredBootstrapCredentialError({}), |
| 296 | }, | 369 | }, |
| 297 | next, | 370 | next, |
| 298 | ]; | 371 | ]; |
| ... | @@ -303,7 +376,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { | ... | @@ -303,7 +376,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { |
| 303 | { | 376 | { |
| 304 | _tag: "error", | 377 | _tag: "error", |
| 305 | reason: "not-found", | 378 | reason: "not-found", |
| 306 | error: invalidBootstrapCredentialError("Bootstrap credential proof key mismatch."), | 379 | error: new BootstrapCredentialProofKeyMismatchError({}), |
| 307 | }, | 380 | }, |
| 308 | next, | 381 | next, |
| 309 | ]; | 382 | ]; |
| ... | @@ -370,41 +443,36 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { | ... | @@ -370,41 +443,36 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { |
| 370 | 443 | ||
| 371 | const matching = yield* pairingLinks.getByCredential({ credential }); | 444 | const matching = yield* pairingLinks.getByCredential({ credential }); |
| 372 | if (Option.isNone(matching)) { | 445 | if (Option.isNone(matching)) { |
| 373 | return yield* invalidBootstrapCredentialError("Unknown bootstrap credential."); | 446 | return yield* new UnknownBootstrapCredentialError({}); |
| 374 | } | 447 | } |
| 375 | 448 | ||
| 376 | if (matching.value.revokedAt !== null) { | 449 | if (matching.value.revokedAt !== null) { |
| 377 | return yield* invalidBootstrapCredentialError( | 450 | return yield* new UnavailableBootstrapCredentialError({}); |
| 378 | "Bootstrap credential is no longer available.", | ||
| 379 | ); | ||
| 380 | } | 451 | } |
| 381 | 452 | ||
| 382 | if (matching.value.consumedAt !== null) { | 453 | if (matching.value.consumedAt !== null) { |
| 383 | return yield* invalidBootstrapCredentialError("Unknown bootstrap credential."); | 454 | return yield* new UnknownBootstrapCredentialError({}); |
| 384 | } | 455 | } |
| 385 | 456 | ||
| 386 | if (DateTime.isGreaterThanOrEqualTo(now, matching.value.expiresAt)) { | 457 | if (DateTime.isGreaterThanOrEqualTo(now, matching.value.expiresAt)) { |
| 387 | return yield* invalidBootstrapCredentialError("Bootstrap credential expired."); | 458 | return yield* new ExpiredBootstrapCredentialError({}); |
| 388 | } | 459 | } |
| 389 | 460 | ||
| 390 | if ( | 461 | if ( |
| 391 | matching.value.proofKeyThumbprint !== null && | 462 | matching.value.proofKeyThumbprint !== null && |
| 392 | matching.value.proofKeyThumbprint !== input?.proofKeyThumbprint | 463 | matching.value.proofKeyThumbprint !== input?.proofKeyThumbprint |
| 393 | ) { | 464 | ) { |
| 394 | return yield* invalidBootstrapCredentialError("Bootstrap credential proof key mismatch."); | 465 | return yield* new BootstrapCredentialProofKeyMismatchError({}); |
| 395 | } | 466 | } |
| 396 | 467 | ||
| 397 | return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available."); | 468 | return yield* new UnavailableBootstrapCredentialError({}); |
| 398 | }, | 469 | }, |
| 399 | Effect.mapError((cause) => | 470 | Effect.mapError((cause) => |
| 400 | cause._tag === "BootstrapCredentialInvalidError" || | 471 | isBootstrapCredentialError(cause) ? cause : new BootstrapCredentialConsumeError({ cause }), |
| 401 | cause._tag === "BootstrapCredentialInternalError" | ||
| 402 | ? cause | ||
| 403 | : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause), | ||
| 404 | ), | 472 | ), |
| 405 | ); | 473 | ); |
| 406 | 474 | ||
| 407 | return { | 475 | return PairingGrantStore.of({ |
| 408 | issueOneTimeToken, | 476 | issueOneTimeToken, |
| 409 | listActive, | 477 | listActive, |
| 410 | get streamChanges() { | 478 | get streamChanges() { |
| ... | @@ -412,9 +480,9 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { | ... | @@ -412,9 +480,9 @@ export const make = Effect.fn("makePairingGrantStore")(function* () { |
| 412 | }, | 480 | }, |
| 413 | revoke, | 481 | revoke, |
| 414 | consume, | 482 | consume, |
| 415 | } satisfies PairingGrantStoreShape; | 483 | }); |
| 416 | }); | 484 | }); |
| 417 | 485 | ||
| 418 | export const layer = Layer.effect(PairingGrantStore, make()).pipe( | 486 | export const layer = Layer.effect(PairingGrantStore, make).pipe( |
| 419 | Layer.provideMerge(AuthPairingLinks.layer), | 487 | Layer.provideMerge(AuthPairingLinks.layer), |
| 420 | ); | 488 | ); |
apps/server/src/auth/ServerSecretStore.test.ts+4-4| ... | @@ -9,7 +9,7 @@ import * as Option from "effect/Option"; | ... | @@ -9,7 +9,7 @@ import * as Option from "effect/Option"; |
| 9 | import * as Ref from "effect/Ref"; | 9 | import * as Ref from "effect/Ref"; |
| 10 | import * as PlatformError from "effect/PlatformError"; | 10 | import * as PlatformError from "effect/PlatformError"; |
| 11 | 11 | ||
| 12 | import { ServerConfig } from "../config.ts"; | 12 | import * as ServerConfig from "../config.ts"; |
| 13 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | 13 | import * as ServerSecretStore from "./ServerSecretStore.ts"; |
| 14 | 14 | ||
| 15 | const makeServerConfigLayer = () => | 15 | const makeServerConfigLayer = () => |
| ... | @@ -231,7 +231,7 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { | ... | @@ -231,7 +231,7 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { |
| 231 | 231 | ||
| 232 | const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32)); | 232 | const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32)); |
| 233 | 233 | ||
| 234 | assert.instanceOf(error, ServerSecretStore.SecretStoreError); | 234 | assert.instanceOf(error, ServerSecretStore.SecretStoreReadError); |
| 235 | assert.include(error.message, "Failed to read secret session-signing-key."); | 235 | assert.include(error.message, "Failed to read secret session-signing-key."); |
| 236 | assert.instanceOf(error.cause, PlatformError.PlatformError); | 236 | assert.instanceOf(error.cause, PlatformError.PlatformError); |
| 237 | assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); | 237 | assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); |
| ... | @@ -246,7 +246,7 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { | ... | @@ -246,7 +246,7 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { |
| 246 | secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])), | 246 | secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])), |
| 247 | ); | 247 | ); |
| 248 | 248 | ||
| 249 | assert.instanceOf(error, ServerSecretStore.SecretStoreError); | 249 | assert.instanceOf(error, ServerSecretStore.SecretStorePersistError); |
| 250 | assert.include(error.message, "Failed to persist secret session-signing-key."); | 250 | assert.include(error.message, "Failed to persist secret session-signing-key."); |
| 251 | assert.instanceOf(error.cause, PlatformError.PlatformError); | 251 | assert.instanceOf(error.cause, PlatformError.PlatformError); |
| 252 | assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); | 252 | assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); |
| ... | @@ -259,7 +259,7 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { | ... | @@ -259,7 +259,7 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { |
| 259 | 259 | ||
| 260 | const error = yield* Effect.flip(secretStore.remove("session-signing-key")); | 260 | const error = yield* Effect.flip(secretStore.remove("session-signing-key")); |
| 261 | 261 | ||
| 262 | assert.instanceOf(error, ServerSecretStore.SecretStoreError); | 262 | assert.instanceOf(error, ServerSecretStore.SecretStoreRemoveError); |
| 263 | assert.include(error.message, "Failed to remove secret session-signing-key."); | 263 | assert.include(error.message, "Failed to remove secret session-signing-key."); |
| 264 | assert.instanceOf(error.cause, PlatformError.PlatformError); | 264 | assert.instanceOf(error.cause, PlatformError.PlatformError); |
| 265 | assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); | 265 | assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied"); |
apps/server/src/auth/ServerSecretStore.ts+157-48| ... | @@ -9,49 +9,158 @@ import * as Predicate from "effect/Predicate"; | ... | @@ -9,49 +9,158 @@ import * as Predicate from "effect/Predicate"; |
| 9 | import * as PlatformError from "effect/PlatformError"; | 9 | import * as PlatformError from "effect/PlatformError"; |
| 10 | import * as Schema from "effect/Schema"; | 10 | import * as Schema from "effect/Schema"; |
| 11 | 11 | ||
| 12 | import { ServerConfig } from "../config.ts"; | 12 | import * as ServerConfig from "../config.ts"; |
| 13 | 13 | ||
| 14 | export class SecretStoreError extends Schema.TaggedErrorClass<SecretStoreError>()( | 14 | const secretStoreErrorContext = { |
| 15 | "SecretStoreError", | 15 | resource: Schema.String, |
| 16 | cause: Schema.Defect(), | ||
| 17 | }; | ||
| 18 | |||
| 19 | export class SecretStoreSecureError extends Schema.TaggedErrorClass<SecretStoreSecureError>()( | ||
| 20 | "SecretStoreSecureError", | ||
| 21 | { | ||
| 22 | ...secretStoreErrorContext, | ||
| 23 | }, | ||
| 24 | ) { | ||
| 25 | override get message(): string { | ||
| 26 | return `Failed to secure ${this.resource}.`; | ||
| 27 | } | ||
| 28 | } | ||
| 29 | |||
| 30 | export class SecretStoreReadError extends Schema.TaggedErrorClass<SecretStoreReadError>()( | ||
| 31 | "SecretStoreReadError", | ||
| 16 | { | 32 | { |
| 17 | message: Schema.String, | 33 | ...secretStoreErrorContext, |
| 18 | cause: Schema.optional(Schema.Defect()), | ||
| 19 | }, | 34 | }, |
| 20 | ) {} | 35 | ) { |
| 36 | override get message(): string { | ||
| 37 | return `Failed to read ${this.resource}.`; | ||
| 38 | } | ||
| 39 | } | ||
| 40 | |||
| 41 | export class SecretStoreTemporaryPathError extends Schema.TaggedErrorClass<SecretStoreTemporaryPathError>()( | ||
| 42 | "SecretStoreTemporaryPathError", | ||
| 43 | { | ||
| 44 | ...secretStoreErrorContext, | ||
| 45 | }, | ||
| 46 | ) { | ||
| 47 | override get message(): string { | ||
| 48 | return `Failed to create temporary path for ${this.resource}.`; | ||
| 49 | } | ||
| 50 | } | ||
| 51 | |||
| 52 | export class SecretStorePersistError extends Schema.TaggedErrorClass<SecretStorePersistError>()( | ||
| 53 | "SecretStorePersistError", | ||
| 54 | { | ||
| 55 | ...secretStoreErrorContext, | ||
| 56 | }, | ||
| 57 | ) { | ||
| 58 | override get message(): string { | ||
| 59 | return `Failed to persist ${this.resource}.`; | ||
| 60 | } | ||
| 61 | } | ||
| 62 | |||
| 63 | export class SecretStoreRandomGenerationError extends Schema.TaggedErrorClass<SecretStoreRandomGenerationError>()( | ||
| 64 | "SecretStoreRandomGenerationError", | ||
| 65 | { | ||
| 66 | ...secretStoreErrorContext, | ||
| 67 | }, | ||
| 68 | ) { | ||
| 69 | override get message(): string { | ||
| 70 | return `Failed to generate random bytes for ${this.resource}.`; | ||
| 71 | } | ||
| 72 | } | ||
| 73 | |||
| 74 | export class SecretStoreConcurrentReadError extends Schema.TaggedErrorClass<SecretStoreConcurrentReadError>()( | ||
| 75 | "SecretStoreConcurrentReadError", | ||
| 76 | { | ||
| 77 | resource: Schema.String, | ||
| 78 | }, | ||
| 79 | ) { | ||
| 80 | override get message(): string { | ||
| 81 | return `Failed to read ${this.resource} after concurrent creation.`; | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 85 | export class SecretStoreRemoveError extends Schema.TaggedErrorClass<SecretStoreRemoveError>()( | ||
| 86 | "SecretStoreRemoveError", | ||
| 87 | { | ||
| 88 | ...secretStoreErrorContext, | ||
| 89 | }, | ||
| 90 | ) { | ||
| 91 | override get message(): string { | ||
| 92 | return `Failed to remove ${this.resource}.`; | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | export class SecretStoreDecodeError extends Schema.TaggedErrorClass<SecretStoreDecodeError>()( | ||
| 97 | "SecretStoreDecodeError", | ||
| 98 | { | ||
| 99 | ...secretStoreErrorContext, | ||
| 100 | }, | ||
| 101 | ) { | ||
| 102 | override get message(): string { | ||
| 103 | return `Failed to decode ${this.resource}.`; | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | export class SecretStoreEncodeError extends Schema.TaggedErrorClass<SecretStoreEncodeError>()( | ||
| 108 | "SecretStoreEncodeError", | ||
| 109 | { | ||
| 110 | ...secretStoreErrorContext, | ||
| 111 | }, | ||
| 112 | ) { | ||
| 113 | override get message(): string { | ||
| 114 | return `Failed to encode ${this.resource}.`; | ||
| 115 | } | ||
| 116 | } | ||
| 117 | |||
| 118 | export const SecretStoreError = Schema.Union([ | ||
| 119 | SecretStoreSecureError, | ||
| 120 | SecretStoreReadError, | ||
| 121 | SecretStoreTemporaryPathError, | ||
| 122 | SecretStorePersistError, | ||
| 123 | SecretStoreRandomGenerationError, | ||
| 124 | SecretStoreConcurrentReadError, | ||
| 125 | SecretStoreRemoveError, | ||
| 126 | SecretStoreDecodeError, | ||
| 127 | SecretStoreEncodeError, | ||
| 128 | ]); | ||
| 129 | export type SecretStoreError = typeof SecretStoreError.Type; | ||
| 130 | export const isSecretStoreError = Schema.is(SecretStoreError); | ||
| 21 | 131 | ||
| 22 | const isPlatformError = (value: unknown): value is PlatformError.PlatformError => | 132 | const isPlatformError = (value: unknown): value is PlatformError.PlatformError => |
| 23 | Predicate.isTagged(value, "PlatformError"); | 133 | Predicate.isTagged(value, "PlatformError"); |
| 24 | 134 | ||
| 25 | export const isSecretAlreadyExistsError = (error: SecretStoreError): boolean => | 135 | export const isSecretAlreadyExistsError = (error: SecretStoreError): boolean => |
| 26 | isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists"; | 136 | "cause" in error && isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists"; |
| 27 | |||
| 28 | export interface ServerSecretStoreShape { | ||
| 29 | readonly get: (name: string) => Effect.Effect<Option.Option<Uint8Array>, SecretStoreError>; | ||
| 30 | readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>; | ||
| 31 | readonly create: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>; | ||
| 32 | readonly getOrCreateRandom: ( | ||
| 33 | name: string, | ||
| 34 | bytes: number, | ||
| 35 | ) => Effect.Effect<Uint8Array, SecretStoreError>; | ||
| 36 | readonly remove: (name: string) => Effect.Effect<void, SecretStoreError>; | ||
| 37 | } | ||
| 38 | 137 | ||
| 39 | export class ServerSecretStore extends Context.Service<ServerSecretStore, ServerSecretStoreShape>()( | 138 | export class ServerSecretStore extends Context.Service< |
| 40 | "t3/auth/ServerSecretStore", | 139 | ServerSecretStore, |
| 41 | ) {} | 140 | { |
| 141 | readonly get: (name: string) => Effect.Effect<Option.Option<Uint8Array>, SecretStoreError>; | ||
| 142 | readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>; | ||
| 143 | readonly create: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>; | ||
| 144 | readonly getOrCreateRandom: ( | ||
| 145 | name: string, | ||
| 146 | bytes: number, | ||
| 147 | ) => Effect.Effect<Uint8Array, SecretStoreError>; | ||
| 148 | readonly remove: (name: string) => Effect.Effect<void, SecretStoreError>; | ||
| 149 | } | ||
| 150 | >()("t3/auth/ServerSecretStore") {} | ||
| 42 | 151 | ||
| 43 | export const make = Effect.fn("makeServerSecretStore")(function* () { | 152 | export const make = Effect.gen(function* () { |
| 44 | const crypto = yield* Crypto.Crypto; | 153 | const crypto = yield* Crypto.Crypto; |
| 45 | const fileSystem = yield* FileSystem.FileSystem; | 154 | const fileSystem = yield* FileSystem.FileSystem; |
| 46 | const path = yield* Path.Path; | 155 | const path = yield* Path.Path; |
| 47 | const serverConfig = yield* ServerConfig; | 156 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 48 | 157 | ||
| 49 | yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true }); | 158 | yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true }); |
| 50 | yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe( | 159 | yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe( |
| 51 | Effect.mapError( | 160 | Effect.mapError( |
| 52 | (cause) => | 161 | (cause) => |
| 53 | new SecretStoreError({ | 162 | new SecretStoreSecureError({ |
| 54 | message: `Failed to secure secrets directory ${serverConfig.secretsDir}.`, | 163 | resource: `secrets directory ${serverConfig.secretsDir}`, |
| 55 | cause, | 164 | cause, |
| 56 | }), | 165 | }), |
| 57 | ), | 166 | ), |
| ... | @@ -59,15 +168,15 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { | ... | @@ -59,15 +168,15 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { |
| 59 | 168 | ||
| 60 | const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); | 169 | const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); |
| 61 | 170 | ||
| 62 | const get: ServerSecretStoreShape["get"] = (name) => | 171 | const get: ServerSecretStore["Service"]["get"] = (name) => |
| 63 | fileSystem.readFile(resolveSecretPath(name)).pipe( | 172 | fileSystem.readFile(resolveSecretPath(name)).pipe( |
| 64 | Effect.map((bytes) => Option.some(Uint8Array.from(bytes))), | 173 | Effect.map((bytes) => Option.some(Uint8Array.from(bytes))), |
| 65 | Effect.catch((cause) => | 174 | Effect.catch((cause) => |
| 66 | cause.reason._tag === "NotFound" | 175 | cause.reason._tag === "NotFound" |
| 67 | ? Effect.succeed(Option.none()) | 176 | ? Effect.succeed(Option.none()) |
| 68 | : Effect.fail( | 177 | : Effect.fail( |
| 69 | new SecretStoreError({ | 178 | new SecretStoreReadError({ |
| 70 | message: `Failed to read secret ${name}.`, | 179 | resource: `secret ${name}`, |
| 71 | cause, | 180 | cause, |
| 72 | }), | 181 | }), |
| 73 | ), | 182 | ), |
| ... | @@ -75,13 +184,13 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { | ... | @@ -75,13 +184,13 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { |
| 75 | Effect.withSpan("ServerSecretStore.get"), | 184 | Effect.withSpan("ServerSecretStore.get"), |
| 76 | ); | 185 | ); |
| 77 | 186 | ||
| 78 | const set: ServerSecretStoreShape["set"] = (name, value) => { | 187 | const set: ServerSecretStore["Service"]["set"] = (name, value) => { |
| 79 | const secretPath = resolveSecretPath(name); | 188 | const secretPath = resolveSecretPath(name); |
| 80 | return crypto.randomUUIDv4.pipe( | 189 | return crypto.randomUUIDv4.pipe( |
| 81 | Effect.mapError( | 190 | Effect.mapError( |
| 82 | (cause) => | 191 | (cause) => |
| 83 | new SecretStoreError({ | 192 | new SecretStoreTemporaryPathError({ |
| 84 | message: `Failed to create temporary path for secret ${name}.`, | 193 | resource: `secret ${name}`, |
| 85 | cause, | 194 | cause, |
| 86 | }), | 195 | }), |
| 87 | ), | 196 | ), |
| ... | @@ -98,8 +207,8 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { | ... | @@ -98,8 +207,8 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { |
| 98 | Effect.ignore, | 207 | Effect.ignore, |
| 99 | Effect.flatMap(() => | 208 | Effect.flatMap(() => |
| 100 | Effect.fail( | 209 | Effect.fail( |
| 101 | new SecretStoreError({ | 210 | new SecretStorePersistError({ |
| 102 | message: `Failed to persist secret ${name}.`, | 211 | resource: `secret ${name}`, |
| 103 | cause, | 212 | cause, |
| 104 | }), | 213 | }), |
| 105 | ), | 214 | ), |
| ... | @@ -112,7 +221,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { | ... | @@ -112,7 +221,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { |
| 112 | ); | 221 | ); |
| 113 | }; | 222 | }; |
| 114 | 223 | ||
| 115 | const create: ServerSecretStoreShape["create"] = (name, value) => { | 224 | const create: ServerSecretStore["Service"]["create"] = (name, value) => { |
| 116 | const secretPath = resolveSecretPath(name); | 225 | const secretPath = resolveSecretPath(name); |
| 117 | return Effect.scoped( | 226 | return Effect.scoped( |
| 118 | Effect.gen(function* () { | 227 | Effect.gen(function* () { |
| ... | @@ -127,15 +236,15 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { | ... | @@ -127,15 +236,15 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { |
| 127 | ).pipe( | 236 | ).pipe( |
| 128 | Effect.mapError( | 237 | Effect.mapError( |
| 129 | (cause) => | 238 | (cause) => |
| 130 | new SecretStoreError({ | 239 | new SecretStorePersistError({ |
| 131 | message: `Failed to persist secret ${name}.`, | 240 | resource: `secret ${name}`, |
| 132 | cause, | 241 | cause, |
| 133 | }), | 242 | }), |
| 134 | ), | 243 | ), |
| 135 | ); | 244 | ); |
| 136 | }; | 245 | }; |
| 137 | 246 | ||
| 138 | const getOrCreateRandom: ServerSecretStoreShape["getOrCreateRandom"] = (name, bytes) => | 247 | const getOrCreateRandom: ServerSecretStore["Service"]["getOrCreateRandom"] = (name, bytes) => |
| 139 | get(name).pipe( | 248 | get(name).pipe( |
| 140 | Effect.flatMap( | 249 | Effect.flatMap( |
| 141 | Option.match({ | 250 | Option.match({ |
| ... | @@ -144,15 +253,15 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { | ... | @@ -144,15 +253,15 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { |
| 144 | crypto.randomBytes(bytes).pipe( | 253 | crypto.randomBytes(bytes).pipe( |
| 145 | Effect.mapError( | 254 | Effect.mapError( |
| 146 | (cause) => | 255 | (cause) => |
| 147 | new SecretStoreError({ | 256 | new SecretStoreRandomGenerationError({ |
| 148 | message: `Failed to generate random bytes for secret ${name}.`, | 257 | resource: `secret ${name}`, |
| 149 | cause, | 258 | cause, |
| 150 | }), | 259 | }), |
| 151 | ), | 260 | ), |
| 152 | Effect.flatMap((generated) => | 261 | Effect.flatMap((generated) => |
| 153 | create(name, generated).pipe( | 262 | create(name, generated).pipe( |
| 154 | Effect.as(Uint8Array.from(generated)), | 263 | Effect.as(Uint8Array.from(generated)), |
| 155 | Effect.catchTag("SecretStoreError", (error) => | 264 | Effect.catchIf(isSecretStoreError, (error) => |
| 156 | isSecretAlreadyExistsError(error) | 265 | isSecretAlreadyExistsError(error) |
| 157 | ? get(name).pipe( | 266 | ? get(name).pipe( |
| 158 | Effect.flatMap( | 267 | Effect.flatMap( |
| ... | @@ -160,8 +269,8 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { | ... | @@ -160,8 +269,8 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { |
| 160 | onSome: Effect.succeed, | 269 | onSome: Effect.succeed, |
| 161 | onNone: () => | 270 | onNone: () => |
| 162 | Effect.fail( | 271 | Effect.fail( |
| 163 | new SecretStoreError({ | 272 | new SecretStoreConcurrentReadError({ |
| 164 | message: `Failed to read secret ${name} after concurrent creation.`, | 273 | resource: `secret ${name}`, |
| 165 | }), | 274 | }), |
| 166 | ), | 275 | ), |
| 167 | }), | 276 | }), |
| ... | @@ -177,14 +286,14 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { | ... | @@ -177,14 +286,14 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { |
| 177 | Effect.withSpan("ServerSecretStore.getOrCreateRandom"), | 286 | Effect.withSpan("ServerSecretStore.getOrCreateRandom"), |
| 178 | ); | 287 | ); |
| 179 | 288 | ||
| 180 | const remove: ServerSecretStoreShape["remove"] = (name) => | 289 | const remove: ServerSecretStore["Service"]["remove"] = (name) => |
| 181 | fileSystem.remove(resolveSecretPath(name)).pipe( | 290 | fileSystem.remove(resolveSecretPath(name)).pipe( |
| 182 | Effect.catch((cause) => | 291 | Effect.catch((cause) => |
| 183 | cause.reason._tag === "NotFound" | 292 | cause.reason._tag === "NotFound" |
| 184 | ? Effect.void | 293 | ? Effect.void |
| 185 | : Effect.fail( | 294 | : Effect.fail( |
| 186 | new SecretStoreError({ | 295 | new SecretStoreRemoveError({ |
| 187 | message: `Failed to remove secret ${name}.`, | 296 | resource: `secret ${name}`, |
| 188 | cause, | 297 | cause, |
| 189 | }), | 298 | }), |
| 190 | ), | 299 | ), |
| ... | @@ -192,13 +301,13 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { | ... | @@ -192,13 +301,13 @@ export const make = Effect.fn("makeServerSecretStore")(function* () { |
| 192 | Effect.withSpan("ServerSecretStore.remove"), | 301 | Effect.withSpan("ServerSecretStore.remove"), |
| 193 | ); | 302 | ); |
| 194 | 303 | ||
| 195 | return { | 304 | return ServerSecretStore.of({ |
| 196 | get, | 305 | get, |
| 197 | set, | 306 | set, |
| 198 | create, | 307 | create, |
| 199 | getOrCreateRandom, | 308 | getOrCreateRandom, |
| 200 | remove, | 309 | remove, |
| 201 | } satisfies ServerSecretStoreShape; | 310 | }); |
| 202 | }); | 311 | }); |
| 203 | 312 | ||
| 204 | export const layer = Layer.effect(ServerSecretStore, make()); | 313 | export const layer = Layer.effect(ServerSecretStore, make); |
apps/server/src/auth/SessionStore.test.ts+4-4| ... | @@ -51,7 +51,7 @@ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessi | ... | @@ -51,7 +51,7 @@ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessi |
| 51 | 51 | ||
| 52 | const failingSessionLookupCredentialLayer = Layer.effect( | 52 | const failingSessionLookupCredentialLayer = Layer.effect( |
| 53 | SessionStore.SessionStore, | 53 | SessionStore.SessionStore, |
| 54 | SessionStore.make(), | 54 | SessionStore.make, |
| 55 | ).pipe( | 55 | ).pipe( |
| 56 | Layer.provide(failingSessionLookupRepositoryLayer), | 56 | Layer.provide(failingSessionLookupRepositoryLayer), |
| 57 | Layer.provide(ServerSecretStore.layer), | 57 | Layer.provide(ServerSecretStore.layer), |
| ... | @@ -89,7 +89,7 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { | ... | @@ -89,7 +89,7 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { |
| 89 | const sessions = yield* SessionStore.SessionStore; | 89 | const sessions = yield* SessionStore.SessionStore; |
| 90 | const error = yield* Effect.flip(sessions.verify("not-a-session-token")); | 90 | const error = yield* Effect.flip(sessions.verify("not-a-session-token")); |
| 91 | 91 | ||
| 92 | expect(error._tag).toBe("SessionCredentialInvalidError"); | 92 | expect(error._tag).toBe("MalformedSessionTokenError"); |
| 93 | expect(error.message).toContain("Malformed session token"); | 93 | expect(error.message).toContain("Malformed session token"); |
| 94 | }).pipe(Effect.provide(makeSessionStoreLayer())), | 94 | }).pipe(Effect.provide(makeSessionStoreLayer())), |
| 95 | ); | 95 | ); |
| ... | @@ -105,8 +105,8 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { | ... | @@ -105,8 +105,8 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { |
| 105 | const sessionError = yield* Effect.flip(sessions.verify(issued.token)); | 105 | const sessionError = yield* Effect.flip(sessions.verify(issued.token)); |
| 106 | const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token)); | 106 | const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token)); |
| 107 | 107 | ||
| 108 | expect(sessionError._tag).toBe("SessionCredentialInternalError"); | 108 | expect(sessionError._tag).toBe("SessionCredentialVerificationError"); |
| 109 | expect(websocketError._tag).toBe("SessionCredentialInternalError"); | 109 | expect(websocketError._tag).toBe("WebSocketTokenVerificationError"); |
| 110 | expect(sessionError.cause).toBe(repositoryFailure); | 110 | expect(sessionError.cause).toBe(repositoryFailure); |
| 111 | expect(websocketError.cause).toBe(repositoryFailure); | 111 | expect(websocketError.cause).toBe(repositoryFailure); |
| 112 | }).pipe(Effect.provide(failingSessionLookupCredentialLayer)), | 112 | }).pipe(Effect.provide(failingSessionLookupCredentialLayer)), |
apps/server/src/auth/SessionStore.ts+343-150| ... | @@ -10,7 +10,6 @@ import { | ... | @@ -10,7 +10,6 @@ import { |
| 10 | import * as Clock from "effect/Clock"; | 10 | import * as Clock from "effect/Clock"; |
| 11 | import * as Context from "effect/Context"; | 11 | import * as Context from "effect/Context"; |
| 12 | import * as Crypto from "effect/Crypto"; | 12 | import * as Crypto from "effect/Crypto"; |
| 13 | import * as Data from "effect/Data"; | ||
| 14 | import * as DateTime from "effect/DateTime"; | 13 | import * as DateTime from "effect/DateTime"; |
| 15 | import * as Duration from "effect/Duration"; | 14 | import * as Duration from "effect/Duration"; |
| 16 | import * as Effect from "effect/Effect"; | 15 | import * as Effect from "effect/Effect"; |
| ... | @@ -21,7 +20,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -21,7 +20,7 @@ import * as Schema from "effect/Schema"; |
| 21 | import * as Stream from "effect/Stream"; | 20 | import * as Stream from "effect/Stream"; |
| 22 | import * as Option from "effect/Option"; | 21 | import * as Option from "effect/Option"; |
| 23 | 22 | ||
| 24 | import { ServerConfig } from "../config.ts"; | 23 | import * as ServerConfig from "../config.ts"; |
| 25 | import * as AuthSessions from "../persistence/AuthSessions.ts"; | 24 | import * as AuthSessions from "../persistence/AuthSessions.ts"; |
| 26 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | 25 | import * as ServerSecretStore from "./ServerSecretStore.ts"; |
| 27 | import { | 26 | import { |
| ... | @@ -63,66 +62,311 @@ export type SessionCredentialChange = | ... | @@ -63,66 +62,311 @@ export type SessionCredentialChange = |
| 63 | readonly sessionId: AuthSessionId; | 62 | readonly sessionId: AuthSessionId; |
| 64 | }; | 63 | }; |
| 65 | 64 | ||
| 66 | export class SessionCredentialInvalidError extends Data.TaggedError( | 65 | export class MalformedSessionTokenError extends Schema.TaggedErrorClass<MalformedSessionTokenError>()( |
| 67 | "SessionCredentialInvalidError", | 66 | "MalformedSessionTokenError", |
| 68 | )<{ | 67 | {}, |
| 69 | readonly message: string; | 68 | ) { |
| 70 | readonly cause?: unknown; | 69 | override get message(): string { |
| 71 | }> {} | 70 | return "Malformed session token."; |
| 72 | 71 | } | |
| 73 | export class SessionCredentialInternalError extends Data.TaggedError( | 72 | } |
| 74 | "SessionCredentialInternalError", | 73 | |
| 75 | )<{ | 74 | export class InvalidSessionTokenSignatureError extends Schema.TaggedErrorClass<InvalidSessionTokenSignatureError>()( |
| 76 | readonly message: string; | 75 | "InvalidSessionTokenSignatureError", |
| 77 | readonly cause?: unknown; | 76 | {}, |
| 78 | }> {} | 77 | ) { |
| 79 | 78 | override get message(): string { | |
| 80 | export type SessionCredentialError = SessionCredentialInvalidError | SessionCredentialInternalError; | 79 | return "Invalid session token signature."; |
| 81 | 80 | } | |
| 82 | export interface SessionStoreShape { | ||
| 83 | readonly cookieName: string; | ||
| 84 | readonly issue: (input?: { | ||
| 85 | readonly ttl?: Duration.Duration; | ||
| 86 | readonly subject?: string; | ||
| 87 | readonly method?: ServerAuthSessionMethod; | ||
| 88 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | ||
| 89 | readonly client?: AuthClientMetadata; | ||
| 90 | readonly proofKeyThumbprint?: string; | ||
| 91 | }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>; | ||
| 92 | readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>; | ||
| 93 | readonly issueWebSocketToken: ( | ||
| 94 | sessionId: AuthSessionId, | ||
| 95 | input?: { | ||
| 96 | readonly ttl?: Duration.Duration; | ||
| 97 | }, | ||
| 98 | ) => Effect.Effect< | ||
| 99 | { | ||
| 100 | readonly token: string; | ||
| 101 | readonly expiresAt: DateTime.DateTime; | ||
| 102 | }, | ||
| 103 | SessionCredentialInternalError | ||
| 104 | >; | ||
| 105 | readonly verifyWebSocketToken: ( | ||
| 106 | token: string, | ||
| 107 | ) => Effect.Effect<VerifiedSession, SessionCredentialError>; | ||
| 108 | readonly listActive: () => Effect.Effect< | ||
| 109 | ReadonlyArray<AuthClientSession>, | ||
| 110 | SessionCredentialInternalError | ||
| 111 | >; | ||
| 112 | readonly streamChanges: Stream.Stream<SessionCredentialChange>; | ||
| 113 | readonly revoke: ( | ||
| 114 | sessionId: AuthSessionId, | ||
| 115 | ) => Effect.Effect<boolean, SessionCredentialInternalError>; | ||
| 116 | readonly revokeAllExcept: ( | ||
| 117 | sessionId: AuthSessionId, | ||
| 118 | ) => Effect.Effect<number, SessionCredentialInternalError>; | ||
| 119 | readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>; | ||
| 120 | readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>; | ||
| 121 | } | 81 | } |
| 122 | 82 | ||
| 123 | export class SessionStore extends Context.Service<SessionStore, SessionStoreShape>()( | 83 | export class InvalidSessionTokenPayloadError extends Schema.TaggedErrorClass<InvalidSessionTokenPayloadError>()( |
| 124 | "t3/auth/SessionStore", | 84 | "InvalidSessionTokenPayloadError", |
| 125 | ) {} | 85 | { |
| 86 | cause: Schema.Defect(), | ||
| 87 | }, | ||
| 88 | ) { | ||
| 89 | override get message(): string { | ||
| 90 | return "Invalid session token payload."; | ||
| 91 | } | ||
| 92 | } | ||
| 93 | |||
| 94 | export class SessionTokenExpiredError extends Schema.TaggedErrorClass<SessionTokenExpiredError>()( | ||
| 95 | "SessionTokenExpiredError", | ||
| 96 | {}, | ||
| 97 | ) { | ||
| 98 | override get message(): string { | ||
| 99 | return "Session token expired."; | ||
| 100 | } | ||
| 101 | } | ||
| 102 | |||
| 103 | export class UnknownSessionTokenError extends Schema.TaggedErrorClass<UnknownSessionTokenError>()( | ||
| 104 | "UnknownSessionTokenError", | ||
| 105 | {}, | ||
| 106 | ) { | ||
| 107 | override get message(): string { | ||
| 108 | return "Unknown session token."; | ||
| 109 | } | ||
| 110 | } | ||
| 111 | |||
| 112 | export class SessionTokenRevokedError extends Schema.TaggedErrorClass<SessionTokenRevokedError>()( | ||
| 113 | "SessionTokenRevokedError", | ||
| 114 | {}, | ||
| 115 | ) { | ||
| 116 | override get message(): string { | ||
| 117 | return "Session token revoked."; | ||
| 118 | } | ||
| 119 | } | ||
| 120 | |||
| 121 | export class InvalidSessionExpirationClaimError extends Schema.TaggedErrorClass<InvalidSessionExpirationClaimError>()( | ||
| 122 | "InvalidSessionExpirationClaimError", | ||
| 123 | {}, | ||
| 124 | ) { | ||
| 125 | override get message(): string { | ||
| 126 | return "Invalid `exp` claim"; | ||
| 127 | } | ||
| 128 | } | ||
| 129 | |||
| 130 | export class MalformedWebSocketTokenError extends Schema.TaggedErrorClass<MalformedWebSocketTokenError>()( | ||
| 131 | "MalformedWebSocketTokenError", | ||
| 132 | {}, | ||
| 133 | ) { | ||
| 134 | override get message(): string { | ||
| 135 | return "Malformed websocket token."; | ||
| 136 | } | ||
| 137 | } | ||
| 138 | |||
| 139 | export class InvalidWebSocketTokenSignatureError extends Schema.TaggedErrorClass<InvalidWebSocketTokenSignatureError>()( | ||
| 140 | "InvalidWebSocketTokenSignatureError", | ||
| 141 | {}, | ||
| 142 | ) { | ||
| 143 | override get message(): string { | ||
| 144 | return "Invalid websocket token signature."; | ||
| 145 | } | ||
| 146 | } | ||
| 147 | |||
| 148 | export class InvalidWebSocketTokenPayloadError extends Schema.TaggedErrorClass<InvalidWebSocketTokenPayloadError>()( | ||
| 149 | "InvalidWebSocketTokenPayloadError", | ||
| 150 | { | ||
| 151 | cause: Schema.Defect(), | ||
| 152 | }, | ||
| 153 | ) { | ||
| 154 | override get message(): string { | ||
| 155 | return "Invalid websocket token payload."; | ||
| 156 | } | ||
| 157 | } | ||
| 158 | |||
| 159 | export class WebSocketTokenExpiredError extends Schema.TaggedErrorClass<WebSocketTokenExpiredError>()( | ||
| 160 | "WebSocketTokenExpiredError", | ||
| 161 | {}, | ||
| 162 | ) { | ||
| 163 | override get message(): string { | ||
| 164 | return "Websocket token expired."; | ||
| 165 | } | ||
| 166 | } | ||
| 167 | |||
| 168 | export class UnknownWebSocketSessionError extends Schema.TaggedErrorClass<UnknownWebSocketSessionError>()( | ||
| 169 | "UnknownWebSocketSessionError", | ||
| 170 | {}, | ||
| 171 | ) { | ||
| 172 | override get message(): string { | ||
| 173 | return "Unknown websocket session."; | ||
| 174 | } | ||
| 175 | } | ||
| 176 | |||
| 177 | export class WebSocketSessionExpiredError extends Schema.TaggedErrorClass<WebSocketSessionExpiredError>()( | ||
| 178 | "WebSocketSessionExpiredError", | ||
| 179 | {}, | ||
| 180 | ) { | ||
| 181 | override get message(): string { | ||
| 182 | return "Websocket session expired."; | ||
| 183 | } | ||
| 184 | } | ||
| 185 | |||
| 186 | export class WebSocketSessionRevokedError extends Schema.TaggedErrorClass<WebSocketSessionRevokedError>()( | ||
| 187 | "WebSocketSessionRevokedError", | ||
| 188 | {}, | ||
| 189 | ) { | ||
| 190 | override get message(): string { | ||
| 191 | return "Websocket session revoked."; | ||
| 192 | } | ||
| 193 | } | ||
| 194 | |||
| 195 | export const SessionCredentialInvalidError = Schema.Union([ | ||
| 196 | MalformedSessionTokenError, | ||
| 197 | InvalidSessionTokenSignatureError, | ||
| 198 | InvalidSessionTokenPayloadError, | ||
| 199 | SessionTokenExpiredError, | ||
| 200 | UnknownSessionTokenError, | ||
| 201 | SessionTokenRevokedError, | ||
| 202 | InvalidSessionExpirationClaimError, | ||
| 203 | MalformedWebSocketTokenError, | ||
| 204 | InvalidWebSocketTokenSignatureError, | ||
| 205 | InvalidWebSocketTokenPayloadError, | ||
| 206 | WebSocketTokenExpiredError, | ||
| 207 | UnknownWebSocketSessionError, | ||
| 208 | WebSocketSessionExpiredError, | ||
| 209 | WebSocketSessionRevokedError, | ||
| 210 | ]); | ||
| 211 | export type SessionCredentialInvalidError = typeof SessionCredentialInvalidError.Type; | ||
| 212 | export const isSessionCredentialInvalidError = Schema.is(SessionCredentialInvalidError); | ||
| 213 | |||
| 214 | const sessionCredentialInternalErrorContext = { | ||
| 215 | cause: Schema.Defect(), | ||
| 216 | }; | ||
| 217 | |||
| 218 | export class SessionClaimsEncodingError extends Schema.TaggedErrorClass<SessionClaimsEncodingError>()( | ||
| 219 | "SessionClaimsEncodingError", | ||
| 220 | { | ||
| 221 | operation: Schema.Literals(["encode_session_claims", "encode_websocket_claims"]), | ||
| 222 | ...sessionCredentialInternalErrorContext, | ||
| 223 | }, | ||
| 224 | ) { | ||
| 225 | override get message(): string { | ||
| 226 | return "Failed to encode claims"; | ||
| 227 | } | ||
| 228 | } | ||
| 229 | |||
| 230 | export class SessionCredentialIssueError extends Schema.TaggedErrorClass<SessionCredentialIssueError>()( | ||
| 231 | "SessionCredentialIssueError", | ||
| 232 | { | ||
| 233 | ...sessionCredentialInternalErrorContext, | ||
| 234 | }, | ||
| 235 | ) { | ||
| 236 | override get message(): string { | ||
| 237 | return "Failed to issue session credential."; | ||
| 238 | } | ||
| 239 | } | ||
| 240 | |||
| 241 | export class SessionCredentialVerificationError extends Schema.TaggedErrorClass<SessionCredentialVerificationError>()( | ||
| 242 | "SessionCredentialVerificationError", | ||
| 243 | { | ||
| 244 | ...sessionCredentialInternalErrorContext, | ||
| 245 | }, | ||
| 246 | ) { | ||
| 247 | override get message(): string { | ||
| 248 | return "Failed to verify session credential."; | ||
| 249 | } | ||
| 250 | } | ||
| 251 | |||
| 252 | export class WebSocketTokenIssueError extends Schema.TaggedErrorClass<WebSocketTokenIssueError>()( | ||
| 253 | "WebSocketTokenIssueError", | ||
| 254 | { | ||
| 255 | ...sessionCredentialInternalErrorContext, | ||
| 256 | }, | ||
| 257 | ) { | ||
| 258 | override get message(): string { | ||
| 259 | return "Failed to issue websocket token."; | ||
| 260 | } | ||
| 261 | } | ||
| 262 | |||
| 263 | export class WebSocketTokenVerificationError extends Schema.TaggedErrorClass<WebSocketTokenVerificationError>()( | ||
| 264 | "WebSocketTokenVerificationError", | ||
| 265 | { | ||
| 266 | ...sessionCredentialInternalErrorContext, | ||
| 267 | }, | ||
| 268 | ) { | ||
| 269 | override get message(): string { | ||
| 270 | return "Failed to verify websocket token."; | ||
| 271 | } | ||
| 272 | } | ||
| 273 | |||
| 274 | export class ActiveSessionsListError extends Schema.TaggedErrorClass<ActiveSessionsListError>()( | ||
| 275 | "ActiveSessionsListError", | ||
| 276 | { | ||
| 277 | ...sessionCredentialInternalErrorContext, | ||
| 278 | }, | ||
| 279 | ) { | ||
| 280 | override get message(): string { | ||
| 281 | return "Failed to list active sessions."; | ||
| 282 | } | ||
| 283 | } | ||
| 284 | |||
| 285 | export class SessionRevocationError extends Schema.TaggedErrorClass<SessionRevocationError>()( | ||
| 286 | "SessionRevocationError", | ||
| 287 | { | ||
| 288 | ...sessionCredentialInternalErrorContext, | ||
| 289 | }, | ||
| 290 | ) { | ||
| 291 | override get message(): string { | ||
| 292 | return "Failed to revoke session."; | ||
| 293 | } | ||
| 294 | } | ||
| 295 | |||
| 296 | export class OtherSessionsRevocationError extends Schema.TaggedErrorClass<OtherSessionsRevocationError>()( | ||
| 297 | "OtherSessionsRevocationError", | ||
| 298 | { | ||
| 299 | ...sessionCredentialInternalErrorContext, | ||
| 300 | }, | ||
| 301 | ) { | ||
| 302 | override get message(): string { | ||
| 303 | return "Failed to revoke other sessions."; | ||
| 304 | } | ||
| 305 | } | ||
| 306 | |||
| 307 | export const SessionCredentialInternalError = Schema.Union([ | ||
| 308 | SessionClaimsEncodingError, | ||
| 309 | SessionCredentialIssueError, | ||
| 310 | SessionCredentialVerificationError, | ||
| 311 | WebSocketTokenIssueError, | ||
| 312 | WebSocketTokenVerificationError, | ||
| 313 | ActiveSessionsListError, | ||
| 314 | SessionRevocationError, | ||
| 315 | OtherSessionsRevocationError, | ||
| 316 | ]); | ||
| 317 | export type SessionCredentialInternalError = typeof SessionCredentialInternalError.Type; | ||
| 318 | export const isSessionCredentialInternalError = Schema.is(SessionCredentialInternalError); | ||
| 319 | |||
| 320 | export const SessionCredentialError = Schema.Union([ | ||
| 321 | SessionCredentialInvalidError, | ||
| 322 | SessionCredentialInternalError, | ||
| 323 | ]); | ||
| 324 | export type SessionCredentialError = typeof SessionCredentialError.Type; | ||
| 325 | export const isSessionCredentialError = Schema.is(SessionCredentialError); | ||
| 326 | |||
| 327 | export class SessionStore extends Context.Service< | ||
| 328 | SessionStore, | ||
| 329 | { | ||
| 330 | readonly cookieName: string; | ||
| 331 | readonly issue: (input?: { | ||
| 332 | readonly ttl?: Duration.Duration; | ||
| 333 | readonly subject?: string; | ||
| 334 | readonly method?: ServerAuthSessionMethod; | ||
| 335 | readonly scopes?: ReadonlyArray<AuthEnvironmentScope>; | ||
| 336 | readonly client?: AuthClientMetadata; | ||
| 337 | readonly proofKeyThumbprint?: string; | ||
| 338 | }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>; | ||
| 339 | readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>; | ||
| 340 | readonly issueWebSocketToken: ( | ||
| 341 | sessionId: AuthSessionId, | ||
| 342 | input?: { | ||
| 343 | readonly ttl?: Duration.Duration; | ||
| 344 | }, | ||
| 345 | ) => Effect.Effect< | ||
| 346 | { | ||
| 347 | readonly token: string; | ||
| 348 | readonly expiresAt: DateTime.DateTime; | ||
| 349 | }, | ||
| 350 | SessionCredentialInternalError | ||
| 351 | >; | ||
| 352 | readonly verifyWebSocketToken: ( | ||
| 353 | token: string, | ||
| 354 | ) => Effect.Effect<VerifiedSession, SessionCredentialError>; | ||
| 355 | readonly listActive: () => Effect.Effect< | ||
| 356 | ReadonlyArray<AuthClientSession>, | ||
| 357 | SessionCredentialInternalError | ||
| 358 | >; | ||
| 359 | readonly streamChanges: Stream.Stream<SessionCredentialChange>; | ||
| 360 | readonly revoke: ( | ||
| 361 | sessionId: AuthSessionId, | ||
| 362 | ) => Effect.Effect<boolean, SessionCredentialInternalError>; | ||
| 363 | readonly revokeAllExcept: ( | ||
| 364 | sessionId: AuthSessionId, | ||
| 365 | ) => Effect.Effect<number, SessionCredentialInternalError>; | ||
| 366 | readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>; | ||
| 367 | readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>; | ||
| 368 | } | ||
| 369 | >()("t3/auth/SessionStore") {} | ||
| 126 | 370 | ||
| 127 | const SIGNING_SECRET_NAME = "server-signing-key"; | 371 | const SIGNING_SECRET_NAME = "server-signing-key"; |
| 128 | const DEFAULT_SESSION_TTL = Duration.days(30); | 372 | const DEFAULT_SESSION_TTL = Duration.days(30); |
| ... | @@ -184,15 +428,9 @@ function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthCli | ... | @@ -184,15 +428,9 @@ function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthCli |
| 184 | }; | 428 | }; |
| 185 | } | 429 | } |
| 186 | 430 | ||
| 187 | const toSessionCredentialInternalError = (message: string) => (cause: unknown) => | 431 | export const make = Effect.gen(function* () { |
| 188 | new SessionCredentialInternalError({ | ||
| 189 | message, | ||
| 190 | cause, | ||
| 191 | }); | ||
| 192 | |||
| 193 | export const make = Effect.fn("makeSessionStore")(function* () { | ||
| 194 | const crypto = yield* Crypto.Crypto; | 432 | const crypto = yield* Crypto.Crypto; |
| 195 | const serverConfig = yield* ServerConfig; | 433 | const serverConfig = yield* ServerConfig.ServerConfig; |
| 196 | const secretStore = yield* ServerSecretStore.ServerSecretStore; | 434 | const secretStore = yield* ServerSecretStore.ServerSecretStore; |
| 197 | const authSessions = yield* AuthSessions.AuthSessionRepository; | 435 | const authSessions = yield* AuthSessions.AuthSessionRepository; |
| 198 | const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); | 436 | const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); |
| ... | @@ -238,7 +476,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { | ... | @@ -238,7 +476,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { |
| 238 | ); | 476 | ); |
| 239 | }); | 477 | }); |
| 240 | 478 | ||
| 241 | const markConnected: SessionStoreShape["markConnected"] = (sessionId) => | 479 | const markConnected: SessionStore["Service"]["markConnected"] = (sessionId) => |
| 242 | Ref.modify(connectedSessionsRef, (current) => { | 480 | Ref.modify(connectedSessionsRef, (current) => { |
| 243 | const next = new Map(current); | 481 | const next = new Map(current); |
| 244 | const wasDisconnected = !next.has(sessionId); | 482 | const wasDisconnected = !next.has(sessionId); |
| ... | @@ -272,7 +510,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { | ... | @@ -272,7 +510,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { |
| 272 | Effect.withSpan("SessionStore.markConnected"), | 510 | Effect.withSpan("SessionStore.markConnected"), |
| 273 | ); | 511 | ); |
| 274 | 512 | ||
| 275 | const markDisconnected: SessionStoreShape["markDisconnected"] = (sessionId) => | 513 | const markDisconnected: SessionStore["Service"]["markDisconnected"] = (sessionId) => |
| 276 | Ref.update(connectedSessionsRef, (current) => { | 514 | Ref.update(connectedSessionsRef, (current) => { |
| 277 | const next = new Map(current); | 515 | const next = new Map(current); |
| 278 | const remaining = (next.get(sessionId) ?? 0) - 1; | 516 | const remaining = (next.get(sessionId) ?? 0) - 1; |
| ... | @@ -299,7 +537,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { | ... | @@ -299,7 +537,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { |
| 299 | ); | 537 | ); |
| 300 | 538 | ||
| 301 | const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims)); | 539 | const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims)); |
| 302 | const issue: SessionStoreShape["issue"] = Effect.fn("SessionStore.issue")( | 540 | const issue: SessionStore["Service"]["issue"] = Effect.fn("SessionStore.issue")( |
| 303 | function* (input) { | 541 | function* (input) { |
| 304 | const sessionId = AuthSessionId.make(yield* crypto.randomUUIDv4); | 542 | const sessionId = AuthSessionId.make(yield* crypto.randomUUIDv4); |
| 305 | const issuedAt = yield* DateTime.now; | 543 | const issuedAt = yield* DateTime.now; |
| ... | @@ -321,8 +559,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { | ... | @@ -321,8 +559,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { |
| 321 | const encodedPayload = yield* encodeClaims(claims).pipe( | 559 | const encodedPayload = yield* encodeClaims(claims).pipe( |
| 322 | Effect.map(base64UrlEncode), | 560 | Effect.map(base64UrlEncode), |
| 323 | Effect.mapError( | 561 | Effect.mapError( |
| 324 | (cause) => | 562 | (cause) => new SessionClaimsEncodingError({ operation: "encode_session_claims", cause }), |
| 325 | new SessionCredentialInternalError({ message: "Failed to encode claims", cause }), | ||
| 326 | ), | 563 | ), |
| 327 | ); | 564 | ); |
| 328 | const signature = signPayload(encodedPayload, signingSecret); | 565 | const signature = signPayload(encodedPayload, signingSecret); |
| ... | @@ -367,59 +604,41 @@ export const make = Effect.fn("makeSessionStore")(function* () { | ... | @@ -367,59 +604,41 @@ export const make = Effect.fn("makeSessionStore")(function* () { |
| 367 | ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), | 604 | ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), |
| 368 | } satisfies IssuedSession; | 605 | } satisfies IssuedSession; |
| 369 | }, | 606 | }, |
| 370 | Effect.mapError(toSessionCredentialInternalError("Failed to issue session credential.")), | 607 | Effect.mapError((cause) => new SessionCredentialIssueError({ cause })), |
| 371 | ); | 608 | ); |
| 372 | 609 | ||
| 373 | const verify: SessionStoreShape["verify"] = Effect.fn("SessionStore.verify")( | 610 | const verify: SessionStore["Service"]["verify"] = Effect.fn("SessionStore.verify")( |
| 374 | function* (token) { | 611 | function* (token) { |
| 375 | const [encodedPayload, signature] = token.split("."); | 612 | const [encodedPayload, signature] = token.split("."); |
| 376 | if (!encodedPayload || !signature) { | 613 | if (!encodedPayload || !signature) { |
| 377 | return yield* new SessionCredentialInvalidError({ | 614 | return yield* new MalformedSessionTokenError({}); |
| 378 | message: "Malformed session token.", | ||
| 379 | }); | ||
| 380 | } | 615 | } |
| 381 | 616 | ||
| 382 | const expectedSignature = signPayload(encodedPayload, signingSecret); | 617 | const expectedSignature = signPayload(encodedPayload, signingSecret); |
| 383 | if (!timingSafeEqualBase64Url(signature, expectedSignature)) { | 618 | if (!timingSafeEqualBase64Url(signature, expectedSignature)) { |
| 384 | return yield* new SessionCredentialInvalidError({ | 619 | return yield* new InvalidSessionTokenSignatureError({}); |
| 385 | message: "Invalid session token signature.", | ||
| 386 | }); | ||
| 387 | } | 620 | } |
| 388 | 621 | ||
| 389 | const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( | 622 | const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( |
| 390 | Effect.mapError( | 623 | Effect.mapError((cause) => new InvalidSessionTokenPayloadError({ cause })), |
| 391 | (cause) => | ||
| 392 | new SessionCredentialInvalidError({ | ||
| 393 | message: "Invalid session token payload.", | ||
| 394 | cause, | ||
| 395 | }), | ||
| 396 | ), | ||
| 397 | ); | 624 | ); |
| 398 | 625 | ||
| 399 | const now = yield* Clock.currentTimeMillis; | 626 | const now = yield* Clock.currentTimeMillis; |
| 400 | if (claims.exp <= now) { | 627 | if (claims.exp <= now) { |
| 401 | return yield* new SessionCredentialInvalidError({ | 628 | return yield* new SessionTokenExpiredError({}); |
| 402 | message: "Session token expired.", | ||
| 403 | }); | ||
| 404 | } | 629 | } |
| 405 | 630 | ||
| 406 | const row = yield* authSessions.getById({ sessionId: claims.sid }); | 631 | const row = yield* authSessions.getById({ sessionId: claims.sid }); |
| 407 | if (Option.isNone(row)) { | 632 | if (Option.isNone(row)) { |
| 408 | return yield* new SessionCredentialInvalidError({ | 633 | return yield* new UnknownSessionTokenError({}); |
| 409 | message: "Unknown session token.", | ||
| 410 | }); | ||
| 411 | } | 634 | } |
| 412 | if (row.value.revokedAt !== null) { | 635 | if (row.value.revokedAt !== null) { |
| 413 | return yield* new SessionCredentialInvalidError({ | 636 | return yield* new SessionTokenRevokedError({}); |
| 414 | message: "Session token revoked.", | ||
| 415 | }); | ||
| 416 | } | 637 | } |
| 417 | 638 | ||
| 418 | const expiresAt = DateTime.make(claims.exp); | 639 | const expiresAt = DateTime.make(claims.exp); |
| 419 | if (Option.isNone(expiresAt)) { | 640 | if (Option.isNone(expiresAt)) { |
| 420 | return yield* new SessionCredentialInvalidError({ | 641 | return yield* new InvalidSessionExpirationClaimError({}); |
| 421 | message: "Invalid `exp` claim", | ||
| 422 | }); | ||
| 423 | } | 642 | } |
| 424 | 643 | ||
| 425 | return { | 644 | return { |
| ... | @@ -434,17 +653,14 @@ export const make = Effect.fn("makeSessionStore")(function* () { | ... | @@ -434,17 +653,14 @@ export const make = Effect.fn("makeSessionStore")(function* () { |
| 434 | } satisfies VerifiedSession; | 653 | } satisfies VerifiedSession; |
| 435 | }, | 654 | }, |
| 436 | Effect.mapError((cause) => | 655 | Effect.mapError((cause) => |
| 437 | cause._tag === "SessionCredentialInvalidError" | 656 | isSessionCredentialInvalidError(cause) |
| 438 | ? cause | 657 | ? cause |
| 439 | : new SessionCredentialInternalError({ | 658 | : new SessionCredentialVerificationError({ cause }), |
| 440 | message: "Failed to verify session credential.", | ||
| 441 | cause, | ||
| 442 | }), | ||
| 443 | ), | 659 | ), |
| 444 | ); | 660 | ); |
| 445 | 661 | ||
| 446 | const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims)); | 662 | const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims)); |
| 447 | const issueWebSocketToken: SessionStoreShape["issueWebSocketToken"] = Effect.fn( | 663 | const issueWebSocketToken: SessionStore["Service"]["issueWebSocketToken"] = Effect.fn( |
| 448 | "SessionStore.issueWebSocketToken", | 664 | "SessionStore.issueWebSocketToken", |
| 449 | )( | 665 | )( |
| 450 | function* (sessionId, input) { | 666 | function* (sessionId, input) { |
| ... | @@ -463,7 +679,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { | ... | @@ -463,7 +679,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { |
| 463 | Effect.map(base64UrlEncode), | 679 | Effect.map(base64UrlEncode), |
| 464 | Effect.mapError( | 680 | Effect.mapError( |
| 465 | (cause) => | 681 | (cause) => |
| 466 | new SessionCredentialInternalError({ message: "Failed to encode claims", cause }), | 682 | new SessionClaimsEncodingError({ operation: "encode_websocket_claims", cause }), |
| 467 | ), | 683 | ), |
| 468 | ); | 684 | ); |
| 469 | const signature = signPayload(encodedPayload, signingSecret); | 685 | const signature = signPayload(encodedPayload, signingSecret); |
| ... | @@ -472,59 +688,41 @@ export const make = Effect.fn("makeSessionStore")(function* () { | ... | @@ -472,59 +688,41 @@ export const make = Effect.fn("makeSessionStore")(function* () { |
| 472 | expiresAt, | 688 | expiresAt, |
| 473 | }; | 689 | }; |
| 474 | }, | 690 | }, |
| 475 | Effect.mapError(toSessionCredentialInternalError("Failed to issue websocket token.")), | 691 | Effect.mapError((cause) => new WebSocketTokenIssueError({ cause })), |
| 476 | ); | 692 | ); |
| 477 | 693 | ||
| 478 | const verifyWebSocketToken: SessionStoreShape["verifyWebSocketToken"] = Effect.fn( | 694 | const verifyWebSocketToken: SessionStore["Service"]["verifyWebSocketToken"] = Effect.fn( |
| 479 | "SessionStore.verifyWebSocketToken", | 695 | "SessionStore.verifyWebSocketToken", |
| 480 | )( | 696 | )( |
| 481 | function* (token) { | 697 | function* (token) { |
| 482 | const [encodedPayload, signature] = token.split("."); | 698 | const [encodedPayload, signature] = token.split("."); |
| 483 | if (!encodedPayload || !signature) { | 699 | if (!encodedPayload || !signature) { |
| 484 | return yield* new SessionCredentialInvalidError({ | 700 | return yield* new MalformedWebSocketTokenError({}); |
| 485 | message: "Malformed websocket token.", | ||
| 486 | }); | ||
| 487 | } | 701 | } |
| 488 | 702 | ||
| 489 | const expectedSignature = signPayload(encodedPayload, signingSecret); | 703 | const expectedSignature = signPayload(encodedPayload, signingSecret); |
| 490 | if (!timingSafeEqualBase64Url(signature, expectedSignature)) { | 704 | if (!timingSafeEqualBase64Url(signature, expectedSignature)) { |
| 491 | return yield* new SessionCredentialInvalidError({ | 705 | return yield* new InvalidWebSocketTokenSignatureError({}); |
| 492 | message: "Invalid websocket token signature.", | ||
| 493 | }); | ||
| 494 | } | 706 | } |
| 495 | 707 | ||
| 496 | const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( | 708 | const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( |
| 497 | Effect.mapError( | 709 | Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })), |
| 498 | (cause) => | ||
| 499 | new SessionCredentialInvalidError({ | ||
| 500 | message: "Invalid websocket token payload.", | ||
| 501 | cause, | ||
| 502 | }), | ||
| 503 | ), | ||
| 504 | ); | 710 | ); |
| 505 | 711 | ||
| 506 | const now = yield* Clock.currentTimeMillis; | 712 | const now = yield* Clock.currentTimeMillis; |
| 507 | if (claims.exp <= now) { | 713 | if (claims.exp <= now) { |
| 508 | return yield* new SessionCredentialInvalidError({ | 714 | return yield* new WebSocketTokenExpiredError({}); |
| 509 | message: "Websocket token expired.", | ||
| 510 | }); | ||
| 511 | } | 715 | } |
| 512 | 716 | ||
| 513 | const row = yield* authSessions.getById({ sessionId: claims.sid }); | 717 | const row = yield* authSessions.getById({ sessionId: claims.sid }); |
| 514 | if (Option.isNone(row)) { | 718 | if (Option.isNone(row)) { |
| 515 | return yield* new SessionCredentialInvalidError({ | 719 | return yield* new UnknownWebSocketSessionError({}); |
| 516 | message: "Unknown websocket session.", | ||
| 517 | }); | ||
| 518 | } | 720 | } |
| 519 | if (row.value.expiresAt.epochMilliseconds <= now) { | 721 | if (row.value.expiresAt.epochMilliseconds <= now) { |
| 520 | return yield* new SessionCredentialInvalidError({ | 722 | return yield* new WebSocketSessionExpiredError({}); |
| 521 | message: "Websocket session expired.", | ||
| 522 | }); | ||
| 523 | } | 723 | } |
| 524 | if (row.value.revokedAt !== null) { | 724 | if (row.value.revokedAt !== null) { |
| 525 | return yield* new SessionCredentialInvalidError({ | 725 | return yield* new WebSocketSessionRevokedError({}); |
| 526 | message: "Websocket session revoked.", | ||
| 527 | }); | ||
| 528 | } | 726 | } |
| 529 | 727 | ||
| 530 | return { | 728 | return { |
| ... | @@ -538,16 +736,13 @@ export const make = Effect.fn("makeSessionStore")(function* () { | ... | @@ -538,16 +736,13 @@ export const make = Effect.fn("makeSessionStore")(function* () { |
| 538 | } satisfies VerifiedSession; | 736 | } satisfies VerifiedSession; |
| 539 | }, | 737 | }, |
| 540 | Effect.mapError((cause) => | 738 | Effect.mapError((cause) => |
| 541 | cause._tag === "SessionCredentialInvalidError" | 739 | isSessionCredentialInvalidError(cause) |
| 542 | ? cause | 740 | ? cause |
| 543 | : new SessionCredentialInternalError({ | 741 | : new WebSocketTokenVerificationError({ cause }), |
| 544 | message: "Failed to verify websocket token.", | ||
| 545 | cause, | ||
| 546 | }), | ||
| 547 | ), | 742 | ), |
| 548 | ); | 743 | ); |
| 549 | 744 | ||
| 550 | const listActive: SessionStoreShape["listActive"] = Effect.fn("SessionStore.listActive")( | 745 | const listActive: SessionStore["Service"]["listActive"] = Effect.fn("SessionStore.listActive")( |
| 551 | function* () { | 746 | function* () { |
| 552 | const now = yield* DateTime.now; | 747 | const now = yield* DateTime.now; |
| 553 | const connectedSessions = yield* Ref.get(connectedSessionsRef); | 748 | const connectedSessions = yield* Ref.get(connectedSessionsRef); |
| ... | @@ -567,10 +762,10 @@ export const make = Effect.fn("makeSessionStore")(function* () { | ... | @@ -567,10 +762,10 @@ export const make = Effect.fn("makeSessionStore")(function* () { |
| 567 | }), | 762 | }), |
| 568 | ); | 763 | ); |
| 569 | }, | 764 | }, |
| 570 | Effect.mapError(toSessionCredentialInternalError("Failed to list active sessions.")), | 765 | Effect.mapError((cause) => new ActiveSessionsListError({ cause })), |
| 571 | ); | 766 | ); |
| 572 | 767 | ||
| 573 | const revoke: SessionStoreShape["revoke"] = Effect.fn("SessionStore.revoke")( | 768 | const revoke: SessionStore["Service"]["revoke"] = Effect.fn("SessionStore.revoke")( |
| 574 | function* (sessionId) { | 769 | function* (sessionId) { |
| 575 | const revokedAt = yield* DateTime.now; | 770 | const revokedAt = yield* DateTime.now; |
| 576 | const revoked = yield* authSessions.revoke({ | 771 | const revoked = yield* authSessions.revoke({ |
| ... | @@ -587,10 +782,10 @@ export const make = Effect.fn("makeSessionStore")(function* () { | ... | @@ -587,10 +782,10 @@ export const make = Effect.fn("makeSessionStore")(function* () { |
| 587 | } | 782 | } |
| 588 | return revoked; | 783 | return revoked; |
| 589 | }, | 784 | }, |
| 590 | Effect.mapError(toSessionCredentialInternalError("Failed to revoke session.")), | 785 | Effect.mapError((cause) => new SessionRevocationError({ cause })), |
| 591 | ); | 786 | ); |
| 592 | 787 | ||
| 593 | const revokeAllExcept: SessionStoreShape["revokeAllExcept"] = Effect.fn( | 788 | const revokeAllExcept: SessionStore["Service"]["revokeAllExcept"] = Effect.fn( |
| 594 | "SessionStore.revokeAllExcept", | 789 | "SessionStore.revokeAllExcept", |
| 595 | )( | 790 | )( |
| 596 | function* (sessionId) { | 791 | function* (sessionId) { |
| ... | @@ -618,10 +813,10 @@ export const make = Effect.fn("makeSessionStore")(function* () { | ... | @@ -618,10 +813,10 @@ export const make = Effect.fn("makeSessionStore")(function* () { |
| 618 | } | 813 | } |
| 619 | return revokedSessionIds.length; | 814 | return revokedSessionIds.length; |
| 620 | }, | 815 | }, |
| 621 | Effect.mapError(toSessionCredentialInternalError("Failed to revoke other sessions.")), | 816 | Effect.mapError((cause) => new OtherSessionsRevocationError({ cause })), |
| 622 | ); | 817 | ); |
| 623 | 818 | ||
| 624 | return { | 819 | return SessionStore.of({ |
| 625 | cookieName, | 820 | cookieName, |
| 626 | issue, | 821 | issue, |
| 627 | verify, | 822 | verify, |
| ... | @@ -635,9 +830,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { | ... | @@ -635,9 +830,7 @@ export const make = Effect.fn("makeSessionStore")(function* () { |
| 635 | revokeAllExcept, | 830 | revokeAllExcept, |
| 636 | markConnected, | 831 | markConnected, |
| 637 | markDisconnected, | 832 | markDisconnected, |
| 638 | } satisfies SessionStoreShape; | 833 | }); |
| 639 | }); | 834 | }); |
| 640 | 835 | ||
| 641 | export const layer = Layer.effect(SessionStore, make()).pipe( | 836 | export const layer = Layer.effect(SessionStore, make).pipe(Layer.provideMerge(AuthSessions.layer)); |
| 642 | Layer.provideMerge(AuthSessions.layer), | ||
| 643 | ); |
apps/server/src/auth/dpop.test.ts+10-6| ... | @@ -1,12 +1,12 @@ | ... | @@ -1,12 +1,12 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import * as PlatformError from "effect/PlatformError"; | 2 | import * as PlatformError from "effect/PlatformError"; |
| 3 | 3 | ||
| 4 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | 4 | import { SecretStorePersistError } from "./ServerSecretStore.ts"; |
| 5 | import { mapDpopReplayStoreError } from "./dpop.ts"; | 5 | import { mapDpopReplayStoreError } from "./dpop.ts"; |
| 6 | 6 | ||
| 7 | const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => | 7 | const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => |
| 8 | new ServerSecretStore.SecretStoreError({ | 8 | new SecretStorePersistError({ |
| 9 | message: "Failed to persist DPoP proof.", | 9 | resource: "DPoP proof", |
| 10 | cause: PlatformError.systemError({ | 10 | cause: PlatformError.systemError({ |
| 11 | _tag: tag, | 11 | _tag: tag, |
| 12 | module: "FileSystem", | 12 | module: "FileSystem", |
| ... | @@ -17,16 +17,20 @@ const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => | ... | @@ -17,16 +17,20 @@ const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => |
| 17 | 17 | ||
| 18 | describe("mapDpopReplayStoreError", () => { | 18 | describe("mapDpopReplayStoreError", () => { |
| 19 | it("reports replay conflicts as invalid credentials", () => { | 19 | it("reports replay conflicts as invalid credentials", () => { |
| 20 | const error = mapDpopReplayStoreError(storeFailure("AlreadyExists")); | 20 | const cause = storeFailure("AlreadyExists"); |
| 21 | const error = mapDpopReplayStoreError(cause); | ||
| 21 | 22 | ||
| 22 | expect(error._tag).toBe("ServerAuthInvalidCredentialError"); | 23 | expect(error._tag).toBe("ServerAuthInvalidCredentialError"); |
| 24 | if (error._tag === "ServerAuthInvalidCredentialError") { | ||
| 25 | expect(error.cause).toBe(cause); | ||
| 26 | } | ||
| 23 | }); | 27 | }); |
| 24 | 28 | ||
| 25 | it("reports replay-store availability failures as internal errors", () => { | 29 | it("reports replay-store availability failures as internal errors", () => { |
| 26 | const error = mapDpopReplayStoreError(storeFailure("PermissionDenied")); | 30 | const error = mapDpopReplayStoreError(storeFailure("PermissionDenied")); |
| 27 | 31 | ||
| 28 | expect(error._tag).toBe("ServerAuthInternalError"); | 32 | expect(error._tag).toBe("ServerAuthDpopReplayStateRecordError"); |
| 29 | if (error._tag === "ServerAuthInternalError") { | 33 | if (error._tag === "ServerAuthDpopReplayStateRecordError") { |
| 30 | expect(error.message).toBe("Failed to record DPoP proof replay state."); | 34 | expect(error.message).toBe("Failed to record DPoP proof replay state."); |
| 31 | } | 35 | } |
| 32 | }); | 36 | }); |
apps/server/src/auth/dpop.ts+17-13| ... | @@ -5,7 +5,12 @@ import * as Effect from "effect/Effect"; | ... | @@ -5,7 +5,12 @@ import * as Effect from "effect/Effect"; |
| 5 | import * as Encoding from "effect/Encoding"; | 5 | import * as Encoding from "effect/Encoding"; |
| 6 | import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; | 6 | import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; |
| 7 | 7 | ||
| 8 | import * as EnvironmentAuth from "./EnvironmentAuth.ts"; | 8 | import { |
| 9 | ServerAuthDpopReplayKeyCalculationError, | ||
| 10 | ServerAuthDpopReplayStateRecordError, | ||
| 11 | ServerAuthInvalidCredentialError, | ||
| 12 | type ServerAuthInternalError, | ||
| 13 | } from "./EnvironmentAuth.ts"; | ||
| 9 | import * as ServerSecretStore from "./ServerSecretStore.ts"; | 14 | import * as ServerSecretStore from "./ServerSecretStore.ts"; |
| 10 | 15 | ||
| 11 | function firstHeaderValue(value: string | undefined): string | undefined { | 16 | function firstHeaderValue(value: string | undefined): string | undefined { |
| ... | @@ -26,14 +31,13 @@ export function requestAbsoluteUrl(request: HttpServerRequest.HttpServerRequest) | ... | @@ -26,14 +31,13 @@ export function requestAbsoluteUrl(request: HttpServerRequest.HttpServerRequest) |
| 26 | 31 | ||
| 27 | export const mapDpopReplayStoreError = ( | 32 | export const mapDpopReplayStoreError = ( |
| 28 | error: ServerSecretStore.SecretStoreError, | 33 | error: ServerSecretStore.SecretStoreError, |
| 29 | ): EnvironmentAuth.ServerAuthInvalidCredentialError | EnvironmentAuth.ServerAuthInternalError => | 34 | ): ServerAuthInvalidCredentialError | ServerAuthInternalError => |
| 30 | ServerSecretStore.isSecretAlreadyExistsError(error) | 35 | ServerSecretStore.isSecretAlreadyExistsError(error) |
| 31 | ? new EnvironmentAuth.ServerAuthInvalidCredentialError({ | 36 | ? new ServerAuthInvalidCredentialError({ |
| 32 | reason: "invalid_credential", | 37 | diagnostic: "DPoP proof replayed.", |
| 33 | cause: "DPoP proof replayed.", | 38 | cause: error, |
| 34 | }) | 39 | }) |
| 35 | : new EnvironmentAuth.ServerAuthInternalError({ | 40 | : new ServerAuthDpopReplayStateRecordError({ |
| 36 | message: "Failed to record DPoP proof replay state.", | ||
| 37 | cause: error, | 41 | cause: error, |
| 38 | }); | 42 | }); |
| 39 | 43 | ||
| ... | @@ -54,9 +58,8 @@ export const verifyRequestDpopProof = (input: { | ... | @@ -54,9 +58,8 @@ export const verifyRequestDpopProof = (input: { |
| 54 | ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}), | 58 | ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}), |
| 55 | }); | 59 | }); |
| 56 | if (!result.ok) { | 60 | if (!result.ok) { |
| 57 | return yield* new EnvironmentAuth.ServerAuthInvalidCredentialError({ | 61 | return yield* new ServerAuthInvalidCredentialError({ |
| 58 | reason: "invalid_credential", | 62 | diagnostic: result.reason, |
| 59 | cause: result.reason, | ||
| 60 | }); | 63 | }); |
| 61 | } | 64 | } |
| 62 | const secretStore = yield* ServerSecretStore.ServerSecretStore; | 65 | const secretStore = yield* ServerSecretStore.ServerSecretStore; |
| ... | @@ -67,8 +70,7 @@ export const verifyRequestDpopProof = (input: { | ... | @@ -67,8 +70,7 @@ export const verifyRequestDpopProof = (input: { |
| 67 | Effect.map(Encoding.encodeBase64Url), | 70 | Effect.map(Encoding.encodeBase64Url), |
| 68 | Effect.mapError( | 71 | Effect.mapError( |
| 69 | (cause) => | 72 | (cause) => |
| 70 | new EnvironmentAuth.ServerAuthInternalError({ | 73 | new ServerAuthDpopReplayKeyCalculationError({ |
| 71 | message: "Failed to calculate DPoP replay key.", | ||
| 72 | cause, | 74 | cause, |
| 73 | }), | 75 | }), |
| 74 | ), | 76 | ), |
| ... | @@ -86,7 +88,9 @@ export const verifyRequestDpopProof = (input: { | ... | @@ -86,7 +88,9 @@ export const verifyRequestDpopProof = (input: { |
| 86 | ), | 88 | ), |
| 87 | ) | 89 | ) |
| 88 | .pipe( | 90 | .pipe( |
| 89 | Effect.catchTag("SecretStoreError", (error) => Effect.fail(mapDpopReplayStoreError(error))), | 91 | Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => |
| 92 | Effect.fail(mapDpopReplayStoreError(error)), | ||
| 93 | ), | ||
| 90 | ); | 94 | ); |
| 91 | return result.thumbprint; | 95 | return result.thumbprint; |
| 92 | }); | 96 | }); |
apps/server/src/auth/http.ts+42-36| ... | @@ -169,10 +169,12 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( | ... | @@ -169,10 +169,12 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( |
| 169 | Effect.gen(function* () { | 169 | Effect.gen(function* () { |
| 170 | const request = yield* HttpServerRequest.HttpServerRequest; | 170 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 171 | const session = yield* serverAuth.authenticateHttpRequest(request).pipe( | 171 | const session = yield* serverAuth.authenticateHttpRequest(request).pipe( |
| 172 | Effect.catchTags({ | 172 | Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => |
| 173 | ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), | 173 | failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), |
| 174 | ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error), | 174 | ), |
| 175 | }), | 175 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 176 | failEnvironmentInternal("internal_error", error), | ||
| 177 | ), | ||
| 176 | ); | 178 | ); |
| 177 | return yield* httpEffect.pipe( | 179 | return yield* httpEffect.pipe( |
| 178 | Effect.provideService(EnvironmentAuthenticatedPrincipal, { | 180 | Effect.provideService(EnvironmentAuthenticatedPrincipal, { |
| ... | @@ -201,7 +203,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( | ... | @@ -201,7 +203,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( |
| 201 | const request = yield* HttpServerRequest.HttpServerRequest; | 203 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 202 | return yield* serverAuth.getSessionState(request); | 204 | return yield* serverAuth.getSessionState(request); |
| 203 | }, | 205 | }, |
| 204 | Effect.catchTag("ServerAuthInternalError", (error) => | 206 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 205 | failEnvironmentInternal("internal_error", error), | 207 | failEnvironmentInternal("internal_error", error), |
| 206 | ), | 208 | ), |
| 207 | ), | 209 | ), |
| ... | @@ -231,11 +233,12 @@ export const authHttpApiLayer = HttpApiBuilder.group( | ... | @@ -231,11 +233,12 @@ export const authHttpApiLayer = HttpApiBuilder.group( |
| 231 | yield* appendCredentialResponseHeaders; | 233 | yield* appendCredentialResponseHeaders; |
| 232 | return result.response; | 234 | return result.response; |
| 233 | }, | 235 | }, |
| 234 | Effect.catchTags({ | 236 | Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => |
| 235 | ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), | 237 | failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), |
| 236 | ServerAuthInternalError: (error) => | 238 | ), |
| 237 | failEnvironmentInternal("browser_session_issuance_failed", error), | 239 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 238 | }), | 240 | failEnvironmentInternal("browser_session_issuance_failed", error), |
| 241 | ), | ||
| 239 | ), | 242 | ), |
| 240 | ) | 243 | ) |
| 241 | .handle( | 244 | .handle( |
| ... | @@ -265,14 +268,14 @@ export const authHttpApiLayer = HttpApiBuilder.group( | ... | @@ -265,14 +268,14 @@ export const authHttpApiLayer = HttpApiBuilder.group( |
| 265 | } | 268 | } |
| 266 | const proofKeyThumbprint = args.headers.dpop | 269 | const proofKeyThumbprint = args.headers.dpop |
| 267 | ? yield* verifyRequestDpopProof({ request }).pipe( | 270 | ? yield* verifyRequestDpopProof({ request }).pipe( |
| 268 | Effect.catchTags({ | 271 | Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, () => |
| 269 | ServerAuthInvalidCredentialError: () => | 272 | appendDpopChallengeHeader.pipe( |
| 270 | appendDpopChallengeHeader.pipe( | 273 | Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")), |
| 271 | Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")), | 274 | ), |
| 272 | ), | 275 | ), |
| 273 | ServerAuthInternalError: (error) => | 276 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 274 | failEnvironmentInternal("access_token_issuance_failed", error), | 277 | failEnvironmentInternal("access_token_issuance_failed", error), |
| 275 | }), | 278 | ), |
| 276 | ) | 279 | ) |
| 277 | : undefined; | 280 | : undefined; |
| 278 | yield* appendCredentialResponseHeaders; | 281 | yield* appendCredentialResponseHeaders; |
| ... | @@ -293,12 +296,15 @@ export const authHttpApiLayer = HttpApiBuilder.group( | ... | @@ -293,12 +296,15 @@ export const authHttpApiLayer = HttpApiBuilder.group( |
| 293 | ); | 296 | ); |
| 294 | }, | 297 | }, |
| 295 | traceRelayRequest, | 298 | traceRelayRequest, |
| 296 | Effect.catchTags({ | 299 | Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => |
| 297 | ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), | 300 | failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), |
| 298 | ServerAuthInvalidRequestError: (error) => failEnvironmentInvalidRequest(error.reason), | 301 | ), |
| 299 | ServerAuthInternalError: (error) => | 302 | Effect.catchIf(EnvironmentAuth.isServerAuthInvalidRequestError, (error) => |
| 300 | failEnvironmentInternal("access_token_issuance_failed", error), | 303 | failEnvironmentInvalidRequest(EnvironmentAuth.serverAuthInvalidRequestReason(error)), |
| 301 | }), | 304 | ), |
| 305 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => | ||
| 306 | failEnvironmentInternal("access_token_issuance_failed", error), | ||
| 307 | ), | ||
| 302 | ), | 308 | ), |
| 303 | ) | 309 | ) |
| 304 | .handle( | 310 | .handle( |
| ... | @@ -310,7 +316,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( | ... | @@ -310,7 +316,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( |
| 310 | yield* appendCredentialResponseHeaders; | 316 | yield* appendCredentialResponseHeaders; |
| 311 | return yield* serverAuth.issueWebSocketTicket(session); | 317 | return yield* serverAuth.issueWebSocketTicket(session); |
| 312 | }, | 318 | }, |
| 313 | Effect.catchTag("ServerAuthInternalError", (error) => | 319 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 314 | failEnvironmentInternal("websocket_ticket_issuance_failed", error), | 320 | failEnvironmentInternal("websocket_ticket_issuance_failed", error), |
| 315 | ), | 321 | ), |
| 316 | ), | 322 | ), |
| ... | @@ -335,7 +341,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( | ... | @@ -335,7 +341,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( |
| 335 | } | 341 | } |
| 336 | return yield* serverAuth.issuePairingCredential(args.payload); | 342 | return yield* serverAuth.issuePairingCredential(args.payload); |
| 337 | }, | 343 | }, |
| 338 | Effect.catchTag("ServerAuthInternalError", (error) => | 344 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 339 | failEnvironmentInternal("pairing_credential_issuance_failed", error), | 345 | failEnvironmentInternal("pairing_credential_issuance_failed", error), |
| 340 | ), | 346 | ), |
| 341 | ), | 347 | ), |
| ... | @@ -348,7 +354,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( | ... | @@ -348,7 +354,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( |
| 348 | yield* requireEnvironmentScope(AuthAccessReadScope); | 354 | yield* requireEnvironmentScope(AuthAccessReadScope); |
| 349 | return yield* serverAuth.listPairingLinks(); | 355 | return yield* serverAuth.listPairingLinks(); |
| 350 | }, | 356 | }, |
| 351 | Effect.catchTag("ServerAuthInternalError", (error) => | 357 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 352 | failEnvironmentInternal("pairing_links_load_failed", error), | 358 | failEnvironmentInternal("pairing_links_load_failed", error), |
| 353 | ), | 359 | ), |
| 354 | ), | 360 | ), |
| ... | @@ -362,7 +368,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( | ... | @@ -362,7 +368,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( |
| 362 | const revoked = yield* serverAuth.revokePairingLink(args.payload.id); | 368 | const revoked = yield* serverAuth.revokePairingLink(args.payload.id); |
| 363 | return { revoked }; | 369 | return { revoked }; |
| 364 | }, | 370 | }, |
| 365 | Effect.catchTag("ServerAuthInternalError", (error) => | 371 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 366 | failEnvironmentInternal("pairing_link_revoke_failed", error), | 372 | failEnvironmentInternal("pairing_link_revoke_failed", error), |
| 367 | ), | 373 | ), |
| 368 | ), | 374 | ), |
| ... | @@ -375,7 +381,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( | ... | @@ -375,7 +381,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( |
| 375 | const session = yield* requireEnvironmentScope(AuthAccessReadScope); | 381 | const session = yield* requireEnvironmentScope(AuthAccessReadScope); |
| 376 | return yield* serverAuth.listClientSessions(session.sessionId); | 382 | return yield* serverAuth.listClientSessions(session.sessionId); |
| 377 | }, | 383 | }, |
| 378 | Effect.catchTag("ServerAuthInternalError", (error) => | 384 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 379 | failEnvironmentInternal("client_sessions_load_failed", error), | 385 | failEnvironmentInternal("client_sessions_load_failed", error), |
| 380 | ), | 386 | ), |
| 381 | ), | 387 | ), |
| ... | @@ -392,12 +398,12 @@ export const authHttpApiLayer = HttpApiBuilder.group( | ... | @@ -392,12 +398,12 @@ export const authHttpApiLayer = HttpApiBuilder.group( |
| 392 | ); | 398 | ); |
| 393 | return { revoked }; | 399 | return { revoked }; |
| 394 | }, | 400 | }, |
| 395 | Effect.catchTags({ | 401 | Effect.catchTag("ServerAuthForbiddenOperationError", () => |
| 396 | ServerAuthForbiddenOperationError: (error) => | 402 | failEnvironmentOperationForbidden("current_session_revoke_not_allowed"), |
| 397 | failEnvironmentOperationForbidden(error.reason), | 403 | ), |
| 398 | ServerAuthInternalError: (error) => | 404 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 399 | failEnvironmentInternal("client_session_revoke_failed", error), | 405 | failEnvironmentInternal("client_session_revoke_failed", error), |
| 400 | }), | 406 | ), |
| 401 | ), | 407 | ), |
| 402 | ) | 408 | ) |
| 403 | .handle( | 409 | .handle( |
| ... | @@ -409,7 +415,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( | ... | @@ -409,7 +415,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( |
| 409 | const revokedCount = yield* serverAuth.revokeOtherClientSessions(session.sessionId); | 415 | const revokedCount = yield* serverAuth.revokeOtherClientSessions(session.sessionId); |
| 410 | return { revokedCount }; | 416 | return { revokedCount }; |
| 411 | }, | 417 | }, |
| 412 | Effect.catchTag("ServerAuthInternalError", (error) => | 418 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 413 | failEnvironmentInternal("client_session_revoke_failed", error), | 419 | failEnvironmentInternal("client_session_revoke_failed", error), |
| 414 | ), | 420 | ), |
| 415 | ), | 421 | ), |
apps/server/src/cloud/environmentKeys.test.ts+4-4| ... | @@ -6,7 +6,7 @@ import * as Option from "effect/Option"; | ... | @@ -6,7 +6,7 @@ import * as Option from "effect/Option"; |
| 6 | import * as PlatformError from "effect/PlatformError"; | 6 | import * as PlatformError from "effect/PlatformError"; |
| 7 | 7 | ||
| 8 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | 8 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 9 | import { ServerConfig } from "../config.ts"; | 9 | import * as ServerConfig from "../config.ts"; |
| 10 | import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; | 10 | import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; |
| 11 | 11 | ||
| 12 | const makeServerSecretStoreLayer = () => | 12 | const makeServerSecretStoreLayer = () => |
| ... | @@ -65,8 +65,8 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it | ... | @@ -65,8 +65,8 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it |
| 65 | }).pipe( | 65 | }).pipe( |
| 66 | Effect.flatMap(() => | 66 | Effect.flatMap(() => |
| 67 | Effect.fail( | 67 | Effect.fail( |
| 68 | new ServerSecretStore.SecretStoreError({ | 68 | new ServerSecretStore.SecretStorePersistError({ |
| 69 | message: "Concurrent keypair creation won.", | 69 | resource: "environment signing key pair", |
| 70 | cause: PlatformError.systemError({ | 70 | cause: PlatformError.systemError({ |
| 71 | _tag: "AlreadyExists", | 71 | _tag: "AlreadyExists", |
| 72 | module: "FileSystem", | 72 | module: "FileSystem", |
| ... | @@ -79,7 +79,7 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it | ... | @@ -79,7 +79,7 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it |
| 79 | ), | 79 | ), |
| 80 | getOrCreateRandom: unusedSecretStoreOperation, | 80 | getOrCreateRandom: unusedSecretStoreOperation, |
| 81 | remove: unusedSecretStoreOperation, | 81 | remove: unusedSecretStoreOperation, |
| 82 | } satisfies ServerSecretStore.ServerSecretStoreShape; | 82 | } satisfies ServerSecretStore.ServerSecretStore["Service"]; |
| 83 | 83 | ||
| 84 | assert.deepEqual(yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore), { | 84 | assert.deepEqual(yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore), { |
| 85 | privateKey: "winner-private", | 85 | privateKey: "winner-private", |
apps/server/src/cloud/environmentKeys.ts+17-18| ... | @@ -27,47 +27,46 @@ function stringToBytes(value: string): Uint8Array { | ... | @@ -27,47 +27,46 @@ function stringToBytes(value: string): Uint8Array { |
| 27 | return new TextEncoder().encode(value); | 27 | return new TextEncoder().encode(value); |
| 28 | } | 28 | } |
| 29 | 29 | ||
| 30 | const keyPairPersistenceError = (message: string, cause?: unknown) => | 30 | const KEY_PAIR_RESOURCE = "environment signing key pair"; |
| 31 | new ServerSecretStore.SecretStoreError({ message, cause }); | 31 | |
| 32 | const keyPairDecodeError = (cause: unknown): ServerSecretStore.SecretStoreDecodeError => | ||
| 33 | new ServerSecretStore.SecretStoreDecodeError({ resource: KEY_PAIR_RESOURCE, cause }); | ||
| 34 | |||
| 35 | const keyPairEncodeError = (cause: unknown): ServerSecretStore.SecretStoreEncodeError => | ||
| 36 | new ServerSecretStore.SecretStoreEncodeError({ resource: KEY_PAIR_RESOURCE, cause }); | ||
| 37 | |||
| 38 | const keyPairConcurrentReadError = (): ServerSecretStore.SecretStoreConcurrentReadError => | ||
| 39 | new ServerSecretStore.SecretStoreConcurrentReadError({ resource: KEY_PAIR_RESOURCE }); | ||
| 32 | 40 | ||
| 33 | const readEnvironmentKeyPair = Effect.fn("readEnvironmentKeyPair")(function* ( | 41 | const readEnvironmentKeyPair = Effect.fn("readEnvironmentKeyPair")(function* ( |
| 34 | secrets: ServerSecretStore.ServerSecretStoreShape, | 42 | secrets: ServerSecretStore.ServerSecretStore["Service"], |
| 35 | ) { | 43 | ) { |
| 36 | const encoded = yield* secrets.get(CLOUD_LINK_KEY_PAIR); | 44 | const encoded = yield* secrets.get(CLOUD_LINK_KEY_PAIR); |
| 37 | if (Option.isNone(encoded)) { | 45 | if (Option.isNone(encoded)) { |
| 38 | return Option.none<EnvironmentKeyPair>(); | 46 | return Option.none<EnvironmentKeyPair>(); |
| 39 | } | 47 | } |
| 40 | const decoded = yield* decodeEnvironmentKeyPair(bytesToString(encoded.value)).pipe( | 48 | const decoded = yield* decodeEnvironmentKeyPair(bytesToString(encoded.value)).pipe( |
| 41 | Effect.mapError((cause) => | 49 | Effect.mapError(keyPairDecodeError), |
| 42 | keyPairPersistenceError("Failed to decode environment signing key pair.", cause), | ||
| 43 | ), | ||
| 44 | ); | 50 | ); |
| 45 | return Option.some(decoded); | 51 | return Option.some(decoded); |
| 46 | }); | 52 | }); |
| 47 | 53 | ||
| 48 | const persistEnvironmentKeyPair = Effect.fn("persistEnvironmentKeyPair")(function* ( | 54 | const persistEnvironmentKeyPair = Effect.fn("persistEnvironmentKeyPair")(function* ( |
| 49 | secrets: ServerSecretStore.ServerSecretStoreShape, | 55 | secrets: ServerSecretStore.ServerSecretStore["Service"], |
| 50 | keyPair: EnvironmentKeyPair, | 56 | keyPair: EnvironmentKeyPair, |
| 51 | ) { | 57 | ) { |
| 52 | const encoded = yield* encodeEnvironmentKeyPair(keyPair).pipe( | 58 | const encoded = yield* encodeEnvironmentKeyPair(keyPair).pipe( |
| 53 | Effect.mapError((cause) => | 59 | Effect.mapError(keyPairEncodeError), |
| 54 | keyPairPersistenceError("Failed to encode environment signing key pair.", cause), | ||
| 55 | ), | ||
| 56 | ); | 60 | ); |
| 57 | return yield* secrets.create(CLOUD_LINK_KEY_PAIR, stringToBytes(encoded)).pipe( | 61 | return yield* secrets.create(CLOUD_LINK_KEY_PAIR, stringToBytes(encoded)).pipe( |
| 58 | Effect.as(keyPair), | 62 | Effect.as(keyPair), |
| 59 | Effect.catchTag("SecretStoreError", (error) => | 63 | Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => |
| 60 | ServerSecretStore.isSecretAlreadyExistsError(error) | 64 | ServerSecretStore.isSecretAlreadyExistsError(error) |
| 61 | ? readEnvironmentKeyPair(secrets).pipe( | 65 | ? readEnvironmentKeyPair(secrets).pipe( |
| 62 | Effect.flatMap( | 66 | Effect.flatMap( |
| 63 | Option.match({ | 67 | Option.match({ |
| 64 | onSome: Effect.succeed, | 68 | onSome: Effect.succeed, |
| 65 | onNone: () => | 69 | onNone: () => Effect.fail(keyPairConcurrentReadError()), |
| 66 | Effect.fail( | ||
| 67 | keyPairPersistenceError( | ||
| 68 | "Failed to read environment signing key pair after concurrent creation.", | ||
| 69 | ), | ||
| 70 | ), | ||
| 71 | }), | 70 | }), |
| 72 | ), | 71 | ), |
| 73 | ) | 72 | ) |
| ... | @@ -77,7 +76,7 @@ const persistEnvironmentKeyPair = Effect.fn("persistEnvironmentKeyPair")(functio | ... | @@ -77,7 +76,7 @@ const persistEnvironmentKeyPair = Effect.fn("persistEnvironmentKeyPair")(functio |
| 77 | }); | 76 | }); |
| 78 | 77 | ||
| 79 | export const getOrCreateEnvironmentKeyPairFromSecretStore = Effect.fn(function* ( | 78 | export const getOrCreateEnvironmentKeyPairFromSecretStore = Effect.fn(function* ( |
| 80 | secrets: ServerSecretStore.ServerSecretStoreShape, | 79 | secrets: ServerSecretStore.ServerSecretStore["Service"], |
| 81 | ) { | 80 | ) { |
| 82 | const existing = yield* readEnvironmentKeyPair(secrets); | 81 | const existing = yield* readEnvironmentKeyPair(secrets); |
| 83 | if (Option.isSome(existing)) { | 82 | if (Option.isSome(existing)) { |
apps/server/src/cloud/http.test.ts+26-2| ... | @@ -16,8 +16,8 @@ import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; | ... | @@ -16,8 +16,8 @@ import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; |
| 16 | import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts"; | 16 | import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts"; |
| 17 | 17 | ||
| 18 | const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => | 18 | const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") => |
| 19 | new ServerSecretStore.SecretStoreError({ | 19 | new ServerSecretStore.SecretStorePersistError({ |
| 20 | message: "Failed to persist cloud replay guard.", | 20 | resource: "cloud replay guard", |
| 21 | cause: PlatformError.systemError({ | 21 | cause: PlatformError.systemError({ |
| 22 | _tag: tag, | 22 | _tag: tag, |
| 23 | module: "FileSystem", | 23 | module: "FileSystem", |
| ... | @@ -40,6 +40,30 @@ function makeSecretStore( | ... | @@ -40,6 +40,30 @@ function makeSecretStore( |
| 40 | }; | 40 | }; |
| 41 | } | 41 | } |
| 42 | 42 | ||
| 43 | it("preserves messages surfaced by cloud 500 responses", () => { | ||
| 44 | const cause = new Error("cloud operation failed"); | ||
| 45 | |||
| 46 | expect([ | ||
| 47 | new EnvironmentAuth.ServerAuthLinkedCloudAccountVerificationError({ cause }).message, | ||
| 48 | new EnvironmentAuth.ServerAuthLinkedCloudAccountReadError({ cause }).message, | ||
| 49 | new EnvironmentAuth.ServerAuthLinkedCloudAccountMissingError({}).message, | ||
| 50 | new EnvironmentAuth.ServerAuthCloudLinkJwtSigningError({ cause }).message, | ||
| 51 | new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({}).message, | ||
| 52 | new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({}).message, | ||
| 53 | new EnvironmentAuth.ServerAuthCloudHealthJwtSigningError({ cause }).message, | ||
| 54 | new EnvironmentAuth.ServerAuthCloudMintJwtSigningError({ cause }).message, | ||
| 55 | ]).toEqual([ | ||
| 56 | "Could not verify the linked cloud account.", | ||
| 57 | "Could not read the linked cloud account.", | ||
| 58 | "Cloud linked user is not installed for this environment.", | ||
| 59 | "Failed to sign cloud link JWT.", | ||
| 60 | "Cloud mint public key is not installed for this environment.", | ||
| 61 | "Cloud relay issuer is not installed for this environment.", | ||
| 62 | "Failed to sign cloud health JWT.", | ||
| 63 | "Failed to sign cloud mint JWT.", | ||
| 64 | ]); | ||
| 65 | }); | ||
| 66 | |||
| 43 | describe("consumeCloudReplayGuards", () => { | 67 | describe("consumeCloudReplayGuards", () => { |
| 44 | it.effect("reports already-created guards as replay conflicts", () => | 68 | it.effect("reports already-created guards as replay conflicts", () => |
| 45 | Effect.gen(function* () { | 69 | Effect.gen(function* () { |
apps/server/src/cloud/http.ts+113-119| ... | @@ -68,7 +68,7 @@ import { | ... | @@ -68,7 +68,7 @@ import { |
| 68 | RELAY_URL_SECRET, | 68 | RELAY_URL_SECRET, |
| 69 | } from "./config.ts"; | 69 | } from "./config.ts"; |
| 70 | import { relayUrlConfig } from "./publicConfig.ts"; | 70 | import { relayUrlConfig } from "./publicConfig.ts"; |
| 71 | import * as CliState from "./CliState.ts"; | 71 | import { setCliDesiredCloudLink } from "./CliState.ts"; |
| 72 | import * as CliTokenManager from "./CliTokenManager.ts"; | 72 | import * as CliTokenManager from "./CliTokenManager.ts"; |
| 73 | import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; | 73 | import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts"; |
| 74 | import { traceRelayRequest } from "./traceRelayRequest.ts"; | 74 | import { traceRelayRequest } from "./traceRelayRequest.ts"; |
| ... | @@ -98,7 +98,7 @@ const failEnvironmentCloudInternalError = | ... | @@ -98,7 +98,7 @@ const failEnvironmentCloudInternalError = |
| 98 | ); | 98 | ); |
| 99 | 99 | ||
| 100 | const failCloudCliTokenManagerError = (error: CliTokenManager.CloudCliTokenManagerError) => | 100 | const failCloudCliTokenManagerError = (error: CliTokenManager.CloudCliTokenManagerError) => |
| 101 | failEnvironmentCloudInternalError(error.message)(error.cause); | 101 | failEnvironmentCloudInternalError(error.message)(error); |
| 102 | 102 | ||
| 103 | const requireRelayUrl = relayUrlConfig.pipe( | 103 | const requireRelayUrl = relayUrlConfig.pipe( |
| 104 | Effect.mapError( | 104 | Effect.mapError( |
| ... | @@ -126,7 +126,7 @@ export function consumeCloudReplayGuards(input: { | ... | @@ -126,7 +126,7 @@ export function consumeCloudReplayGuards(input: { |
| 126 | input.names.map((name) => | 126 | input.names.map((name) => |
| 127 | input.secrets.create(name, input.value).pipe( | 127 | input.secrets.create(name, input.value).pipe( |
| 128 | Effect.as(true), | 128 | Effect.as(true), |
| 129 | Effect.catchTag("SecretStoreError", (error) => | 129 | Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => |
| 130 | ServerSecretStore.isSecretAlreadyExistsError(error) | 130 | ServerSecretStore.isSecretAlreadyExistsError(error) |
| 131 | ? Effect.succeed(false) | 131 | ? Effect.succeed(false) |
| 132 | : Effect.fail(error), | 132 | : Effect.fail(error), |
| ... | @@ -211,8 +211,7 @@ function validateLinkedCloudUser(input: { | ... | @@ -211,8 +211,7 @@ function validateLinkedCloudUser(input: { |
| 211 | return input.secrets.get(CLOUD_LINKED_USER_ID).pipe( | 211 | return input.secrets.get(CLOUD_LINKED_USER_ID).pipe( |
| 212 | Effect.mapError( | 212 | Effect.mapError( |
| 213 | (cause) => | 213 | (cause) => |
| 214 | new EnvironmentAuth.ServerAuthInternalError({ | 214 | new EnvironmentAuth.ServerAuthLinkedCloudAccountVerificationError({ |
| 215 | message: "Could not verify the linked cloud account.", | ||
| 216 | cause, | 215 | cause, |
| 217 | }), | 216 | }), |
| 218 | ), | 217 | ), |
| ... | @@ -239,19 +238,14 @@ function readInstalledCloudUserId( | ... | @@ -239,19 +238,14 @@ function readInstalledCloudUserId( |
| 239 | return secrets.get(CLOUD_LINKED_USER_ID).pipe( | 238 | return secrets.get(CLOUD_LINKED_USER_ID).pipe( |
| 240 | Effect.mapError( | 239 | Effect.mapError( |
| 241 | (cause) => | 240 | (cause) => |
| 242 | new EnvironmentAuth.ServerAuthInternalError({ | 241 | new EnvironmentAuth.ServerAuthLinkedCloudAccountReadError({ |
| 243 | message: "Could not read the linked cloud account.", | ||
| 244 | cause, | 242 | cause, |
| 245 | }), | 243 | }), |
| 246 | ), | 244 | ), |
| 247 | Effect.flatMap((bytes) => | 245 | Effect.flatMap((bytes) => |
| 248 | Option.isSome(bytes) | 246 | Option.isSome(bytes) |
| 249 | ? Effect.succeed(bytesToString(bytes.value)) | 247 | ? Effect.succeed(bytesToString(bytes.value)) |
| 250 | : Effect.fail( | 248 | : Effect.fail(new EnvironmentAuth.ServerAuthLinkedCloudAccountMissingError({})), |
| 251 | new EnvironmentAuth.ServerAuthInternalError({ | ||
| 252 | message: "Cloud linked user is not installed for this environment.", | ||
| 253 | }), | ||
| 254 | ), | ||
| 255 | ), | 249 | ), |
| 256 | ); | 250 | ); |
| 257 | } | 251 | } |
| ... | @@ -394,8 +388,7 @@ const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function | ... | @@ -394,8 +388,7 @@ const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function |
| 394 | }).pipe( | 388 | }).pipe( |
| 395 | Effect.mapError( | 389 | Effect.mapError( |
| 396 | (cause) => | 390 | (cause) => |
| 397 | new EnvironmentAuth.ServerAuthInternalError({ | 391 | new EnvironmentAuth.ServerAuthCloudLinkJwtSigningError({ |
| 398 | message: "Failed to sign cloud link JWT.", | ||
| 399 | cause, | 392 | cause, |
| 400 | }), | 393 | }), |
| 401 | ), | 394 | ), |
| ... | @@ -416,15 +409,17 @@ const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")( | ... | @@ -416,15 +409,17 @@ const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")( |
| 416 | yield* appendCloudCredentialResponseHeaders; | 409 | yield* appendCloudCredentialResponseHeaders; |
| 417 | return proof satisfies RelayEnvironmentLinkProof; | 410 | return proof satisfies RelayEnvironmentLinkProof; |
| 418 | }, | 411 | }, |
| 419 | Effect.catchTag("ServerAuthInternalError", (error) => | 412 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 420 | failEnvironmentCloudInternalError(error.message)(error.cause), | 413 | failEnvironmentCloudInternalError(error.message)(error), |
| 414 | ), | ||
| 415 | Effect.catchIf( | ||
| 416 | ServerSecretStore.isSecretStoreError, | ||
| 417 | failEnvironmentCloudInternalError("Could not generate environment link proof."), | ||
| 418 | ), | ||
| 419 | Effect.catchTag( | ||
| 420 | "PlatformError", | ||
| 421 | failEnvironmentCloudInternalError("Could not generate environment link proof."), | ||
| 421 | ), | 422 | ), |
| 422 | Effect.catchTags({ | ||
| 423 | PlatformError: failEnvironmentCloudInternalError("Could not generate environment link proof."), | ||
| 424 | SecretStoreError: failEnvironmentCloudInternalError( | ||
| 425 | "Could not generate environment link proof.", | ||
| 426 | ), | ||
| 427 | }), | ||
| 428 | ); | 423 | ); |
| 429 | 424 | ||
| 430 | const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(function* ( | 425 | const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(function* ( |
| ... | @@ -477,17 +472,17 @@ const cloudRelayConfigHandler = Effect.fn("environment.cloud.relayConfig")( | ... | @@ -477,17 +472,17 @@ const cloudRelayConfigHandler = Effect.fn("environment.cloud.relayConfig")( |
| 477 | yield* requireEnvironmentScope(AuthRelayWriteScope); | 472 | yield* requireEnvironmentScope(AuthRelayWriteScope); |
| 478 | return yield* applyCloudRelayConfig(dependencies, payload); | 473 | return yield* applyCloudRelayConfig(dependencies, payload); |
| 479 | }, | 474 | }, |
| 480 | Effect.catchTag("ServerAuthInternalError", (error) => | 475 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 481 | failEnvironmentCloudInternalError(error.message)(error.cause), | 476 | failEnvironmentCloudInternalError(error.message)(error), |
| 477 | ), | ||
| 478 | Effect.catchIf( | ||
| 479 | ServerSecretStore.isSecretStoreError, | ||
| 480 | failEnvironmentCloudInternalError("Could not persist environment relay configuration."), | ||
| 481 | ), | ||
| 482 | Effect.catchTag( | ||
| 483 | "SchemaError", | ||
| 484 | failEnvironmentCloudInternalError("Could not persist environment relay configuration."), | ||
| 482 | ), | 485 | ), |
| 483 | Effect.catchTags({ | ||
| 484 | SchemaError: failEnvironmentCloudInternalError( | ||
| 485 | "Could not persist environment relay configuration.", | ||
| 486 | ), | ||
| 487 | SecretStoreError: failEnvironmentCloudInternalError( | ||
| 488 | "Could not persist environment relay configuration.", | ||
| 489 | ), | ||
| 490 | }), | ||
| 491 | ); | 486 | ); |
| 492 | 487 | ||
| 493 | const relayClientRequest = <A>( | 488 | const relayClientRequest = <A>( |
| ... | @@ -581,7 +576,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi | ... | @@ -581,7 +576,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi |
| 581 | }, | 576 | }, |
| 582 | schema: RelayEnvironmentLinkResponse, | 577 | schema: RelayEnvironmentLinkResponse, |
| 583 | }); | 578 | }); |
| 584 | yield* CliState.setCliDesiredCloudLink(true); | 579 | yield* setCliDesiredCloudLink(true); |
| 585 | return yield* applyCloudRelayConfig(dependencies, { | 580 | return yield* applyCloudRelayConfig(dependencies, { |
| 586 | relayUrl, | 581 | relayUrl, |
| 587 | relayIssuer: link.relayIssuer, | 582 | relayIssuer: link.relayIssuer, |
| ... | @@ -591,15 +586,16 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi | ... | @@ -591,15 +586,16 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi |
| 591 | endpointRuntime: link.endpointRuntime, | 586 | endpointRuntime: link.endpointRuntime, |
| 592 | }); | 587 | }); |
| 593 | }, | 588 | }, |
| 589 | Effect.catchIf( | ||
| 590 | ServerSecretStore.isSecretStoreError, | ||
| 591 | failEnvironmentCloudInternalError("Could not persist desired T3 Connect link state."), | ||
| 592 | ), | ||
| 594 | Effect.catchTags({ | 593 | Effect.catchTags({ |
| 595 | CloudCliCredentialRemovalError: failCloudCliTokenManagerError, | 594 | CloudCliCredentialRemovalError: failCloudCliTokenManagerError, |
| 596 | CloudCliCredentialRefreshError: failCloudCliTokenManagerError, | 595 | CloudCliCredentialRefreshError: failCloudCliTokenManagerError, |
| 597 | CloudCliCredentialReadError: failCloudCliTokenManagerError, | 596 | CloudCliCredentialReadError: failCloudCliTokenManagerError, |
| 598 | CloudCliAuthorizationError: failCloudCliTokenManagerError, | 597 | CloudCliAuthorizationError: failCloudCliTokenManagerError, |
| 599 | CloudCliAuthorizationTimeoutError: failCloudCliTokenManagerError, | 598 | CloudCliAuthorizationTimeoutError: failCloudCliTokenManagerError, |
| 600 | SecretStoreError: failEnvironmentCloudInternalError( | ||
| 601 | "Could not persist desired T3 Connect link state.", | ||
| 602 | ), | ||
| 603 | }), | 599 | }), |
| 604 | ); | 600 | ); |
| 605 | 601 | ||
| ... | @@ -637,8 +633,8 @@ const cloudLinkStateHandler = Effect.fn("environment.cloud.linkState")( | ... | @@ -637,8 +633,8 @@ const cloudLinkStateHandler = Effect.fn("environment.cloud.linkState")( |
| 637 | yield* requireEnvironmentScope(AuthRelayReadScope); | 633 | yield* requireEnvironmentScope(AuthRelayReadScope); |
| 638 | return yield* readCloudLinkState(dependencies); | 634 | return yield* readCloudLinkState(dependencies); |
| 639 | }, | 635 | }, |
| 640 | Effect.catchTag( | 636 | Effect.catchIf( |
| 641 | "SecretStoreError", | 637 | ServerSecretStore.isSecretStoreError, |
| 642 | failEnvironmentCloudInternalError("Could not read environment relay configuration."), | 638 | failEnvironmentCloudInternalError("Could not read environment relay configuration."), |
| 643 | ), | 639 | ), |
| 644 | ); | 640 | ); |
| ... | @@ -659,11 +655,11 @@ const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")( | ... | @@ -659,11 +655,11 @@ const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")( |
| 659 | ], | 655 | ], |
| 660 | { concurrency: 7 }, | 656 | { concurrency: 7 }, |
| 661 | ); | 657 | ); |
| 662 | yield* CliState.setCliDesiredCloudLink(false); | 658 | yield* setCliDesiredCloudLink(false); |
| 663 | return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; | 659 | return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult; |
| 664 | }, | 660 | }, |
| 665 | Effect.catchTag( | 661 | Effect.catchIf( |
| 666 | "SecretStoreError", | 662 | ServerSecretStore.isSecretStoreError, |
| 667 | failEnvironmentCloudInternalError("Could not remove environment relay configuration."), | 663 | failEnvironmentCloudInternalError("Could not remove environment relay configuration."), |
| 668 | ), | 664 | ), |
| 669 | ); | 665 | ); |
| ... | @@ -680,42 +676,40 @@ const cloudPreferencesHandler = Effect.fn("environment.cloud.preferences")( | ... | @@ -680,42 +676,40 @@ const cloudPreferencesHandler = Effect.fn("environment.cloud.preferences")( |
| 680 | ); | 676 | ); |
| 681 | return yield* readCloudLinkState(dependencies); | 677 | return yield* readCloudLinkState(dependencies); |
| 682 | }, | 678 | }, |
| 683 | Effect.catchTag( | 679 | Effect.catchIf( |
| 684 | "SecretStoreError", | 680 | ServerSecretStore.isSecretStoreError, |
| 685 | failEnvironmentCloudInternalError("Could not persist environment cloud preferences."), | 681 | failEnvironmentCloudInternalError("Could not persist environment cloud preferences."), |
| 686 | ), | 682 | ), |
| 687 | ); | 683 | ); |
| 688 | 684 | ||
| 689 | const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( | 685 | const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( |
| 690 | function* (dependencies: CloudHttpDependencies, request: RelayCloudEnvironmentHealthRequest) { | 686 | function* (dependencies: CloudHttpDependencies, request: RelayCloudEnvironmentHealthRequest) { |
| 691 | const cloudMintPublicKey = yield* dependencies.secrets.get(CLOUD_MINT_PUBLIC_KEY).pipe( | 687 | const cloudMintPublicKey = yield* dependencies.secrets |
| 692 | Effect.flatMap((bytes) => | 688 | .get(CLOUD_MINT_PUBLIC_KEY) |
| 693 | Option.isSome(bytes) | 689 | .pipe( |
| 694 | ? Effect.succeed(bytesToString(bytes.value)) | 690 | Effect.flatMap((bytes) => |
| 695 | : Effect.fail( | 691 | Option.isSome(bytes) |
| 696 | new EnvironmentAuth.ServerAuthInternalError({ | 692 | ? Effect.succeed(bytesToString(bytes.value)) |
| 697 | message: "Cloud mint public key is not installed for this environment.", | 693 | : Effect.fail(new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({})), |
| 698 | }), | 694 | ), |
| 699 | ), | 695 | ); |
| 700 | ), | 696 | const relayIssuer = yield* dependencies.secrets |
| 701 | ); | 697 | .get(RELAY_ISSUER_SECRET) |
| 702 | const relayIssuer = yield* dependencies.secrets.get(RELAY_ISSUER_SECRET).pipe( | 698 | .pipe( |
| 703 | Effect.flatMap((bytes) => | 699 | Effect.flatMap((bytes) => |
| 704 | Option.isSome(bytes) | 700 | Option.isSome(bytes) |
| 705 | ? Effect.succeed(bytesToString(bytes.value)) | 701 | ? Effect.succeed(bytesToString(bytes.value)) |
| 706 | : dependencies.secrets.get(RELAY_URL_SECRET).pipe( | 702 | : dependencies.secrets |
| 707 | Effect.flatMap((fallbackBytes) => | 703 | .get(RELAY_URL_SECRET) |
| 708 | Option.isSome(fallbackBytes) | 704 | .pipe( |
| 709 | ? Effect.succeed(bytesToString(fallbackBytes.value)) | 705 | Effect.flatMap((fallbackBytes) => |
| 710 | : Effect.fail( | 706 | Option.isSome(fallbackBytes) |
| 711 | new EnvironmentAuth.ServerAuthInternalError({ | 707 | ? Effect.succeed(bytesToString(fallbackBytes.value)) |
| 712 | message: "Cloud relay issuer is not installed for this environment.", | 708 | : Effect.fail(new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({})), |
| 713 | }), | 709 | ), |
| 714 | ), | 710 | ), |
| 715 | ), | 711 | ), |
| 716 | ), | 712 | ); |
| 717 | ), | ||
| 718 | ); | ||
| 719 | const environmentId = yield* dependencies.environment.getEnvironmentId; | 713 | const environmentId = yield* dependencies.environment.getEnvironmentId; |
| 720 | const linkedCloudUserId = yield* readInstalledCloudUserId(dependencies.secrets); | 714 | const linkedCloudUserId = yield* readInstalledCloudUserId(dependencies.secrets); |
| 721 | const now = yield* DateTime.now; | 715 | const now = yield* DateTime.now; |
| ... | @@ -777,8 +771,7 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( | ... | @@ -777,8 +771,7 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( |
| 777 | }).pipe( | 771 | }).pipe( |
| 778 | Effect.mapError( | 772 | Effect.mapError( |
| 779 | (cause) => | 773 | (cause) => |
| 780 | new EnvironmentAuth.ServerAuthInternalError({ | 774 | new EnvironmentAuth.ServerAuthCloudHealthJwtSigningError({ |
| 781 | message: "Failed to sign cloud health JWT.", | ||
| 782 | cause, | 775 | cause, |
| 783 | }), | 776 | }), |
| 784 | ), | 777 | ), |
| ... | @@ -794,45 +787,47 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( | ... | @@ -794,45 +787,47 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")( |
| 794 | yield* appendCloudCredentialResponseHeaders; | 787 | yield* appendCloudCredentialResponseHeaders; |
| 795 | return response; | 788 | return response; |
| 796 | }, | 789 | }, |
| 797 | Effect.catchTag("ServerAuthInternalError", (error) => | 790 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 798 | failEnvironmentCloudInternalError(error.message)(error.cause), | 791 | failEnvironmentCloudInternalError(error.message)(error), |
| 792 | ), | ||
| 793 | Effect.catchIf( | ||
| 794 | ServerSecretStore.isSecretStoreError, | ||
| 795 | failEnvironmentCloudInternalError("Could not answer cloud health request."), | ||
| 796 | ), | ||
| 797 | Effect.catchTag( | ||
| 798 | "PlatformError", | ||
| 799 | failEnvironmentCloudInternalError("Could not answer cloud health request."), | ||
| 799 | ), | 800 | ), |
| 800 | Effect.catchTags({ | ||
| 801 | PlatformError: failEnvironmentCloudInternalError("Could not answer cloud health request."), | ||
| 802 | SecretStoreError: failEnvironmentCloudInternalError("Could not answer cloud health request."), | ||
| 803 | }), | ||
| 804 | ); | 801 | ); |
| 805 | 802 | ||
| 806 | const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential")( | 803 | const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential")( |
| 807 | function* (dependencies: CloudHttpDependencies, request: RelayCloudMintCredentialRequest) { | 804 | function* (dependencies: CloudHttpDependencies, request: RelayCloudMintCredentialRequest) { |
| 808 | const cloudMintPublicKey = yield* dependencies.secrets.get(CLOUD_MINT_PUBLIC_KEY).pipe( | 805 | const cloudMintPublicKey = yield* dependencies.secrets |
| 809 | Effect.flatMap((bytes) => | 806 | .get(CLOUD_MINT_PUBLIC_KEY) |
| 810 | Option.isSome(bytes) | 807 | .pipe( |
| 811 | ? Effect.succeed(bytesToString(bytes.value)) | 808 | Effect.flatMap((bytes) => |
| 812 | : Effect.fail( | 809 | Option.isSome(bytes) |
| 813 | new EnvironmentAuth.ServerAuthInternalError({ | 810 | ? Effect.succeed(bytesToString(bytes.value)) |
| 814 | message: "Cloud mint public key is not installed for this environment.", | 811 | : Effect.fail(new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({})), |
| 815 | }), | 812 | ), |
| 816 | ), | 813 | ); |
| 817 | ), | 814 | const relayIssuer = yield* dependencies.secrets |
| 818 | ); | 815 | .get(RELAY_ISSUER_SECRET) |
| 819 | const relayIssuer = yield* dependencies.secrets.get(RELAY_ISSUER_SECRET).pipe( | 816 | .pipe( |
| 820 | Effect.flatMap((bytes) => | 817 | Effect.flatMap((bytes) => |
| 821 | Option.isSome(bytes) | 818 | Option.isSome(bytes) |
| 822 | ? Effect.succeed(bytesToString(bytes.value)) | 819 | ? Effect.succeed(bytesToString(bytes.value)) |
| 823 | : dependencies.secrets.get(RELAY_URL_SECRET).pipe( | 820 | : dependencies.secrets |
| 824 | Effect.flatMap((fallbackBytes) => | 821 | .get(RELAY_URL_SECRET) |
| 825 | Option.isSome(fallbackBytes) | 822 | .pipe( |
| 826 | ? Effect.succeed(bytesToString(fallbackBytes.value)) | 823 | Effect.flatMap((fallbackBytes) => |
| 827 | : Effect.fail( | 824 | Option.isSome(fallbackBytes) |
| 828 | new EnvironmentAuth.ServerAuthInternalError({ | 825 | ? Effect.succeed(bytesToString(fallbackBytes.value)) |
| 829 | message: "Cloud relay issuer is not installed for this environment.", | 826 | : Effect.fail(new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({})), |
| 830 | }), | 827 | ), |
| 831 | ), | 828 | ), |
| 832 | ), | 829 | ), |
| 833 | ), | 830 | ); |
| 834 | ), | ||
| 835 | ); | ||
| 836 | const environmentId = yield* dependencies.environment.getEnvironmentId; | 831 | const environmentId = yield* dependencies.environment.getEnvironmentId; |
| 837 | const linkedCloudUserId = yield* readInstalledCloudUserId(dependencies.secrets); | 832 | const linkedCloudUserId = yield* readInstalledCloudUserId(dependencies.secrets); |
| 838 | const now = yield* DateTime.now; | 833 | const now = yield* DateTime.now; |
| ... | @@ -899,8 +894,7 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") | ... | @@ -899,8 +894,7 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") |
| 899 | }).pipe( | 894 | }).pipe( |
| 900 | Effect.mapError( | 895 | Effect.mapError( |
| 901 | (cause) => | 896 | (cause) => |
| 902 | new EnvironmentAuth.ServerAuthInternalError({ | 897 | new EnvironmentAuth.ServerAuthCloudMintJwtSigningError({ |
| 903 | message: "Failed to sign cloud mint JWT.", | ||
| 904 | cause, | 898 | cause, |
| 905 | }), | 899 | }), |
| 906 | ), | 900 | ), |
| ... | @@ -914,17 +908,17 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") | ... | @@ -914,17 +908,17 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential") |
| 914 | yield* appendCloudCredentialResponseHeaders; | 908 | yield* appendCloudCredentialResponseHeaders; |
| 915 | return response; | 909 | return response; |
| 916 | }, | 910 | }, |
| 917 | Effect.catchTag("ServerAuthInternalError", (error) => | 911 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 918 | failEnvironmentCloudInternalError(error.message)(error.cause), | 912 | failEnvironmentCloudInternalError(error.message)(error), |
| 913 | ), | ||
| 914 | Effect.catchIf( | ||
| 915 | ServerSecretStore.isSecretStoreError, | ||
| 916 | failEnvironmentCloudInternalError("Could not issue cloud connection credential."), | ||
| 917 | ), | ||
| 918 | Effect.catchTag( | ||
| 919 | "PlatformError", | ||
| 920 | failEnvironmentCloudInternalError("Could not issue cloud connection credential."), | ||
| 919 | ), | 921 | ), |
| 920 | Effect.catchTags({ | ||
| 921 | PlatformError: failEnvironmentCloudInternalError( | ||
| 922 | "Could not issue cloud connection credential.", | ||
| 923 | ), | ||
| 924 | SecretStoreError: failEnvironmentCloudInternalError( | ||
| 925 | "Could not issue cloud connection credential.", | ||
| 926 | ), | ||
| 927 | }), | ||
| 928 | ); | 922 | ); |
| 929 | 923 | ||
| 930 | export const connectHttpApiLayer = HttpApiBuilder.group( | 924 | export const connectHttpApiLayer = HttpApiBuilder.group( |
apps/server/src/http.ts+6-4| ... | @@ -81,10 +81,12 @@ const authenticateRawRouteWithScope = ( | ... | @@ -81,10 +81,12 @@ const authenticateRawRouteWithScope = ( |
| 81 | const request = yield* HttpServerRequest.HttpServerRequest; | 81 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 82 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | 82 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 83 | const session = yield* serverAuth.authenticateHttpRequest(request).pipe( | 83 | const session = yield* serverAuth.authenticateHttpRequest(request).pipe( |
| 84 | Effect.catchTags({ | 84 | Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => |
| 85 | ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), | 85 | failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), |
| 86 | ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error), | 86 | ), |
| 87 | }), | 87 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 88 | failEnvironmentInternal("internal_error", error), | ||
| 89 | ), | ||
| 88 | ); | 90 | ); |
| 89 | if (!session.scopes.includes(scope)) { | 91 | if (!session.scopes.includes(scope)) { |
| 90 | return yield* failEnvironmentScopeRequired(scope); | 92 | return yield* failEnvironmentScopeRequired(scope); |
apps/server/src/relay/AgentAwarenessRelay.test.ts+6-6| ... | @@ -67,15 +67,15 @@ function makeMemorySecretStore() { | ... | @@ -67,15 +67,15 @@ function makeMemorySecretStore() { |
| 67 | Effect.sync(() => { | 67 | Effect.sync(() => { |
| 68 | const value = values.get(name); | 68 | const value = values.get(name); |
| 69 | return value === undefined ? Option.none() : Option.some(Uint8Array.from(value)); | 69 | return value === undefined ? Option.none() : Option.some(Uint8Array.from(value)); |
| 70 | })) satisfies ServerSecretStore.ServerSecretStoreShape["get"], | 70 | })) satisfies ServerSecretStore.ServerSecretStore["Service"]["get"], |
| 71 | set: ((name, value) => | 71 | set: ((name, value) => |
| 72 | Effect.sync(() => { | 72 | Effect.sync(() => { |
| 73 | values.set(name, Uint8Array.from(value)); | 73 | values.set(name, Uint8Array.from(value)); |
| 74 | })) satisfies ServerSecretStore.ServerSecretStoreShape["set"], | 74 | })) satisfies ServerSecretStore.ServerSecretStore["Service"]["set"], |
| 75 | create: ((name, value) => | 75 | create: ((name, value) => |
| 76 | Effect.sync(() => { | 76 | Effect.sync(() => { |
| 77 | values.set(name, Uint8Array.from(value)); | 77 | values.set(name, Uint8Array.from(value)); |
| 78 | })) satisfies ServerSecretStore.ServerSecretStoreShape["create"], | 78 | })) satisfies ServerSecretStore.ServerSecretStore["Service"]["create"], |
| 79 | getOrCreateRandom: ((name, bytes) => | 79 | getOrCreateRandom: ((name, bytes) => |
| 80 | Effect.sync(() => { | 80 | Effect.sync(() => { |
| 81 | const existing = values.get(name); | 81 | const existing = values.get(name); |
| ... | @@ -85,12 +85,12 @@ function makeMemorySecretStore() { | ... | @@ -85,12 +85,12 @@ function makeMemorySecretStore() { |
| 85 | const generated = new Uint8Array(bytes); | 85 | const generated = new Uint8Array(bytes); |
| 86 | values.set(name, generated); | 86 | values.set(name, generated); |
| 87 | return generated; | 87 | return generated; |
| 88 | })) satisfies ServerSecretStore.ServerSecretStoreShape["getOrCreateRandom"], | 88 | })) satisfies ServerSecretStore.ServerSecretStore["Service"]["getOrCreateRandom"], |
| 89 | remove: ((name) => | 89 | remove: ((name) => |
| 90 | Effect.sync(() => { | 90 | Effect.sync(() => { |
| 91 | values.delete(name); | 91 | values.delete(name); |
| 92 | })) satisfies ServerSecretStore.ServerSecretStoreShape["remove"], | 92 | })) satisfies ServerSecretStore.ServerSecretStore["Service"]["remove"], |
| 93 | } satisfies ServerSecretStore.ServerSecretStoreShape; | 93 | } satisfies ServerSecretStore.ServerSecretStore["Service"]; |
| 94 | return { | 94 | return { |
| 95 | store, | 95 | store, |
| 96 | setString: (name: string, value: string) => store.set(name, encodeSecret(value)), | 96 | setString: (name: string, value: string) => store.set(name, encodeSecret(value)), |
apps/server/src/ws.ts+6-4| ... | @@ -1726,10 +1726,12 @@ export const websocketRpcRouteLayer = Layer.unwrap( | ... | @@ -1726,10 +1726,12 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 1726 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; | 1726 | const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; |
| 1727 | const sessions = yield* SessionStore.SessionStore; | 1727 | const sessions = yield* SessionStore.SessionStore; |
| 1728 | const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( | 1728 | const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( |
| 1729 | Effect.catchTags({ | 1729 | Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => |
| 1730 | ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason), | 1730 | failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), |
| 1731 | ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error), | 1731 | ), |
| 1732 | }), | 1732 | Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => |
| 1733 | failEnvironmentInternal("internal_error", error), | ||
| 1734 | ), | ||
| 1733 | ); | 1735 | ); |
| 1734 | const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { | 1736 | const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { |
| 1735 | disableTracing: true, | 1737 | disableTracing: true, |