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 @@
11import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient";
22import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
33import * as NodeServices from "@effect/platform-node/NodeServices";
4import { homedir } from "node:os";
4import * as NodeOS from "node:os";
55import * as Effect from "effect/Effect";
66import * as Layer from "effect/Layer";
77import * as Option from "effect/Option";
......@@ -60,7 +60,7 @@ const desktopEnvironmentLayer = Layer.unwrap(
6060 const processArch = yield* HostProcessArchitecture;
6161 return DesktopEnvironment.layer({
6262 dirname: __dirname,
63 homeDirectory: homedir(),
63 homeDirectory: NodeOS.homedir(),
6464 platform,
6565 processArch,
6666 ...metadata,
apps/server/src/assets/AssetAccess.ts+6-6
......@@ -19,9 +19,9 @@ import {
1919 signPayload,
2020 timingSafeEqualBase64Url,
2121} from "../auth/utils.ts";
22import { ServerSecretStore } from "../auth/ServerSecretStore.ts";
22import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
2323import { resolveAttachmentPathById } from "../attachmentStore.ts";
24import { ServerConfig } from "../config.ts";
24import * as ServerConfig from "../config.ts";
2525import * as ProjectFaviconResolver from "../project/ProjectFaviconResolver.ts";
2626import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
2727
......@@ -181,7 +181,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
181181 break;
182182 }
183183 case "attachment": {
184 const config = yield* ServerConfig;
184 const config = yield* ServerConfig.ServerConfig;
185185 const attachmentPath = resolveAttachmentPathById({
186186 attachmentsDir: config.attachmentsDir,
187187 attachmentId: input.resource.attachmentId,
......@@ -225,7 +225,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
225225 }
226226 }
227227
228 const secretStore = yield* ServerSecretStore;
228 const secretStore = yield* ServerSecretStore.ServerSecretStore;
229229 const signingSecret = yield* secretStore
230230 .getOrCreateRandom(SIGNING_SECRET_NAME, 32)
231231 .pipe(Effect.mapError((cause) => failAccess(cause.message, cause)));
......@@ -244,7 +244,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
244244 const [encodedPayload, signature] = token.split(".");
245245 if (!encodedPayload || !signature) return null;
246246
247 const secretStore = yield* ServerSecretStore;
247 const secretStore = yield* ServerSecretStore.ServerSecretStore;
248248 const signingSecret = yield* secretStore
249249 .getOrCreateRandom(SIGNING_SECRET_NAME, 32)
250250 .pipe(Effect.orElseSucceed(() => null));
......@@ -255,7 +255,7 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* (
255255 if (!claims || claims.expiresAt <= (yield* Clock.currentTimeMillis)) return null;
256256
257257 if (claims.kind === "attachment") {
258 const config = yield* ServerConfig;
258 const config = yield* ServerConfig.ServerConfig;
259259 const attachmentPath = resolveAttachmentPathById({
260260 attachmentsDir: config.attachmentsDir,
261261 attachmentId: claims.attachmentId,
apps/server/src/auth/EnvironmentAuth.test.ts+10-17
......@@ -53,29 +53,25 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
5353 it.effect("classifies invalid bootstrap credential failures for the HTTP boundary", () =>
5454 Effect.sync(() => {
5555 const error = EnvironmentAuth.toBootstrapExchangeError(
56 new PairingGrantStore.BootstrapCredentialInvalidError({
57 message: "Unknown bootstrap credential.",
58 }),
56 new PairingGrantStore.UnknownBootstrapCredentialError({}),
5957 );
6058
6159 expect(error._tag).toBe("ServerAuthInvalidCredentialError");
62 if (error._tag === "ServerAuthInvalidCredentialError") {
63 expect(error.reason).toBe("invalid_credential");
64 }
6560 }),
6661 );
6762
6863 it.effect("maps unexpected bootstrap failures to 500", () =>
6964 Effect.sync(() => {
70 const error = EnvironmentAuth.toBootstrapExchangeError(
71 new PairingGrantStore.BootstrapCredentialInternalError({
72 message: "Failed to consume bootstrap credential.",
73 cause: new Error("sqlite is unavailable"),
74 }),
75 );
65 const cause = new PairingGrantStore.BootstrapCredentialConsumeError({
66 cause: new Error("sqlite is unavailable"),
67 });
68 const error = EnvironmentAuth.toBootstrapExchangeError(cause);
7669
77 expect(error._tag).toBe("ServerAuthInternalError");
70 expect(error._tag).toBe("ServerAuthBootstrapCredentialValidationError");
7871 expect(error.message).toBe("Failed to validate bootstrap credential.");
72 if (error._tag === "ServerAuthBootstrapCredentialValidationError") {
73 expect(error.cause).toBe(cause);
74 }
7975 }),
8076 );
8177
......@@ -117,10 +113,7 @@ it.layer(NodeServices.layer)("EnvironmentAuth.layer", (it) => {
117113 )
118114 .pipe(Effect.flip);
119115
120 expect(error._tag).toBe("ServerAuthInvalidRequestError");
121 if (error._tag === "ServerAuthInvalidRequestError") {
122 expect(error.reason).toBe("scope_not_granted");
123 }
116 expect(error._tag).toBe("ServerAuthScopeNotGrantedError");
124117 }).pipe(Effect.provide(makeEnvironmentAuthLayer())),
125118 );
126119
apps/server/src/auth/EnvironmentAuth.ts+492-222
......@@ -20,12 +20,12 @@ import {
2020import { encodeOAuthScope } from "@t3tools/shared/oauthScope";
2121import * as Context from "effect/Context";
2222import * as Crypto from "effect/Crypto";
23import * as Data from "effect/Data";
2423import * as DateTime from "effect/DateTime";
2524import * as Duration from "effect/Duration";
2625import * as Effect from "effect/Effect";
2726import * as Layer from "effect/Layer";
2827import * as Option from "effect/Option";
28import * as Schema from "effect/Schema";
2929import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
3030
3131import * as EnvironmentAuthPolicy from "./EnvironmentAuthPolicy.ts";
......@@ -67,123 +67,429 @@ export interface AuthenticatedSession {
6767 readonly expiresAt?: DateTime.DateTime;
6868}
6969
70export class ServerAuthInternalError extends Data.TaggedError("ServerAuthInternalError")<{
71 readonly message: string;
72 readonly cause?: unknown;
73}> {}
70const serverAuthInternalErrorContext = {
71 cause: Schema.Defect(),
72};
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>()(
76347 "ServerAuthInvalidCredentialError",
77)<{
78 readonly reason: "missing_credential" | "invalid_credential";
79 readonly cause?: unknown;
80}> {}
81
82export class ServerAuthInvalidRequestError extends Data.TaggedError(
83 "ServerAuthInvalidRequestError",
84)<{
85 readonly reason: "invalid_scope" | "scope_not_granted";
86}> {}
87
88export class ServerAuthForbiddenOperationError extends Data.TaggedError(
89 "ServerAuthForbiddenOperationError",
90)<{
91 readonly reason: "current_session_revoke_not_allowed";
92}> {}
348 {
349 diagnostic: Schema.optional(Schema.String),
350 cause: Schema.optional(Schema.Defect()),
351 },
352) {
353 override get message(): string {
354 return "Server authentication credential is invalid.";
355 }
356}
93357
94export interface EnvironmentAuthShape {
95 readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>;
96 readonly getSessionState: (
97 request: HttpServerRequest.HttpServerRequest,
98 ) => Effect.Effect<AuthSessionState, ServerAuthInternalError>;
99 readonly createBrowserSession: (
100 credential: string,
101 requestMetadata: AuthClientMetadata,
102 ) => Effect.Effect<
103 {
104 readonly response: AuthBrowserSessionResult;
105 readonly sessionToken: string;
106 },
107 ServerAuthInvalidCredentialError | ServerAuthInternalError
108 >;
109 readonly exchangeBootstrapCredentialForAccessToken: (
110 credential: string,
111 requestedScopes: ReadonlyArray<AuthEnvironmentScope> | undefined,
112 requestMetadata: AuthClientMetadata,
113 input?: {
114 readonly proofKeyThumbprint?: string;
115 },
116 ) => Effect.Effect<
117 AuthAccessTokenResult,
118 ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError
119 >;
120 readonly createPairingLink: (input?: {
121 readonly ttl?: Duration.Duration;
122 readonly label?: string;
123 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
124 readonly subject?: string;
125 readonly proofKeyThumbprint?: string;
126 }) => Effect.Effect<IssuedPairingLink, ServerAuthInternalError>;
127 readonly issuePairingCredential: (
128 input?: AuthCreatePairingCredentialInput,
129 ) => Effect.Effect<AuthPairingCredentialResult, ServerAuthInternalError>;
130 readonly issueStartupPairingCredential: () => Effect.Effect<
131 AuthPairingCredentialResult,
132 ServerAuthInternalError
133 >;
134 readonly listPairingLinks: (input?: {
135 readonly excludeSubjects?: ReadonlyArray<string>;
136 }) => Effect.Effect<ReadonlyArray<AuthPairingLink>, ServerAuthInternalError>;
137 readonly revokePairingLink: (id: string) => Effect.Effect<boolean, ServerAuthInternalError>;
138 readonly issueSession: (input?: {
139 readonly ttl?: Duration.Duration;
140 readonly subject?: string;
141 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
142 readonly label?: string;
143 }) => Effect.Effect<IssuedBearerSession, ServerAuthInternalError>;
144 readonly listSessions: () => Effect.Effect<
145 ReadonlyArray<AuthClientSession>,
146 ServerAuthInternalError
147 >;
148 readonly revokeSession: (
149 sessionId: AuthSessionId,
150 ) => Effect.Effect<boolean, ServerAuthInternalError>;
151 readonly revokeOtherSessionsExcept: (
152 sessionId: AuthSessionId,
153 ) => Effect.Effect<number, ServerAuthInternalError>;
154 readonly listClientSessions: (
155 currentSessionId: AuthSessionId,
156 ) => Effect.Effect<ReadonlyArray<AuthClientSession>, ServerAuthInternalError>;
157 readonly revokeClientSession: (
158 currentSessionId: AuthSessionId,
159 targetSessionId: AuthSessionId,
160 ) => Effect.Effect<boolean, ServerAuthForbiddenOperationError | ServerAuthInternalError>;
161 readonly revokeOtherClientSessions: (
162 currentSessionId: AuthSessionId,
163 ) => Effect.Effect<number, ServerAuthInternalError>;
164 readonly authenticateHttpRequest: (
165 request: HttpServerRequest.HttpServerRequest,
166 ) => Effect.Effect<
167 AuthenticatedSession,
168 ServerAuthInvalidCredentialError | ServerAuthInternalError
169 >;
170 readonly authenticateWebSocketUpgrade: (
171 request: HttpServerRequest.HttpServerRequest,
172 ) => Effect.Effect<
173 AuthenticatedSession,
174 ServerAuthInvalidCredentialError | ServerAuthInternalError
175 >;
176 readonly issueWebSocketTicket: (
177 session: Pick<AuthenticatedSession, "sessionId">,
178 ) => Effect.Effect<AuthWebSocketTicketResult, ServerAuthInternalError>;
179 readonly issueStartupPairingUrl: (
180 baseUrl: string,
181 ) => Effect.Effect<string, ServerAuthInternalError>;
358export const ServerAuthCredentialError = Schema.Union([
359 ServerAuthMissingCredentialError,
360 ServerAuthInvalidCredentialError,
361]);
362export type ServerAuthCredentialError = typeof ServerAuthCredentialError.Type;
363export const isServerAuthCredentialError = Schema.is(ServerAuthCredentialError);
364export const serverAuthCredentialReason = (
365 error: ServerAuthCredentialError,
366): "missing_credential" | "invalid_credential" =>
367 error._tag === "ServerAuthMissingCredentialError" ? "missing_credential" : "invalid_credential";
368
369export class ServerAuthInvalidScopeError extends Schema.TaggedErrorClass<ServerAuthInvalidScopeError>()(
370 "ServerAuthInvalidScopeError",
371 {},
372) {
373 override get message(): string {
374 return "The requested authentication scope is invalid.";
375 }
182376}
183377
184export class EnvironmentAuth extends Context.Service<EnvironmentAuth, EnvironmentAuthShape>()(
185 "t3/auth/EnvironmentAuth",
186) {}
378export class ServerAuthScopeNotGrantedError extends Schema.TaggedErrorClass<ServerAuthScopeNotGrantedError>()(
379 "ServerAuthScopeNotGrantedError",
380 {},
381) {
382 override get message(): string {
383 return "The requested authentication scope was not granted.";
384 }
385}
386
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
188494type BootstrapExchangeResult = {
189495 readonly response: AuthBrowserSessionResult;
......@@ -206,23 +512,14 @@ const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) =>
206512 return right.issuedAt.epochMilliseconds - left.issuedAt.epochMilliseconds;
207513};
208514
209const toInternalError =
210 (message: string) =>
211 (cause: unknown): ServerAuthInternalError =>
212 new ServerAuthInternalError({ message, cause });
213
214515export function toBootstrapExchangeError(
215516 cause: PairingGrantStore.BootstrapCredentialError,
216517): ServerAuthInvalidCredentialError | ServerAuthInternalError {
217 if (cause._tag === "BootstrapCredentialInternalError") {
218 return new ServerAuthInternalError({
219 message: "Failed to validate bootstrap credential.",
220 cause,
221 });
518 if (PairingGrantStore.isBootstrapCredentialInternalError(cause)) {
519 return new ServerAuthBootstrapCredentialValidationError({ cause });
222520 }
223521
224522 return new ServerAuthInvalidCredentialError({
225 reason: "invalid_credential",
226523 cause,
227524 });
228525}
......@@ -231,17 +528,11 @@ const mapSessionVerificationErrors = <A, R>(
231528 effect: Effect.Effect<A, SessionStore.SessionCredentialError, R>,
232529): Effect.Effect<A, ServerAuthInvalidCredentialError | ServerAuthInternalError, R> =>
233530 effect.pipe(
234 Effect.catchTags({
235 SessionCredentialInvalidError: (cause) =>
236 Effect.fail(new ServerAuthInvalidCredentialError({ reason: "invalid_credential", cause })),
237 SessionCredentialInternalError: (cause) =>
238 Effect.fail(
239 new ServerAuthInternalError({
240 message: "Failed to validate session credential.",
241 cause,
242 }),
243 ),
244 }),
531 Effect.mapError((cause) =>
532 SessionStore.isSessionCredentialInvalidError(cause)
533 ? new ServerAuthInvalidCredentialError({ cause })
534 : new ServerAuthSessionCredentialValidationError({ cause }),
535 ),
245536 );
246537
247538function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null {
......@@ -262,7 +553,7 @@ function parseDpopToken(request: HttpServerRequest.HttpServerRequest): string |
262553 return token.length > 0 ? token : null;
263554}
264555
265export const make = Effect.fn("makeEnvironmentAuth")(function* () {
556export const make = Effect.gen(function* () {
266557 const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy;
267558 const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore;
268559 const sessions = yield* SessionStore.SessionStore;
......@@ -277,12 +568,14 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
277568 ServerAuthInvalidCredentialError | ServerAuthInternalError
278569 > =>
279570 sessions.verify(token).pipe(
280 Effect.tapErrorTag("SessionCredentialInvalidError", (cause) =>
281 Effect.logWarning("Rejected authenticated session credential.").pipe(
282 Effect.annotateLogs({
283 reason: cause.message,
284 }),
285 ),
571 Effect.tapError((cause) =>
572 SessionStore.isSessionCredentialInvalidError(cause)
573 ? Effect.logWarning("Rejected authenticated session credential.").pipe(
574 Effect.annotateLogs({
575 reason: cause.message,
576 }),
577 )
578 : Effect.void,
286579 ),
287580 Effect.map((session) => ({
288581 sessionId: session.sessionId,
......@@ -295,13 +588,15 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
295588 mapSessionVerificationErrors,
296589 );
297590
298 const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => {
591 const authenticateRequest = (
592 request: HttpServerRequest.HttpServerRequest,
593 ): Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError> => {
299594 const cookieToken = request.cookies[sessions.cookieName];
300595 const bearerToken = parseBearerToken(request);
301596 const dpopToken = parseDpopToken(request);
302597 const credential = cookieToken ?? bearerToken ?? dpopToken;
303598 if (!credential) {
304 return Effect.fail(new ServerAuthInvalidCredentialError({ reason: "missing_credential" }));
599 return Effect.fail(new ServerAuthMissingCredentialError({}));
305600 }
306601 return authenticateToken(credential).pipe(
307602 Effect.flatMap((session) => {
......@@ -309,8 +604,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
309604 if (!dpopToken || dpopToken !== credential) {
310605 return Effect.fail(
311606 new ServerAuthInvalidCredentialError({
312 reason: "invalid_credential",
313 cause: "DPoP-bound access token requires DPoP authorization.",
607 diagnostic: "DPoP-bound access token requires DPoP authorization.",
314608 }),
315609 );
316610 }
......@@ -327,8 +621,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
327621 if (dpopToken) {
328622 return Effect.fail(
329623 new ServerAuthInvalidCredentialError({
330 reason: "invalid_credential",
331 cause: "DPoP authorization requires a proof-bound access token.",
624 diagnostic: "DPoP authorization requires a proof-bound access token.",
332625 }),
333626 );
334627 }
......@@ -337,7 +630,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
337630 );
338631 };
339632
340 const getSessionState: EnvironmentAuthShape["getSessionState"] = (request) =>
633 const getSessionState: EnvironmentAuth["Service"]["getSessionState"] = (request) =>
341634 authenticateRequest(request).pipe(
342635 Effect.map(
343636 (session) =>
......@@ -349,7 +642,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
349642 ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}),
350643 }) satisfies AuthSessionState,
351644 ),
352 Effect.catchTag("ServerAuthInvalidCredentialError", () =>
645 Effect.catchIf(isServerAuthCredentialError, () =>
353646 Effect.succeed({
354647 authenticated: false,
355648 auth: descriptor,
......@@ -358,7 +651,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
358651 Effect.withSpan("EnvironmentAuth.getSessionState"),
359652 );
360653
361 const createBrowserSession: EnvironmentAuthShape["createBrowserSession"] = (
654 const createBrowserSession: EnvironmentAuth["Service"]["createBrowserSession"] = (
362655 credential,
363656 requestMetadata,
364657 ) =>
......@@ -376,13 +669,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
376669 },
377670 })
378671 .pipe(
379 Effect.mapError(
380 (cause) =>
381 new ServerAuthInternalError({
382 message: "Failed to issue authenticated session.",
383 cause,
384 }),
385 ),
672 Effect.mapError((cause) => new ServerAuthAuthenticatedSessionIssueError({ cause })),
386673 ),
387674 ),
388675 Effect.map(
......@@ -400,7 +687,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
400687 Effect.withSpan("EnvironmentAuth.createBrowserSession"),
401688 );
402689
403 const exchangeBootstrapCredentialForAccessToken: EnvironmentAuthShape["exchangeBootstrapCredentialForAccessToken"] =
690 const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] =
404691 (credential, requestedScopes, requestMetadata, input) =>
405692 bootstrapCredentials.consume(credential, input).pipe(
406693 Effect.mapError(toBootstrapExchangeError),
......@@ -408,9 +695,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
408695 Effect.gen(function* () {
409696 const grantedScopes = requestedScopes ?? grant.scopes;
410697 if (!grantedScopes.every((scope) => grant.scopes.includes(scope))) {
411 return yield* new ServerAuthInvalidRequestError({
412 reason: "scope_not_granted",
413 });
698 return yield* new ServerAuthScopeNotGrantedError({});
414699 }
415700 return yield* sessions
416701 .issue({
......@@ -430,11 +715,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
430715 })
431716 .pipe(
432717 Effect.mapError(
433 (cause) =>
434 new ServerAuthInternalError({
435 message: "Failed to issue authenticated access token.",
436 cause,
437 }),
718 (cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause }),
438719 ),
439720 );
440721 }),
......@@ -482,7 +763,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
482763 ),
483764 );
484765
485 const createPairingLink: EnvironmentAuthShape["createPairingLink"] = Effect.fn(
766 const createPairingLink: EnvironmentAuth["Service"]["createPairingLink"] = Effect.fn(
486767 "EnvironmentAuth.createPairingLink",
487768 )(
488769 function* (input) {
......@@ -504,10 +785,10 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
504785 expiresAt: DateTime.toUtc(issued.expiresAt),
505786 } satisfies IssuedPairingLink;
506787 },
507 Effect.mapError(toInternalError("Failed to create pairing link.")),
788 Effect.mapError((cause) => new ServerAuthPairingLinkCreationError({ cause })),
508789 );
509790
510 const listPairingLinks: EnvironmentAuthShape["listPairingLinks"] = (input) =>
791 const listPairingLinks: EnvironmentAuth["Service"]["listPairingLinks"] = (input) =>
511792 bootstrapCredentials.listActive().pipe(
512793 Effect.map((pairingLinks) => {
513794 const excludedSubjects = input?.excludeSubjects ?? [
......@@ -519,19 +800,17 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
519800 (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds,
520801 );
521802 }),
522 Effect.mapError(toInternalError("Failed to list pairing links.")),
803 Effect.mapError((cause) => new ServerAuthPairingLinksListError({ cause })),
523804 Effect.withSpan("EnvironmentAuth.listPairingLinks"),
524805 );
525806
526 const revokePairingLink: EnvironmentAuthShape["revokePairingLink"] = (id) =>
527 bootstrapCredentials
528 .revoke(id)
529 .pipe(
530 Effect.mapError(toInternalError("Failed to revoke pairing link.")),
531 Effect.withSpan("EnvironmentAuth.revokePairingLink"),
532 );
807 const revokePairingLink: EnvironmentAuth["Service"]["revokePairingLink"] = (id) =>
808 bootstrapCredentials.revoke(id).pipe(
809 Effect.mapError((cause) => new ServerAuthPairingLinkRevocationError({ cause })),
810 Effect.withSpan("EnvironmentAuth.revokePairingLink"),
811 );
533812
534 const issueSession: EnvironmentAuthShape["issueSession"] = (input) =>
813 const issueSession: EnvironmentAuth["Service"]["issueSession"] = (input) =>
535814 sessions
536815 .issue({
537816 subject: input?.subject ?? DEFAULT_SESSION_SUBJECT,
......@@ -556,49 +835,46 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
556835 expiresAt: DateTime.toUtc(issued.expiresAt),
557836 }) satisfies IssuedBearerSession,
558837 ),
559 Effect.mapError(toInternalError("Failed to issue session token.")),
838 Effect.mapError((cause) => new ServerAuthSessionTokenIssueError({ cause })),
560839 Effect.withSpan("EnvironmentAuth.issueSession"),
561840 );
562841
563 const listSessions: EnvironmentAuthShape["listSessions"] = () =>
842 const listSessions: EnvironmentAuth["Service"]["listSessions"] = () =>
564843 sessions.listActive().pipe(
565844 Effect.map((activeSessions) => activeSessions.toSorted(bySessionPriority)),
566 Effect.mapError(toInternalError("Failed to list sessions.")),
845 Effect.mapError((cause) => new ServerAuthSessionsListError({ cause })),
567846 Effect.withSpan("EnvironmentAuth.listSessions"),
568847 );
569848
570 const revokeSession: EnvironmentAuthShape["revokeSession"] = (sessionId) =>
571 sessions
572 .revoke(sessionId)
573 .pipe(
574 Effect.mapError(toInternalError("Failed to revoke session.")),
575 Effect.withSpan("EnvironmentAuth.revokeSession"),
576 );
849 const revokeSession: EnvironmentAuth["Service"]["revokeSession"] = (sessionId) =>
850 sessions.revoke(sessionId).pipe(
851 Effect.mapError((cause) => new ServerAuthSessionRevocationError({ cause })),
852 Effect.withSpan("EnvironmentAuth.revokeSession"),
853 );
577854
578 const revokeOtherSessionsExcept: EnvironmentAuthShape["revokeOtherSessionsExcept"] = (
855 const revokeOtherSessionsExcept: EnvironmentAuth["Service"]["revokeOtherSessionsExcept"] = (
579856 sessionId,
580857 ) =>
581 sessions
582 .revokeAllExcept(sessionId)
583 .pipe(
584 Effect.mapError(toInternalError("Failed to revoke other sessions.")),
585 Effect.withSpan("EnvironmentAuth.revokeOtherSessionsExcept"),
586 );
858 sessions.revokeAllExcept(sessionId).pipe(
859 Effect.mapError((cause) => new ServerAuthOtherSessionsRevocationError({ cause })),
860 Effect.withSpan("EnvironmentAuth.revokeOtherSessionsExcept"),
861 );
587862
588 const issuePairingCredential: EnvironmentAuthShape["issuePairingCredential"] = (input) =>
863 const issuePairingCredential: EnvironmentAuth["Service"]["issuePairingCredential"] = (input) =>
589864 issuePairingCredentialForSubject({
590865 scopes: input?.scopes ?? AuthStandardClientScopes,
591866 subject: "one-time-token",
592867 ...(input?.label ? { label: input.label } : {}),
593868 }).pipe(Effect.withSpan("EnvironmentAuth.issuePairingCredential"));
594869
595 const issueStartupPairingCredential: EnvironmentAuthShape["issueStartupPairingCredential"] = () =>
596 issuePairingCredentialForSubject({
597 scopes: AuthAdministrativeScopes,
598 subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT,
599 }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential"));
870 const issueStartupPairingCredential: EnvironmentAuth["Service"]["issueStartupPairingCredential"] =
871 () =>
872 issuePairingCredentialForSubject({
873 scopes: AuthAdministrativeScopes,
874 subject: INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT,
875 }).pipe(Effect.withSpan("EnvironmentAuth.issueStartupPairingCredential"));
600876
601 const listClientSessions: EnvironmentAuthShape["listClientSessions"] = (currentSessionId) =>
877 const listClientSessions: EnvironmentAuth["Service"]["listClientSessions"] = (currentSessionId) =>
602878 listSessions().pipe(
603879 Effect.map((clientSessions) =>
604880 clientSessions.map(
......@@ -611,25 +887,23 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
611887 Effect.withSpan("EnvironmentAuth.listClientSessions"),
612888 );
613889
614 const revokeClientSession: EnvironmentAuthShape["revokeClientSession"] = Effect.fn(
890 const revokeClientSession: EnvironmentAuth["Service"]["revokeClientSession"] = Effect.fn(
615891 "EnvironmentAuth.revokeClientSession",
616892 )(function* (currentSessionId, targetSessionId) {
617893 if (currentSessionId === targetSessionId) {
618 return yield* new ServerAuthForbiddenOperationError({
619 reason: "current_session_revoke_not_allowed",
620 });
894 return yield* new ServerAuthForbiddenOperationError({});
621895 }
622896 return yield* revokeSession(targetSessionId);
623897 });
624898
625 const revokeOtherClientSessions: EnvironmentAuthShape["revokeOtherClientSessions"] = (
899 const revokeOtherClientSessions: EnvironmentAuth["Service"]["revokeOtherClientSessions"] = (
626900 currentSessionId,
627901 ) =>
628902 revokeOtherSessionsExcept(currentSessionId).pipe(
629903 Effect.withSpan("EnvironmentAuth.revokeOtherClientSessions"),
630904 );
631905
632 const issueStartupPairingUrl: EnvironmentAuthShape["issueStartupPairingUrl"] = (baseUrl) =>
906 const issueStartupPairingUrl: EnvironmentAuth["Service"]["issueStartupPairingUrl"] = (baseUrl) =>
633907 issueStartupPairingCredential().pipe(
634908 Effect.map((issued) => {
635909 const url = new URL(baseUrl);
......@@ -641,15 +915,9 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
641915 Effect.withSpan("EnvironmentAuth.issueStartupPairingUrl"),
642916 );
643917
644 const issueWebSocketTicket: EnvironmentAuthShape["issueWebSocketTicket"] = (session) =>
918 const issueWebSocketTicket: EnvironmentAuth["Service"]["issueWebSocketTicket"] = (session) =>
645919 sessions.issueWebSocketToken(session.sessionId).pipe(
646 Effect.mapError(
647 (cause) =>
648 new ServerAuthInternalError({
649 message: "Failed to issue websocket token.",
650 cause,
651 }),
652 ),
920 Effect.mapError((cause) => new ServerAuthWebSocketTokenIssueError({ cause })),
653921 Effect.map(
654922 (issued) =>
655923 ({
......@@ -660,10 +928,12 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
660928 Effect.withSpan("EnvironmentAuth.issueWebSocketTicket"),
661929 );
662930
663 const authenticateHttpRequest: EnvironmentAuthShape["authenticateHttpRequest"] = (request) =>
931 const authenticateHttpRequest: EnvironmentAuth["Service"]["authenticateHttpRequest"] = (
932 request,
933 ) =>
664934 authenticateRequest(request).pipe(Effect.withSpan("EnvironmentAuth.authenticateHttpRequest"));
665935
666 const authenticateWebSocketUpgrade: EnvironmentAuthShape["authenticateWebSocketUpgrade"] =
936 const authenticateWebSocketUpgrade: EnvironmentAuth["Service"]["authenticateWebSocketUpgrade"] =
667937 Effect.fn("EnvironmentAuth.authenticateWebSocketUpgrade")(function* (request) {
668938 const requestUrl = HttpServerRequest.toURL(request);
669939 if (Option.isSome(requestUrl)) {
......@@ -685,7 +955,7 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
685955 return yield* authenticateRequest(request);
686956 });
687957
688 return {
958 return EnvironmentAuth.of({
689959 getDescriptor: () =>
690960 Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuth.getDescriptor")),
691961 getSessionState,
......@@ -707,10 +977,10 @@ export const make = Effect.fn("makeEnvironmentAuth")(function* () {
707977 authenticateWebSocketUpgrade,
708978 issueWebSocketTicket,
709979 issueStartupPairingUrl,
710 } satisfies EnvironmentAuthShape;
980 });
711981});
712982
713export const layer = Layer.effect(EnvironmentAuth, make()).pipe(
983export const layer = Layer.effect(EnvironmentAuth, make).pipe(
714984 Layer.provideMerge(PairingGrantStore.layer),
715985 Layer.provideMerge(SessionStore.layer),
716986 Layer.provideMerge(EnvironmentAuthPolicy.layer),
apps/server/src/auth/EnvironmentAuthAdmin.test.ts+5-1
......@@ -22,7 +22,11 @@ const makeServerConfigLayer = (
2222 } satisfies ServerConfig.ServerConfig["Service"];
2323 }),
2424 ).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 ),
2630 );
2731
2832const makeEnvironmentAuthLayer = (
apps/server/src/auth/EnvironmentAuthPolicy.ts+9-11
......@@ -3,21 +3,19 @@ import * as Context from "effect/Context";
33import * as Effect from "effect/Effect";
44import * as Layer from "effect/Layer";
55
6import { ServerConfig } from "../config.ts";
6import * as ServerConfig from "../config.ts";
77import { resolveSessionCookieName } from "./utils.ts";
88import { isLoopbackHost, isWildcardHost } from "../startupAccess.ts";
99
10export interface EnvironmentAuthPolicyShape {
11 readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>;
12}
13
1410export class EnvironmentAuthPolicy extends Context.Service<
1511 EnvironmentAuthPolicy,
16 EnvironmentAuthPolicyShape
12 {
13 readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>;
14 }
1715>()("t3/auth/EnvironmentAuthPolicy") {}
1816
19export const make = Effect.fn("makeEnvironmentAuthPolicy")(function* () {
20 const config = yield* ServerConfig;
17export const make = Effect.gen(function* () {
18 const config = yield* ServerConfig.ServerConfig;
2119 const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host);
2220
2321 const policy =
......@@ -46,10 +44,10 @@ export const make = Effect.fn("makeEnvironmentAuthPolicy")(function* () {
4644 }),
4745 };
4846
49 return {
47 return EnvironmentAuthPolicy.of({
5048 getDescriptor: () =>
5149 Effect.succeed(descriptor).pipe(Effect.withSpan("EnvironmentAuthPolicy.getDescriptor")),
52 } satisfies EnvironmentAuthPolicyShape;
50 });
5351});
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) => {
6161 expect(first.subject).toBe("one-time-token");
6262 expect(first.label).toBe("Julius iPhone");
6363 expect(issued.label).toBe("Julius iPhone");
64 expect(second._tag).toBe("BootstrapCredentialInvalidError");
64 expect(second._tag).toBe("UnknownBootstrapCredentialError");
6565 expect(second.message).toContain("Unknown bootstrap credential");
6666 }).pipe(Effect.provide(makePairingGrantStoreLayer())),
6767 );
......@@ -85,7 +85,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
8585 expect(successes).toHaveLength(1);
8686 expect(failures).toHaveLength(7);
8787 for (const failure of failures) {
88 expect(failure.failure._tag).toBe("BootstrapCredentialInvalidError");
88 expect(failure.failure._tag).toBe("UnknownBootstrapCredentialError");
8989 expect(failure.failure.message).toContain("Unknown bootstrap credential");
9090 }
9191 }).pipe(Effect.provide(makePairingGrantStoreLayer())),
......@@ -132,7 +132,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
132132 "relay:write",
133133 ]);
134134 expect(first.subject).toBe("desktop-bootstrap");
135 expect(second._tag).toBe("BootstrapCredentialInvalidError");
135 expect(second._tag).toBe("UnknownBootstrapCredentialError");
136136 }).pipe(
137137 Effect.provide(
138138 makePairingGrantStoreLayer({
......@@ -149,7 +149,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
149149 yield* TestClock.adjust(Duration.minutes(6));
150150 const expired = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token"));
151151
152 expect(expired._tag).toBe("BootstrapCredentialInvalidError");
152 expect(expired._tag).toBe("ExpiredBootstrapCredentialError");
153153 expect(expired.message).toContain("Bootstrap credential expired");
154154 }).pipe(
155155 Effect.provide(
......@@ -183,7 +183,7 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
183183 expect(activeAfterRevoke.map((entry) => entry.id)).not.toContain(first.id);
184184 expect(activeAfterRevoke.map((entry) => entry.id)).toContain(second.id);
185185 expect(revokedConsume.message).toContain("no longer available");
186 expect(revokedConsume._tag).toBe("BootstrapCredentialInvalidError");
186 expect(revokedConsume._tag).toBe("UnavailableBootstrapCredentialError");
187187 }).pipe(Effect.provide(makePairingGrantStoreLayer())),
188188 );
189189});
apps/server/src/auth/PairingGrantStore.ts+150-82
......@@ -7,17 +7,17 @@ import {
77} from "@t3tools/contracts";
88import * as Context from "effect/Context";
99import * as Crypto from "effect/Crypto";
10import * as Data from "effect/Data";
1110import * as DateTime from "effect/DateTime";
1211import * as Duration from "effect/Duration";
1312import * as Effect from "effect/Effect";
1413import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";
1515import * as PubSub from "effect/PubSub";
1616import * as Ref from "effect/Ref";
17import * as Schema from "effect/Schema";
1718import * as Stream from "effect/Stream";
18import * as Option from "effect/Option";
1919
20import { ServerConfig } from "../config.ts";
20import * as ServerConfig from "../config.ts";
2121import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts";
2222
2323export interface BootstrapGrant {
......@@ -29,22 +29,110 @@ export interface BootstrapGrant {
2929 readonly expiresAt: DateTime.DateTime;
3030}
3131
32export class BootstrapCredentialInvalidError extends Data.TaggedError(
33 "BootstrapCredentialInvalidError",
34)<{
35 readonly message: string;
36}> {}
32export class UnknownBootstrapCredentialError extends Schema.TaggedErrorClass<UnknownBootstrapCredentialError>()(
33 "UnknownBootstrapCredentialError",
34 {},
35) {
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(
39 "BootstrapCredentialInternalError",
40)<{
41 readonly message: string;
42 readonly cause?: unknown;
43}> {}
99export class PairingCredentialIssueError extends Schema.TaggedErrorClass<PairingCredentialIssueError>()(
100 "PairingCredentialIssueError",
101 {
102 cause: Schema.Defect(),
103 },
104) {
105 override get message(): string {
106 return "Failed to issue pairing credential.";
107 }
108}
44109
45export type BootstrapCredentialError =
46 | BootstrapCredentialInvalidError
47 | BootstrapCredentialInternalError;
110export class BootstrapCredentialConsumeError extends Schema.TaggedErrorClass<BootstrapCredentialConsumeError>()(
111 "BootstrapCredentialConsumeError",
112 {
113 cause: Schema.Defect(),
114 },
115) {
116 override get message(): string {
117 return "Failed to consume bootstrap credential.";
118 }
119}
120
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
49137export interface IssuedBootstrapCredential {
50138 readonly id: string;
......@@ -64,31 +152,30 @@ export type BootstrapCredentialChange =
64152 readonly id: string;
65153 };
66154
67export interface PairingGrantStoreShape {
68 readonly issueOneTimeToken: (input?: {
69 readonly ttl?: Duration.Duration;
70 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
71 readonly subject?: string;
72 readonly label?: string;
73 readonly proofKeyThumbprint?: string;
74 }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialInternalError>;
75 readonly listActive: () => Effect.Effect<
76 ReadonlyArray<AuthPairingLink>,
77 BootstrapCredentialInternalError
78 >;
79 readonly streamChanges: Stream.Stream<BootstrapCredentialChange>;
80 readonly revoke: (id: string) => Effect.Effect<boolean, BootstrapCredentialInternalError>;
81 readonly consume: (
82 credential: string,
83 input?: {
155export class PairingGrantStore extends Context.Service<
156 PairingGrantStore,
157 {
158 readonly issueOneTimeToken: (input?: {
159 readonly ttl?: Duration.Duration;
160 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
161 readonly subject?: string;
162 readonly label?: string;
84163 readonly proofKeyThumbprint?: string;
85 },
86 ) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>;
87}
88
89export class PairingGrantStore extends Context.Service<PairingGrantStore, PairingGrantStoreShape>()(
90 "t3/auth/PairingGrantStore",
91) {}
164 }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialInternalError>;
165 readonly listActive: () => Effect.Effect<
166 ReadonlyArray<AuthPairingLink>,
167 BootstrapCredentialInternalError
168 >;
169 readonly streamChanges: Stream.Stream<BootstrapCredentialChange>;
170 readonly revoke: (id: string) => Effect.Effect<boolean, BootstrapCredentialInternalError>;
171 readonly consume: (
172 credential: string,
173 input?: {
174 readonly proofKeyThumbprint?: string;
175 },
176 ) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>;
177 }
178>()("t3/auth/PairingGrantStore") {}
92179
93180interface StoredBootstrapGrant extends BootstrapGrant {
94181 readonly remainingUses: number | "unbounded";
......@@ -111,20 +198,9 @@ const PAIRING_TOKEN_LENGTH = 12;
111198const PAIRING_TOKEN_REJECTION_LIMIT =
112199 Math.floor(256 / PAIRING_TOKEN_ALPHABET.length) * PAIRING_TOKEN_ALPHABET.length;
113200
114const invalidBootstrapCredentialError = (message: string) =>
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* () {
201export const make = Effect.gen(function* () {
126202 const crypto = yield* Crypto.Crypto;
127 const config = yield* ServerConfig;
203 const config = yield* ServerConfig.ServerConfig;
128204 const pairingLinks = yield* AuthPairingLinks.AuthPairingLinkRepository;
129205 const seededGrantsRef = yield* Ref.make(new Map<string, StoredBootstrapGrant>());
130206 const changesPubSub = yield* PubSub.unbounded<BootstrapCredentialChange>();
......@@ -177,10 +253,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () {
177253 });
178254 }
179255
180 const toBootstrapCredentialError = (message: string) => (cause: unknown) =>
181 internalBootstrapCredentialError(message, cause);
182
183 const listActive: PairingGrantStoreShape["listActive"] = Effect.fn(
256 const listActive: PairingGrantStore["Service"]["listActive"] = Effect.fn(
184257 "PairingGrantStore.listActive",
185258 )(
186259 function* () {
......@@ -208,10 +281,10 @@ export const make = Effect.fn("makePairingGrantStore")(function* () {
208281 } satisfies AuthPairingLink),
209282 );
210283 },
211 Effect.mapError(toBootstrapCredentialError("Failed to load active pairing links.")),
284 Effect.mapError((cause) => new ActivePairingLinksLoadError({ cause })),
212285 );
213286
214 const revoke: PairingGrantStoreShape["revoke"] = Effect.fn("PairingGrantStore.revoke")(
287 const revoke: PairingGrantStore["Service"]["revoke"] = Effect.fn("PairingGrantStore.revoke")(
215288 function* (id) {
216289 const revokedAt = yield* DateTime.now;
217290 const revoked = yield* pairingLinks.revoke({
......@@ -223,10 +296,10 @@ export const make = Effect.fn("makePairingGrantStore")(function* () {
223296 }
224297 return revoked;
225298 },
226 Effect.mapError(toBootstrapCredentialError("Failed to revoke pairing link.")),
299 Effect.mapError((cause) => new PairingLinkRevokeError({ cause })),
227300 );
228301
229 const issueOneTimeToken: PairingGrantStoreShape["issueOneTimeToken"] = Effect.fn(
302 const issueOneTimeToken: PairingGrantStore["Service"]["issueOneTimeToken"] = Effect.fn(
230303 "PairingGrantStore.issueOneTimeToken",
231304 )(
232305 function* (input) {
......@@ -264,10 +337,10 @@ export const make = Effect.fn("makePairingGrantStore")(function* () {
264337 });
265338 return issued;
266339 },
267 Effect.mapError(toBootstrapCredentialError("Failed to issue pairing credential.")),
340 Effect.mapError((cause) => new PairingCredentialIssueError({ cause })),
268341 );
269342
270 const consume: PairingGrantStoreShape["consume"] = Effect.fn("PairingGrantStore.consume")(
343 const consume: PairingGrantStore["Service"]["consume"] = Effect.fn("PairingGrantStore.consume")(
271344 function* (credential, input) {
272345 const now = yield* DateTime.now;
273346 const seededResult: ConsumeResult = yield* Ref.modify(
......@@ -279,7 +352,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () {
279352 {
280353 _tag: "error",
281354 reason: "not-found",
282 error: invalidBootstrapCredentialError("Unknown bootstrap credential."),
355 error: new UnknownBootstrapCredentialError({}),
283356 },
284357 current,
285358 ];
......@@ -292,7 +365,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () {
292365 {
293366 _tag: "error",
294367 reason: "expired",
295 error: invalidBootstrapCredentialError("Bootstrap credential expired."),
368 error: new ExpiredBootstrapCredentialError({}),
296369 },
297370 next,
298371 ];
......@@ -303,7 +376,7 @@ export const make = Effect.fn("makePairingGrantStore")(function* () {
303376 {
304377 _tag: "error",
305378 reason: "not-found",
306 error: invalidBootstrapCredentialError("Bootstrap credential proof key mismatch."),
379 error: new BootstrapCredentialProofKeyMismatchError({}),
307380 },
308381 next,
309382 ];
......@@ -370,41 +443,36 @@ export const make = Effect.fn("makePairingGrantStore")(function* () {
370443
371444 const matching = yield* pairingLinks.getByCredential({ credential });
372445 if (Option.isNone(matching)) {
373 return yield* invalidBootstrapCredentialError("Unknown bootstrap credential.");
446 return yield* new UnknownBootstrapCredentialError({});
374447 }
375448
376449 if (matching.value.revokedAt !== null) {
377 return yield* invalidBootstrapCredentialError(
378 "Bootstrap credential is no longer available.",
379 );
450 return yield* new UnavailableBootstrapCredentialError({});
380451 }
381452
382453 if (matching.value.consumedAt !== null) {
383 return yield* invalidBootstrapCredentialError("Unknown bootstrap credential.");
454 return yield* new UnknownBootstrapCredentialError({});
384455 }
385456
386457 if (DateTime.isGreaterThanOrEqualTo(now, matching.value.expiresAt)) {
387 return yield* invalidBootstrapCredentialError("Bootstrap credential expired.");
458 return yield* new ExpiredBootstrapCredentialError({});
388459 }
389460
390461 if (
391462 matching.value.proofKeyThumbprint !== null &&
392463 matching.value.proofKeyThumbprint !== input?.proofKeyThumbprint
393464 ) {
394 return yield* invalidBootstrapCredentialError("Bootstrap credential proof key mismatch.");
465 return yield* new BootstrapCredentialProofKeyMismatchError({});
395466 }
396467
397 return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available.");
468 return yield* new UnavailableBootstrapCredentialError({});
398469 },
399470 Effect.mapError((cause) =>
400 cause._tag === "BootstrapCredentialInvalidError" ||
401 cause._tag === "BootstrapCredentialInternalError"
402 ? cause
403 : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause),
471 isBootstrapCredentialError(cause) ? cause : new BootstrapCredentialConsumeError({ cause }),
404472 ),
405473 );
406474
407 return {
475 return PairingGrantStore.of({
408476 issueOneTimeToken,
409477 listActive,
410478 get streamChanges() {
......@@ -412,9 +480,9 @@ export const make = Effect.fn("makePairingGrantStore")(function* () {
412480 },
413481 revoke,
414482 consume,
415 } satisfies PairingGrantStoreShape;
483 });
416484});
417485
418export const layer = Layer.effect(PairingGrantStore, make()).pipe(
486export const layer = Layer.effect(PairingGrantStore, make).pipe(
419487 Layer.provideMerge(AuthPairingLinks.layer),
420488);
apps/server/src/auth/ServerSecretStore.test.ts+4-4
......@@ -9,7 +9,7 @@ import * as Option from "effect/Option";
99import * as Ref from "effect/Ref";
1010import * as PlatformError from "effect/PlatformError";
1111
12import { ServerConfig } from "../config.ts";
12import * as ServerConfig from "../config.ts";
1313import * as ServerSecretStore from "./ServerSecretStore.ts";
1414
1515const makeServerConfigLayer = () =>
......@@ -231,7 +231,7 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => {
231231
232232 const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32));
233233
234 assert.instanceOf(error, ServerSecretStore.SecretStoreError);
234 assert.instanceOf(error, ServerSecretStore.SecretStoreReadError);
235235 assert.include(error.message, "Failed to read secret session-signing-key.");
236236 assert.instanceOf(error.cause, PlatformError.PlatformError);
237237 assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied");
......@@ -246,7 +246,7 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => {
246246 secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])),
247247 );
248248
249 assert.instanceOf(error, ServerSecretStore.SecretStoreError);
249 assert.instanceOf(error, ServerSecretStore.SecretStorePersistError);
250250 assert.include(error.message, "Failed to persist secret session-signing-key.");
251251 assert.instanceOf(error.cause, PlatformError.PlatformError);
252252 assert.equal((error.cause as PlatformError.PlatformError).reason._tag, "PermissionDenied");
......@@ -259,7 +259,7 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => {
259259
260260 const error = yield* Effect.flip(secretStore.remove("session-signing-key"));
261261
262 assert.instanceOf(error, ServerSecretStore.SecretStoreError);
262 assert.instanceOf(error, ServerSecretStore.SecretStoreRemoveError);
263263 assert.include(error.message, "Failed to remove secret session-signing-key.");
264264 assert.instanceOf(error.cause, PlatformError.PlatformError);
265265 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";
99import * as PlatformError from "effect/PlatformError";
1010import * 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>()(
15 "SecretStoreError",
14const secretStoreErrorContext = {
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",
1632 {
17 message: Schema.String,
18 cause: Schema.optional(Schema.Defect()),
33 ...secretStoreErrorContext,
1934 },
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
22132const isPlatformError = (value: unknown): value is PlatformError.PlatformError =>
23133 Predicate.isTagged(value, "PlatformError");
24134
25135export const isSecretAlreadyExistsError = (error: SecretStoreError): boolean =>
26 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}
136 "cause" in error && isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists";
38137
39export class ServerSecretStore extends Context.Service<ServerSecretStore, ServerSecretStoreShape>()(
40 "t3/auth/ServerSecretStore",
41) {}
138export class ServerSecretStore extends Context.Service<
139 ServerSecretStore,
140 {
141 readonly get: (name: string) => Effect.Effect<Option.Option<Uint8Array>, SecretStoreError>;
142 readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
143 readonly create: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
144 readonly getOrCreateRandom: (
145 name: string,
146 bytes: number,
147 ) => Effect.Effect<Uint8Array, SecretStoreError>;
148 readonly remove: (name: string) => Effect.Effect<void, SecretStoreError>;
149 }
150>()("t3/auth/ServerSecretStore") {}
42151
43export const make = Effect.fn("makeServerSecretStore")(function* () {
152export const make = Effect.gen(function* () {
44153 const crypto = yield* Crypto.Crypto;
45154 const fileSystem = yield* FileSystem.FileSystem;
46155 const path = yield* Path.Path;
47 const serverConfig = yield* ServerConfig;
156 const serverConfig = yield* ServerConfig.ServerConfig;
48157
49158 yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true });
50159 yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe(
51160 Effect.mapError(
52161 (cause) =>
53 new SecretStoreError({
54 message: `Failed to secure secrets directory ${serverConfig.secretsDir}.`,
162 new SecretStoreSecureError({
163 resource: `secrets directory ${serverConfig.secretsDir}`,
55164 cause,
56165 }),
57166 ),
......@@ -59,15 +168,15 @@ export const make = Effect.fn("makeServerSecretStore")(function* () {
59168
60169 const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`);
61170
62 const get: ServerSecretStoreShape["get"] = (name) =>
171 const get: ServerSecretStore["Service"]["get"] = (name) =>
63172 fileSystem.readFile(resolveSecretPath(name)).pipe(
64173 Effect.map((bytes) => Option.some(Uint8Array.from(bytes))),
65174 Effect.catch((cause) =>
66175 cause.reason._tag === "NotFound"
67176 ? Effect.succeed(Option.none())
68177 : Effect.fail(
69 new SecretStoreError({
70 message: `Failed to read secret ${name}.`,
178 new SecretStoreReadError({
179 resource: `secret ${name}`,
71180 cause,
72181 }),
73182 ),
......@@ -75,13 +184,13 @@ export const make = Effect.fn("makeServerSecretStore")(function* () {
75184 Effect.withSpan("ServerSecretStore.get"),
76185 );
77186
78 const set: ServerSecretStoreShape["set"] = (name, value) => {
187 const set: ServerSecretStore["Service"]["set"] = (name, value) => {
79188 const secretPath = resolveSecretPath(name);
80189 return crypto.randomUUIDv4.pipe(
81190 Effect.mapError(
82191 (cause) =>
83 new SecretStoreError({
84 message: `Failed to create temporary path for secret ${name}.`,
192 new SecretStoreTemporaryPathError({
193 resource: `secret ${name}`,
85194 cause,
86195 }),
87196 ),
......@@ -98,8 +207,8 @@ export const make = Effect.fn("makeServerSecretStore")(function* () {
98207 Effect.ignore,
99208 Effect.flatMap(() =>
100209 Effect.fail(
101 new SecretStoreError({
102 message: `Failed to persist secret ${name}.`,
210 new SecretStorePersistError({
211 resource: `secret ${name}`,
103212 cause,
104213 }),
105214 ),
......@@ -112,7 +221,7 @@ export const make = Effect.fn("makeServerSecretStore")(function* () {
112221 );
113222 };
114223
115 const create: ServerSecretStoreShape["create"] = (name, value) => {
224 const create: ServerSecretStore["Service"]["create"] = (name, value) => {
116225 const secretPath = resolveSecretPath(name);
117226 return Effect.scoped(
118227 Effect.gen(function* () {
......@@ -127,15 +236,15 @@ export const make = Effect.fn("makeServerSecretStore")(function* () {
127236 ).pipe(
128237 Effect.mapError(
129238 (cause) =>
130 new SecretStoreError({
131 message: `Failed to persist secret ${name}.`,
239 new SecretStorePersistError({
240 resource: `secret ${name}`,
132241 cause,
133242 }),
134243 ),
135244 );
136245 };
137246
138 const getOrCreateRandom: ServerSecretStoreShape["getOrCreateRandom"] = (name, bytes) =>
247 const getOrCreateRandom: ServerSecretStore["Service"]["getOrCreateRandom"] = (name, bytes) =>
139248 get(name).pipe(
140249 Effect.flatMap(
141250 Option.match({
......@@ -144,15 +253,15 @@ export const make = Effect.fn("makeServerSecretStore")(function* () {
144253 crypto.randomBytes(bytes).pipe(
145254 Effect.mapError(
146255 (cause) =>
147 new SecretStoreError({
148 message: `Failed to generate random bytes for secret ${name}.`,
256 new SecretStoreRandomGenerationError({
257 resource: `secret ${name}`,
149258 cause,
150259 }),
151260 ),
152261 Effect.flatMap((generated) =>
153262 create(name, generated).pipe(
154263 Effect.as(Uint8Array.from(generated)),
155 Effect.catchTag("SecretStoreError", (error) =>
264 Effect.catchIf(isSecretStoreError, (error) =>
156265 isSecretAlreadyExistsError(error)
157266 ? get(name).pipe(
158267 Effect.flatMap(
......@@ -160,8 +269,8 @@ export const make = Effect.fn("makeServerSecretStore")(function* () {
160269 onSome: Effect.succeed,
161270 onNone: () =>
162271 Effect.fail(
163 new SecretStoreError({
164 message: `Failed to read secret ${name} after concurrent creation.`,
272 new SecretStoreConcurrentReadError({
273 resource: `secret ${name}`,
165274 }),
166275 ),
167276 }),
......@@ -177,14 +286,14 @@ export const make = Effect.fn("makeServerSecretStore")(function* () {
177286 Effect.withSpan("ServerSecretStore.getOrCreateRandom"),
178287 );
179288
180 const remove: ServerSecretStoreShape["remove"] = (name) =>
289 const remove: ServerSecretStore["Service"]["remove"] = (name) =>
181290 fileSystem.remove(resolveSecretPath(name)).pipe(
182291 Effect.catch((cause) =>
183292 cause.reason._tag === "NotFound"
184293 ? Effect.void
185294 : Effect.fail(
186 new SecretStoreError({
187 message: `Failed to remove secret ${name}.`,
295 new SecretStoreRemoveError({
296 resource: `secret ${name}`,
188297 cause,
189298 }),
190299 ),
......@@ -192,13 +301,13 @@ export const make = Effect.fn("makeServerSecretStore")(function* () {
192301 Effect.withSpan("ServerSecretStore.remove"),
193302 );
194303
195 return {
304 return ServerSecretStore.of({
196305 get,
197306 set,
198307 create,
199308 getOrCreateRandom,
200309 remove,
201 } satisfies ServerSecretStoreShape;
310 });
202311});
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
5151
5252const failingSessionLookupCredentialLayer = Layer.effect(
5353 SessionStore.SessionStore,
54 SessionStore.make(),
54 SessionStore.make,
5555).pipe(
5656 Layer.provide(failingSessionLookupRepositoryLayer),
5757 Layer.provide(ServerSecretStore.layer),
......@@ -89,7 +89,7 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
8989 const sessions = yield* SessionStore.SessionStore;
9090 const error = yield* Effect.flip(sessions.verify("not-a-session-token"));
9191
92 expect(error._tag).toBe("SessionCredentialInvalidError");
92 expect(error._tag).toBe("MalformedSessionTokenError");
9393 expect(error.message).toContain("Malformed session token");
9494 }).pipe(Effect.provide(makeSessionStoreLayer())),
9595 );
......@@ -105,8 +105,8 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
105105 const sessionError = yield* Effect.flip(sessions.verify(issued.token));
106106 const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token));
107107
108 expect(sessionError._tag).toBe("SessionCredentialInternalError");
109 expect(websocketError._tag).toBe("SessionCredentialInternalError");
108 expect(sessionError._tag).toBe("SessionCredentialVerificationError");
109 expect(websocketError._tag).toBe("WebSocketTokenVerificationError");
110110 expect(sessionError.cause).toBe(repositoryFailure);
111111 expect(websocketError.cause).toBe(repositoryFailure);
112112 }).pipe(Effect.provide(failingSessionLookupCredentialLayer)),
apps/server/src/auth/SessionStore.ts+343-150
......@@ -10,7 +10,6 @@ import {
1010import * as Clock from "effect/Clock";
1111import * as Context from "effect/Context";
1212import * as Crypto from "effect/Crypto";
13import * as Data from "effect/Data";
1413import * as DateTime from "effect/DateTime";
1514import * as Duration from "effect/Duration";
1615import * as Effect from "effect/Effect";
......@@ -21,7 +20,7 @@ import * as Schema from "effect/Schema";
2120import * as Stream from "effect/Stream";
2221import * as Option from "effect/Option";
2322
24import { ServerConfig } from "../config.ts";
23import * as ServerConfig from "../config.ts";
2524import * as AuthSessions from "../persistence/AuthSessions.ts";
2625import * as ServerSecretStore from "./ServerSecretStore.ts";
2726import {
......@@ -63,66 +62,311 @@ export type SessionCredentialChange =
6362 readonly sessionId: AuthSessionId;
6463 };
6564
66export class SessionCredentialInvalidError extends Data.TaggedError(
67 "SessionCredentialInvalidError",
68)<{
69 readonly message: string;
70 readonly cause?: unknown;
71}> {}
72
73export class SessionCredentialInternalError extends Data.TaggedError(
74 "SessionCredentialInternalError",
75)<{
76 readonly message: string;
77 readonly cause?: unknown;
78}> {}
79
80export type SessionCredentialError = SessionCredentialInvalidError | SessionCredentialInternalError;
81
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>;
65export class MalformedSessionTokenError extends Schema.TaggedErrorClass<MalformedSessionTokenError>()(
66 "MalformedSessionTokenError",
67 {},
68) {
69 override get message(): string {
70 return "Malformed session token.";
71 }
72}
73
74export class InvalidSessionTokenSignatureError extends Schema.TaggedErrorClass<InvalidSessionTokenSignatureError>()(
75 "InvalidSessionTokenSignatureError",
76 {},
77) {
78 override get message(): string {
79 return "Invalid session token signature.";
80 }
12181}
12282
123export class SessionStore extends Context.Service<SessionStore, SessionStoreShape>()(
124 "t3/auth/SessionStore",
125) {}
83export class InvalidSessionTokenPayloadError extends Schema.TaggedErrorClass<InvalidSessionTokenPayloadError>()(
84 "InvalidSessionTokenPayloadError",
85 {
86 cause: Schema.Defect(),
87 },
88) {
89 override get message(): string {
90 return "Invalid session token payload.";
91 }
92}
93
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
127371const SIGNING_SECRET_NAME = "server-signing-key";
128372const DEFAULT_SESSION_TTL = Duration.days(30);
......@@ -184,15 +428,9 @@ function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthCli
184428 };
185429}
186430
187const toSessionCredentialInternalError = (message: string) => (cause: unknown) =>
188 new SessionCredentialInternalError({
189 message,
190 cause,
191 });
192
193export const make = Effect.fn("makeSessionStore")(function* () {
431export const make = Effect.gen(function* () {
194432 const crypto = yield* Crypto.Crypto;
195 const serverConfig = yield* ServerConfig;
433 const serverConfig = yield* ServerConfig.ServerConfig;
196434 const secretStore = yield* ServerSecretStore.ServerSecretStore;
197435 const authSessions = yield* AuthSessions.AuthSessionRepository;
198436 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);
......@@ -238,7 +476,7 @@ export const make = Effect.fn("makeSessionStore")(function* () {
238476 );
239477 });
240478
241 const markConnected: SessionStoreShape["markConnected"] = (sessionId) =>
479 const markConnected: SessionStore["Service"]["markConnected"] = (sessionId) =>
242480 Ref.modify(connectedSessionsRef, (current) => {
243481 const next = new Map(current);
244482 const wasDisconnected = !next.has(sessionId);
......@@ -272,7 +510,7 @@ export const make = Effect.fn("makeSessionStore")(function* () {
272510 Effect.withSpan("SessionStore.markConnected"),
273511 );
274512
275 const markDisconnected: SessionStoreShape["markDisconnected"] = (sessionId) =>
513 const markDisconnected: SessionStore["Service"]["markDisconnected"] = (sessionId) =>
276514 Ref.update(connectedSessionsRef, (current) => {
277515 const next = new Map(current);
278516 const remaining = (next.get(sessionId) ?? 0) - 1;
......@@ -299,7 +537,7 @@ export const make = Effect.fn("makeSessionStore")(function* () {
299537 );
300538
301539 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")(
303541 function* (input) {
304542 const sessionId = AuthSessionId.make(yield* crypto.randomUUIDv4);
305543 const issuedAt = yield* DateTime.now;
......@@ -321,8 +559,7 @@ export const make = Effect.fn("makeSessionStore")(function* () {
321559 const encodedPayload = yield* encodeClaims(claims).pipe(
322560 Effect.map(base64UrlEncode),
323561 Effect.mapError(
324 (cause) =>
325 new SessionCredentialInternalError({ message: "Failed to encode claims", cause }),
562 (cause) => new SessionClaimsEncodingError({ operation: "encode_session_claims", cause }),
326563 ),
327564 );
328565 const signature = signPayload(encodedPayload, signingSecret);
......@@ -367,59 +604,41 @@ export const make = Effect.fn("makeSessionStore")(function* () {
367604 ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}),
368605 } satisfies IssuedSession;
369606 },
370 Effect.mapError(toSessionCredentialInternalError("Failed to issue session credential.")),
607 Effect.mapError((cause) => new SessionCredentialIssueError({ cause })),
371608 );
372609
373 const verify: SessionStoreShape["verify"] = Effect.fn("SessionStore.verify")(
610 const verify: SessionStore["Service"]["verify"] = Effect.fn("SessionStore.verify")(
374611 function* (token) {
375612 const [encodedPayload, signature] = token.split(".");
376613 if (!encodedPayload || !signature) {
377 return yield* new SessionCredentialInvalidError({
378 message: "Malformed session token.",
379 });
614 return yield* new MalformedSessionTokenError({});
380615 }
381616
382617 const expectedSignature = signPayload(encodedPayload, signingSecret);
383618 if (!timingSafeEqualBase64Url(signature, expectedSignature)) {
384 return yield* new SessionCredentialInvalidError({
385 message: "Invalid session token signature.",
386 });
619 return yield* new InvalidSessionTokenSignatureError({});
387620 }
388621
389622 const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
390 Effect.mapError(
391 (cause) =>
392 new SessionCredentialInvalidError({
393 message: "Invalid session token payload.",
394 cause,
395 }),
396 ),
623 Effect.mapError((cause) => new InvalidSessionTokenPayloadError({ cause })),
397624 );
398625
399626 const now = yield* Clock.currentTimeMillis;
400627 if (claims.exp <= now) {
401 return yield* new SessionCredentialInvalidError({
402 message: "Session token expired.",
403 });
628 return yield* new SessionTokenExpiredError({});
404629 }
405630
406631 const row = yield* authSessions.getById({ sessionId: claims.sid });
407632 if (Option.isNone(row)) {
408 return yield* new SessionCredentialInvalidError({
409 message: "Unknown session token.",
410 });
633 return yield* new UnknownSessionTokenError({});
411634 }
412635 if (row.value.revokedAt !== null) {
413 return yield* new SessionCredentialInvalidError({
414 message: "Session token revoked.",
415 });
636 return yield* new SessionTokenRevokedError({});
416637 }
417638
418639 const expiresAt = DateTime.make(claims.exp);
419640 if (Option.isNone(expiresAt)) {
420 return yield* new SessionCredentialInvalidError({
421 message: "Invalid `exp` claim",
422 });
641 return yield* new InvalidSessionExpirationClaimError({});
423642 }
424643
425644 return {
......@@ -434,17 +653,14 @@ export const make = Effect.fn("makeSessionStore")(function* () {
434653 } satisfies VerifiedSession;
435654 },
436655 Effect.mapError((cause) =>
437 cause._tag === "SessionCredentialInvalidError"
656 isSessionCredentialInvalidError(cause)
438657 ? cause
439 : new SessionCredentialInternalError({
440 message: "Failed to verify session credential.",
441 cause,
442 }),
658 : new SessionCredentialVerificationError({ cause }),
443659 ),
444660 );
445661
446662 const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims));
447 const issueWebSocketToken: SessionStoreShape["issueWebSocketToken"] = Effect.fn(
663 const issueWebSocketToken: SessionStore["Service"]["issueWebSocketToken"] = Effect.fn(
448664 "SessionStore.issueWebSocketToken",
449665 )(
450666 function* (sessionId, input) {
......@@ -463,7 +679,7 @@ export const make = Effect.fn("makeSessionStore")(function* () {
463679 Effect.map(base64UrlEncode),
464680 Effect.mapError(
465681 (cause) =>
466 new SessionCredentialInternalError({ message: "Failed to encode claims", cause }),
682 new SessionClaimsEncodingError({ operation: "encode_websocket_claims", cause }),
467683 ),
468684 );
469685 const signature = signPayload(encodedPayload, signingSecret);
......@@ -472,59 +688,41 @@ export const make = Effect.fn("makeSessionStore")(function* () {
472688 expiresAt,
473689 };
474690 },
475 Effect.mapError(toSessionCredentialInternalError("Failed to issue websocket token.")),
691 Effect.mapError((cause) => new WebSocketTokenIssueError({ cause })),
476692 );
477693
478 const verifyWebSocketToken: SessionStoreShape["verifyWebSocketToken"] = Effect.fn(
694 const verifyWebSocketToken: SessionStore["Service"]["verifyWebSocketToken"] = Effect.fn(
479695 "SessionStore.verifyWebSocketToken",
480696 )(
481697 function* (token) {
482698 const [encodedPayload, signature] = token.split(".");
483699 if (!encodedPayload || !signature) {
484 return yield* new SessionCredentialInvalidError({
485 message: "Malformed websocket token.",
486 });
700 return yield* new MalformedWebSocketTokenError({});
487701 }
488702
489703 const expectedSignature = signPayload(encodedPayload, signingSecret);
490704 if (!timingSafeEqualBase64Url(signature, expectedSignature)) {
491 return yield* new SessionCredentialInvalidError({
492 message: "Invalid websocket token signature.",
493 });
705 return yield* new InvalidWebSocketTokenSignatureError({});
494706 }
495707
496708 const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
497 Effect.mapError(
498 (cause) =>
499 new SessionCredentialInvalidError({
500 message: "Invalid websocket token payload.",
501 cause,
502 }),
503 ),
709 Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })),
504710 );
505711
506712 const now = yield* Clock.currentTimeMillis;
507713 if (claims.exp <= now) {
508 return yield* new SessionCredentialInvalidError({
509 message: "Websocket token expired.",
510 });
714 return yield* new WebSocketTokenExpiredError({});
511715 }
512716
513717 const row = yield* authSessions.getById({ sessionId: claims.sid });
514718 if (Option.isNone(row)) {
515 return yield* new SessionCredentialInvalidError({
516 message: "Unknown websocket session.",
517 });
719 return yield* new UnknownWebSocketSessionError({});
518720 }
519721 if (row.value.expiresAt.epochMilliseconds <= now) {
520 return yield* new SessionCredentialInvalidError({
521 message: "Websocket session expired.",
522 });
722 return yield* new WebSocketSessionExpiredError({});
523723 }
524724 if (row.value.revokedAt !== null) {
525 return yield* new SessionCredentialInvalidError({
526 message: "Websocket session revoked.",
527 });
725 return yield* new WebSocketSessionRevokedError({});
528726 }
529727
530728 return {
......@@ -538,16 +736,13 @@ export const make = Effect.fn("makeSessionStore")(function* () {
538736 } satisfies VerifiedSession;
539737 },
540738 Effect.mapError((cause) =>
541 cause._tag === "SessionCredentialInvalidError"
739 isSessionCredentialInvalidError(cause)
542740 ? cause
543 : new SessionCredentialInternalError({
544 message: "Failed to verify websocket token.",
545 cause,
546 }),
741 : new WebSocketTokenVerificationError({ cause }),
547742 ),
548743 );
549744
550 const listActive: SessionStoreShape["listActive"] = Effect.fn("SessionStore.listActive")(
745 const listActive: SessionStore["Service"]["listActive"] = Effect.fn("SessionStore.listActive")(
551746 function* () {
552747 const now = yield* DateTime.now;
553748 const connectedSessions = yield* Ref.get(connectedSessionsRef);
......@@ -567,10 +762,10 @@ export const make = Effect.fn("makeSessionStore")(function* () {
567762 }),
568763 );
569764 },
570 Effect.mapError(toSessionCredentialInternalError("Failed to list active sessions.")),
765 Effect.mapError((cause) => new ActiveSessionsListError({ cause })),
571766 );
572767
573 const revoke: SessionStoreShape["revoke"] = Effect.fn("SessionStore.revoke")(
768 const revoke: SessionStore["Service"]["revoke"] = Effect.fn("SessionStore.revoke")(
574769 function* (sessionId) {
575770 const revokedAt = yield* DateTime.now;
576771 const revoked = yield* authSessions.revoke({
......@@ -587,10 +782,10 @@ export const make = Effect.fn("makeSessionStore")(function* () {
587782 }
588783 return revoked;
589784 },
590 Effect.mapError(toSessionCredentialInternalError("Failed to revoke session.")),
785 Effect.mapError((cause) => new SessionRevocationError({ cause })),
591786 );
592787
593 const revokeAllExcept: SessionStoreShape["revokeAllExcept"] = Effect.fn(
788 const revokeAllExcept: SessionStore["Service"]["revokeAllExcept"] = Effect.fn(
594789 "SessionStore.revokeAllExcept",
595790 )(
596791 function* (sessionId) {
......@@ -618,10 +813,10 @@ export const make = Effect.fn("makeSessionStore")(function* () {
618813 }
619814 return revokedSessionIds.length;
620815 },
621 Effect.mapError(toSessionCredentialInternalError("Failed to revoke other sessions.")),
816 Effect.mapError((cause) => new OtherSessionsRevocationError({ cause })),
622817 );
623818
624 return {
819 return SessionStore.of({
625820 cookieName,
626821 issue,
627822 verify,
......@@ -635,9 +830,7 @@ export const make = Effect.fn("makeSessionStore")(function* () {
635830 revokeAllExcept,
636831 markConnected,
637832 markDisconnected,
638 } satisfies SessionStoreShape;
833 });
639834});
640835
641export const layer = Layer.effect(SessionStore, make()).pipe(
642 Layer.provideMerge(AuthSessions.layer),
643);
836export const layer = Layer.effect(SessionStore, make).pipe(Layer.provideMerge(AuthSessions.layer));
apps/server/src/auth/dpop.test.ts+10-6
......@@ -1,12 +1,12 @@
11import { describe, expect, it } from "vite-plus/test";
22import * as PlatformError from "effect/PlatformError";
33
4import * as ServerSecretStore from "./ServerSecretStore.ts";
4import { SecretStorePersistError } from "./ServerSecretStore.ts";
55import { mapDpopReplayStoreError } from "./dpop.ts";
66
77const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") =>
8 new ServerSecretStore.SecretStoreError({
9 message: "Failed to persist DPoP proof.",
8 new SecretStorePersistError({
9 resource: "DPoP proof",
1010 cause: PlatformError.systemError({
1111 _tag: tag,
1212 module: "FileSystem",
......@@ -17,16 +17,20 @@ const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") =>
1717
1818describe("mapDpopReplayStoreError", () => {
1919 it("reports replay conflicts as invalid credentials", () => {
20 const error = mapDpopReplayStoreError(storeFailure("AlreadyExists"));
20 const cause = storeFailure("AlreadyExists");
21 const error = mapDpopReplayStoreError(cause);
2122
2223 expect(error._tag).toBe("ServerAuthInvalidCredentialError");
24 if (error._tag === "ServerAuthInvalidCredentialError") {
25 expect(error.cause).toBe(cause);
26 }
2327 });
2428
2529 it("reports replay-store availability failures as internal errors", () => {
2630 const error = mapDpopReplayStoreError(storeFailure("PermissionDenied"));
2731
28 expect(error._tag).toBe("ServerAuthInternalError");
29 if (error._tag === "ServerAuthInternalError") {
32 expect(error._tag).toBe("ServerAuthDpopReplayStateRecordError");
33 if (error._tag === "ServerAuthDpopReplayStateRecordError") {
3034 expect(error.message).toBe("Failed to record DPoP proof replay state.");
3135 }
3236 });
apps/server/src/auth/dpop.ts+17-13
......@@ -5,7 +5,12 @@ import * as Effect from "effect/Effect";
55import * as Encoding from "effect/Encoding";
66import 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";
914import * as ServerSecretStore from "./ServerSecretStore.ts";
1015
1116function firstHeaderValue(value: string | undefined): string | undefined {
......@@ -26,14 +31,13 @@ export function requestAbsoluteUrl(request: HttpServerRequest.HttpServerRequest)
2631
2732export const mapDpopReplayStoreError = (
2833 error: ServerSecretStore.SecretStoreError,
29): EnvironmentAuth.ServerAuthInvalidCredentialError | EnvironmentAuth.ServerAuthInternalError =>
34): ServerAuthInvalidCredentialError | ServerAuthInternalError =>
3035 ServerSecretStore.isSecretAlreadyExistsError(error)
31 ? new EnvironmentAuth.ServerAuthInvalidCredentialError({
32 reason: "invalid_credential",
33 cause: "DPoP proof replayed.",
36 ? new ServerAuthInvalidCredentialError({
37 diagnostic: "DPoP proof replayed.",
38 cause: error,
3439 })
35 : new EnvironmentAuth.ServerAuthInternalError({
36 message: "Failed to record DPoP proof replay state.",
40 : new ServerAuthDpopReplayStateRecordError({
3741 cause: error,
3842 });
3943
......@@ -54,9 +58,8 @@ export const verifyRequestDpopProof = (input: {
5458 ...(input.expectedAccessToken ? { expectedAccessToken: input.expectedAccessToken } : {}),
5559 });
5660 if (!result.ok) {
57 return yield* new EnvironmentAuth.ServerAuthInvalidCredentialError({
58 reason: "invalid_credential",
59 cause: result.reason,
61 return yield* new ServerAuthInvalidCredentialError({
62 diagnostic: result.reason,
6063 });
6164 }
6265 const secretStore = yield* ServerSecretStore.ServerSecretStore;
......@@ -67,8 +70,7 @@ export const verifyRequestDpopProof = (input: {
6770 Effect.map(Encoding.encodeBase64Url),
6871 Effect.mapError(
6972 (cause) =>
70 new EnvironmentAuth.ServerAuthInternalError({
71 message: "Failed to calculate DPoP replay key.",
73 new ServerAuthDpopReplayKeyCalculationError({
7274 cause,
7375 }),
7476 ),
......@@ -86,7 +88,9 @@ export const verifyRequestDpopProof = (input: {
8688 ),
8789 )
8890 .pipe(
89 Effect.catchTag("SecretStoreError", (error) => Effect.fail(mapDpopReplayStoreError(error))),
91 Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) =>
92 Effect.fail(mapDpopReplayStoreError(error)),
93 ),
9094 );
9195 return result.thumbprint;
9296 });
apps/server/src/auth/http.ts+42-36
......@@ -169,10 +169,12 @@ export const environmentAuthenticatedAuthLayer = Layer.effect(
169169 Effect.gen(function* () {
170170 const request = yield* HttpServerRequest.HttpServerRequest;
171171 const session = yield* serverAuth.authenticateHttpRequest(request).pipe(
172 Effect.catchTags({
173 ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason),
174 ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error),
175 }),
172 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
173 failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),
174 ),
175 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
176 failEnvironmentInternal("internal_error", error),
177 ),
176178 );
177179 return yield* httpEffect.pipe(
178180 Effect.provideService(EnvironmentAuthenticatedPrincipal, {
......@@ -201,7 +203,7 @@ export const authHttpApiLayer = HttpApiBuilder.group(
201203 const request = yield* HttpServerRequest.HttpServerRequest;
202204 return yield* serverAuth.getSessionState(request);
203205 },
204 Effect.catchTag("ServerAuthInternalError", (error) =>
206 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
205207 failEnvironmentInternal("internal_error", error),
206208 ),
207209 ),
......@@ -231,11 +233,12 @@ export const authHttpApiLayer = HttpApiBuilder.group(
231233 yield* appendCredentialResponseHeaders;
232234 return result.response;
233235 },
234 Effect.catchTags({
235 ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason),
236 ServerAuthInternalError: (error) =>
237 failEnvironmentInternal("browser_session_issuance_failed", error),
238 }),
236 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
237 failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),
238 ),
239 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
240 failEnvironmentInternal("browser_session_issuance_failed", error),
241 ),
239242 ),
240243 )
241244 .handle(
......@@ -265,14 +268,14 @@ export const authHttpApiLayer = HttpApiBuilder.group(
265268 }
266269 const proofKeyThumbprint = args.headers.dpop
267270 ? yield* verifyRequestDpopProof({ request }).pipe(
268 Effect.catchTags({
269 ServerAuthInvalidCredentialError: () =>
270 appendDpopChallengeHeader.pipe(
271 Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")),
272 ),
273 ServerAuthInternalError: (error) =>
274 failEnvironmentInternal("access_token_issuance_failed", error),
275 }),
271 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, () =>
272 appendDpopChallengeHeader.pipe(
273 Effect.andThen(failEnvironmentAuthInvalid("invalid_credential")),
274 ),
275 ),
276 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
277 failEnvironmentInternal("access_token_issuance_failed", error),
278 ),
276279 )
277280 : undefined;
278281 yield* appendCredentialResponseHeaders;
......@@ -293,12 +296,15 @@ export const authHttpApiLayer = HttpApiBuilder.group(
293296 );
294297 },
295298 traceRelayRequest,
296 Effect.catchTags({
297 ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason),
298 ServerAuthInvalidRequestError: (error) => failEnvironmentInvalidRequest(error.reason),
299 ServerAuthInternalError: (error) =>
300 failEnvironmentInternal("access_token_issuance_failed", error),
301 }),
299 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
300 failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),
301 ),
302 Effect.catchIf(EnvironmentAuth.isServerAuthInvalidRequestError, (error) =>
303 failEnvironmentInvalidRequest(EnvironmentAuth.serverAuthInvalidRequestReason(error)),
304 ),
305 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
306 failEnvironmentInternal("access_token_issuance_failed", error),
307 ),
302308 ),
303309 )
304310 .handle(
......@@ -310,7 +316,7 @@ export const authHttpApiLayer = HttpApiBuilder.group(
310316 yield* appendCredentialResponseHeaders;
311317 return yield* serverAuth.issueWebSocketTicket(session);
312318 },
313 Effect.catchTag("ServerAuthInternalError", (error) =>
319 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
314320 failEnvironmentInternal("websocket_ticket_issuance_failed", error),
315321 ),
316322 ),
......@@ -335,7 +341,7 @@ export const authHttpApiLayer = HttpApiBuilder.group(
335341 }
336342 return yield* serverAuth.issuePairingCredential(args.payload);
337343 },
338 Effect.catchTag("ServerAuthInternalError", (error) =>
344 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
339345 failEnvironmentInternal("pairing_credential_issuance_failed", error),
340346 ),
341347 ),
......@@ -348,7 +354,7 @@ export const authHttpApiLayer = HttpApiBuilder.group(
348354 yield* requireEnvironmentScope(AuthAccessReadScope);
349355 return yield* serverAuth.listPairingLinks();
350356 },
351 Effect.catchTag("ServerAuthInternalError", (error) =>
357 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
352358 failEnvironmentInternal("pairing_links_load_failed", error),
353359 ),
354360 ),
......@@ -362,7 +368,7 @@ export const authHttpApiLayer = HttpApiBuilder.group(
362368 const revoked = yield* serverAuth.revokePairingLink(args.payload.id);
363369 return { revoked };
364370 },
365 Effect.catchTag("ServerAuthInternalError", (error) =>
371 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
366372 failEnvironmentInternal("pairing_link_revoke_failed", error),
367373 ),
368374 ),
......@@ -375,7 +381,7 @@ export const authHttpApiLayer = HttpApiBuilder.group(
375381 const session = yield* requireEnvironmentScope(AuthAccessReadScope);
376382 return yield* serverAuth.listClientSessions(session.sessionId);
377383 },
378 Effect.catchTag("ServerAuthInternalError", (error) =>
384 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
379385 failEnvironmentInternal("client_sessions_load_failed", error),
380386 ),
381387 ),
......@@ -392,12 +398,12 @@ export const authHttpApiLayer = HttpApiBuilder.group(
392398 );
393399 return { revoked };
394400 },
395 Effect.catchTags({
396 ServerAuthForbiddenOperationError: (error) =>
397 failEnvironmentOperationForbidden(error.reason),
398 ServerAuthInternalError: (error) =>
399 failEnvironmentInternal("client_session_revoke_failed", error),
400 }),
401 Effect.catchTag("ServerAuthForbiddenOperationError", () =>
402 failEnvironmentOperationForbidden("current_session_revoke_not_allowed"),
403 ),
404 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
405 failEnvironmentInternal("client_session_revoke_failed", error),
406 ),
401407 ),
402408 )
403409 .handle(
......@@ -409,7 +415,7 @@ export const authHttpApiLayer = HttpApiBuilder.group(
409415 const revokedCount = yield* serverAuth.revokeOtherClientSessions(session.sessionId);
410416 return { revokedCount };
411417 },
412 Effect.catchTag("ServerAuthInternalError", (error) =>
418 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
413419 failEnvironmentInternal("client_session_revoke_failed", error),
414420 ),
415421 ),
apps/server/src/cloud/environmentKeys.test.ts+4-4
......@@ -6,7 +6,7 @@ import * as Option from "effect/Option";
66import * as PlatformError from "effect/PlatformError";
77
88import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
9import { ServerConfig } from "../config.ts";
9import * as ServerConfig from "../config.ts";
1010import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts";
1111
1212const makeServerSecretStoreLayer = () =>
......@@ -65,8 +65,8 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it
6565 }).pipe(
6666 Effect.flatMap(() =>
6767 Effect.fail(
68 new ServerSecretStore.SecretStoreError({
69 message: "Concurrent keypair creation won.",
68 new ServerSecretStore.SecretStorePersistError({
69 resource: "environment signing key pair",
7070 cause: PlatformError.systemError({
7171 _tag: "AlreadyExists",
7272 module: "FileSystem",
......@@ -79,7 +79,7 @@ it.layer(NodeServices.layer)("getOrCreateEnvironmentKeyPairFromSecretStore", (it
7979 ),
8080 getOrCreateRandom: unusedSecretStoreOperation,
8181 remove: unusedSecretStoreOperation,
82 } satisfies ServerSecretStore.ServerSecretStoreShape;
82 } satisfies ServerSecretStore.ServerSecretStore["Service"];
8383
8484 assert.deepEqual(yield* getOrCreateEnvironmentKeyPairFromSecretStore(secretStore), {
8585 privateKey: "winner-private",
apps/server/src/cloud/environmentKeys.ts+17-18
......@@ -27,47 +27,46 @@ function stringToBytes(value: string): Uint8Array {
2727 return new TextEncoder().encode(value);
2828}
2929
30const keyPairPersistenceError = (message: string, cause?: unknown) =>
31 new ServerSecretStore.SecretStoreError({ message, cause });
30const KEY_PAIR_RESOURCE = "environment signing key pair";
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
3341const readEnvironmentKeyPair = Effect.fn("readEnvironmentKeyPair")(function* (
34 secrets: ServerSecretStore.ServerSecretStoreShape,
42 secrets: ServerSecretStore.ServerSecretStore["Service"],
3543) {
3644 const encoded = yield* secrets.get(CLOUD_LINK_KEY_PAIR);
3745 if (Option.isNone(encoded)) {
3846 return Option.none<EnvironmentKeyPair>();
3947 }
4048 const decoded = yield* decodeEnvironmentKeyPair(bytesToString(encoded.value)).pipe(
41 Effect.mapError((cause) =>
42 keyPairPersistenceError("Failed to decode environment signing key pair.", cause),
43 ),
49 Effect.mapError(keyPairDecodeError),
4450 );
4551 return Option.some(decoded);
4652});
4753
4854const persistEnvironmentKeyPair = Effect.fn("persistEnvironmentKeyPair")(function* (
49 secrets: ServerSecretStore.ServerSecretStoreShape,
55 secrets: ServerSecretStore.ServerSecretStore["Service"],
5056 keyPair: EnvironmentKeyPair,
5157) {
5258 const encoded = yield* encodeEnvironmentKeyPair(keyPair).pipe(
53 Effect.mapError((cause) =>
54 keyPairPersistenceError("Failed to encode environment signing key pair.", cause),
55 ),
59 Effect.mapError(keyPairEncodeError),
5660 );
5761 return yield* secrets.create(CLOUD_LINK_KEY_PAIR, stringToBytes(encoded)).pipe(
5862 Effect.as(keyPair),
59 Effect.catchTag("SecretStoreError", (error) =>
63 Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) =>
6064 ServerSecretStore.isSecretAlreadyExistsError(error)
6165 ? readEnvironmentKeyPair(secrets).pipe(
6266 Effect.flatMap(
6367 Option.match({
6468 onSome: Effect.succeed,
65 onNone: () =>
66 Effect.fail(
67 keyPairPersistenceError(
68 "Failed to read environment signing key pair after concurrent creation.",
69 ),
70 ),
69 onNone: () => Effect.fail(keyPairConcurrentReadError()),
7170 }),
7271 ),
7372 )
......@@ -77,7 +76,7 @@ const persistEnvironmentKeyPair = Effect.fn("persistEnvironmentKeyPair")(functio
7776});
7877
7978export const getOrCreateEnvironmentKeyPairFromSecretStore = Effect.fn(function* (
80 secrets: ServerSecretStore.ServerSecretStoreShape,
79 secrets: ServerSecretStore.ServerSecretStore["Service"],
8180) {
8281 const existing = yield* readEnvironmentKeyPair(secrets);
8382 if (Option.isSome(existing)) {
apps/server/src/cloud/http.test.ts+26-2
......@@ -16,8 +16,8 @@ import * as ManagedEndpointRuntime from "./ManagedEndpointRuntime.ts";
1616import { traceAuthenticatedRelayRequest, traceRelayRequest } from "./traceRelayRequest.ts";
1717
1818const storeFailure = (tag: "AlreadyExists" | "PermissionDenied") =>
19 new ServerSecretStore.SecretStoreError({
20 message: "Failed to persist cloud replay guard.",
19 new ServerSecretStore.SecretStorePersistError({
20 resource: "cloud replay guard",
2121 cause: PlatformError.systemError({
2222 _tag: tag,
2323 module: "FileSystem",
......@@ -40,6 +40,30 @@ function makeSecretStore(
4040 };
4141}
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
4367describe("consumeCloudReplayGuards", () => {
4468 it.effect("reports already-created guards as replay conflicts", () =>
4569 Effect.gen(function* () {
apps/server/src/cloud/http.ts+113-119
......@@ -68,7 +68,7 @@ import {
6868 RELAY_URL_SECRET,
6969} from "./config.ts";
7070import { relayUrlConfig } from "./publicConfig.ts";
71import * as CliState from "./CliState.ts";
71import { setCliDesiredCloudLink } from "./CliState.ts";
7272import * as CliTokenManager from "./CliTokenManager.ts";
7373import { getOrCreateEnvironmentKeyPairFromSecretStore } from "./environmentKeys.ts";
7474import { traceRelayRequest } from "./traceRelayRequest.ts";
......@@ -98,7 +98,7 @@ const failEnvironmentCloudInternalError =
9898 );
9999
100100const failCloudCliTokenManagerError = (error: CliTokenManager.CloudCliTokenManagerError) =>
101 failEnvironmentCloudInternalError(error.message)(error.cause);
101 failEnvironmentCloudInternalError(error.message)(error);
102102
103103const requireRelayUrl = relayUrlConfig.pipe(
104104 Effect.mapError(
......@@ -126,7 +126,7 @@ export function consumeCloudReplayGuards(input: {
126126 input.names.map((name) =>
127127 input.secrets.create(name, input.value).pipe(
128128 Effect.as(true),
129 Effect.catchTag("SecretStoreError", (error) =>
129 Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) =>
130130 ServerSecretStore.isSecretAlreadyExistsError(error)
131131 ? Effect.succeed(false)
132132 : Effect.fail(error),
......@@ -211,8 +211,7 @@ function validateLinkedCloudUser(input: {
211211 return input.secrets.get(CLOUD_LINKED_USER_ID).pipe(
212212 Effect.mapError(
213213 (cause) =>
214 new EnvironmentAuth.ServerAuthInternalError({
215 message: "Could not verify the linked cloud account.",
214 new EnvironmentAuth.ServerAuthLinkedCloudAccountVerificationError({
216215 cause,
217216 }),
218217 ),
......@@ -239,19 +238,14 @@ function readInstalledCloudUserId(
239238 return secrets.get(CLOUD_LINKED_USER_ID).pipe(
240239 Effect.mapError(
241240 (cause) =>
242 new EnvironmentAuth.ServerAuthInternalError({
243 message: "Could not read the linked cloud account.",
241 new EnvironmentAuth.ServerAuthLinkedCloudAccountReadError({
244242 cause,
245243 }),
246244 ),
247245 Effect.flatMap((bytes) =>
248246 Option.isSome(bytes)
249247 ? Effect.succeed(bytesToString(bytes.value))
250 : Effect.fail(
251 new EnvironmentAuth.ServerAuthInternalError({
252 message: "Cloud linked user is not installed for this environment.",
253 }),
254 ),
248 : Effect.fail(new EnvironmentAuth.ServerAuthLinkedCloudAccountMissingError({})),
255249 ),
256250 );
257251}
......@@ -394,8 +388,7 @@ const makeCloudLinkProof = Effect.fn("environment.cloud.makeLinkProof")(function
394388 }).pipe(
395389 Effect.mapError(
396390 (cause) =>
397 new EnvironmentAuth.ServerAuthInternalError({
398 message: "Failed to sign cloud link JWT.",
391 new EnvironmentAuth.ServerAuthCloudLinkJwtSigningError({
399392 cause,
400393 }),
401394 ),
......@@ -416,15 +409,17 @@ const cloudLinkProofHandler = Effect.fn("environment.cloud.linkProof")(
416409 yield* appendCloudCredentialResponseHeaders;
417410 return proof satisfies RelayEnvironmentLinkProof;
418411 },
419 Effect.catchTag("ServerAuthInternalError", (error) =>
420 failEnvironmentCloudInternalError(error.message)(error.cause),
412 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
413 failEnvironmentCloudInternalError(error.message)(error),
414 ),
415 Effect.catchIf(
416 ServerSecretStore.isSecretStoreError,
417 failEnvironmentCloudInternalError("Could not generate environment link proof."),
418 ),
419 Effect.catchTag(
420 "PlatformError",
421 failEnvironmentCloudInternalError("Could not generate environment link proof."),
421422 ),
422 Effect.catchTags({
423 PlatformError: failEnvironmentCloudInternalError("Could not generate environment link proof."),
424 SecretStoreError: failEnvironmentCloudInternalError(
425 "Could not generate environment link proof.",
426 ),
427 }),
428423);
429424
430425const applyCloudRelayConfig = Effect.fn("environment.cloud.applyRelayConfig")(function* (
......@@ -477,17 +472,17 @@ const cloudRelayConfigHandler = Effect.fn("environment.cloud.relayConfig")(
477472 yield* requireEnvironmentScope(AuthRelayWriteScope);
478473 return yield* applyCloudRelayConfig(dependencies, payload);
479474 },
480 Effect.catchTag("ServerAuthInternalError", (error) =>
481 failEnvironmentCloudInternalError(error.message)(error.cause),
475 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
476 failEnvironmentCloudInternalError(error.message)(error),
477 ),
478 Effect.catchIf(
479 ServerSecretStore.isSecretStoreError,
480 failEnvironmentCloudInternalError("Could not persist environment relay configuration."),
481 ),
482 Effect.catchTag(
483 "SchemaError",
484 failEnvironmentCloudInternalError("Could not persist environment relay configuration."),
482485 ),
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 }),
491486);
492487
493488const relayClientRequest = <A>(
......@@ -581,7 +576,7 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi
581576 },
582577 schema: RelayEnvironmentLinkResponse,
583578 });
584 yield* CliState.setCliDesiredCloudLink(true);
579 yield* setCliDesiredCloudLink(true);
585580 return yield* applyCloudRelayConfig(dependencies, {
586581 relayUrl,
587582 relayIssuer: link.relayIssuer,
......@@ -591,15 +586,16 @@ const reconcileDesiredCloudLinkWith = Effect.fn("environment.cloud.reconcileDesi
591586 endpointRuntime: link.endpointRuntime,
592587 });
593588 },
589 Effect.catchIf(
590 ServerSecretStore.isSecretStoreError,
591 failEnvironmentCloudInternalError("Could not persist desired T3 Connect link state."),
592 ),
594593 Effect.catchTags({
595594 CloudCliCredentialRemovalError: failCloudCliTokenManagerError,
596595 CloudCliCredentialRefreshError: failCloudCliTokenManagerError,
597596 CloudCliCredentialReadError: failCloudCliTokenManagerError,
598597 CloudCliAuthorizationError: failCloudCliTokenManagerError,
599598 CloudCliAuthorizationTimeoutError: failCloudCliTokenManagerError,
600 SecretStoreError: failEnvironmentCloudInternalError(
601 "Could not persist desired T3 Connect link state.",
602 ),
603599 }),
604600);
605601
......@@ -637,8 +633,8 @@ const cloudLinkStateHandler = Effect.fn("environment.cloud.linkState")(
637633 yield* requireEnvironmentScope(AuthRelayReadScope);
638634 return yield* readCloudLinkState(dependencies);
639635 },
640 Effect.catchTag(
641 "SecretStoreError",
636 Effect.catchIf(
637 ServerSecretStore.isSecretStoreError,
642638 failEnvironmentCloudInternalError("Could not read environment relay configuration."),
643639 ),
644640);
......@@ -659,11 +655,11 @@ const cloudUnlinkHandler = Effect.fn("environment.cloud.unlink")(
659655 ],
660656 { concurrency: 7 },
661657 );
662 yield* CliState.setCliDesiredCloudLink(false);
658 yield* setCliDesiredCloudLink(false);
663659 return { ok: true, endpointRuntimeStatus } satisfies EnvironmentCloudRelayConfigResult;
664660 },
665 Effect.catchTag(
666 "SecretStoreError",
661 Effect.catchIf(
662 ServerSecretStore.isSecretStoreError,
667663 failEnvironmentCloudInternalError("Could not remove environment relay configuration."),
668664 ),
669665);
......@@ -680,42 +676,40 @@ const cloudPreferencesHandler = Effect.fn("environment.cloud.preferences")(
680676 );
681677 return yield* readCloudLinkState(dependencies);
682678 },
683 Effect.catchTag(
684 "SecretStoreError",
679 Effect.catchIf(
680 ServerSecretStore.isSecretStoreError,
685681 failEnvironmentCloudInternalError("Could not persist environment cloud preferences."),
686682 ),
687683);
688684
689685const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")(
690686 function* (dependencies: CloudHttpDependencies, request: RelayCloudEnvironmentHealthRequest) {
691 const cloudMintPublicKey = yield* dependencies.secrets.get(CLOUD_MINT_PUBLIC_KEY).pipe(
692 Effect.flatMap((bytes) =>
693 Option.isSome(bytes)
694 ? Effect.succeed(bytesToString(bytes.value))
695 : Effect.fail(
696 new EnvironmentAuth.ServerAuthInternalError({
697 message: "Cloud mint public key is not installed for this environment.",
698 }),
699 ),
700 ),
701 );
702 const relayIssuer = yield* dependencies.secrets.get(RELAY_ISSUER_SECRET).pipe(
703 Effect.flatMap((bytes) =>
704 Option.isSome(bytes)
705 ? Effect.succeed(bytesToString(bytes.value))
706 : dependencies.secrets.get(RELAY_URL_SECRET).pipe(
707 Effect.flatMap((fallbackBytes) =>
708 Option.isSome(fallbackBytes)
709 ? Effect.succeed(bytesToString(fallbackBytes.value))
710 : Effect.fail(
711 new EnvironmentAuth.ServerAuthInternalError({
712 message: "Cloud relay issuer is not installed for this environment.",
713 }),
714 ),
715 ),
716 ),
717 ),
718 );
687 const cloudMintPublicKey = yield* dependencies.secrets
688 .get(CLOUD_MINT_PUBLIC_KEY)
689 .pipe(
690 Effect.flatMap((bytes) =>
691 Option.isSome(bytes)
692 ? Effect.succeed(bytesToString(bytes.value))
693 : Effect.fail(new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({})),
694 ),
695 );
696 const relayIssuer = yield* dependencies.secrets
697 .get(RELAY_ISSUER_SECRET)
698 .pipe(
699 Effect.flatMap((bytes) =>
700 Option.isSome(bytes)
701 ? Effect.succeed(bytesToString(bytes.value))
702 : dependencies.secrets
703 .get(RELAY_URL_SECRET)
704 .pipe(
705 Effect.flatMap((fallbackBytes) =>
706 Option.isSome(fallbackBytes)
707 ? Effect.succeed(bytesToString(fallbackBytes.value))
708 : Effect.fail(new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({})),
709 ),
710 ),
711 ),
712 );
719713 const environmentId = yield* dependencies.environment.getEnvironmentId;
720714 const linkedCloudUserId = yield* readInstalledCloudUserId(dependencies.secrets);
721715 const now = yield* DateTime.now;
......@@ -777,8 +771,7 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")(
777771 }).pipe(
778772 Effect.mapError(
779773 (cause) =>
780 new EnvironmentAuth.ServerAuthInternalError({
781 message: "Failed to sign cloud health JWT.",
774 new EnvironmentAuth.ServerAuthCloudHealthJwtSigningError({
782775 cause,
783776 }),
784777 ),
......@@ -794,45 +787,47 @@ const cloudEnvironmentHealthHandler = Effect.fn("environment.cloud.health")(
794787 yield* appendCloudCredentialResponseHeaders;
795788 return response;
796789 },
797 Effect.catchTag("ServerAuthInternalError", (error) =>
798 failEnvironmentCloudInternalError(error.message)(error.cause),
790 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
791 failEnvironmentCloudInternalError(error.message)(error),
792 ),
793 Effect.catchIf(
794 ServerSecretStore.isSecretStoreError,
795 failEnvironmentCloudInternalError("Could not answer cloud health request."),
796 ),
797 Effect.catchTag(
798 "PlatformError",
799 failEnvironmentCloudInternalError("Could not answer cloud health request."),
799800 ),
800 Effect.catchTags({
801 PlatformError: failEnvironmentCloudInternalError("Could not answer cloud health request."),
802 SecretStoreError: failEnvironmentCloudInternalError("Could not answer cloud health request."),
803 }),
804801);
805802
806803const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential")(
807804 function* (dependencies: CloudHttpDependencies, request: RelayCloudMintCredentialRequest) {
808 const cloudMintPublicKey = yield* dependencies.secrets.get(CLOUD_MINT_PUBLIC_KEY).pipe(
809 Effect.flatMap((bytes) =>
810 Option.isSome(bytes)
811 ? Effect.succeed(bytesToString(bytes.value))
812 : Effect.fail(
813 new EnvironmentAuth.ServerAuthInternalError({
814 message: "Cloud mint public key is not installed for this environment.",
815 }),
816 ),
817 ),
818 );
819 const relayIssuer = yield* dependencies.secrets.get(RELAY_ISSUER_SECRET).pipe(
820 Effect.flatMap((bytes) =>
821 Option.isSome(bytes)
822 ? Effect.succeed(bytesToString(bytes.value))
823 : dependencies.secrets.get(RELAY_URL_SECRET).pipe(
824 Effect.flatMap((fallbackBytes) =>
825 Option.isSome(fallbackBytes)
826 ? Effect.succeed(bytesToString(fallbackBytes.value))
827 : Effect.fail(
828 new EnvironmentAuth.ServerAuthInternalError({
829 message: "Cloud relay issuer is not installed for this environment.",
830 }),
831 ),
832 ),
833 ),
834 ),
835 );
805 const cloudMintPublicKey = yield* dependencies.secrets
806 .get(CLOUD_MINT_PUBLIC_KEY)
807 .pipe(
808 Effect.flatMap((bytes) =>
809 Option.isSome(bytes)
810 ? Effect.succeed(bytesToString(bytes.value))
811 : Effect.fail(new EnvironmentAuth.ServerAuthCloudMintPublicKeyMissingError({})),
812 ),
813 );
814 const relayIssuer = yield* dependencies.secrets
815 .get(RELAY_ISSUER_SECRET)
816 .pipe(
817 Effect.flatMap((bytes) =>
818 Option.isSome(bytes)
819 ? Effect.succeed(bytesToString(bytes.value))
820 : dependencies.secrets
821 .get(RELAY_URL_SECRET)
822 .pipe(
823 Effect.flatMap((fallbackBytes) =>
824 Option.isSome(fallbackBytes)
825 ? Effect.succeed(bytesToString(fallbackBytes.value))
826 : Effect.fail(new EnvironmentAuth.ServerAuthCloudRelayIssuerMissingError({})),
827 ),
828 ),
829 ),
830 );
836831 const environmentId = yield* dependencies.environment.getEnvironmentId;
837832 const linkedCloudUserId = yield* readInstalledCloudUserId(dependencies.secrets);
838833 const now = yield* DateTime.now;
......@@ -899,8 +894,7 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential")
899894 }).pipe(
900895 Effect.mapError(
901896 (cause) =>
902 new EnvironmentAuth.ServerAuthInternalError({
903 message: "Failed to sign cloud mint JWT.",
897 new EnvironmentAuth.ServerAuthCloudMintJwtSigningError({
904898 cause,
905899 }),
906900 ),
......@@ -914,17 +908,17 @@ const cloudMintCredentialHandler = Effect.fn("environment.cloud.mintCredential")
914908 yield* appendCloudCredentialResponseHeaders;
915909 return response;
916910 },
917 Effect.catchTag("ServerAuthInternalError", (error) =>
918 failEnvironmentCloudInternalError(error.message)(error.cause),
911 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
912 failEnvironmentCloudInternalError(error.message)(error),
913 ),
914 Effect.catchIf(
915 ServerSecretStore.isSecretStoreError,
916 failEnvironmentCloudInternalError("Could not issue cloud connection credential."),
917 ),
918 Effect.catchTag(
919 "PlatformError",
920 failEnvironmentCloudInternalError("Could not issue cloud connection credential."),
919921 ),
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 }),
928922);
929923
930924export const connectHttpApiLayer = HttpApiBuilder.group(
apps/server/src/http.ts+6-4
......@@ -81,10 +81,12 @@ const authenticateRawRouteWithScope = (
8181 const request = yield* HttpServerRequest.HttpServerRequest;
8282 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
8383 const session = yield* serverAuth.authenticateHttpRequest(request).pipe(
84 Effect.catchTags({
85 ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason),
86 ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error),
87 }),
84 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
85 failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),
86 ),
87 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
88 failEnvironmentInternal("internal_error", error),
89 ),
8890 );
8991 if (!session.scopes.includes(scope)) {
9092 return yield* failEnvironmentScopeRequired(scope);
apps/server/src/relay/AgentAwarenessRelay.test.ts+6-6
......@@ -67,15 +67,15 @@ function makeMemorySecretStore() {
6767 Effect.sync(() => {
6868 const value = values.get(name);
6969 return value === undefined ? Option.none() : Option.some(Uint8Array.from(value));
70 })) satisfies ServerSecretStore.ServerSecretStoreShape["get"],
70 })) satisfies ServerSecretStore.ServerSecretStore["Service"]["get"],
7171 set: ((name, value) =>
7272 Effect.sync(() => {
7373 values.set(name, Uint8Array.from(value));
74 })) satisfies ServerSecretStore.ServerSecretStoreShape["set"],
74 })) satisfies ServerSecretStore.ServerSecretStore["Service"]["set"],
7575 create: ((name, value) =>
7676 Effect.sync(() => {
7777 values.set(name, Uint8Array.from(value));
78 })) satisfies ServerSecretStore.ServerSecretStoreShape["create"],
78 })) satisfies ServerSecretStore.ServerSecretStore["Service"]["create"],
7979 getOrCreateRandom: ((name, bytes) =>
8080 Effect.sync(() => {
8181 const existing = values.get(name);
......@@ -85,12 +85,12 @@ function makeMemorySecretStore() {
8585 const generated = new Uint8Array(bytes);
8686 values.set(name, generated);
8787 return generated;
88 })) satisfies ServerSecretStore.ServerSecretStoreShape["getOrCreateRandom"],
88 })) satisfies ServerSecretStore.ServerSecretStore["Service"]["getOrCreateRandom"],
8989 remove: ((name) =>
9090 Effect.sync(() => {
9191 values.delete(name);
92 })) satisfies ServerSecretStore.ServerSecretStoreShape["remove"],
93 } satisfies ServerSecretStore.ServerSecretStoreShape;
92 })) satisfies ServerSecretStore.ServerSecretStore["Service"]["remove"],
93 } satisfies ServerSecretStore.ServerSecretStore["Service"];
9494 return {
9595 store,
9696 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(
17261726 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
17271727 const sessions = yield* SessionStore.SessionStore;
17281728 const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe(
1729 Effect.catchTags({
1730 ServerAuthInvalidCredentialError: (error) => failEnvironmentAuthInvalid(error.reason),
1731 ServerAuthInternalError: (error) => failEnvironmentInternal("internal_error", error),
1732 }),
1729 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
1730 failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)),
1731 ),
1732 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
1733 failEnvironmentInternal("internal_error", error),
1734 ),
17331735 );
17341736 const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, {
17351737 disableTracing: true,