Skip to content
Source
Sign inSign up

SessionStore.ts

1,120 lines36.0 KB

1import {
2 AuthSessionId,
3 AuthStandardClientScopes,
4 AuthEnvironmentScopes,
5 type AuthClientMetadata,
6 type AuthClientSession,
7 type AuthEnvironmentScope,
8 AuthWebSocketTicketUse,
9 RuntimeMode,
10 type ServerAuthSessionMethod,
11} from "@t3tools/contracts";
12import * as Context from "effect/Context";
13import * as SecureRandom from "effect/crypto/SecureRandom";
14import * as DateTime from "effect/DateTime";
15import * as Duration from "effect/Duration";
16import * as Effect from "effect/Effect";
17import * as Layer from "effect/Layer";
18import * as PubSub from "effect/PubSub";
19import * as Ref from "effect/Ref";
20import * as Schema from "effect/Schema";
21import * as Stream from "effect/Stream";
22import * as Option from "effect/Option";
23​
24import * as ServerConfig from "../config.ts";
25import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
26import * as AuthSessions from "../persistence/AuthSessions.ts";
27import * as PayloadSigner from "./PayloadSigner.ts";
28import * as ServerSecretStore from "./ServerSecretStore.ts";
29import {
30 REUSABLE_DEV_SESSION_EXPIRES_AT,
31 REUSABLE_DEV_SESSION_PREFIX,
32 resolveReusableDevAuth,
33} from "./ReusableDevAuth.ts";
34import {
35 base64UrlDecodeUtf8,
36 base64UrlEncode,
37 resolveLegacySessionCookieName,
38 resolveSessionCookieName,
39} from "./utils.ts";
40​
41export interface IssuedSession {
42 readonly sessionId: AuthSessionId;
43 readonly token: string;
44 readonly method: ServerAuthSessionMethod;
45 readonly client: AuthClientMetadata;
46 readonly expiresAt: DateTime.DateTime;
47 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
48 readonly proofKeyThumbprint?: string;
50​
51export interface VerifiedSession {
52 readonly sessionId: AuthSessionId;
53 readonly token: string;
54 readonly method: ServerAuthSessionMethod;
55 readonly client: AuthClientMetadata;
56 readonly expiresAt?: DateTime.DateTime;
57 readonly subject: string;
58 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
59 readonly proofKeyThumbprint?: string;
60 readonly runtimeModeCeiling?: RuntimeMode;
61 /** An MCP client that is another T3 Code environment's peer link. */
62 readonly peerLink?: true;
64​
65export interface VerifiedWebSocketTicket extends VerifiedSession {
66 readonly audience?: AuthWebSocketTicketUse;
68​
69export type SessionCredentialChange =
70 | {
71 readonly type: "clientUpserted";
72 readonly clientSession: AuthClientSession;
73 }
74 | {
75 readonly type: "clientRemoved";
76 readonly sessionId: AuthSessionId;
77 };
78​
79export class MalformedSessionTokenError extends Schema.TaggedError<MalformedSessionTokenError>()(
80 "MalformedSessionTokenError",
81 {},
82) {
83 override get message(): string {
84 return "Malformed session token.";
85 }
87​
88export class InvalidSessionTokenSignatureError extends Schema.TaggedError<InvalidSessionTokenSignatureError>()(
89 "InvalidSessionTokenSignatureError",
90 {},
91) {
92 override get message(): string {
93 return "Invalid session token signature.";
94 }
96​
97export class InvalidSessionTokenPayloadError extends Schema.TaggedError<InvalidSessionTokenPayloadError>()(
98 "InvalidSessionTokenPayloadError",
99 {
100 cause: Schema.Defect(),
101 },
102) {
103 override get message(): string {
104 return "Invalid session token payload.";
105 }
107​
108export class SessionTokenExpiredError extends Schema.TaggedError<SessionTokenExpiredError>()(
109 "SessionTokenExpiredError",
110 {
111 sessionId: AuthSessionId,
112 expiresAt: Schema.DateTimeUtc,
113 observedAt: Schema.DateTimeUtc,
114 },
115) {
116 override get message(): string {
117 return "Session token expired.";
118 }
120​
121export class UnknownSessionTokenError extends Schema.TaggedError<UnknownSessionTokenError>()(
122 "UnknownSessionTokenError",
123 {
124 sessionId: AuthSessionId,
125 },
126) {
127 override get message(): string {
128 return "Unknown session token.";
129 }
131​
132export class SessionTokenRevokedError extends Schema.TaggedError<SessionTokenRevokedError>()(
133 "SessionTokenRevokedError",
134 {
135 sessionId: AuthSessionId,
136 revokedAt: Schema.DateTimeUtc,
137 },
138) {
139 override get message(): string {
140 return "Session token revoked.";
141 }
143​
144export class InvalidSessionExpirationClaimError extends Schema.TaggedError<InvalidSessionExpirationClaimError>()(
145 "InvalidSessionExpirationClaimError",
146 {
147 sessionId: AuthSessionId,
148 expirationClaim: Schema.Number,
149 },
150) {
151 override get message(): string {
152 return "Invalid `exp` claim";
153 }
155​
156export class MalformedWebSocketTokenError extends Schema.TaggedError<MalformedWebSocketTokenError>()(
157 "MalformedWebSocketTokenError",
158 {},
159) {
160 override get message(): string {
161 return "Malformed websocket token.";
162 }
164​
165export class InvalidWebSocketTokenSignatureError extends Schema.TaggedError<InvalidWebSocketTokenSignatureError>()(
166 "InvalidWebSocketTokenSignatureError",
167 {},
168) {
169 override get message(): string {
170 return "Invalid websocket token signature.";
171 }
173​
174export class InvalidWebSocketTokenPayloadError extends Schema.TaggedError<InvalidWebSocketTokenPayloadError>()(
175 "InvalidWebSocketTokenPayloadError",
176 {
177 cause: Schema.Defect(),
178 },
179) {
180 override get message(): string {
181 return "Invalid websocket token payload.";
182 }
184​
185export class WebSocketTokenExpiredError extends Schema.TaggedError<WebSocketTokenExpiredError>()(
186 "WebSocketTokenExpiredError",
187 {
188 sessionId: AuthSessionId,
189 expiresAt: Schema.DateTimeUtc,
190 observedAt: Schema.DateTimeUtc,
191 },
192) {
193 override get message(): string {
194 return "Websocket token expired.";
195 }
197​
198export class UnknownWebSocketSessionError extends Schema.TaggedError<UnknownWebSocketSessionError>()(
199 "UnknownWebSocketSessionError",
200 {
201 sessionId: AuthSessionId,
202 },
203) {
204 override get message(): string {
205 return "Unknown websocket session.";
206 }
208​
209export class WebSocketSessionExpiredError extends Schema.TaggedError<WebSocketSessionExpiredError>()(
210 "WebSocketSessionExpiredError",
211 {
212 sessionId: AuthSessionId,
213 expiresAt: Schema.DateTimeUtc,
214 observedAt: Schema.DateTimeUtc,
215 },
216) {
217 override get message(): string {
218 return "Websocket session expired.";
219 }
221​
222export class WebSocketSessionRevokedError extends Schema.TaggedError<WebSocketSessionRevokedError>()(
223 "WebSocketSessionRevokedError",
224 {
225 sessionId: AuthSessionId,
226 revokedAt: Schema.DateTimeUtc,
227 },
228) {
229 override get message(): string {
230 return "Websocket session revoked.";
231 }
233​
234export const SessionCredentialInvalidError = Schema.Union([
235 MalformedSessionTokenError,
236 InvalidSessionTokenSignatureError,
237 InvalidSessionTokenPayloadError,
238 SessionTokenExpiredError,
239 UnknownSessionTokenError,
240 SessionTokenRevokedError,
241 InvalidSessionExpirationClaimError,
242 MalformedWebSocketTokenError,
243 InvalidWebSocketTokenSignatureError,
244 InvalidWebSocketTokenPayloadError,
245 WebSocketTokenExpiredError,
246 UnknownWebSocketSessionError,
247 WebSocketSessionExpiredError,
248 WebSocketSessionRevokedError,
249]);
250export type SessionCredentialInvalidError = typeof SessionCredentialInvalidError.Type;
251export const isSessionCredentialInvalidError = Schema.is(SessionCredentialInvalidError);
252​
253const sessionCredentialInternalErrorContext = {
254 cause: Schema.Defect(),
255};
256​
257export class SessionClaimsEncodingError extends Schema.TaggedError<SessionClaimsEncodingError>()(
258 "SessionClaimsEncodingError",
259 {
260 sessionId: AuthSessionId,
261 operation: Schema.Literals(["encode_session_claims", "encode_websocket_claims"]),
262 ...sessionCredentialInternalErrorContext,
263 },
264) {
265 override get message(): string {
266 return "Failed to encode claims";
267 }
269​
270export class SessionCredentialIssueError extends Schema.TaggedError<SessionCredentialIssueError>()(
271 "SessionCredentialIssueError",
272 {
273 sessionId: Schema.optional(AuthSessionId),
274 ...sessionCredentialInternalErrorContext,
275 },
276) {
277 override get message(): string {
278 return "Failed to issue session credential.";
279 }
281​
282export class SessionCredentialVerificationError extends Schema.TaggedError<SessionCredentialVerificationError>()(
283 "SessionCredentialVerificationError",
284 {
285 sessionId: AuthSessionId,
286 ...sessionCredentialInternalErrorContext,
287 },
288) {
289 override get message(): string {
290 return "Failed to verify session credential.";
291 }
293​
294export class WebSocketTokenIssueError extends Schema.TaggedError<WebSocketTokenIssueError>()(
295 "WebSocketTokenIssueError",
296 {
297 sessionId: AuthSessionId,
298 ...sessionCredentialInternalErrorContext,
299 },
300) {
301 override get message(): string {
302 return "Failed to issue websocket token.";
303 }
305​
306export class WebSocketTokenVerificationError extends Schema.TaggedError<WebSocketTokenVerificationError>()(
307 "WebSocketTokenVerificationError",
308 {
309 sessionId: AuthSessionId,
310 ...sessionCredentialInternalErrorContext,
311 },
312) {
313 override get message(): string {
314 return "Failed to verify websocket token.";
315 }
317​
318export class ActiveSessionsListError extends Schema.TaggedError<ActiveSessionsListError>()(
319 "ActiveSessionsListError",
320 {
321 ...sessionCredentialInternalErrorContext,
322 },
323) {
324 override get message(): string {
325 return "Failed to list active sessions.";
326 }
328​
329export class SessionRevocationError extends Schema.TaggedError<SessionRevocationError>()(
330 "SessionRevocationError",
331 {
332 sessionId: AuthSessionId,
333 ...sessionCredentialInternalErrorContext,
334 },
335) {
336 override get message(): string {
337 return "Failed to revoke session.";
338 }
340​
341export class OtherSessionsRevocationError extends Schema.TaggedError<OtherSessionsRevocationError>()(
342 "OtherSessionsRevocationError",
343 {
344 currentSessionId: AuthSessionId,
345 ...sessionCredentialInternalErrorContext,
346 },
347) {
348 override get message(): string {
349 return "Failed to revoke other sessions.";
350 }
352​
353export const SessionCredentialInternalError = Schema.Union([
354 SessionClaimsEncodingError,
355 SessionCredentialIssueError,
356 SessionCredentialVerificationError,
357 WebSocketTokenIssueError,
358 WebSocketTokenVerificationError,
359 ActiveSessionsListError,
360 SessionRevocationError,
361 OtherSessionsRevocationError,
362]);
363export type SessionCredentialInternalError = typeof SessionCredentialInternalError.Type;
364​
365export const SessionCredentialError = Schema.Union([
366 SessionCredentialInvalidError,
367 SessionCredentialInternalError,
368]);
369export type SessionCredentialError = typeof SessionCredentialError.Type;
370​
371export class SessionStore extends Context.Service<
372 SessionStore,
373 {
374 readonly cookieName: string;
375 readonly legacyCookieName: string | undefined;
376 readonly issue: (input?: {
377 readonly ttl?: Duration.Duration;
378 readonly subject?: string;
379 readonly method?: ServerAuthSessionMethod;
380 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
381 readonly client?: AuthClientMetadata;
382 readonly proofKeyThumbprint?: string;
383 readonly runtimeModeCeiling?: RuntimeMode;
384 readonly peerLink?: boolean;
385 readonly replaceActiveForSubjectAndMethod?: boolean;
386 readonly replaceSessionId?: AuthSessionId;
387 }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>;
388 readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>;
389 readonly issueWebSocketToken: (
390 sessionId: AuthSessionId,
391 input?: {
392 readonly ttl?: Duration.Duration;
393 readonly audience?: AuthWebSocketTicketUse;
394 readonly proofKeyThumbprint?: string;
395 },
396 ) => Effect.Effect<
397 {
398 readonly token: string;
399 readonly expiresAt: DateTime.DateTime;
400 },
401 SessionCredentialInternalError
402 >;
403 readonly verifyWebSocketToken: (
404 token: string,
405 ) => Effect.Effect<VerifiedWebSocketTicket, SessionCredentialError>;
406 readonly listActive: () => Effect.Effect<
407 ReadonlyArray<AuthClientSession>,
408 SessionCredentialInternalError
409 >;
410 readonly streamChanges: Stream.Stream<SessionCredentialChange>;
411 /** Completes when a live socket's session is revoked or expires without a renewal for its DPoP key. */
412 readonly awaitInvalidation: (
413 sessionId: AuthSessionId,
414 proofKeyThumbprint?: string,
415 ) => Effect.Effect<void, SessionCredentialVerificationError>;
416 readonly revoke: (
417 sessionId: AuthSessionId,
418 ) => Effect.Effect<boolean, SessionCredentialInternalError>;
419 readonly revokeAllExcept: (
420 sessionId: AuthSessionId,
421 ) => Effect.Effect<number, SessionCredentialInternalError>;
422 readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
423 readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
424 }
425>()("t3/auth/SessionStore") {}
426​
427const SIGNING_SECRET_NAME = "server-signing-key";
428const DEFAULT_SESSION_TTL = Duration.days(30);
429const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5);
430​
431const SessionClaims = Schema.Struct({
432 v: Schema.Literals([1, 2]),
433 kind: Schema.Literal("session"),
434 sid: AuthSessionId,
435 sub: Schema.String,
436 scopes: AuthEnvironmentScopes,
437 method: Schema.Literals(["browser-session-cookie", "bearer-access-token", "dpop-access-token"]),
438 jkt: Schema.optionalKey(Schema.String),
439 rtc: Schema.optionalKey(RuntimeMode),
440 /** Signed, so a client cannot shed its peer link's limits. */
441 plk: Schema.optionalKey(Schema.Literal(1)),
442 iat: Schema.Number,
443 exp: Schema.Number,
444});
445type SessionClaims = typeof SessionClaims.Type;
446​
447const WebSocketClaims = Schema.Struct({
448 v: Schema.Literal(1),
449 kind: Schema.Literal("websocket"),
450 sid: AuthSessionId,
451 aud: Schema.optionalKey(AuthWebSocketTicketUse),
452 jkt: Schema.optionalKey(Schema.String),
453 iat: Schema.Number,
454 exp: Schema.Number,
455});
456type WebSocketClaims = typeof WebSocketClaims.Type;
457​
458const decodeSessionClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(SessionClaims));
459const decodeWebSocketClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(WebSocketClaims));
460​
461function createDefaultClientMetadata(): AuthClientMetadata {
462 return {
463 deviceType: "unknown",
464 };
466​
467function toClientMetadata(record: {
468 readonly label: string | null;
469 readonly ipAddress: string | null;
470 readonly userAgent: string | null;
471 readonly deviceType: AuthClientMetadata["deviceType"];
472 readonly os: string | null;
473 readonly browser: string | null;
474}): AuthClientMetadata {
475 return {
476 ...(record.label ? { label: record.label } : {}),
477 ...(record.ipAddress ? { ipAddress: record.ipAddress } : {}),
478 ...(record.userAgent ? { userAgent: record.userAgent } : {}),
479 deviceType: record.deviceType,
480 ...(record.os ? { os: record.os } : {}),
481 ...(record.browser ? { browser: record.browser } : {}),
482 };
484​
485function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthClientSession {
486 return {
487 ...input,
488 current: false,
489 };
491​
492const make = Effect.gen(function* () {
493 const random = yield* SecureRandom.SecureRandom;
494 const serverConfig = yield* ServerConfig.ServerConfig;
495 const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity;
496 const secretStore = yield* ServerSecretStore.ServerSecretStore;
497 const authSessions = yield* AuthSessions.AuthSessionRepository;
498 const signer = yield* PayloadSigner.fromSecret(
499 yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32),
500 );
501 const connectedSessionsRef = yield* Ref.make(new Map<AuthSessionId, number>());
502 const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>();
503 // Newest session per DPoP key. A live socket moves to it at expiry, so a
504 // client that renews its short-lived token keeps its socket.
505 const latestSessionByProofKey = new Map<string, AuthSessionId>();
506 const cookieInput = {
507 mode: serverConfig.mode,
508 port: serverConfig.port,
509 host: serverConfig.host,
510 instanceKey: serverConfig.stateDir,
511 environmentId: yield* serverEnvironment.getEnvironmentId,
512 development: serverConfig.devUrl !== undefined,
513 } as const;
514 const cookieName = resolveSessionCookieName(cookieInput);
515 const legacyCookieName = resolveLegacySessionCookieName(cookieInput);
516 const devAuth = resolveReusableDevAuth(serverConfig);
517 if (devAuth) {
518 yield* authSessions
519 .createIfAbsent({
520 sessionId: devAuth.sessionId,
521 subject: "reusable-dev-token",
522 scopes: AuthStandardClientScopes,
523 method: "browser-session-cookie",
524 client: {
525 label: "Reusable dev token",
526 ipAddress: null,
527 userAgent: null,
528 deviceType: "unknown",
529 os: null,
530 browser: null,
531 },
532 issuedAt: yield* DateTime.now,
533 expiresAt: REUSABLE_DEV_SESSION_EXPIRES_AT,
534 })
535 .pipe(
536 Effect.mapError(
537 (cause) => new SessionCredentialIssueError({ sessionId: devAuth.sessionId, cause }),
538 ),
539 );
540 }
541​
542 const emitUpsert = (clientSession: AuthClientSession) =>
543 PubSub.publish(changesPubSub, {
544 type: "clientUpserted",
545 clientSession,
546 }).pipe(Effect.asVoid);
547​
548 const emitRemoved = (sessionId: AuthSessionId) =>
549 PubSub.publish(changesPubSub, {
550 type: "clientRemoved",
551 sessionId,
552 }).pipe(Effect.asVoid);
553​
554 const loadActiveSession = Effect.fn("SessionStore.loadActiveSession")(function* (
555 sessionId: AuthSessionId,
556 ) {
557 const row = yield* authSessions.getById({ sessionId });
558 if (Option.isNone(row) || row.value.revokedAt !== null) {
559 return Option.none<AuthClientSession>();
560 }
561​
562 const connectedSessions = yield* Ref.get(connectedSessionsRef);
563 const connected = connectedSessions.has(row.value.sessionId);
564 const now = yield* DateTime.now;
565 if (!connected && row.value.expiresAt.epochMilliseconds <= now.epochMilliseconds) {
566 return Option.none<AuthClientSession>();
567 }
568 return Option.some(
569 toAuthClientSession({
570 sessionId: row.value.sessionId,
571 subject: row.value.subject,
572 scopes: row.value.scopes,
573 method: row.value.method,
574 client: toClientMetadata(row.value.client),
575 issuedAt: row.value.issuedAt,
576 expiresAt: row.value.expiresAt,
577 lastConnectedAt: row.value.lastConnectedAt,
578 connected,
579 }),
580 );
581 });
582​
583 const markConnected: SessionStore["Service"]["markConnected"] = (sessionId) =>
584 Ref.modify(connectedSessionsRef, (current) => {
585 const next = new Map(current);
586 const wasDisconnected = !next.has(sessionId);
587 next.set(sessionId, (next.get(sessionId) ?? 0) + 1);
588 return [wasDisconnected, next] as const;
589 }).pipe(
590 Effect.flatMap((wasDisconnected) =>
591 wasDisconnected
592 ? DateTime.now.pipe(
593 Effect.flatMap((lastConnectedAt) =>
594 authSessions.setLastConnectedAt({
595 sessionId,
596 lastConnectedAt,
597 }),
598 ),
599 )
600 : Effect.void,
601 ),
602 Effect.flatMap(() => loadActiveSession(sessionId)),
603 Effect.flatMap((session) =>
604 Option.isSome(session) ? emitUpsert(session.value) : Effect.void,
605 ),
606 Effect.catchCause((cause) =>
607 Effect.logError("Failed to publish connected-session auth update.").pipe(
608 Effect.annotateLogs({
609 sessionId,
610 cause,
611 }),
612 ),
613 ),
614 Effect.withSpan("SessionStore.markConnected"),
615 );
616​
617 const markDisconnected: SessionStore["Service"]["markDisconnected"] = (sessionId) =>
618 Ref.update(connectedSessionsRef, (current) => {
619 const next = new Map(current);
620 const remaining = (next.get(sessionId) ?? 0) - 1;
621 if (remaining > 0) {
622 next.set(sessionId, remaining);
623 } else {
624 next.delete(sessionId);
625 }
626 return next;
627 }).pipe(
628 Effect.flatMap(() => loadActiveSession(sessionId)),
629 Effect.flatMap((session) =>
630 Option.isSome(session) ? emitUpsert(session.value) : emitRemoved(sessionId),
631 ),
632 Effect.catchCause((cause) =>
633 Effect.logError("Failed to publish disconnected-session auth update.").pipe(
634 Effect.annotateLogs({
635 sessionId,
636 cause,
637 }),
638 ),
639 ),
640 Effect.withSpan("SessionStore.markDisconnected"),
641 );
642​
643 const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims));
644 const issue: SessionStore["Service"]["issue"] = Effect.fn("SessionStore.issue")(
645 function* (input) {
646 const sessionId = AuthSessionId.make(
647 yield* random
648 .uuidV4()
649 .pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ cause }))),
650 );
651 const issuedAt = yield* DateTime.now;
652 const expiresAt = DateTime.add(issuedAt, {
653 milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL),
654 });
655 const claims: SessionClaims = {
656 v: 1,
657 kind: "session",
658 sid: sessionId,
659 sub: input?.subject ?? "browser",
660 scopes: input?.scopes ?? AuthStandardClientScopes,
661 method: input?.method ?? "browser-session-cookie",
662 ...(input?.proofKeyThumbprint ? { jkt: input.proofKeyThumbprint } : {}),
663 ...(input?.runtimeModeCeiling ? { rtc: input.runtimeModeCeiling } : {}),
664 ...(input?.peerLink === true ? { plk: 1 as const } : {}),
665 iat: issuedAt.epochMilliseconds,
666 exp: expiresAt.epochMilliseconds,
667 };
668​
669 const encodedPayload = yield* encodeClaims(claims).pipe(
670 Effect.map(base64UrlEncode),
671 Effect.mapError(
672 (cause) =>
673 new SessionCredentialIssueError({
674 sessionId,
675 cause: new SessionClaimsEncodingError({
676 sessionId,
677 operation: "encode_session_claims",
678 cause,
679 }),
680 }),
681 ),
682 );
683 const signature = yield* signer.sign(encodedPayload);
684 const client = input?.client ?? createDefaultClientMetadata();
685 const sessionRecord = {
686 sessionId,
687 subject: claims.sub,
688 scopes: claims.scopes,
689 method: claims.method,
690 client: {
691 label: client.label ?? null,
692 ipAddress: client.ipAddress ?? null,
693 userAgent: client.userAgent ?? null,
694 deviceType: client.deviceType,
695 os: client.os ?? null,
696 browser: client.browser ?? null,
697 },
698 issuedAt,
699 expiresAt,
700 } satisfies AuthSessions.CreateAuthSessionInput;
701 const replacedSessionIds = yield* (
702 input?.replaceSessionId !== undefined || input?.replaceActiveForSubjectAndMethod
703 ? authSessions.createReplacingActive({
704 session: sessionRecord,
705 revokedAt: issuedAt,
706 ...(input.replaceSessionId !== undefined
707 ? { replaceSessionId: input.replaceSessionId }
708 : {}),
709 })
710 : authSessions.create(sessionRecord).pipe(Effect.as([] as ReadonlyArray<AuthSessionId>))
711 ).pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause })));
712 if (claims.jkt) latestSessionByProofKey.set(claims.jkt, sessionId);
713 if (replacedSessionIds.length > 0) {
714 yield* Ref.update(connectedSessionsRef, (current) => {
715 const next = new Map(current);
716 for (const replacedSessionId of replacedSessionIds) {
717 next.delete(replacedSessionId);
718 }
719 return next;
720 });
721 yield* Effect.forEach(replacedSessionIds, emitRemoved, {
722 concurrency: "unbounded",
723 discard: true,
724 });
725 }
726 yield* emitUpsert(
727 toAuthClientSession({
728 sessionId,
729 subject: claims.sub,
730 scopes: claims.scopes,
731 method: claims.method,
732 client,
733 issuedAt,
734 expiresAt,
735 lastConnectedAt: null,
736 connected: false,
737 }),
738 );
739​
740 return {
741 sessionId,
742 token: `${encodedPayload}.${signature}`,
743 method: claims.method,
744 client,
745 expiresAt: expiresAt,
746 scopes: claims.scopes,
747 ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}),
748 } satisfies IssuedSession;
749 },
750 );
751​
752 const verify: SessionStore["Service"]["verify"] = Effect.fn("SessionStore.verify")(
753 function* (token) {
754 if (devAuth?.matches(token)) {
755 const row = yield* authSessions
756 .getById({ sessionId: devAuth.sessionId })
757 .pipe(
758 Effect.mapError(
759 (cause) =>
760 new SessionCredentialVerificationError({ sessionId: devAuth.sessionId, cause }),
761 ),
762 );
763 if (Option.isNone(row)) {
764 return yield* new UnknownSessionTokenError({ sessionId: devAuth.sessionId });
765 }
766 if (row.value.revokedAt !== null) {
767 return yield* new SessionTokenRevokedError({
768 sessionId: devAuth.sessionId,
769 revokedAt: row.value.revokedAt,
770 });
771 }
772 const observedAt = yield* DateTime.now;
773 if (row.value.expiresAt.epochMilliseconds <= observedAt.epochMilliseconds) {
774 return yield* new SessionTokenExpiredError({
775 sessionId: devAuth.sessionId,
776 expiresAt: row.value.expiresAt,
777 observedAt,
778 });
779 }
780 return {
781 sessionId: row.value.sessionId,
782 token,
783 method: row.value.method,
784 client: toClientMetadata(row.value.client),
785 expiresAt: row.value.expiresAt,
786 subject: row.value.subject,
787 scopes: row.value.scopes,
788 } satisfies VerifiedSession;
789 }
790 const [encodedPayload, signature] = token.split(".");
791 if (!encodedPayload || !signature) {
792 return yield* new MalformedSessionTokenError({});
793 }
794​
795 if (!(yield* signer.verify(encodedPayload, signature))) {
796 return yield* new InvalidSessionTokenSignatureError({});
797 }
798​
799 const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
800 Effect.mapError((cause) => new InvalidSessionTokenPayloadError({ cause })),
801 );
802​
803 const observedAt = yield* DateTime.now;
804 const expiresAt = DateTime.make(claims.exp);
805 if (Option.isNone(expiresAt)) {
806 return yield* new InvalidSessionExpirationClaimError({
807 sessionId: claims.sid,
808 expirationClaim: claims.exp,
809 });
810 }
811 if (claims.exp <= observedAt.epochMilliseconds) {
812 return yield* new SessionTokenExpiredError({
813 sessionId: claims.sid,
814 expiresAt: expiresAt.value,
815 observedAt,
816 });
817 }
818​
819 const row = yield* authSessions
820 .getById({ sessionId: claims.sid })
821 .pipe(
822 Effect.mapError(
823 (cause) => new SessionCredentialVerificationError({ sessionId: claims.sid, cause }),
824 ),
825 );
826 if (Option.isNone(row)) {
827 return yield* new UnknownSessionTokenError({ sessionId: claims.sid });
828 }
829 if (row.value.revokedAt !== null) {
830 return yield* new SessionTokenRevokedError({
831 sessionId: claims.sid,
832 revokedAt: row.value.revokedAt,
833 });
834 }
835​
836 return {
837 sessionId: claims.sid,
838 token,
839 method: claims.method,
840 client: toClientMetadata(row.value.client),
841 expiresAt: expiresAt.value,
842 subject: claims.sub,
843 scopes: row.value.scopes,
844 ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}),
845 ...(claims.rtc ? { runtimeModeCeiling: claims.rtc } : {}),
846 ...(claims.plk === 1 ? { peerLink: true as const } : {}),
847 } satisfies VerifiedSession;
848 },
849 );
850​
851 const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims));
852 const issueWebSocketToken: SessionStore["Service"]["issueWebSocketToken"] = Effect.fn(
853 "SessionStore.issueWebSocketToken",
854 )(function* (sessionId, input) {
855 const issuedAt = yield* DateTime.now;
856 const expiresAt = DateTime.add(issuedAt, {
857 milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL),
858 });
859 const claims: WebSocketClaims = {
860 v: 1,
861 kind: "websocket",
862 sid: sessionId,
863 ...(input?.audience ? { aud: input.audience } : {}),
864 ...(input?.proofKeyThumbprint ? { jkt: input.proofKeyThumbprint } : {}),
865 iat: issuedAt.epochMilliseconds,
866 exp: expiresAt.epochMilliseconds,
867 };
868 const encodedPayload = yield* encodeWsClaims(claims).pipe(
869 Effect.map(base64UrlEncode),
870 Effect.mapError(
871 (cause) =>
872 new WebSocketTokenIssueError({
873 sessionId,
874 cause: new SessionClaimsEncodingError({
875 sessionId,
876 operation: "encode_websocket_claims",
877 cause,
878 }),
879 }),
880 ),
881 );
882 const signature = yield* signer.sign(encodedPayload);
883 return {
884 token: `${encodedPayload}.${signature}`,
885 expiresAt,
886 };
887 });
888​
889 const verifyWebSocketToken: SessionStore["Service"]["verifyWebSocketToken"] = Effect.fn(
890 "SessionStore.verifyWebSocketToken",
891 )(function* (token) {
892 const [encodedPayload, signature] = token.split(".");
893 if (!encodedPayload || !signature) {
894 return yield* new MalformedWebSocketTokenError({});
895 }
896​
897 if (!(yield* signer.verify(encodedPayload, signature))) {
898 return yield* new InvalidWebSocketTokenSignatureError({});
899 }
900​
901 const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
902 Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })),
903 );
904 if (claims.sid.startsWith(REUSABLE_DEV_SESSION_PREFIX) && claims.sid !== devAuth?.sessionId) {
905 return yield* new UnknownWebSocketSessionError({ sessionId: claims.sid });
906 }
907​
908 const observedAt = yield* DateTime.now;
909 const expiresAt = DateTime.make(claims.exp);
910 if (Option.isNone(expiresAt)) {
911 return yield* new InvalidSessionExpirationClaimError({
912 sessionId: claims.sid,
913 expirationClaim: claims.exp,
914 });
915 }
916 if (claims.exp <= observedAt.epochMilliseconds) {
917 return yield* new WebSocketTokenExpiredError({
918 sessionId: claims.sid,
919 expiresAt: expiresAt.value,
920 observedAt,
921 });
922 }
923​
924 const row = yield* authSessions
925 .getById({ sessionId: claims.sid })
926 .pipe(
927 Effect.mapError(
928 (cause) => new WebSocketTokenVerificationError({ sessionId: claims.sid, cause }),
929 ),
930 );
931 if (Option.isNone(row)) {
932 return yield* new UnknownWebSocketSessionError({ sessionId: claims.sid });
933 }
934 if (row.value.expiresAt.epochMilliseconds <= observedAt.epochMilliseconds) {
935 return yield* new WebSocketSessionExpiredError({
936 sessionId: claims.sid,
937 expiresAt: row.value.expiresAt,
938 observedAt,
939 });
940 }
941 if (row.value.revokedAt !== null) {
942 return yield* new WebSocketSessionRevokedError({
943 sessionId: claims.sid,
944 revokedAt: row.value.revokedAt,
945 });
946 }
947​
948 return {
949 sessionId: row.value.sessionId,
950 token,
951 method: row.value.method,
952 client: toClientMetadata(row.value.client),
953 expiresAt: row.value.expiresAt,
954 subject: row.value.subject,
955 scopes: row.value.scopes,
956 ...(claims.aud ? { audience: claims.aud } : {}),
957 ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}),
958 } satisfies VerifiedWebSocketTicket;
959 });
960​
961 const listActive: SessionStore["Service"]["listActive"] = Effect.fn("SessionStore.listActive")(
962 function* () {
963 const now = yield* DateTime.now;
964 const connectedSessions = yield* Ref.get(connectedSessionsRef);
965 const rows = yield* authSessions.listActive({
966 now,
967 connectedSessionIds: Array.from(connectedSessions.keys()),
968 });
969​
970 return rows.map((row) =>
971 toAuthClientSession({
972 sessionId: row.sessionId,
973 subject: row.subject,
974 scopes: row.scopes,
975 method: row.method,
976 client: toClientMetadata(row.client),
977 issuedAt: row.issuedAt,
978 expiresAt: row.expiresAt,
979 lastConnectedAt: row.lastConnectedAt,
980 connected: connectedSessions.has(row.sessionId),
981 }),
982 );
983 },
984 Effect.mapError((cause) => new ActiveSessionsListError({ cause })),
985 );
986​
987 const revoke: SessionStore["Service"]["revoke"] = Effect.fn("SessionStore.revoke")(
988 function* (sessionId) {
989 const revokedAt = yield* DateTime.now;
990 const revoked = yield* authSessions
991 .revoke({
992 sessionId,
993 revokedAt,
994 })
995 .pipe(Effect.mapError((cause) => new SessionRevocationError({ sessionId, cause })));
996 if (revoked) {
997 yield* Ref.update(connectedSessionsRef, (current) => {
998 const next = new Map(current);
999 next.delete(sessionId);
1000 return next;
1001 });
1002 yield* emitRemoved(sessionId);
1004 return revoked;
1005 },
1006 );
1007​
1008 const revokeAllExcept: SessionStore["Service"]["revokeAllExcept"] = Effect.fn(
1009 "SessionStore.revokeAllExcept",
1010 )(function* (sessionId) {
1011 const revokedAt = yield* DateTime.now;
1012 const revokedSessionIds = yield* authSessions
1013 .revokeAllExcept({
1014 currentSessionId: sessionId,
1015 revokedAt,
1016 })
1017 .pipe(
1018 Effect.mapError(
1019 (cause) => new OtherSessionsRevocationError({ currentSessionId: sessionId, cause }),
1020 ),
1021 );
1022 if (revokedSessionIds.length > 0) {
1023 yield* Ref.update(connectedSessionsRef, (current) => {
1024 const next = new Map(current);
1025 for (const revokedSessionId of revokedSessionIds) {
1026 next.delete(revokedSessionId);
1028 return next;
1029 });
1030 yield* Effect.forEach(
1031 revokedSessionIds,
1032 (revokedSessionId) => emitRemoved(revokedSessionId),
1034 concurrency: "unbounded",
1035 discard: true,
1036 },
1037 );
1039 return revokedSessionIds.length;
1040 });
1041​
1042 const awaitInvalidation = Effect.fn(function* (
1043 sessionId: AuthSessionId,
1044 proofKeyThumbprint?: string,
1045 ) {
1046 return yield* Effect.scoped(
1047 Effect.gen(function* () {
1048 // Subscribe before reading: revocation can race with the WebSocket upgrade.
1049 const subscription = yield* PubSub.subscribe(changesPubSub);
1050 const readRow = (id: AuthSessionId) =>
1051 authSessions
1052 .getById({ sessionId: id })
1053 .pipe(
1054 Effect.mapError(
1055 (cause) => new SessionCredentialVerificationError({ sessionId: id, cause }),
1056 ),
1057 );
1058 const original = yield* readRow(sessionId);
1059 if (Option.isNone(original)) return;
1060 // Revoking any session the socket has used closes it. Events only wake this loop
1061 // and the rows decide: revocations are written before they are published, and
1062 // clientRemoved also reports an expired session losing its last socket.
1063 const chain = [sessionId];
1064 const nextRemoval = Effect.gen(function* () {
1065 while (true) {
1066 const change = yield* PubSub.take(subscription);
1067 if (change.type === "clientRemoved" && chain.includes(change.sessionId)) return;
1069 });
1070 let current = original.value;
1071 while (true) {
1072 const rows = yield* Effect.forEach(chain, readRow);
1073 if (rows.some((row) => Option.isNone(row) || row.value.revokedAt !== null)) return;
1074 const now = yield* DateTime.now;
1075 const remaining = current.expiresAt.epochMilliseconds - now.epochMilliseconds;
1076 if (remaining > 0) {
1077 yield* Effect.raceFirst(nextRemoval, Effect.sleep(Duration.millis(remaining)));
1078 continue;
1080 const successorId =
1081 proofKeyThumbprint === undefined
1082 ? undefined
1083 : latestSessionByProofKey.get(proofKeyThumbprint);
1084 if (successorId === undefined || successorId === current.sessionId) return;
1085 const successor = yield* readRow(successorId);
1086 // The socket keeps its original scopes, so the renewal must still grant all of them.
1087 if (
1088 Option.isNone(successor) ||
1089 successor.value.subject !== original.value.subject ||
1090 !original.value.scopes.every((scope) => successor.value.scopes.includes(scope))
1091 ) {
1092 return;
1094 chain.push(successorId);
1095 current = successor.value;
1097 }),
1098 );
1099 });
1100​
1101 return SessionStore.of({
1102 cookieName,
1103 legacyCookieName,
1104 issue,
1105 verify,
1106 issueWebSocketToken,
1107 verifyWebSocketToken,
1108 listActive,
1109 awaitInvalidation,
1110 get streamChanges() {
1111 return Stream.fromPubSub(changesPubSub);
1112 },
1113 revoke,
1114 revokeAllExcept,
1115 markConnected,
1116 markDisconnected,
1117 });
1118});
1119​
1120export const layer = Layer.effect(SessionStore, make).pipe(Layer.provideMerge(AuthSessions.layer));