1import {
2 AuthSessionId,
3 AuthAdministrativeScopes,
4 AuthAccessWriteScope,
5 AuthStandardClientScopes,
6 AuthEnvironmentScopes,
7 type AuthClientMetadata,
8 type AuthClientSession,
9 type AuthEnvironmentScope,
10 type ClientSurface,
11 RuntimeMode,
12 type ServerAuthSessionMethod,
13} from "@t3tools/contracts";
14import * as Context from "effect/Context";
15import * as Crypto from "effect/Crypto";
16import * as DateTime from "effect/DateTime";
17import * as Duration from "effect/Duration";
18import * as Effect from "effect/Effect";
19import * as Layer from "effect/Layer";
20import * as PubSub from "effect/PubSub";
21import * as Ref from "effect/Ref";
22import * as Schema from "effect/Schema";
23import * as Stream from "effect/Stream";
24import * as Option from "effect/Option";
25
26import * as ServerConfig from "../config.ts";
27import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
28import * as AuthSessions from "../persistence/AuthSessions.ts";
29import * as ServerSecretStore from "./ServerSecretStore.ts";
30import {
31 REUSABLE_DEV_SESSION_EXPIRES_AT,
32 REUSABLE_DEV_SESSION_PREFIX,
33 resolveReusableDevAuth,
34} from "./ReusableDevAuth.ts";
35import {
36 base64UrlDecodeUtf8,
37 base64UrlEncode,
38 resolveLegacySessionCookieName,
39 resolveSessionCookieName,
40 signPayload,
41 timingSafeEqualBase64Url,
42} from "./utils.ts";
43
44export interface IssuedSession {
45 readonly sessionId: AuthSessionId;
46 readonly token: string;
47 readonly method: ServerAuthSessionMethod;
48 readonly client: AuthClientMetadata;
49 readonly expiresAt: DateTime.DateTime;
50 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
51 readonly proofKeyThumbprint?: string;
52}
53
54export interface VerifiedSession {
55 readonly parentSessionId?: AuthSessionId;
56 readonly deviceId?: string;
57 readonly sessionId: AuthSessionId;
58 readonly token: string;
59 readonly method: ServerAuthSessionMethod;
60 readonly client: AuthClientMetadata;
61 readonly expiresAt?: DateTime.DateTime;
62 readonly subject: string;
63 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
64 readonly proofKeyThumbprint?: string;
65 /** The most an MCP client approved through OAuth may hand to the threads it drives. */
66 readonly runtimeModeCeiling?: RuntimeMode;
67}
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 }
86}
87
88export class InvalidSessionTokenSignatureError extends Schema.TaggedError<InvalidSessionTokenSignatureError>()(
89 "InvalidSessionTokenSignatureError",
90 {},
91) {
92 override get message(): string {
93 return "Invalid session token signature.";
94 }
95}
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 }
106}
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 }
119}
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 }
130}
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 }
142}
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 }
154}
155
156export class MalformedWebSocketTokenError extends Schema.TaggedError<MalformedWebSocketTokenError>()(
157 "MalformedWebSocketTokenError",
158 {},
159) {
160 override get message(): string {
161 return "Malformed websocket token.";
162 }
163}
164
165export class InvalidWebSocketTokenSignatureError extends Schema.TaggedError<InvalidWebSocketTokenSignatureError>()(
166 "InvalidWebSocketTokenSignatureError",
167 {},
168) {
169 override get message(): string {
170 return "Invalid websocket token signature.";
171 }
172}
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 }
183}
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 }
196}
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 }
207}
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 }
220}
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 }
232}
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 }
268}
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 }
280}
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 }
292}
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 }
304}
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 }
316}
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 }
327}
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 }
339}
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 }
351}
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 parentSessionId?: AuthSessionId;
378 readonly deviceId?: string;
379 readonly ttl?: Duration.Duration;
380 readonly subject?: string;
381 readonly method?: ServerAuthSessionMethod;
382 readonly scopes?: ReadonlyArray<AuthEnvironmentScope>;
383 readonly client?: AuthClientMetadata;
384 readonly proofKeyThumbprint?: string;
385 readonly runtimeModeCeiling?: RuntimeMode;
386 /**
387 * Atomically revoke active sessions with the same subject and method
388 * before storing this session.
389 */
390 readonly replaceActiveForSubjectAndMethod?: boolean;
391 /** Replace only this session, of the same method, in the issuance transaction. */
392 readonly replaceSessionId?: AuthSessionId;
393 }) => Effect.Effect<IssuedSession, SessionCredentialInternalError>;
394 readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>;
395 readonly verifyActive: (
396 sessionId: AuthSessionId,
397 ) => Effect.Effect<void, SessionCredentialError>;
398 readonly issueWebSocketToken: (
399 sessionId: AuthSessionId,
400 input?: {
401 readonly ttl?: Duration.Duration;
402 },
403 ) => Effect.Effect<
404 {
405 readonly token: string;
406 readonly expiresAt: DateTime.DateTime;
407 },
408 SessionCredentialInternalError
409 >;
410 readonly verifyWebSocketToken: (
411 token: string,
412 ) => Effect.Effect<VerifiedSession, SessionCredentialError>;
413 readonly listActive: () => Effect.Effect<
414 ReadonlyArray<AuthClientSession>,
415 SessionCredentialInternalError
416 >;
417 readonly streamChanges: Stream.Stream<SessionCredentialChange>;
418 readonly awaitInvalidation: (
419 sessionId: AuthSessionId,
420 ) => Effect.Effect<void, SessionCredentialVerificationError>;
421 readonly revoke: (
422 sessionId: AuthSessionId,
423 ) => Effect.Effect<boolean, SessionCredentialInternalError>;
424 readonly revokeAllExcept: (
425 sessionId: AuthSessionId,
426 ) => Effect.Effect<number, SessionCredentialInternalError>;
427 readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
428 readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
429 readonly recordClientConnection: (
430 sessionId: AuthSessionId,
431 client: {
432 readonly surface?: ClientSurface | undefined;
433 readonly appVersion?: string | undefined;
434 },
435 ) => Effect.Effect<void, never>;
436 }
437>()("t3/auth/SessionStore") {}
438
439const SIGNING_SECRET_NAME = "server-signing-key";
440const DEFAULT_SESSION_TTL = Duration.days(30);
441const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5);
442
443const SessionClaims = Schema.Struct({
444 v: Schema.Literals([1, 2]),
445 kind: Schema.Literal("session"),
446 sid: AuthSessionId,
447 sub: Schema.String,
448 scopes: AuthEnvironmentScopes,
449 method: Schema.Literals(["browser-session-cookie", "bearer-access-token", "dpop-access-token"]),
450 jkt: Schema.optionalKey(Schema.String),
451 rtc: Schema.optionalKey(RuntimeMode),
452 iat: Schema.Number,
453 exp: Schema.Number,
454});
455type SessionClaims = typeof SessionClaims.Type;
456
457const WebSocketClaims = Schema.Struct({
458 v: Schema.Literal(1),
459 kind: Schema.Literal("websocket"),
460 sid: AuthSessionId,
461 iat: Schema.Number,
462 exp: Schema.Number,
463});
464type WebSocketClaims = typeof WebSocketClaims.Type;
465
466const decodeSessionClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(SessionClaims));
467const decodeWebSocketClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(WebSocketClaims));
468
469function createDefaultClientMetadata(): AuthClientMetadata {
470 return {
471 deviceType: "unknown",
472 };
473}
474
475function toClientMetadata(record: {
476 readonly label: string | null;
477 readonly ipAddress: string | null;
478 readonly userAgent: string | null;
479 readonly deviceType: AuthClientMetadata["deviceType"];
480 readonly os: string | null;
481 readonly browser: string | null;
482}): AuthClientMetadata {
483 return {
484 ...(record.label ? { label: record.label } : {}),
485 ...(record.ipAddress ? { ipAddress: record.ipAddress } : {}),
486 ...(record.userAgent ? { userAgent: record.userAgent } : {}),
487 deviceType: record.deviceType,
488 ...(record.os ? { os: record.os } : {}),
489 ...(record.browser ? { browser: record.browser } : {}),
490 };
491}
492
493function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthClientSession {
494 return {
495 ...input,
496 current: false,
497 };
498}
499
500export const make = Effect.gen(function* () {
501 const crypto = yield* Crypto.Crypto;
502 const serverConfig = yield* ServerConfig.ServerConfig;
503 const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity;
504 const secretStore = yield* ServerSecretStore.ServerSecretStore;
505 const authSessions = yield* AuthSessions.AuthSessionRepository;
506 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);
507 const connectedSessionsRef = yield* Ref.make(new Map<AuthSessionId, number>());
508 const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>();
509 const cookieInput = {
510 mode: serverConfig.mode,
511 port: serverConfig.port,
512 host: serverConfig.host,
513 instanceKey: serverConfig.stateDir,
514 environmentId: yield* serverEnvironment.getEnvironmentId,
515 development: serverConfig.devUrl !== undefined,
516 } as const;
517 const cookieName = resolveSessionCookieName(cookieInput);
518 const legacyCookieName = resolveLegacySessionCookieName(cookieInput);
519 const devAuth = resolveReusableDevAuth(serverConfig);
520 if (devAuth) {
521 yield* authSessions
522 .createIfAbsent({
523 sessionId: devAuth.sessionId,
524 subject: "reusable-dev-token",
525 scopes: AuthAdministrativeScopes,
526 method: "browser-session-cookie",
527 client: {
528 label: "Reusable dev token",
529 ipAddress: null,
530 userAgent: null,
531 deviceType: "unknown",
532 os: null,
533 browser: null,
534 },
535 issuedAt: yield* DateTime.now,
536 expiresAt: REUSABLE_DEV_SESSION_EXPIRES_AT,
537 })
538 .pipe(
539 Effect.mapError(
540 (cause) => new SessionCredentialIssueError({ sessionId: devAuth.sessionId, cause }),
541 ),
542 );
543 }
544
545 const emitUpsert = (clientSession: AuthClientSession) =>
546 PubSub.publish(changesPubSub, {
547 type: "clientUpserted",
548 clientSession,
549 }).pipe(Effect.asVoid);
550
551 const emitRemoved = (sessionId: AuthSessionId) =>
552 PubSub.publish(changesPubSub, {
553 type: "clientRemoved",
554 sessionId,
555 }).pipe(Effect.asVoid);
556
557 const verifyDelegationParent = Effect.fn("SessionStore.verifyDelegationParent")(function* (
558 parentSessionId: AuthSessionId,
559 observedAt: DateTime.Utc,
560 ) {
561 const parent = yield* authSessions
562 .getById({ sessionId: parentSessionId })
563 .pipe(
564 Effect.mapError(
565 (cause) => new SessionCredentialVerificationError({ sessionId: parentSessionId, cause }),
566 ),
567 );
568 if (Option.isNone(parent) || parent.value.parentSessionId !== undefined) {
569 return yield* new UnknownSessionTokenError({ sessionId: parentSessionId });
570 }
571 if (parent.value.revokedAt !== null) {
572 return yield* new SessionTokenRevokedError({
573 sessionId: parentSessionId,
574 revokedAt: parent.value.revokedAt,
575 });
576 }
577 if (parent.value.expiresAt.epochMilliseconds <= observedAt.epochMilliseconds) {
578 return yield* new SessionTokenExpiredError({
579 sessionId: parentSessionId,
580 expiresAt: parent.value.expiresAt,
581 observedAt,
582 });
583 }
584 return parent.value;
585 });
586
587 const verifyActive = Effect.fn("SessionStore.verifyActive")(function* (sessionId: AuthSessionId) {
588 const observedAt = yield* DateTime.now;
589 const row = yield* authSessions
590 .getById({ sessionId })
591 .pipe(
592 Effect.mapError((cause) => new SessionCredentialVerificationError({ sessionId, cause })),
593 );
594 if (Option.isNone(row)) return yield* new UnknownSessionTokenError({ sessionId });
595 if (row.value.revokedAt !== null)
596 return yield* new SessionTokenRevokedError({ sessionId, revokedAt: row.value.revokedAt });
597 if (row.value.expiresAt.epochMilliseconds <= observedAt.epochMilliseconds)
598 return yield* new SessionTokenExpiredError({
599 sessionId,
600 expiresAt: row.value.expiresAt,
601 observedAt,
602 });
603 if (row.value.parentSessionId !== undefined)
604 yield* verifyDelegationParent(row.value.parentSessionId, observedAt);
605 });
606
607 const loadActiveSession = (sessionId: AuthSessionId) =>
608 Effect.gen(function* () {
609 const row = yield* authSessions.getById({ sessionId });
610 if (Option.isNone(row) || row.value.revokedAt !== null) {
611 return Option.none<AuthClientSession>();
612 }
613
614 const connectedSessions = yield* Ref.get(connectedSessionsRef);
615 const connected = connectedSessions.has(row.value.sessionId);
616 const now = yield* DateTime.now;
617 if (!connected && row.value.expiresAt.epochMilliseconds <= now.epochMilliseconds) {
618 return Option.none<AuthClientSession>();
619 }
620 if (row.value.parentSessionId !== undefined) {
621 const parent = yield* verifyDelegationParent(row.value.parentSessionId, now).pipe(
622 Effect.catchIf(isSessionCredentialInvalidError, () => Effect.succeed(undefined)),
623 );
624 if (parent === undefined) return Option.none<AuthClientSession>();
625 }
626 return Option.some(
627 toAuthClientSession({
628 sessionId: row.value.sessionId,
629 subject: row.value.subject,
630 scopes: row.value.scopes,
631 method: row.value.method,
632 client: toClientMetadata(row.value.client),
633 issuedAt: row.value.issuedAt,
634 expiresAt: row.value.expiresAt,
635 lastConnectedAt: row.value.lastConnectedAt,
636 connected,
637 }),
638 );
639 });
640
641 const markConnected: SessionStore["Service"]["markConnected"] = (sessionId) =>
642 Ref.modify(connectedSessionsRef, (current) => {
643 const next = new Map(current);
644 const wasDisconnected = !next.has(sessionId);
645 next.set(sessionId, (next.get(sessionId) ?? 0) + 1);
646 return [wasDisconnected, next] as const;
647 }).pipe(
648 Effect.flatMap((wasDisconnected) =>
649 wasDisconnected
650 ? DateTime.now.pipe(
651 Effect.flatMap((lastConnectedAt) =>
652 authSessions.setLastConnectedAt({
653 sessionId,
654 lastConnectedAt,
655 }),
656 ),
657 )
658 : Effect.void,
659 ),
660 Effect.flatMap(() => loadActiveSession(sessionId)),
661 Effect.flatMap((session) =>
662 Option.isSome(session) ? emitUpsert(session.value) : Effect.void,
663 ),
664 Effect.catchCause((cause) =>
665 Effect.logError("Failed to publish connected-session auth update.").pipe(
666 Effect.annotateLogs({
667 sessionId,
668 cause,
669 }),
670 ),
671 ),
672 Effect.withSpan("SessionStore.markConnected"),
673 );
674
675 // Best-effort: connection metadata must never block or fail a connect.
676 const recordClientConnection: SessionStore["Service"]["recordClientConnection"] = (
677 sessionId,
678 client,
679 ) =>
680 client.surface === undefined && client.appVersion === undefined
681 ? Effect.void
682 : authSessions
683 .setClientConnection({
684 sessionId,
685 surface: client.surface ?? null,
686 appVersion: client.appVersion ?? null,
687 })
688 .pipe(
689 Effect.catchCause((cause) =>
690 Effect.logWarning("Failed to record session client connection metadata.").pipe(
691 Effect.annotateLogs({ sessionId, cause }),
692 ),
693 ),
694 Effect.withSpan("SessionStore.recordClientConnection"),
695 );
696
697 const markDisconnected: SessionStore["Service"]["markDisconnected"] = (sessionId) =>
698 Ref.update(connectedSessionsRef, (current) => {
699 const next = new Map(current);
700 const remaining = (next.get(sessionId) ?? 0) - 1;
701 if (remaining > 0) {
702 next.set(sessionId, remaining);
703 } else {
704 next.delete(sessionId);
705 }
706 return next;
707 }).pipe(
708 Effect.flatMap(() => loadActiveSession(sessionId)),
709 Effect.flatMap((session) =>
710 Option.isSome(session) ? emitUpsert(session.value) : emitRemoved(sessionId),
711 ),
712 Effect.catchCause((cause) =>
713 Effect.logError("Failed to publish disconnected-session auth update.").pipe(
714 Effect.annotateLogs({
715 sessionId,
716 cause,
717 }),
718 ),
719 ),
720 Effect.withSpan("SessionStore.markDisconnected"),
721 );
722
723 const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims));
724 const issue: SessionStore["Service"]["issue"] = Effect.fn("SessionStore.issue")(
725 function* (input) {
726 const sessionId = AuthSessionId.make(
727 yield* crypto
728 .randomUUIDv4()
729 .pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ cause }))),
730 );
731 const issuedAt = yield* DateTime.now;
732 const requestedExpiresAt = DateTime.add(issuedAt, {
733 milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL),
734 });
735 const parent =
736 input?.parentSessionId === undefined
737 ? undefined
738 : yield* verifyDelegationParent(input.parentSessionId, issuedAt).pipe(
739 Effect.flatMap((parent) =>
740 parent.scopes.includes(AuthAccessWriteScope) &&
741 (input.scopes ?? AuthStandardClientScopes).every((scope) =>
742 parent.scopes.includes(scope),
743 )
744 ? Effect.succeed(parent)
745 : Effect.fail(new UnknownSessionTokenError({ sessionId: parent.sessionId })),
746 ),
747 Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause })),
748 );
749 const expiresAt =
750 parent && parent.expiresAt.epochMilliseconds < requestedExpiresAt.epochMilliseconds
751 ? parent.expiresAt
752 : requestedExpiresAt;
753 const claims: SessionClaims = {
754 v: 1,
755 kind: "session",
756 sid: sessionId,
757 sub: input?.subject ?? "browser",
758 scopes: input?.scopes ?? AuthStandardClientScopes,
759 method: input?.method ?? "browser-session-cookie",
760 ...(input?.proofKeyThumbprint ? { jkt: input.proofKeyThumbprint } : {}),
761 ...(input?.runtimeModeCeiling ? { rtc: input.runtimeModeCeiling } : {}),
762 iat: issuedAt.epochMilliseconds,
763 exp: expiresAt.epochMilliseconds,
764 };
765
766 const encodedPayload = yield* encodeClaims(claims).pipe(
767 Effect.map(base64UrlEncode),
768 Effect.mapError(
769 (cause) =>
770 new SessionCredentialIssueError({
771 sessionId,
772 cause: new SessionClaimsEncodingError({
773 sessionId,
774 operation: "encode_session_claims",
775 cause,
776 }),
777 }),
778 ),
779 );
780 const signature = signPayload(encodedPayload, signingSecret);
781 const client = input?.client ?? createDefaultClientMetadata();
782 const deviceId = input?.deviceId ?? parent?.deviceId;
783 const sessionRecord = {
784 sessionId,
785 ...(input?.parentSessionId === undefined ? {} : { parentSessionId: input.parentSessionId }),
786 ...(deviceId === undefined ? {} : { deviceId }),
787 subject: claims.sub,
788 scopes: claims.scopes,
789 method: claims.method,
790 client: {
791 label: client.label ?? null,
792 ipAddress: client.ipAddress ?? null,
793 userAgent: client.userAgent ?? null,
794 deviceType: client.deviceType,
795 os: client.os ?? null,
796 browser: client.browser ?? null,
797 },
798 issuedAt,
799 expiresAt,
800 } satisfies AuthSessions.CreateAuthSessionInput;
801 const replacedSessionIds = yield* (
802 input?.replaceSessionId !== undefined || input?.replaceActiveForSubjectAndMethod
803 ? authSessions.createReplacingActive({
804 session: sessionRecord,
805 revokedAt: issuedAt,
806 ...(input.replaceSessionId !== undefined
807 ? { replaceSessionId: input.replaceSessionId }
808 : {}),
809 })
810 : authSessions.create(sessionRecord).pipe(Effect.as([] as ReadonlyArray<AuthSessionId>))
811 ).pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause })));
812 if (replacedSessionIds.length > 0) {
813 yield* Ref.update(connectedSessionsRef, (current) => {
814 const next = new Map(current);
815 for (const replacedSessionId of replacedSessionIds) {
816 next.delete(replacedSessionId);
817 }
818 return next;
819 });
820 yield* Effect.forEach(replacedSessionIds, emitRemoved, {
821 concurrency: "unbounded",
822 discard: true,
823 });
824 }
825 yield* emitUpsert(
826 toAuthClientSession({
827 sessionId,
828 subject: claims.sub,
829 scopes: claims.scopes,
830 method: claims.method,
831 client,
832 issuedAt,
833 expiresAt,
834 lastConnectedAt: null,
835 connected: false,
836 }),
837 );
838
839 return {
840 sessionId,
841 token: `${encodedPayload}.${signature}`,
842 method: claims.method,
843 client,
844 expiresAt: expiresAt,
845 scopes: claims.scopes,
846 ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}),
847 } satisfies IssuedSession;
848 },
849 );
850
851 const verify: SessionStore["Service"]["verify"] = Effect.fn("SessionStore.verify")(
852 function* (token) {
853 if (devAuth?.matches(token)) {
854 const row = yield* authSessions
855 .getById({ sessionId: devAuth.sessionId })
856 .pipe(
857 Effect.mapError(
858 (cause) =>
859 new SessionCredentialVerificationError({ sessionId: devAuth.sessionId, cause }),
860 ),
861 );
862 if (Option.isNone(row)) {
863 return yield* new UnknownSessionTokenError({ sessionId: devAuth.sessionId });
864 }
865 if (row.value.revokedAt !== null) {
866 return yield* new SessionTokenRevokedError({
867 sessionId: devAuth.sessionId,
868 revokedAt: row.value.revokedAt,
869 });
870 }
871 const observedAt = yield* DateTime.now;
872 if (row.value.expiresAt.epochMilliseconds <= observedAt.epochMilliseconds) {
873 return yield* new SessionTokenExpiredError({
874 sessionId: devAuth.sessionId,
875 expiresAt: row.value.expiresAt,
876 observedAt,
877 });
878 }
879 return {
880 sessionId: row.value.sessionId,
881 token,
882 method: row.value.method,
883 client: toClientMetadata(row.value.client),
884 expiresAt: row.value.expiresAt,
885 subject: row.value.subject,
886 scopes: row.value.scopes,
887 } satisfies VerifiedSession;
888 }
889 const [encodedPayload, signature] = token.split(".");
890 if (!encodedPayload || !signature) {
891 return yield* new MalformedSessionTokenError({});
892 }
893
894 const expectedSignature = signPayload(encodedPayload, signingSecret);
895 if (!timingSafeEqualBase64Url(signature, expectedSignature)) {
896 return yield* new InvalidSessionTokenSignatureError({});
897 }
898
899 const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
900 Effect.mapError((cause) => new InvalidSessionTokenPayloadError({ cause })),
901 );
902
903 const observedAt = yield* DateTime.now;
904 const expiresAt = DateTime.make(claims.exp);
905 if (Option.isNone(expiresAt)) {
906 return yield* new InvalidSessionExpirationClaimError({
907 sessionId: claims.sid,
908 expirationClaim: claims.exp,
909 });
910 }
911 if (claims.exp <= observedAt.epochMilliseconds) {
912 return yield* new SessionTokenExpiredError({
913 sessionId: claims.sid,
914 expiresAt: expiresAt.value,
915 observedAt,
916 });
917 }
918
919 const row = yield* authSessions
920 .getById({ sessionId: claims.sid })
921 .pipe(
922 Effect.mapError(
923 (cause) => new SessionCredentialVerificationError({ sessionId: claims.sid, cause }),
924 ),
925 );
926 if (Option.isNone(row)) {
927 return yield* new UnknownSessionTokenError({ sessionId: claims.sid });
928 }
929 if (row.value.revokedAt !== null) {
930 return yield* new SessionTokenRevokedError({
931 sessionId: claims.sid,
932 revokedAt: row.value.revokedAt,
933 });
934 }
935
936 if (row.value.parentSessionId !== undefined) {
937 yield* verifyDelegationParent(row.value.parentSessionId, observedAt);
938 }
939 return {
940 sessionId: claims.sid,
941 ...(row.value.parentSessionId === undefined
942 ? {}
943 : { parentSessionId: row.value.parentSessionId }),
944 ...(row.value.deviceId === undefined ? {} : { deviceId: row.value.deviceId }),
945 token,
946 method: claims.method,
947 client: toClientMetadata(row.value.client),
948 expiresAt: expiresAt.value,
949 subject: claims.sub,
950 scopes: claims.scopes,
951 ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}),
952 ...(claims.rtc ? { runtimeModeCeiling: claims.rtc } : {}),
953 } satisfies VerifiedSession;
954 },
955 );
956
957 const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims));
958 const issueWebSocketToken: SessionStore["Service"]["issueWebSocketToken"] = Effect.fn(
959 "SessionStore.issueWebSocketToken",
960 )(function* (sessionId, input) {
961 const issuedAt = yield* DateTime.now;
962 const expiresAt = DateTime.add(issuedAt, {
963 milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL),
964 });
965 const claims: WebSocketClaims = {
966 v: 1,
967 kind: "websocket",
968 sid: sessionId,
969 iat: issuedAt.epochMilliseconds,
970 exp: expiresAt.epochMilliseconds,
971 };
972 const encodedPayload = yield* encodeWsClaims(claims).pipe(
973 Effect.map(base64UrlEncode),
974 Effect.mapError(
975 (cause) =>
976 new WebSocketTokenIssueError({
977 sessionId,
978 cause: new SessionClaimsEncodingError({
979 sessionId,
980 operation: "encode_websocket_claims",
981 cause,
982 }),
983 }),
984 ),
985 );
986 const signature = signPayload(encodedPayload, signingSecret);
987 return {
988 token: `${encodedPayload}.${signature}`,
989 expiresAt,
990 };
991 });
992
993 const verifyWebSocketToken: SessionStore["Service"]["verifyWebSocketToken"] = Effect.fn(
994 "SessionStore.verifyWebSocketToken",
995 )(function* (token) {
996 const [encodedPayload, signature] = token.split(".");
997 if (!encodedPayload || !signature) {
998 return yield* new MalformedWebSocketTokenError({});
999 }
1000
1001 const expectedSignature = signPayload(encodedPayload, signingSecret);
1002 if (!timingSafeEqualBase64Url(signature, expectedSignature)) {
1003 return yield* new InvalidWebSocketTokenSignatureError({});
1004 }
1005
1006 const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
1007 Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })),
1008 );
1009 if (claims.sid.startsWith(REUSABLE_DEV_SESSION_PREFIX) && claims.sid !== devAuth?.sessionId) {
1010 return yield* new UnknownWebSocketSessionError({ sessionId: claims.sid });
1011 }
1012
1013 const observedAt = yield* DateTime.now;
1014 const expiresAt = DateTime.make(claims.exp);
1015 if (Option.isNone(expiresAt)) {
1016 return yield* new InvalidSessionExpirationClaimError({
1017 sessionId: claims.sid,
1018 expirationClaim: claims.exp,
1019 });
1020 }
1021 if (claims.exp <= observedAt.epochMilliseconds) {
1022 return yield* new WebSocketTokenExpiredError({
1023 sessionId: claims.sid,
1024 expiresAt: expiresAt.value,
1025 observedAt,
1026 });
1027 }
1028
1029 const row = yield* authSessions
1030 .getById({ sessionId: claims.sid })
1031 .pipe(
1032 Effect.mapError(
1033 (cause) => new WebSocketTokenVerificationError({ sessionId: claims.sid, cause }),
1034 ),
1035 );
1036 if (Option.isNone(row)) {
1037 return yield* new UnknownWebSocketSessionError({ sessionId: claims.sid });
1038 }
1039 if (row.value.expiresAt.epochMilliseconds <= observedAt.epochMilliseconds) {
1040 return yield* new WebSocketSessionExpiredError({
1041 sessionId: claims.sid,
1042 expiresAt: row.value.expiresAt,
1043 observedAt,
1044 });
1045 }
1046 if (row.value.revokedAt !== null) {
1047 return yield* new WebSocketSessionRevokedError({
1048 sessionId: claims.sid,
1049 revokedAt: row.value.revokedAt,
1050 });
1051 }
1052
1053 if (row.value.parentSessionId !== undefined) {
1054 yield* verifyDelegationParent(row.value.parentSessionId, observedAt);
1055 }
1056 return {
1057 sessionId: row.value.sessionId,
1058 ...(row.value.parentSessionId === undefined
1059 ? {}
1060 : { parentSessionId: row.value.parentSessionId }),
1061 ...(row.value.deviceId === undefined ? {} : { deviceId: row.value.deviceId }),
1062 token,
1063 method: row.value.method,
1064 client: toClientMetadata(row.value.client),
1065 expiresAt: row.value.expiresAt,
1066 subject: row.value.subject,
1067 scopes: row.value.scopes,
1068 } satisfies VerifiedSession;
1069 });
1070
1071 const listActive: SessionStore["Service"]["listActive"] = Effect.fn("SessionStore.listActive")(
1072 function* () {
1073 const now = yield* DateTime.now;
1074 const connectedSessions = yield* Ref.get(connectedSessionsRef);
1075 const rows = yield* authSessions.listActive({
1076 now,
1077 connectedSessionIds: Array.from(connectedSessions.keys()),
1078 });
1079
1080 return rows.map((row) =>
1081 toAuthClientSession({
1082 sessionId: row.sessionId,
1083 subject: row.subject,
1084 scopes: row.scopes,
1085 method: row.method,
1086 client: toClientMetadata(row.client),
1087 issuedAt: row.issuedAt,
1088 expiresAt: row.expiresAt,
1089 lastConnectedAt: row.lastConnectedAt,
1090 connected: connectedSessions.has(row.sessionId),
1091 }),
1092 );
1093 },
1094 Effect.mapError((cause) => new ActiveSessionsListError({ cause })),
1095 );
1096
1097 const revoke: SessionStore["Service"]["revoke"] = Effect.fn("SessionStore.revoke")(
1098 function* (sessionId) {
1099 const revokedAt = yield* DateTime.now;
1100 const revokedSessionIds = yield* authSessions
1101 .revoke({
1102 sessionId,
1103 revokedAt,
1104 })
1105 .pipe(Effect.mapError((cause) => new SessionRevocationError({ sessionId, cause })));
1106 if (revokedSessionIds.length > 0) {
1107 yield* Ref.update(connectedSessionsRef, (current) => {
1108 const next = new Map(current);
1109 for (const revokedSessionId of revokedSessionIds) next.delete(revokedSessionId);
1110 return next;
1111 });
1112 yield* Effect.forEach(revokedSessionIds, emitRemoved, {
1113 concurrency: "unbounded",
1114 discard: true,
1115 });
1116 }
1117 return revokedSessionIds.length > 0;
1118 },
1119 );
1120
1121 const revokeAllExcept: SessionStore["Service"]["revokeAllExcept"] = Effect.fn(
1122 "SessionStore.revokeAllExcept",
1123 )(function* (sessionId) {
1124 const revokedAt = yield* DateTime.now;
1125 const revokedSessionIds = yield* authSessions
1126 .revokeAllExcept({
1127 currentSessionId: sessionId,
1128 revokedAt,
1129 })
1130 .pipe(
1131 Effect.mapError(
1132 (cause) => new OtherSessionsRevocationError({ currentSessionId: sessionId, cause }),
1133 ),
1134 );
1135 if (revokedSessionIds.length > 0) {
1136 yield* Ref.update(connectedSessionsRef, (current) => {
1137 const next = new Map(current);
1138 for (const revokedSessionId of revokedSessionIds) {
1139 next.delete(revokedSessionId);
1140 }
1141 return next;
1142 });
1143 yield* Effect.forEach(
1144 revokedSessionIds,
1145 (revokedSessionId) => emitRemoved(revokedSessionId),
1146 {
1147 concurrency: "unbounded",
1148 discard: true,
1149 },
1150 );
1151 }
1152 return revokedSessionIds.length;
1153 });
1154
1155 const awaitInvalidation = Effect.fn(function* (sessionId: AuthSessionId) {
1156 return yield* Effect.scoped(
1157 Effect.gen(function* () {
1158 // Subscribe before reading: revocation can race with the WebSocket upgrade.
1159 const subscription = yield* PubSub.subscribe(changesPubSub);
1160 while (true) {
1161 const row = yield* authSessions
1162 .getById({ sessionId })
1163 .pipe(
1164 Effect.mapError(
1165 (cause) => new SessionCredentialVerificationError({ sessionId, cause }),
1166 ),
1167 );
1168 if (Option.isNone(row) || row.value.revokedAt !== null) return;
1169 const now = yield* DateTime.now;
1170 const parent =
1171 row.value.parentSessionId === undefined
1172 ? undefined
1173 : yield* authSessions
1174 .getById({ sessionId: row.value.parentSessionId })
1175 .pipe(
1176 Effect.mapError(
1177 (cause) => new SessionCredentialVerificationError({ sessionId, cause }),
1178 ),
1179 );
1180 if (parent !== undefined && (Option.isNone(parent) || parent.value.revokedAt !== null))
1181 return;
1182 const expiresAt =
1183 parent !== undefined && Option.isSome(parent)
1184 ? Math.min(
1185 row.value.expiresAt.epochMilliseconds,
1186 parent.value.expiresAt.epochMilliseconds,
1187 )
1188 : row.value.expiresAt.epochMilliseconds;
1189 const remaining = expiresAt - now.epochMilliseconds;
1190 if (remaining <= 0) return;
1191 const removed = Stream.fromSubscription(subscription).pipe(
1192 Stream.filter(
1193 (change) =>
1194 change.type === "clientRemoved" &&
1195 (change.sessionId === sessionId || change.sessionId === row.value.parentSessionId),
1196 ),
1197 Stream.runHead,
1198 Effect.as(true),
1199 );
1200 // CLI revocation writes the same database from another process.
1201 const recheck = Effect.sleep(Duration.millis(Math.min(remaining, 5_000))).pipe(
1202 Effect.as(false),
1203 );
1204 if (yield* Effect.raceFirst(removed, recheck)) return;
1205 }
1206 }),
1207 );
1208 });
1209
1210 return SessionStore.of({
1211 cookieName,
1212 legacyCookieName,
1213 issue,
1214 verify,
1215 verifyActive,
1216 issueWebSocketToken,
1217 verifyWebSocketToken,
1218 listActive,
1219 awaitInvalidation,
1220 get streamChanges() {
1221 return Stream.fromPubSub(changesPubSub);
1222 },
1223 revoke,
1224 revokeAllExcept,
1225 markConnected,
1226 markDisconnected,
1227 recordClientConnection,
1228 });
1229});
1230
1231export const layer = Layer.effect(SessionStore, make).pipe(Layer.provideMerge(AuthSessions.layer));