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...@@ -44,8 +44,8 @@ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessi
44 create: () => Effect.void,44 create: () => Effect.void,
45 getById: () => Effect.fail(repositoryFailure),45 getById: () => Effect.fail(repositoryFailure),
46 listActive: () => Effect.succeed([]),46 listActive: () => Effect.succeed([]),
47 revoke: () => Effect.succeed(false),47 revoke: () => Effect.fail(repositoryFailure),
48 revokeAllExcept: () => Effect.succeed([]),48 revokeAllExcept: () => Effect.fail(repositoryFailure),
49 setLastConnectedAt: () => Effect.void,49 setLastConnectedAt: () => Effect.void,
50});50});
5151
...@@ -104,11 +104,29 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {...@@ -104,11 +104,29 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
104104
105 const sessionError = yield* Effect.flip(sessions.verify(issued.token));105 const sessionError = yield* Effect.flip(sessions.verify(issued.token));
106 const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token));106 const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token));
107 const revokeError = yield* Effect.flip(sessions.revoke(issued.sessionId));
108 const revokeOthersError = yield* Effect.flip(sessions.revokeAllExcept(issued.sessionId));
107109
108 expect(sessionError._tag).toBe("SessionCredentialVerificationError");110 expect(sessionError._tag).toBe("SessionCredentialVerificationError");
109 expect(websocketError._tag).toBe("WebSocketTokenVerificationError");111 expect(websocketError._tag).toBe("WebSocketTokenVerificationError");
110 expect(sessionError.cause).toBe(repositoryFailure);112 expect(sessionError.cause).toBe(repositoryFailure);
111 expect(websocketError.cause).toBe(repositoryFailure);113 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 });
112 }).pipe(Effect.provide(failingSessionLookupCredentialLayer)),130 }).pipe(Effect.provide(failingSessionLookupCredentialLayer)),
113 );131 );
114 it.effect("verifies session tokens against the Effect clock", () =>132 it.effect("verifies session tokens against the Effect clock", () =>
...@@ -145,7 +163,52 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {...@@ -145,7 +163,52 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
145 yield* TestClock.adjust(Duration.seconds(2));163 yield* TestClock.adjust(Duration.seconds(2));
146164
147 const error = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token));165 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 }
149 }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))),212 }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))),
150 );213 );
151214
...@@ -173,12 +236,16 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {...@@ -173,12 +236,16 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
173 ipAddress: "192.168.1.88",236 ipAddress: "192.168.1.88",
174 },237 },
175 });238 });
239 const clientWebSocket = yield* sessions.issueWebSocketToken(client.sessionId);
176240
177 yield* sessions.markConnected(client.sessionId);241 yield* sessions.markConnected(client.sessionId);
178 const beforeRevoke = yield* sessions.listActive();242 const beforeRevoke = yield* sessions.listActive();
179 const revokedCount = yield* sessions.revokeAllExcept(administrative.sessionId);243 const revokedCount = yield* sessions.revokeAllExcept(administrative.sessionId);
180 const afterRevoke = yield* sessions.listActive();244 const afterRevoke = yield* sessions.listActive();
181 const revokedClient = yield* Effect.flip(sessions.verify(client.token));245 const revokedClient = yield* Effect.flip(sessions.verify(client.token));
246 const revokedClientWebSocket = yield* Effect.flip(
247 sessions.verifyWebSocketToken(clientWebSocket.token),
248 );
182249
183 expect(beforeRevoke).toHaveLength(2);250 expect(beforeRevoke).toHaveLength(2);
184 expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.connected).toBe(251 expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.connected).toBe(
...@@ -194,7 +261,16 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {...@@ -194,7 +261,16 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => {
194 expect(revokedCount).toBe(1);261 expect(revokedCount).toBe(1);
195 expect(afterRevoke).toHaveLength(1);262 expect(afterRevoke).toHaveLength(1);
196 expect(afterRevoke[0]?.sessionId).toBe(administrative.sessionId);263 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 }
198 }).pipe(Effect.provide(makeSessionStoreLayer())),274 }).pipe(Effect.provide(makeSessionStoreLayer())),
199 );275 );
200276
apps/server/src/auth/SessionStore.ts+224-145
...@@ -7,7 +7,6 @@ import {...@@ -7,7 +7,6 @@ import {
7 type AuthEnvironmentScope,7 type AuthEnvironmentScope,
8 type ServerAuthSessionMethod,8 type ServerAuthSessionMethod,
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import * as Clock from "effect/Clock";
11import * as Context from "effect/Context";10import * as Context from "effect/Context";
12import * as Crypto from "effect/Crypto";11import * as Crypto from "effect/Crypto";
13import * as DateTime from "effect/DateTime";12import * as DateTime from "effect/DateTime";
...@@ -93,7 +92,11 @@ export class InvalidSessionTokenPayloadError extends Schema.TaggedErrorClass<Inv...@@ -93,7 +92,11 @@ export class InvalidSessionTokenPayloadError extends Schema.TaggedErrorClass<Inv
9392
94export class SessionTokenExpiredError extends Schema.TaggedErrorClass<SessionTokenExpiredError>()(93export class SessionTokenExpiredError extends Schema.TaggedErrorClass<SessionTokenExpiredError>()(
95 "SessionTokenExpiredError",94 "SessionTokenExpiredError",
96 {},95 {
96 sessionId: AuthSessionId,
97 expiresAt: Schema.DateTimeUtc,
98 observedAt: Schema.DateTimeUtc,
99 },
97) {100) {
98 override get message(): string {101 override get message(): string {
99 return "Session token expired.";102 return "Session token expired.";
...@@ -102,7 +105,9 @@ export class SessionTokenExpiredError extends Schema.TaggedErrorClass<SessionTok...@@ -102,7 +105,9 @@ export class SessionTokenExpiredError extends Schema.TaggedErrorClass<SessionTok
102105
103export class UnknownSessionTokenError extends Schema.TaggedErrorClass<UnknownSessionTokenError>()(106export class UnknownSessionTokenError extends Schema.TaggedErrorClass<UnknownSessionTokenError>()(
104 "UnknownSessionTokenError",107 "UnknownSessionTokenError",
105 {},108 {
109 sessionId: AuthSessionId,
110 },
106) {111) {
107 override get message(): string {112 override get message(): string {
108 return "Unknown session token.";113 return "Unknown session token.";
...@@ -111,7 +116,10 @@ export class UnknownSessionTokenError extends Schema.TaggedErrorClass<UnknownSes...@@ -111,7 +116,10 @@ export class UnknownSessionTokenError extends Schema.TaggedErrorClass<UnknownSes
111116
112export class SessionTokenRevokedError extends Schema.TaggedErrorClass<SessionTokenRevokedError>()(117export class SessionTokenRevokedError extends Schema.TaggedErrorClass<SessionTokenRevokedError>()(
113 "SessionTokenRevokedError",118 "SessionTokenRevokedError",
114 {},119 {
120 sessionId: AuthSessionId,
121 revokedAt: Schema.DateTimeUtc,
122 },
115) {123) {
116 override get message(): string {124 override get message(): string {
117 return "Session token revoked.";125 return "Session token revoked.";
...@@ -120,7 +128,10 @@ export class SessionTokenRevokedError extends Schema.TaggedErrorClass<SessionTok...@@ -120,7 +128,10 @@ export class SessionTokenRevokedError extends Schema.TaggedErrorClass<SessionTok
120128
121export class InvalidSessionExpirationClaimError extends Schema.TaggedErrorClass<InvalidSessionExpirationClaimError>()(129export class InvalidSessionExpirationClaimError extends Schema.TaggedErrorClass<InvalidSessionExpirationClaimError>()(
122 "InvalidSessionExpirationClaimError",130 "InvalidSessionExpirationClaimError",
123 {},131 {
132 sessionId: AuthSessionId,
133 expirationClaim: Schema.Number,
134 },
124) {135) {
125 override get message(): string {136 override get message(): string {
126 return "Invalid `exp` claim";137 return "Invalid `exp` claim";
...@@ -158,7 +169,11 @@ export class InvalidWebSocketTokenPayloadError extends Schema.TaggedErrorClass<I...@@ -158,7 +169,11 @@ export class InvalidWebSocketTokenPayloadError extends Schema.TaggedErrorClass<I
158169
159export class WebSocketTokenExpiredError extends Schema.TaggedErrorClass<WebSocketTokenExpiredError>()(170export class WebSocketTokenExpiredError extends Schema.TaggedErrorClass<WebSocketTokenExpiredError>()(
160 "WebSocketTokenExpiredError",171 "WebSocketTokenExpiredError",
161 {},172 {
173 sessionId: AuthSessionId,
174 expiresAt: Schema.DateTimeUtc,
175 observedAt: Schema.DateTimeUtc,
176 },
162) {177) {
163 override get message(): string {178 override get message(): string {
164 return "Websocket token expired.";179 return "Websocket token expired.";
...@@ -167,7 +182,9 @@ export class WebSocketTokenExpiredError extends Schema.TaggedErrorClass<WebSocke...@@ -167,7 +182,9 @@ export class WebSocketTokenExpiredError extends Schema.TaggedErrorClass<WebSocke
167182
168export class UnknownWebSocketSessionError extends Schema.TaggedErrorClass<UnknownWebSocketSessionError>()(183export class UnknownWebSocketSessionError extends Schema.TaggedErrorClass<UnknownWebSocketSessionError>()(
169 "UnknownWebSocketSessionError",184 "UnknownWebSocketSessionError",
170 {},185 {
186 sessionId: AuthSessionId,
187 },
171) {188) {
172 override get message(): string {189 override get message(): string {
173 return "Unknown websocket session.";190 return "Unknown websocket session.";
...@@ -176,7 +193,11 @@ export class UnknownWebSocketSessionError extends Schema.TaggedErrorClass<Unknow...@@ -176,7 +193,11 @@ export class UnknownWebSocketSessionError extends Schema.TaggedErrorClass<Unknow
176193
177export class WebSocketSessionExpiredError extends Schema.TaggedErrorClass<WebSocketSessionExpiredError>()(194export class WebSocketSessionExpiredError extends Schema.TaggedErrorClass<WebSocketSessionExpiredError>()(
178 "WebSocketSessionExpiredError",195 "WebSocketSessionExpiredError",
179 {},196 {
197 sessionId: AuthSessionId,
198 expiresAt: Schema.DateTimeUtc,
199 observedAt: Schema.DateTimeUtc,
200 },
180) {201) {
181 override get message(): string {202 override get message(): string {
182 return "Websocket session expired.";203 return "Websocket session expired.";
...@@ -185,7 +206,10 @@ export class WebSocketSessionExpiredError extends Schema.TaggedErrorClass<WebSoc...@@ -185,7 +206,10 @@ export class WebSocketSessionExpiredError extends Schema.TaggedErrorClass<WebSoc
185206
186export class WebSocketSessionRevokedError extends Schema.TaggedErrorClass<WebSocketSessionRevokedError>()(207export class WebSocketSessionRevokedError extends Schema.TaggedErrorClass<WebSocketSessionRevokedError>()(
187 "WebSocketSessionRevokedError",208 "WebSocketSessionRevokedError",
188 {},209 {
210 sessionId: AuthSessionId,
211 revokedAt: Schema.DateTimeUtc,
212 },
189) {213) {
190 override get message(): string {214 override get message(): string {
191 return "Websocket session revoked.";215 return "Websocket session revoked.";
...@@ -218,6 +242,7 @@ const sessionCredentialInternalErrorContext = {...@@ -218,6 +242,7 @@ const sessionCredentialInternalErrorContext = {
218export class SessionClaimsEncodingError extends Schema.TaggedErrorClass<SessionClaimsEncodingError>()(242export class SessionClaimsEncodingError extends Schema.TaggedErrorClass<SessionClaimsEncodingError>()(
219 "SessionClaimsEncodingError",243 "SessionClaimsEncodingError",
220 {244 {
245 sessionId: AuthSessionId,
221 operation: Schema.Literals(["encode_session_claims", "encode_websocket_claims"]),246 operation: Schema.Literals(["encode_session_claims", "encode_websocket_claims"]),
222 ...sessionCredentialInternalErrorContext,247 ...sessionCredentialInternalErrorContext,
223 },248 },
...@@ -230,6 +255,7 @@ export class SessionClaimsEncodingError extends Schema.TaggedErrorClass<SessionC...@@ -230,6 +255,7 @@ export class SessionClaimsEncodingError extends Schema.TaggedErrorClass<SessionC
230export class SessionCredentialIssueError extends Schema.TaggedErrorClass<SessionCredentialIssueError>()(255export class SessionCredentialIssueError extends Schema.TaggedErrorClass<SessionCredentialIssueError>()(
231 "SessionCredentialIssueError",256 "SessionCredentialIssueError",
232 {257 {
258 sessionId: Schema.optional(AuthSessionId),
233 ...sessionCredentialInternalErrorContext,259 ...sessionCredentialInternalErrorContext,
234 },260 },
235) {261) {
...@@ -241,6 +267,7 @@ export class SessionCredentialIssueError extends Schema.TaggedErrorClass<Session...@@ -241,6 +267,7 @@ export class SessionCredentialIssueError extends Schema.TaggedErrorClass<Session
241export class SessionCredentialVerificationError extends Schema.TaggedErrorClass<SessionCredentialVerificationError>()(267export class SessionCredentialVerificationError extends Schema.TaggedErrorClass<SessionCredentialVerificationError>()(
242 "SessionCredentialVerificationError",268 "SessionCredentialVerificationError",
243 {269 {
270 sessionId: AuthSessionId,
244 ...sessionCredentialInternalErrorContext,271 ...sessionCredentialInternalErrorContext,
245 },272 },
246) {273) {
...@@ -252,6 +279,7 @@ export class SessionCredentialVerificationError extends Schema.TaggedErrorClass<...@@ -252,6 +279,7 @@ export class SessionCredentialVerificationError extends Schema.TaggedErrorClass<
252export class WebSocketTokenIssueError extends Schema.TaggedErrorClass<WebSocketTokenIssueError>()(279export class WebSocketTokenIssueError extends Schema.TaggedErrorClass<WebSocketTokenIssueError>()(
253 "WebSocketTokenIssueError",280 "WebSocketTokenIssueError",
254 {281 {
282 sessionId: AuthSessionId,
255 ...sessionCredentialInternalErrorContext,283 ...sessionCredentialInternalErrorContext,
256 },284 },
257) {285) {
...@@ -263,6 +291,7 @@ export class WebSocketTokenIssueError extends Schema.TaggedErrorClass<WebSocketT...@@ -263,6 +291,7 @@ export class WebSocketTokenIssueError extends Schema.TaggedErrorClass<WebSocketT
263export class WebSocketTokenVerificationError extends Schema.TaggedErrorClass<WebSocketTokenVerificationError>()(291export class WebSocketTokenVerificationError extends Schema.TaggedErrorClass<WebSocketTokenVerificationError>()(
264 "WebSocketTokenVerificationError",292 "WebSocketTokenVerificationError",
265 {293 {
294 sessionId: AuthSessionId,
266 ...sessionCredentialInternalErrorContext,295 ...sessionCredentialInternalErrorContext,
267 },296 },
268) {297) {
...@@ -285,6 +314,7 @@ export class ActiveSessionsListError extends Schema.TaggedErrorClass<ActiveSessi...@@ -285,6 +314,7 @@ export class ActiveSessionsListError extends Schema.TaggedErrorClass<ActiveSessi
285export class SessionRevocationError extends Schema.TaggedErrorClass<SessionRevocationError>()(314export class SessionRevocationError extends Schema.TaggedErrorClass<SessionRevocationError>()(
286 "SessionRevocationError",315 "SessionRevocationError",
287 {316 {
317 sessionId: AuthSessionId,
288 ...sessionCredentialInternalErrorContext,318 ...sessionCredentialInternalErrorContext,
289 },319 },
290) {320) {
...@@ -296,6 +326,7 @@ export class SessionRevocationError extends Schema.TaggedErrorClass<SessionRevoc...@@ -296,6 +326,7 @@ export class SessionRevocationError extends Schema.TaggedErrorClass<SessionRevoc
296export class OtherSessionsRevocationError extends Schema.TaggedErrorClass<OtherSessionsRevocationError>()(326export class OtherSessionsRevocationError extends Schema.TaggedErrorClass<OtherSessionsRevocationError>()(
297 "OtherSessionsRevocationError",327 "OtherSessionsRevocationError",
298 {328 {
329 currentSessionId: AuthSessionId,
299 ...sessionCredentialInternalErrorContext,330 ...sessionCredentialInternalErrorContext,
300 },331 },
301) {332) {
...@@ -539,7 +570,11 @@ export const make = Effect.gen(function* () {...@@ -539,7 +570,11 @@ export const make = Effect.gen(function* () {
539 const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims));570 const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims));
540 const issue: SessionStore["Service"]["issue"] = Effect.fn("SessionStore.issue")(571 const issue: SessionStore["Service"]["issue"] = Effect.fn("SessionStore.issue")(
541 function* (input) {572 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 );
543 const issuedAt = yield* DateTime.now;578 const issuedAt = yield* DateTime.now;
544 const expiresAt = DateTime.add(issuedAt, {579 const expiresAt = DateTime.add(issuedAt, {
545 milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL),580 milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL),
...@@ -559,27 +594,37 @@ export const make = Effect.gen(function* () {...@@ -559,27 +594,37 @@ export const make = Effect.gen(function* () {
559 const encodedPayload = yield* encodeClaims(claims).pipe(594 const encodedPayload = yield* encodeClaims(claims).pipe(
560 Effect.map(base64UrlEncode),595 Effect.map(base64UrlEncode),
561 Effect.mapError(596 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 }),
563 ),606 ),
564 );607 );
565 const signature = signPayload(encodedPayload, signingSecret);608 const signature = signPayload(encodedPayload, signingSecret);
566 const client = input?.client ?? createDefaultClientMetadata();609 const client = input?.client ?? createDefaultClientMetadata();
567 yield* authSessions.create({610 yield* authSessions
568 sessionId,611 .create({
569 subject: claims.sub,612 sessionId,
570 scopes: claims.scopes,613 subject: claims.sub,
571 method: claims.method,614 scopes: claims.scopes,
572 client: {615 method: claims.method,
573 label: client.label ?? null,616 client: {
574 ipAddress: client.ipAddress ?? null,617 label: client.label ?? null,
575 userAgent: client.userAgent ?? null,618 ipAddress: client.ipAddress ?? null,
576 deviceType: client.deviceType,619 userAgent: client.userAgent ?? null,
577 os: client.os ?? null,620 deviceType: client.deviceType,
578 browser: client.browser ?? null,621 os: client.os ?? null,
579 },622 browser: client.browser ?? null,
580 issuedAt,623 },
581 expiresAt,624 issuedAt,
582 });625 expiresAt,
626 })
627 .pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause })));
583 yield* emitUpsert(628 yield* emitUpsert(
584 toAuthClientSession({629 toAuthClientSession({
585 sessionId,630 sessionId,
...@@ -604,7 +649,6 @@ export const make = Effect.gen(function* () {...@@ -604,7 +649,6 @@ export const make = Effect.gen(function* () {
604 ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}),649 ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}),
605 } satisfies IssuedSession;650 } satisfies IssuedSession;
606 },651 },
607 Effect.mapError((cause) => new SessionCredentialIssueError({ cause })),
608 );652 );
609653
610 const verify: SessionStore["Service"]["verify"] = Effect.fn("SessionStore.verify")(654 const verify: SessionStore["Service"]["verify"] = Effect.fn("SessionStore.verify")(
...@@ -623,22 +667,37 @@ export const make = Effect.gen(function* () {...@@ -623,22 +667,37 @@ export const make = Effect.gen(function* () {
623 Effect.mapError((cause) => new InvalidSessionTokenPayloadError({ cause })),667 Effect.mapError((cause) => new InvalidSessionTokenPayloadError({ cause })),
624 );668 );
625669
626 const now = yield* Clock.currentTimeMillis;670 const observedAt = yield* DateTime.now;
627 if (claims.exp <= now) {671 const expiresAt = DateTime.make(claims.exp);
628 return yield* new SessionTokenExpiredError({});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 });
629 }684 }
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 );
632 if (Option.isNone(row)) {693 if (Option.isNone(row)) {
633 return yield* new UnknownSessionTokenError({});694 return yield* new UnknownSessionTokenError({ sessionId: claims.sid });
634 }695 }
635 if (row.value.revokedAt !== null) {696 if (row.value.revokedAt !== null) {
636 return yield* new SessionTokenRevokedError({});697 return yield* new SessionTokenRevokedError({
637 }698 sessionId: claims.sid,
638699 revokedAt: row.value.revokedAt,
639 const expiresAt = DateTime.make(claims.exp);700 });
640 if (Option.isNone(expiresAt)) {
641 return yield* new InvalidSessionExpirationClaimError({});
642 }701 }
643702
644 return {703 return {
...@@ -652,95 +711,111 @@ export const make = Effect.gen(function* () {...@@ -652,95 +711,111 @@ export const make = Effect.gen(function* () {
652 ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}),711 ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}),
653 } satisfies VerifiedSession;712 } satisfies VerifiedSession;
654 },713 },
655 Effect.mapError((cause) =>
656 isSessionCredentialInvalidError(cause)
657 ? cause
658 : new SessionCredentialVerificationError({ cause }),
659 ),
660 );714 );
661715
662 const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims));716 const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims));
663 const issueWebSocketToken: SessionStore["Service"]["issueWebSocketToken"] = Effect.fn(717 const issueWebSocketToken: SessionStore["Service"]["issueWebSocketToken"] = Effect.fn(
664 "SessionStore.issueWebSocketToken",718 "SessionStore.issueWebSocketToken",
665 )(719 )(function* (sessionId, input) {
666 function* (sessionId, input) {720 const issuedAt = yield* DateTime.now;
667 const issuedAt = yield* DateTime.now;721 const expiresAt = DateTime.add(issuedAt, {
668 const expiresAt = DateTime.add(issuedAt, {722 milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL),
669 milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL),723 });
670 });724 const claims: WebSocketClaims = {
671 const claims: WebSocketClaims = {725 v: 1,
672 v: 1,726 kind: "websocket",
673 kind: "websocket",727 sid: sessionId,
674 sid: sessionId,728 iat: issuedAt.epochMilliseconds,
675 iat: issuedAt.epochMilliseconds,729 exp: expiresAt.epochMilliseconds,
676 exp: expiresAt.epochMilliseconds,730 };
677 };731 const encodedPayload = yield* encodeWsClaims(claims).pipe(
678 const encodedPayload = yield* encodeWsClaims(claims).pipe(732 Effect.map(base64UrlEncode),
679 Effect.map(base64UrlEncode),733 Effect.mapError(
680 Effect.mapError(734 (cause) =>
681 (cause) =>735 new WebSocketTokenIssueError({
682 new SessionClaimsEncodingError({ operation: "encode_websocket_claims", cause }),736 sessionId,
683 ),737 cause: new SessionClaimsEncodingError({
684 );738 sessionId,
685 const signature = signPayload(encodedPayload, signingSecret);739 operation: "encode_websocket_claims",
686 return {740 cause,
687 token: `${encodedPayload}.${signature}`,741 }),
688 expiresAt,742 }),
689 };743 ),
690 },744 );
691 Effect.mapError((cause) => new WebSocketTokenIssueError({ cause })),745 const signature = signPayload(encodedPayload, signingSecret);
692 );746 return {
747 token: `${encodedPayload}.${signature}`,
748 expiresAt,
749 };
750 });
693751
694 const verifyWebSocketToken: SessionStore["Service"]["verifyWebSocketToken"] = Effect.fn(752 const verifyWebSocketToken: SessionStore["Service"]["verifyWebSocketToken"] = Effect.fn(
695 "SessionStore.verifyWebSocketToken",753 "SessionStore.verifyWebSocketToken",
696 )(754 )(function* (token) {
697 function* (token) {755 const [encodedPayload, signature] = token.split(".");
698 const [encodedPayload, signature] = token.split(".");756 if (!encodedPayload || !signature) {
699 if (!encodedPayload || !signature) {757 return yield* new MalformedWebSocketTokenError({});
700 return yield* new MalformedWebSocketTokenError({});758 }
701 }
702
703 const expectedSignature = signPayload(encodedPayload, signingSecret);
704 if (!timingSafeEqualBase64Url(signature, expectedSignature)) {
705 return yield* new InvalidWebSocketTokenSignatureError({});
706 }
707759
708 const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(760 const expectedSignature = signPayload(encodedPayload, signingSecret);
709 Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })),761 if (!timingSafeEqualBase64Url(signature, expectedSignature)) {
710 );762 return yield* new InvalidWebSocketTokenSignatureError({});
763 }
711764
712 const now = yield* Clock.currentTimeMillis;765 const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
713 if (claims.exp <= now) {766 Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })),
714 return yield* new WebSocketTokenExpiredError({});767 );
715 }
716768
717 const row = yield* authSessions.getById({ sessionId: claims.sid });769 const observedAt = yield* DateTime.now;
718 if (Option.isNone(row)) {770 const expiresAt = DateTime.make(claims.exp);
719 return yield* new UnknownWebSocketSessionError({});771 if (Option.isNone(expiresAt)) {
720 }772 return yield* new InvalidSessionExpirationClaimError({
721 if (row.value.expiresAt.epochMilliseconds <= now) {773 sessionId: claims.sid,
722 return yield* new WebSocketSessionExpiredError({});774 expirationClaim: claims.exp,
723 }775 });
724 if (row.value.revokedAt !== null) {776 }
725 return yield* new WebSocketSessionRevokedError({});777 if (claims.exp <= observedAt.epochMilliseconds) {
726 }778 return yield* new WebSocketTokenExpiredError({
779 sessionId: claims.sid,
780 expiresAt: expiresAt.value,
781 observedAt,
782 });
783 }
727784
728 return {785 const row = yield* authSessions
729 sessionId: row.value.sessionId,786 .getById({ sessionId: claims.sid })
730 token,787 .pipe(
731 method: row.value.method,788 Effect.mapError(
732 client: toClientMetadata(row.value.client),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,
733 expiresAt: row.value.expiresAt,798 expiresAt: row.value.expiresAt,
734 subject: row.value.subject,799 observedAt,
735 scopes: row.value.scopes,800 });
736 } satisfies VerifiedSession;801 }
737 },802 if (row.value.revokedAt !== null) {
738 Effect.mapError((cause) =>803 return yield* new WebSocketSessionRevokedError({
739 isSessionCredentialInvalidError(cause)804 sessionId: claims.sid,
740 ? cause805 revokedAt: row.value.revokedAt,
741 : new WebSocketTokenVerificationError({ cause }),806 });
742 ),807 }
743 );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
745 const listActive: SessionStore["Service"]["listActive"] = Effect.fn("SessionStore.listActive")(820 const listActive: SessionStore["Service"]["listActive"] = Effect.fn("SessionStore.listActive")(
746 function* () {821 function* () {
...@@ -768,10 +843,12 @@ export const make = Effect.gen(function* () {...@@ -768,10 +843,12 @@ export const make = Effect.gen(function* () {
768 const revoke: SessionStore["Service"]["revoke"] = Effect.fn("SessionStore.revoke")(843 const revoke: SessionStore["Service"]["revoke"] = Effect.fn("SessionStore.revoke")(
769 function* (sessionId) {844 function* (sessionId) {
770 const revokedAt = yield* DateTime.now;845 const revokedAt = yield* DateTime.now;
771 const revoked = yield* authSessions.revoke({846 const revoked = yield* authSessions
772 sessionId,847 .revoke({
773 revokedAt,848 sessionId,
774 });849 revokedAt,
850 })
851 .pipe(Effect.mapError((cause) => new SessionRevocationError({ sessionId, cause })));
775 if (revoked) {852 if (revoked) {
776 yield* Ref.update(connectedSessionsRef, (current) => {853 yield* Ref.update(connectedSessionsRef, (current) => {
777 const next = new Map(current);854 const next = new Map(current);
...@@ -782,39 +859,41 @@ export const make = Effect.gen(function* () {...@@ -782,39 +859,41 @@ export const make = Effect.gen(function* () {
782 }859 }
783 return revoked;860 return revoked;
784 },861 },
785 Effect.mapError((cause) => new SessionRevocationError({ cause })),
786 );862 );
787863
788 const revokeAllExcept: SessionStore["Service"]["revokeAllExcept"] = Effect.fn(864 const revokeAllExcept: SessionStore["Service"]["revokeAllExcept"] = Effect.fn(
789 "SessionStore.revokeAllExcept",865 "SessionStore.revokeAllExcept",
790 )(866 )(function* (sessionId) {
791 function* (sessionId) {867 const revokedAt = yield* DateTime.now;
792 const revokedAt = yield* DateTime.now;868 const revokedSessionIds = yield* authSessions
793 const revokedSessionIds = yield* authSessions.revokeAllExcept({869 .revokeAllExcept({
794 currentSessionId: sessionId,870 currentSessionId: sessionId,
795 revokedAt,871 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;
796 });885 });
797 if (revokedSessionIds.length > 0) {886 yield* Effect.forEach(
798 yield* Ref.update(connectedSessionsRef, (current) => {887 revokedSessionIds,
799 const next = new Map(current);888 (revokedSessionId) => emitRemoved(revokedSessionId),
800 for (const revokedSessionId of revokedSessionIds) {889 {
801 next.delete(revokedSessionId);890 concurrency: "unbounded",
802 }891 discard: true,
803 return next;892 },
804 });893 );
805 yield* Effect.forEach(894 }
806 revokedSessionIds,895 return revokedSessionIds.length;
807 (revokedSessionId) => emitRemoved(revokedSessionId),896 });
808 {
809 concurrency: "unbounded",
810 discard: true,
811 },
812 );
813 }
814 return revokedSessionIds.length;
815 },
816 Effect.mapError((cause) => new OtherSessionsRevocationError({ cause })),
817 );
818897
819 return SessionStore.of({898 return SessionStore.of({
820 cookieName,899 cookieName,