History

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

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

Commit 82a9bcc1 parent 61f8d462 files · +304 −149Browse files

Unified · Split

2 files
  1. apps/server/src/auth/SessionStore.test.ts +80 −4
  2. apps/server/src/auth/SessionStore.ts +224 −145
apps/server/src/auth/SessionStore.test.ts+80−4
@@ -44,8 +44,8 @@ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessi · 43 unmodified lines
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) => { · 52 unmodified lines
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) => { · 30 unmodified lines
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) => { · 21 unmodified lines
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) => { · 9 unmodified lines
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 { · 6 unmodified lines
77 type AuthEnvironmentScope,
88 type ServerAuthSessionMethod,
99 } from "@t3tools/contracts";
10−import * as Clock from "effect/Clock";
1110 import * as Context from "effect/Context";
1211 import * as Crypto from "effect/Crypto";
1312 import * as DateTime from "effect/DateTime";
@@ -93,7 +92,11 @@ export class InvalidSessionTokenPayloadError extends Schema.TaggedErrorClass<Inv · 79 unmodified lines
9392
9493 export 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 · 2 unmodified lines
102105
103106 export 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 · 2 unmodified lines
111116
112117 export 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 · 2 unmodified lines
120128
121129 export 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 · 31 unmodified lines
158169
159170 export 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 · 2 unmodified lines
167182
168183 export 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 · 2 unmodified lines
176193
177194 export 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 · 2 unmodified lines
185206
186207 export 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 = { · 26 unmodified lines
218242 export 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 · 6 unmodified lines
230255 export 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 · 5 unmodified lines
241267 export 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< · 5 unmodified lines
252279 export 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 · 5 unmodified lines
263291 export 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 · 16 unmodified lines
285314 export 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 · 5 unmodified lines
296326 export 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* () { · 237 unmodified lines
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* () { · 13 unmodified lines
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* () { · 18 unmodified lines
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* () { · 12 unmodified lines
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* () { · 7 unmodified lines
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* () { · 21 unmodified lines
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* () { · 4 unmodified lines
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,

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t