| author | |
| committer | |
| log | 97e5cd3bf7bbee427a177d5017aa2d250429bbf6 |
| tree | af3da5b7dc8ca22415e555cf1ae1ff8ff9268c4e |
| parent | b19fc1b87b22a3c923e60c2ef2a1dae336279924 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>22 files changed, 1434 insertions(+), 764 deletions(-)
apps/desktop/src/main.ts+2-2| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 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( |
| 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, |
apps/server/src/assets/AssetAccess.ts+6-6| ... | ... | @@ -19,9 +19,9 @@ import { |
| 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 |
| 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 |
| 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* ( |
| 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* ( |
| 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, |
apps/server/src/auth/EnvironmentAuth.test.ts+10-17| ... | ... | @@ -53,29 +53,25 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => { |
| 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) => { |
| 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 |
apps/server/src/auth/EnvironmentAuth.ts+492-222| ... | ... | @@ -20,12 +20,12 @@ import { |
| 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 { |
| 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) => |
| 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>( |
| 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 | |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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), |
apps/server/src/auth/EnvironmentAuthAdmin.test.ts+5-1| ... | ... | @@ -22,7 +22,11 @@ const makeServerConfigLayer = ( |
| 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 = ( |
apps/server/src/auth/EnvironmentAuthPolicy.ts+9-11| ... | ... | @@ -3,21 +3,19 @@ import * as Context from "effect/Context"; |
| 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* () { |
| 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); |
apps/server/src/auth/PairingGrantStore.test.ts+5-5| ... | ... | @@ -61,7 +61,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => { |
| 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) => { |
| 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) => { |
| 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) => { |
| 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) => { |
| 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 | }); |
apps/server/src/auth/PairingGrantStore.ts+150-82| ... | ... | @@ -7,17 +7,17 @@ import { |
| 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 { |
| 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 = |
| 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; |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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 | ); |
apps/server/src/auth/ServerSecretStore.test.ts+4-4| ... | ... | @@ -9,7 +9,7 @@ import * as Option from "effect/Option"; |
| 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) => { |
| 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) => { |
| 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) => { |
| 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"); |
apps/server/src/auth/ServerSecretStore.ts+157-48| ... | ... | @@ -9,49 +9,158 @@ import * as Predicate from "effect/Predicate"; |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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); |
apps/server/src/auth/SessionStore.test.ts+4-4| ... | ... | @@ -51,7 +51,7 @@ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessi |
| 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) => { |
| 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) => { |
| 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)), |
apps/server/src/auth/SessionStore.ts+343-150| ... | ... | @@ -10,7 +10,6 @@ import { |
| 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"; |
| 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 = |
| 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 |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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* () { |
| 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)); |
apps/server/src/auth/dpop.test.ts+10-6| ... | ... | @@ -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") => |
| 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 | }); |
apps/server/src/auth/dpop.ts+17-13| ... | ... | @@ -5,7 +5,12 @@ import * as Effect from "effect/Effect"; |
| 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) |
| 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: { |
| 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: { |
| 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: { |
| 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 | }); |
apps/server/src/auth/http.ts+42-36| ... | ... | @@ -169,10 +169,12 @@ export const environmentAuthenticatedAuthLayer = Layer.effect( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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( |
| 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 | ), |
apps/server/src/cloud/environmentKeys.test.ts+4-4| ... | ... | @@ -6,7 +6,7 @@ import * as Option from "effect/Option"; |
| 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 |
| 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 |
| 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", |
apps/server/src/cloud/environmentKeys.ts+17-18| ... | ... | @@ -27,47 +27,46 @@ function stringToBytes(value: string): Uint8Array { |
| 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 |
| 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)) { |
apps/server/src/cloud/http.test.ts+26-2| ... | ... | @@ -16,8 +16,8 @@ import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts"; |
| 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( |
| 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* () { |
apps/server/src/cloud/http.ts+113-119| ... | ... | @@ -68,7 +68,7 @@ import { |
| 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 = |
| 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: { |
| 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: { |
| 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( |
| 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 |
| 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")( |
| 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")( |
| 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 |
| 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 |
| 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")( |
| 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")( |
| 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")( |
| 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")( |
| 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")( |
| 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") |
| 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") |
| 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( |
apps/server/src/http.ts+6-4| ... | ... | @@ -81,10 +81,12 @@ const authenticateRawRouteWithScope = ( |
| 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); |
apps/server/src/relay/AgentAwarenessRelay.test.ts+6-6| ... | ... | @@ -67,15 +67,15 @@ function makeMemorySecretStore() { |
| 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() { |
| 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)), |
apps/server/src/ws.ts+6-4| ... | ... | @@ -1726,10 +1726,12 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 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, |