authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 01:34:52-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 01:34:52-07:00
log97e5cd3bf7bbee427a177d5017aa2d250429bbf6
treeaf3da5b7dc8ca22415e555cf1ae1ff8ff9268c4e
parentb19fc1b87b22a3c923e60c2ef2a1dae336279924
signature Signed by PGP key B5690EEEBB952194

[codex] align server auth Effect services (#3180)

Co-authored-by: codex <codex@users.noreply.github.com>

22 files changed, 1434 insertions(+), 764 deletions(-)

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