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