authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 20:45:17-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 20:45:17-07:00
log82a9bcc7f4e1c96bfb9cdc5acdd5be67151c707c
treec4ea560764831c0e1972329f1ac189d14e57b112
parent61f8d46fa2300e63c57229a6867c89b26c2f8f7c
signature Signed by PGP key B5690EEEBB952194

[codex] add session context to credential errors (#3349)


2 files changed, 304 insertions(+), 149 deletions(-)

apps/server/src/auth/SessionStore.test.ts+80-4
......@@ -44,8 +44,8 @@ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessi
4444 create: () => Effect.void,
4545 getById: () => Effect.fail(repositoryFailure),
4646 listActive: () => Effect.succeed([]),
47 revoke: () => Effect.succeed(false),
48 revokeAllExcept: () => Effect.succeed([]),
47 revoke: () => Effect.fail(repositoryFailure),
48 revokeAllExcept: () => Effect.fail(repositoryFailure),
4949 setLastConnectedAt: () => Effect.void,
5050});
5151
......@@ -104,11 +104,29 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
104104
105105 const sessionError = yield* Effect.flip(sessions.verify(issued.token));
106106 const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token));
107 const revokeError = yield* Effect.flip(sessions.revoke(issued.sessionId));
108 const revokeOthersError = yield* Effect.flip(sessions.revokeAllExcept(issued.sessionId));
107109
108110 expect(sessionError._tag).toBe("SessionCredentialVerificationError");
109111 expect(websocketError._tag).toBe("WebSocketTokenVerificationError");
110112 expect(sessionError.cause).toBe(repositoryFailure);
111113 expect(websocketError.cause).toBe(repositoryFailure);
114 if (sessionError._tag === "SessionCredentialVerificationError") {
115 expect(sessionError.sessionId).toBe(issued.sessionId);
116 }
117 if (websocketError._tag === "WebSocketTokenVerificationError") {
118 expect(websocketError.sessionId).toBe(issued.sessionId);
119 }
120 expect(revokeError).toMatchObject({
121 _tag: "SessionRevocationError",
122 sessionId: issued.sessionId,
123 cause: repositoryFailure,
124 });
125 expect(revokeOthersError).toMatchObject({
126 _tag: "OtherSessionsRevocationError",
127 currentSessionId: issued.sessionId,
128 cause: repositoryFailure,
129 });
112130 }).pipe(Effect.provide(failingSessionLookupCredentialLayer)),
113131 );
114132 it.effect("verifies session tokens against the Effect clock", () =>
......@@ -145,7 +163,52 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
145163 yield* TestClock.adjust(Duration.seconds(2));
146164
147165 const error = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token));
148 expect(error.message).toContain("expired");
166 expect(error._tag).toBe("WebSocketSessionExpiredError");
167 if (error._tag === "WebSocketSessionExpiredError") {
168 expect(error.sessionId).toBe(issued.sessionId);
169 expect(error.expiresAt.epochMilliseconds).toBe(issued.expiresAt.epochMilliseconds);
170 expect(error.observedAt.epochMilliseconds).toBeGreaterThan(
171 error.expiresAt.epochMilliseconds,
172 );
173 }
174 }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))),
175 );
176
177 it.effect("includes expiry context when session and websocket tokens expire", () =>
178 Effect.gen(function* () {
179 const sessions = yield* SessionStore.SessionStore;
180 const issued = yield* sessions.issue({
181 method: "bearer-access-token",
182 subject: "short-lived-token",
183 ttl: Duration.seconds(1),
184 });
185 const websocket = yield* sessions.issueWebSocketToken(issued.sessionId, {
186 ttl: Duration.seconds(1),
187 });
188
189 yield* TestClock.adjust(Duration.seconds(2));
190
191 const sessionError = yield* Effect.flip(sessions.verify(issued.token));
192 const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token));
193
194 expect(sessionError._tag).toBe("SessionTokenExpiredError");
195 if (sessionError._tag === "SessionTokenExpiredError") {
196 expect(sessionError.sessionId).toBe(issued.sessionId);
197 expect(sessionError.expiresAt.epochMilliseconds).toBe(issued.expiresAt.epochMilliseconds);
198 expect(sessionError.observedAt.epochMilliseconds).toBeGreaterThan(
199 sessionError.expiresAt.epochMilliseconds,
200 );
201 }
202 expect(websocketError._tag).toBe("WebSocketTokenExpiredError");
203 if (websocketError._tag === "WebSocketTokenExpiredError") {
204 expect(websocketError.sessionId).toBe(issued.sessionId);
205 expect(websocketError.expiresAt.epochMilliseconds).toBe(
206 websocket.expiresAt.epochMilliseconds,
207 );
208 expect(websocketError.observedAt.epochMilliseconds).toBeGreaterThan(
209 websocketError.expiresAt.epochMilliseconds,
210 );
211 }
149212 }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))),
150213 );
151214
......@@ -173,12 +236,16 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
173236 ipAddress: "192.168.1.88",
174237 },
175238 });
239 const clientWebSocket = yield* sessions.issueWebSocketToken(client.sessionId);
176240
177241 yield* sessions.markConnected(client.sessionId);
178242 const beforeRevoke = yield* sessions.listActive();
179243 const revokedCount = yield* sessions.revokeAllExcept(administrative.sessionId);
180244 const afterRevoke = yield* sessions.listActive();
181245 const revokedClient = yield* Effect.flip(sessions.verify(client.token));
246 const revokedClientWebSocket = yield* Effect.flip(
247 sessions.verifyWebSocketToken(clientWebSocket.token),
248 );
182249
183250 expect(beforeRevoke).toHaveLength(2);
184251 expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.connected).toBe(
......@@ -194,7 +261,16 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
194261 expect(revokedCount).toBe(1);
195262 expect(afterRevoke).toHaveLength(1);
196263 expect(afterRevoke[0]?.sessionId).toBe(administrative.sessionId);
197 expect(revokedClient.message).toContain("revoked");
264 expect(revokedClient._tag).toBe("SessionTokenRevokedError");
265 if (revokedClient._tag === "SessionTokenRevokedError") {
266 expect(revokedClient.sessionId).toBe(client.sessionId);
267 expect(revokedClient.revokedAt.epochMilliseconds).toBeGreaterThanOrEqual(0);
268 }
269 expect(revokedClientWebSocket._tag).toBe("WebSocketSessionRevokedError");
270 if (revokedClientWebSocket._tag === "WebSocketSessionRevokedError") {
271 expect(revokedClientWebSocket.sessionId).toBe(client.sessionId);
272 expect(revokedClientWebSocket.revokedAt.epochMilliseconds).toBeGreaterThanOrEqual(0);
273 }
198274 }).pipe(Effect.provide(makeSessionStoreLayer())),
199275 );
200276
apps/server/src/auth/SessionStore.ts+224-145
......@@ -7,7 +7,6 @@ import {
77 type AuthEnvironmentScope,
88 type ServerAuthSessionMethod,
99} from "@t3tools/contracts";
10import * as Clock from "effect/Clock";
1110import * as Context from "effect/Context";
1211import * as Crypto from "effect/Crypto";
1312import * as DateTime from "effect/DateTime";
......@@ -93,7 +92,11 @@ export class InvalidSessionTokenPayloadError extends Schema.TaggedErrorClass<Inv
9392
9493export class SessionTokenExpiredError extends Schema.TaggedErrorClass<SessionTokenExpiredError>()(
9594 "SessionTokenExpiredError",
96 {},
95 {
96 sessionId: AuthSessionId,
97 expiresAt: Schema.DateTimeUtc,
98 observedAt: Schema.DateTimeUtc,
99 },
97100) {
98101 override get message(): string {
99102 return "Session token expired.";
......@@ -102,7 +105,9 @@ export class SessionTokenExpiredError extends Schema.TaggedErrorClass<SessionTok
102105
103106export class UnknownSessionTokenError extends Schema.TaggedErrorClass<UnknownSessionTokenError>()(
104107 "UnknownSessionTokenError",
105 {},
108 {
109 sessionId: AuthSessionId,
110 },
106111) {
107112 override get message(): string {
108113 return "Unknown session token.";
......@@ -111,7 +116,10 @@ export class UnknownSessionTokenError extends Schema.TaggedErrorClass<UnknownSes
111116
112117export class SessionTokenRevokedError extends Schema.TaggedErrorClass<SessionTokenRevokedError>()(
113118 "SessionTokenRevokedError",
114 {},
119 {
120 sessionId: AuthSessionId,
121 revokedAt: Schema.DateTimeUtc,
122 },
115123) {
116124 override get message(): string {
117125 return "Session token revoked.";
......@@ -120,7 +128,10 @@ export class SessionTokenRevokedError extends Schema.TaggedErrorClass<SessionTok
120128
121129export class InvalidSessionExpirationClaimError extends Schema.TaggedErrorClass<InvalidSessionExpirationClaimError>()(
122130 "InvalidSessionExpirationClaimError",
123 {},
131 {
132 sessionId: AuthSessionId,
133 expirationClaim: Schema.Number,
134 },
124135) {
125136 override get message(): string {
126137 return "Invalid `exp` claim";
......@@ -158,7 +169,11 @@ export class InvalidWebSocketTokenPayloadError extends Schema.TaggedErrorClass<I
158169
159170export class WebSocketTokenExpiredError extends Schema.TaggedErrorClass<WebSocketTokenExpiredError>()(
160171 "WebSocketTokenExpiredError",
161 {},
172 {
173 sessionId: AuthSessionId,
174 expiresAt: Schema.DateTimeUtc,
175 observedAt: Schema.DateTimeUtc,
176 },
162177) {
163178 override get message(): string {
164179 return "Websocket token expired.";
......@@ -167,7 +182,9 @@ export class WebSocketTokenExpiredError extends Schema.TaggedErrorClass<WebSocke
167182
168183export class UnknownWebSocketSessionError extends Schema.TaggedErrorClass<UnknownWebSocketSessionError>()(
169184 "UnknownWebSocketSessionError",
170 {},
185 {
186 sessionId: AuthSessionId,
187 },
171188) {
172189 override get message(): string {
173190 return "Unknown websocket session.";
......@@ -176,7 +193,11 @@ export class UnknownWebSocketSessionError extends Schema.TaggedErrorClass<Unknow
176193
177194export class WebSocketSessionExpiredError extends Schema.TaggedErrorClass<WebSocketSessionExpiredError>()(
178195 "WebSocketSessionExpiredError",
179 {},
196 {
197 sessionId: AuthSessionId,
198 expiresAt: Schema.DateTimeUtc,
199 observedAt: Schema.DateTimeUtc,
200 },
180201) {
181202 override get message(): string {
182203 return "Websocket session expired.";
......@@ -185,7 +206,10 @@ export class WebSocketSessionExpiredError extends Schema.TaggedErrorClass<WebSoc
185206
186207export class WebSocketSessionRevokedError extends Schema.TaggedErrorClass<WebSocketSessionRevokedError>()(
187208 "WebSocketSessionRevokedError",
188 {},
209 {
210 sessionId: AuthSessionId,
211 revokedAt: Schema.DateTimeUtc,
212 },
189213) {
190214 override get message(): string {
191215 return "Websocket session revoked.";
......@@ -218,6 +242,7 @@ const sessionCredentialInternalErrorContext = {
218242export class SessionClaimsEncodingError extends Schema.TaggedErrorClass<SessionClaimsEncodingError>()(
219243 "SessionClaimsEncodingError",
220244 {
245 sessionId: AuthSessionId,
221246 operation: Schema.Literals(["encode_session_claims", "encode_websocket_claims"]),
222247 ...sessionCredentialInternalErrorContext,
223248 },
......@@ -230,6 +255,7 @@ export class SessionClaimsEncodingError extends Schema.TaggedErrorClass<SessionC
230255export class SessionCredentialIssueError extends Schema.TaggedErrorClass<SessionCredentialIssueError>()(
231256 "SessionCredentialIssueError",
232257 {
258 sessionId: Schema.optional(AuthSessionId),
233259 ...sessionCredentialInternalErrorContext,
234260 },
235261) {
......@@ -241,6 +267,7 @@ export class SessionCredentialIssueError extends Schema.TaggedErrorClass<Session
241267export class SessionCredentialVerificationError extends Schema.TaggedErrorClass<SessionCredentialVerificationError>()(
242268 "SessionCredentialVerificationError",
243269 {
270 sessionId: AuthSessionId,
244271 ...sessionCredentialInternalErrorContext,
245272 },
246273) {
......@@ -252,6 +279,7 @@ export class SessionCredentialVerificationError extends Schema.TaggedErrorClass<
252279export class WebSocketTokenIssueError extends Schema.TaggedErrorClass<WebSocketTokenIssueError>()(
253280 "WebSocketTokenIssueError",
254281 {
282 sessionId: AuthSessionId,
255283 ...sessionCredentialInternalErrorContext,
256284 },
257285) {
......@@ -263,6 +291,7 @@ export class WebSocketTokenIssueError extends Schema.TaggedErrorClass<WebSocketT
263291export class WebSocketTokenVerificationError extends Schema.TaggedErrorClass<WebSocketTokenVerificationError>()(
264292 "WebSocketTokenVerificationError",
265293 {
294 sessionId: AuthSessionId,
266295 ...sessionCredentialInternalErrorContext,
267296 },
268297) {
......@@ -285,6 +314,7 @@ export class ActiveSessionsListError extends Schema.TaggedErrorClass<ActiveSessi
285314export class SessionRevocationError extends Schema.TaggedErrorClass<SessionRevocationError>()(
286315 "SessionRevocationError",
287316 {
317 sessionId: AuthSessionId,
288318 ...sessionCredentialInternalErrorContext,
289319 },
290320) {
......@@ -296,6 +326,7 @@ export class SessionRevocationError extends Schema.TaggedErrorClass<SessionRevoc
296326export class OtherSessionsRevocationError extends Schema.TaggedErrorClass<OtherSessionsRevocationError>()(
297327 "OtherSessionsRevocationError",
298328 {
329 currentSessionId: AuthSessionId,
299330 ...sessionCredentialInternalErrorContext,
300331 },
301332) {
......@@ -539,7 +570,11 @@ export const make = Effect.gen(function* () {
539570 const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims));
540571 const issue: SessionStore["Service"]["issue"] = Effect.fn("SessionStore.issue")(
541572 function* (input) {
542 const sessionId = AuthSessionId.make(yield* crypto.randomUUIDv4);
573 const sessionId = AuthSessionId.make(
574 yield* crypto.randomUUIDv4.pipe(
575 Effect.mapError((cause) => new SessionCredentialIssueError({ cause })),
576 ),
577 );
543578 const issuedAt = yield* DateTime.now;
544579 const expiresAt = DateTime.add(issuedAt, {
545580 milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL),
......@@ -559,27 +594,37 @@ export const make = Effect.gen(function* () {
559594 const encodedPayload = yield* encodeClaims(claims).pipe(
560595 Effect.map(base64UrlEncode),
561596 Effect.mapError(
562 (cause) => new SessionClaimsEncodingError({ operation: "encode_session_claims", cause }),
597 (cause) =>
598 new SessionCredentialIssueError({
599 sessionId,
600 cause: new SessionClaimsEncodingError({
601 sessionId,
602 operation: "encode_session_claims",
603 cause,
604 }),
605 }),
563606 ),
564607 );
565608 const signature = signPayload(encodedPayload, signingSecret);
566609 const client = input?.client ?? createDefaultClientMetadata();
567 yield* authSessions.create({
568 sessionId,
569 subject: claims.sub,
570 scopes: claims.scopes,
571 method: claims.method,
572 client: {
573 label: client.label ?? null,
574 ipAddress: client.ipAddress ?? null,
575 userAgent: client.userAgent ?? null,
576 deviceType: client.deviceType,
577 os: client.os ?? null,
578 browser: client.browser ?? null,
579 },
580 issuedAt,
581 expiresAt,
582 });
610 yield* authSessions
611 .create({
612 sessionId,
613 subject: claims.sub,
614 scopes: claims.scopes,
615 method: claims.method,
616 client: {
617 label: client.label ?? null,
618 ipAddress: client.ipAddress ?? null,
619 userAgent: client.userAgent ?? null,
620 deviceType: client.deviceType,
621 os: client.os ?? null,
622 browser: client.browser ?? null,
623 },
624 issuedAt,
625 expiresAt,
626 })
627 .pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause })));
583628 yield* emitUpsert(
584629 toAuthClientSession({
585630 sessionId,
......@@ -604,7 +649,6 @@ export const make = Effect.gen(function* () {
604649 ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}),
605650 } satisfies IssuedSession;
606651 },
607 Effect.mapError((cause) => new SessionCredentialIssueError({ cause })),
608652 );
609653
610654 const verify: SessionStore["Service"]["verify"] = Effect.fn("SessionStore.verify")(
......@@ -623,22 +667,37 @@ export const make = Effect.gen(function* () {
623667 Effect.mapError((cause) => new InvalidSessionTokenPayloadError({ cause })),
624668 );
625669
626 const now = yield* Clock.currentTimeMillis;
627 if (claims.exp <= now) {
628 return yield* new SessionTokenExpiredError({});
670 const observedAt = yield* DateTime.now;
671 const expiresAt = DateTime.make(claims.exp);
672 if (Option.isNone(expiresAt)) {
673 return yield* new InvalidSessionExpirationClaimError({
674 sessionId: claims.sid,
675 expirationClaim: claims.exp,
676 });
677 }
678 if (claims.exp <= observedAt.epochMilliseconds) {
679 return yield* new SessionTokenExpiredError({
680 sessionId: claims.sid,
681 expiresAt: expiresAt.value,
682 observedAt,
683 });
629684 }
630685
631 const row = yield* authSessions.getById({ sessionId: claims.sid });
686 const row = yield* authSessions
687 .getById({ sessionId: claims.sid })
688 .pipe(
689 Effect.mapError(
690 (cause) => new SessionCredentialVerificationError({ sessionId: claims.sid, cause }),
691 ),
692 );
632693 if (Option.isNone(row)) {
633 return yield* new UnknownSessionTokenError({});
694 return yield* new UnknownSessionTokenError({ sessionId: claims.sid });
634695 }
635696 if (row.value.revokedAt !== null) {
636 return yield* new SessionTokenRevokedError({});
637 }
638
639 const expiresAt = DateTime.make(claims.exp);
640 if (Option.isNone(expiresAt)) {
641 return yield* new InvalidSessionExpirationClaimError({});
697 return yield* new SessionTokenRevokedError({
698 sessionId: claims.sid,
699 revokedAt: row.value.revokedAt,
700 });
642701 }
643702
644703 return {
......@@ -652,95 +711,111 @@ export const make = Effect.gen(function* () {
652711 ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}),
653712 } satisfies VerifiedSession;
654713 },
655 Effect.mapError((cause) =>
656 isSessionCredentialInvalidError(cause)
657 ? cause
658 : new SessionCredentialVerificationError({ cause }),
659 ),
660714 );
661715
662716 const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims));
663717 const issueWebSocketToken: SessionStore["Service"]["issueWebSocketToken"] = Effect.fn(
664718 "SessionStore.issueWebSocketToken",
665 )(
666 function* (sessionId, input) {
667 const issuedAt = yield* DateTime.now;
668 const expiresAt = DateTime.add(issuedAt, {
669 milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL),
670 });
671 const claims: WebSocketClaims = {
672 v: 1,
673 kind: "websocket",
674 sid: sessionId,
675 iat: issuedAt.epochMilliseconds,
676 exp: expiresAt.epochMilliseconds,
677 };
678 const encodedPayload = yield* encodeWsClaims(claims).pipe(
679 Effect.map(base64UrlEncode),
680 Effect.mapError(
681 (cause) =>
682 new SessionClaimsEncodingError({ operation: "encode_websocket_claims", cause }),
683 ),
684 );
685 const signature = signPayload(encodedPayload, signingSecret);
686 return {
687 token: `${encodedPayload}.${signature}`,
688 expiresAt,
689 };
690 },
691 Effect.mapError((cause) => new WebSocketTokenIssueError({ cause })),
692 );
719 )(function* (sessionId, input) {
720 const issuedAt = yield* DateTime.now;
721 const expiresAt = DateTime.add(issuedAt, {
722 milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL),
723 });
724 const claims: WebSocketClaims = {
725 v: 1,
726 kind: "websocket",
727 sid: sessionId,
728 iat: issuedAt.epochMilliseconds,
729 exp: expiresAt.epochMilliseconds,
730 };
731 const encodedPayload = yield* encodeWsClaims(claims).pipe(
732 Effect.map(base64UrlEncode),
733 Effect.mapError(
734 (cause) =>
735 new WebSocketTokenIssueError({
736 sessionId,
737 cause: new SessionClaimsEncodingError({
738 sessionId,
739 operation: "encode_websocket_claims",
740 cause,
741 }),
742 }),
743 ),
744 );
745 const signature = signPayload(encodedPayload, signingSecret);
746 return {
747 token: `${encodedPayload}.${signature}`,
748 expiresAt,
749 };
750 });
693751
694752 const verifyWebSocketToken: SessionStore["Service"]["verifyWebSocketToken"] = Effect.fn(
695753 "SessionStore.verifyWebSocketToken",
696 )(
697 function* (token) {
698 const [encodedPayload, signature] = token.split(".");
699 if (!encodedPayload || !signature) {
700 return yield* new MalformedWebSocketTokenError({});
701 }
702
703 const expectedSignature = signPayload(encodedPayload, signingSecret);
704 if (!timingSafeEqualBase64Url(signature, expectedSignature)) {
705 return yield* new InvalidWebSocketTokenSignatureError({});
706 }
754 )(function* (token) {
755 const [encodedPayload, signature] = token.split(".");
756 if (!encodedPayload || !signature) {
757 return yield* new MalformedWebSocketTokenError({});
758 }
707759
708 const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
709 Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })),
710 );
760 const expectedSignature = signPayload(encodedPayload, signingSecret);
761 if (!timingSafeEqualBase64Url(signature, expectedSignature)) {
762 return yield* new InvalidWebSocketTokenSignatureError({});
763 }
711764
712 const now = yield* Clock.currentTimeMillis;
713 if (claims.exp <= now) {
714 return yield* new WebSocketTokenExpiredError({});
715 }
765 const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
766 Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })),
767 );
716768
717 const row = yield* authSessions.getById({ sessionId: claims.sid });
718 if (Option.isNone(row)) {
719 return yield* new UnknownWebSocketSessionError({});
720 }
721 if (row.value.expiresAt.epochMilliseconds <= now) {
722 return yield* new WebSocketSessionExpiredError({});
723 }
724 if (row.value.revokedAt !== null) {
725 return yield* new WebSocketSessionRevokedError({});
726 }
769 const observedAt = yield* DateTime.now;
770 const expiresAt = DateTime.make(claims.exp);
771 if (Option.isNone(expiresAt)) {
772 return yield* new InvalidSessionExpirationClaimError({
773 sessionId: claims.sid,
774 expirationClaim: claims.exp,
775 });
776 }
777 if (claims.exp <= observedAt.epochMilliseconds) {
778 return yield* new WebSocketTokenExpiredError({
779 sessionId: claims.sid,
780 expiresAt: expiresAt.value,
781 observedAt,
782 });
783 }
727784
728 return {
729 sessionId: row.value.sessionId,
730 token,
731 method: row.value.method,
732 client: toClientMetadata(row.value.client),
785 const row = yield* authSessions
786 .getById({ sessionId: claims.sid })
787 .pipe(
788 Effect.mapError(
789 (cause) => new WebSocketTokenVerificationError({ sessionId: claims.sid, cause }),
790 ),
791 );
792 if (Option.isNone(row)) {
793 return yield* new UnknownWebSocketSessionError({ sessionId: claims.sid });
794 }
795 if (row.value.expiresAt.epochMilliseconds <= observedAt.epochMilliseconds) {
796 return yield* new WebSocketSessionExpiredError({
797 sessionId: claims.sid,
733798 expiresAt: row.value.expiresAt,
734 subject: row.value.subject,
735 scopes: row.value.scopes,
736 } satisfies VerifiedSession;
737 },
738 Effect.mapError((cause) =>
739 isSessionCredentialInvalidError(cause)
740 ? cause
741 : new WebSocketTokenVerificationError({ cause }),
742 ),
743 );
799 observedAt,
800 });
801 }
802 if (row.value.revokedAt !== null) {
803 return yield* new WebSocketSessionRevokedError({
804 sessionId: claims.sid,
805 revokedAt: row.value.revokedAt,
806 });
807 }
808
809 return {
810 sessionId: row.value.sessionId,
811 token,
812 method: row.value.method,
813 client: toClientMetadata(row.value.client),
814 expiresAt: row.value.expiresAt,
815 subject: row.value.subject,
816 scopes: row.value.scopes,
817 } satisfies VerifiedSession;
818 });
744819
745820 const listActive: SessionStore["Service"]["listActive"] = Effect.fn("SessionStore.listActive")(
746821 function* () {
......@@ -768,10 +843,12 @@ export const make = Effect.gen(function* () {
768843 const revoke: SessionStore["Service"]["revoke"] = Effect.fn("SessionStore.revoke")(
769844 function* (sessionId) {
770845 const revokedAt = yield* DateTime.now;
771 const revoked = yield* authSessions.revoke({
772 sessionId,
773 revokedAt,
774 });
846 const revoked = yield* authSessions
847 .revoke({
848 sessionId,
849 revokedAt,
850 })
851 .pipe(Effect.mapError((cause) => new SessionRevocationError({ sessionId, cause })));
775852 if (revoked) {
776853 yield* Ref.update(connectedSessionsRef, (current) => {
777854 const next = new Map(current);
......@@ -782,39 +859,41 @@ export const make = Effect.gen(function* () {
782859 }
783860 return revoked;
784861 },
785 Effect.mapError((cause) => new SessionRevocationError({ cause })),
786862 );
787863
788864 const revokeAllExcept: SessionStore["Service"]["revokeAllExcept"] = Effect.fn(
789865 "SessionStore.revokeAllExcept",
790 )(
791 function* (sessionId) {
792 const revokedAt = yield* DateTime.now;
793 const revokedSessionIds = yield* authSessions.revokeAllExcept({
866 )(function* (sessionId) {
867 const revokedAt = yield* DateTime.now;
868 const revokedSessionIds = yield* authSessions
869 .revokeAllExcept({
794870 currentSessionId: sessionId,
795871 revokedAt,
872 })
873 .pipe(
874 Effect.mapError(
875 (cause) => new OtherSessionsRevocationError({ currentSessionId: sessionId, cause }),
876 ),
877 );
878 if (revokedSessionIds.length > 0) {
879 yield* Ref.update(connectedSessionsRef, (current) => {
880 const next = new Map(current);
881 for (const revokedSessionId of revokedSessionIds) {
882 next.delete(revokedSessionId);
883 }
884 return next;
796885 });
797 if (revokedSessionIds.length > 0) {
798 yield* Ref.update(connectedSessionsRef, (current) => {
799 const next = new Map(current);
800 for (const revokedSessionId of revokedSessionIds) {
801 next.delete(revokedSessionId);
802 }
803 return next;
804 });
805 yield* Effect.forEach(
806 revokedSessionIds,
807 (revokedSessionId) => emitRemoved(revokedSessionId),
808 {
809 concurrency: "unbounded",
810 discard: true,
811 },
812 );
813 }
814 return revokedSessionIds.length;
815 },
816 Effect.mapError((cause) => new OtherSessionsRevocationError({ cause })),
817 );
886 yield* Effect.forEach(
887 revokedSessionIds,
888 (revokedSessionId) => emitRemoved(revokedSessionId),
889 {
890 concurrency: "unbounded",
891 discard: true,
892 },
893 );
894 }
895 return revokedSessionIds.length;
896 });
818897
819898 return SessionStore.of({
820899 cookieName,