History

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

Julius Marminge committed 4 months agov0.0.28-nightly.20260620.608

Commit 97e5cd31 parent b19fc1b22 files · +1434 −764Browse files

Unified · Split

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