diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 0dd5d797d19..334c24ef52f 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -44,8 +44,8 @@ const failingSessionLookupRepositoryLayer = Layer.succeed(AuthSessions.AuthSessi create: () => Effect.void, getById: () => Effect.fail(repositoryFailure), listActive: () => Effect.succeed([]), - revoke: () => Effect.succeed(false), - revokeAllExcept: () => Effect.succeed([]), + revoke: () => Effect.fail(repositoryFailure), + revokeAllExcept: () => Effect.fail(repositoryFailure), setLastConnectedAt: () => Effect.void, }); @@ -104,11 +104,29 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { const sessionError = yield* Effect.flip(sessions.verify(issued.token)); const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token)); + const revokeError = yield* Effect.flip(sessions.revoke(issued.sessionId)); + const revokeOthersError = yield* Effect.flip(sessions.revokeAllExcept(issued.sessionId)); expect(sessionError._tag).toBe("SessionCredentialVerificationError"); expect(websocketError._tag).toBe("WebSocketTokenVerificationError"); expect(sessionError.cause).toBe(repositoryFailure); expect(websocketError.cause).toBe(repositoryFailure); + if (sessionError._tag === "SessionCredentialVerificationError") { + expect(sessionError.sessionId).toBe(issued.sessionId); + } + if (websocketError._tag === "WebSocketTokenVerificationError") { + expect(websocketError.sessionId).toBe(issued.sessionId); + } + expect(revokeError).toMatchObject({ + _tag: "SessionRevocationError", + sessionId: issued.sessionId, + cause: repositoryFailure, + }); + expect(revokeOthersError).toMatchObject({ + _tag: "OtherSessionsRevocationError", + currentSessionId: issued.sessionId, + cause: repositoryFailure, + }); }).pipe(Effect.provide(failingSessionLookupCredentialLayer)), ); it.effect("verifies session tokens against the Effect clock", () => @@ -145,7 +163,52 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { yield* TestClock.adjust(Duration.seconds(2)); const error = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token)); - expect(error.message).toContain("expired"); + expect(error._tag).toBe("WebSocketSessionExpiredError"); + if (error._tag === "WebSocketSessionExpiredError") { + expect(error.sessionId).toBe(issued.sessionId); + expect(error.expiresAt.epochMilliseconds).toBe(issued.expiresAt.epochMilliseconds); + expect(error.observedAt.epochMilliseconds).toBeGreaterThan( + error.expiresAt.epochMilliseconds, + ); + } + }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), + ); + + it.effect("includes expiry context when session and websocket tokens expire", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const issued = yield* sessions.issue({ + method: "bearer-access-token", + subject: "short-lived-token", + ttl: Duration.seconds(1), + }); + const websocket = yield* sessions.issueWebSocketToken(issued.sessionId, { + ttl: Duration.seconds(1), + }); + + yield* TestClock.adjust(Duration.seconds(2)); + + const sessionError = yield* Effect.flip(sessions.verify(issued.token)); + const websocketError = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token)); + + expect(sessionError._tag).toBe("SessionTokenExpiredError"); + if (sessionError._tag === "SessionTokenExpiredError") { + expect(sessionError.sessionId).toBe(issued.sessionId); + expect(sessionError.expiresAt.epochMilliseconds).toBe(issued.expiresAt.epochMilliseconds); + expect(sessionError.observedAt.epochMilliseconds).toBeGreaterThan( + sessionError.expiresAt.epochMilliseconds, + ); + } + expect(websocketError._tag).toBe("WebSocketTokenExpiredError"); + if (websocketError._tag === "WebSocketTokenExpiredError") { + expect(websocketError.sessionId).toBe(issued.sessionId); + expect(websocketError.expiresAt.epochMilliseconds).toBe( + websocket.expiresAt.epochMilliseconds, + ); + expect(websocketError.observedAt.epochMilliseconds).toBeGreaterThan( + websocketError.expiresAt.epochMilliseconds, + ); + } }).pipe(Effect.provide(Layer.merge(makeSessionStoreLayer(), TestClock.layer()))), ); @@ -173,12 +236,16 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { ipAddress: "192.168.1.88", }, }); + const clientWebSocket = yield* sessions.issueWebSocketToken(client.sessionId); yield* sessions.markConnected(client.sessionId); const beforeRevoke = yield* sessions.listActive(); const revokedCount = yield* sessions.revokeAllExcept(administrative.sessionId); const afterRevoke = yield* sessions.listActive(); const revokedClient = yield* Effect.flip(sessions.verify(client.token)); + const revokedClientWebSocket = yield* Effect.flip( + sessions.verifyWebSocketToken(clientWebSocket.token), + ); expect(beforeRevoke).toHaveLength(2); expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.connected).toBe( @@ -194,7 +261,16 @@ it.layer(NodeServices.layer)("SessionStore.layer", (it) => { expect(revokedCount).toBe(1); expect(afterRevoke).toHaveLength(1); expect(afterRevoke[0]?.sessionId).toBe(administrative.sessionId); - expect(revokedClient.message).toContain("revoked"); + expect(revokedClient._tag).toBe("SessionTokenRevokedError"); + if (revokedClient._tag === "SessionTokenRevokedError") { + expect(revokedClient.sessionId).toBe(client.sessionId); + expect(revokedClient.revokedAt.epochMilliseconds).toBeGreaterThanOrEqual(0); + } + expect(revokedClientWebSocket._tag).toBe("WebSocketSessionRevokedError"); + if (revokedClientWebSocket._tag === "WebSocketSessionRevokedError") { + expect(revokedClientWebSocket.sessionId).toBe(client.sessionId); + expect(revokedClientWebSocket.revokedAt.epochMilliseconds).toBeGreaterThanOrEqual(0); + } }).pipe(Effect.provide(makeSessionStoreLayer())), ); diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 18008a7d0a1..12ecb7dba4d 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -7,7 +7,6 @@ import { type AuthEnvironmentScope, type ServerAuthSessionMethod, } from "@t3tools/contracts"; -import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -93,7 +92,11 @@ export class InvalidSessionTokenPayloadError extends Schema.TaggedErrorClass()( "SessionTokenExpiredError", - {}, + { + sessionId: AuthSessionId, + expiresAt: Schema.DateTimeUtc, + observedAt: Schema.DateTimeUtc, + }, ) { override get message(): string { return "Session token expired."; @@ -102,7 +105,9 @@ export class SessionTokenExpiredError extends Schema.TaggedErrorClass()( "UnknownSessionTokenError", - {}, + { + sessionId: AuthSessionId, + }, ) { override get message(): string { return "Unknown session token."; @@ -111,7 +116,10 @@ export class UnknownSessionTokenError extends Schema.TaggedErrorClass()( "SessionTokenRevokedError", - {}, + { + sessionId: AuthSessionId, + revokedAt: Schema.DateTimeUtc, + }, ) { override get message(): string { return "Session token revoked."; @@ -120,7 +128,10 @@ export class SessionTokenRevokedError extends Schema.TaggedErrorClass()( "InvalidSessionExpirationClaimError", - {}, + { + sessionId: AuthSessionId, + expirationClaim: Schema.Number, + }, ) { override get message(): string { return "Invalid `exp` claim"; @@ -158,7 +169,11 @@ export class InvalidWebSocketTokenPayloadError extends Schema.TaggedErrorClass()( "WebSocketTokenExpiredError", - {}, + { + sessionId: AuthSessionId, + expiresAt: Schema.DateTimeUtc, + observedAt: Schema.DateTimeUtc, + }, ) { override get message(): string { return "Websocket token expired."; @@ -167,7 +182,9 @@ export class WebSocketTokenExpiredError extends Schema.TaggedErrorClass()( "UnknownWebSocketSessionError", - {}, + { + sessionId: AuthSessionId, + }, ) { override get message(): string { return "Unknown websocket session."; @@ -176,7 +193,11 @@ export class UnknownWebSocketSessionError extends Schema.TaggedErrorClass()( "WebSocketSessionExpiredError", - {}, + { + sessionId: AuthSessionId, + expiresAt: Schema.DateTimeUtc, + observedAt: Schema.DateTimeUtc, + }, ) { override get message(): string { return "Websocket session expired."; @@ -185,7 +206,10 @@ export class WebSocketSessionExpiredError extends Schema.TaggedErrorClass()( "WebSocketSessionRevokedError", - {}, + { + sessionId: AuthSessionId, + revokedAt: Schema.DateTimeUtc, + }, ) { override get message(): string { return "Websocket session revoked."; @@ -218,6 +242,7 @@ const sessionCredentialInternalErrorContext = { export class SessionClaimsEncodingError extends Schema.TaggedErrorClass()( "SessionClaimsEncodingError", { + sessionId: AuthSessionId, operation: Schema.Literals(["encode_session_claims", "encode_websocket_claims"]), ...sessionCredentialInternalErrorContext, }, @@ -230,6 +255,7 @@ export class SessionClaimsEncodingError extends Schema.TaggedErrorClass()( "SessionCredentialIssueError", { + sessionId: Schema.optional(AuthSessionId), ...sessionCredentialInternalErrorContext, }, ) { @@ -241,6 +267,7 @@ export class SessionCredentialIssueError extends Schema.TaggedErrorClass()( "SessionCredentialVerificationError", { + sessionId: AuthSessionId, ...sessionCredentialInternalErrorContext, }, ) { @@ -252,6 +279,7 @@ export class SessionCredentialVerificationError extends Schema.TaggedErrorClass< export class WebSocketTokenIssueError extends Schema.TaggedErrorClass()( "WebSocketTokenIssueError", { + sessionId: AuthSessionId, ...sessionCredentialInternalErrorContext, }, ) { @@ -263,6 +291,7 @@ export class WebSocketTokenIssueError extends Schema.TaggedErrorClass()( "WebSocketTokenVerificationError", { + sessionId: AuthSessionId, ...sessionCredentialInternalErrorContext, }, ) { @@ -285,6 +314,7 @@ export class ActiveSessionsListError extends Schema.TaggedErrorClass()( "SessionRevocationError", { + sessionId: AuthSessionId, ...sessionCredentialInternalErrorContext, }, ) { @@ -296,6 +326,7 @@ export class SessionRevocationError extends Schema.TaggedErrorClass()( "OtherSessionsRevocationError", { + currentSessionId: AuthSessionId, ...sessionCredentialInternalErrorContext, }, ) { @@ -539,7 +570,11 @@ export const make = Effect.gen(function* () { const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims)); const issue: SessionStore["Service"]["issue"] = Effect.fn("SessionStore.issue")( function* (input) { - const sessionId = AuthSessionId.make(yield* crypto.randomUUIDv4); + const sessionId = AuthSessionId.make( + yield* crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => new SessionCredentialIssueError({ cause })), + ), + ); const issuedAt = yield* DateTime.now; const expiresAt = DateTime.add(issuedAt, { milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL), @@ -559,27 +594,37 @@ export const make = Effect.gen(function* () { const encodedPayload = yield* encodeClaims(claims).pipe( Effect.map(base64UrlEncode), Effect.mapError( - (cause) => new SessionClaimsEncodingError({ operation: "encode_session_claims", cause }), + (cause) => + new SessionCredentialIssueError({ + sessionId, + cause: new SessionClaimsEncodingError({ + sessionId, + operation: "encode_session_claims", + cause, + }), + }), ), ); const signature = signPayload(encodedPayload, signingSecret); const client = input?.client ?? createDefaultClientMetadata(); - yield* authSessions.create({ - sessionId, - subject: claims.sub, - scopes: claims.scopes, - method: claims.method, - client: { - label: client.label ?? null, - ipAddress: client.ipAddress ?? null, - userAgent: client.userAgent ?? null, - deviceType: client.deviceType, - os: client.os ?? null, - browser: client.browser ?? null, - }, - issuedAt, - expiresAt, - }); + yield* authSessions + .create({ + sessionId, + subject: claims.sub, + scopes: claims.scopes, + method: claims.method, + client: { + label: client.label ?? null, + ipAddress: client.ipAddress ?? null, + userAgent: client.userAgent ?? null, + deviceType: client.deviceType, + os: client.os ?? null, + browser: client.browser ?? null, + }, + issuedAt, + expiresAt, + }) + .pipe(Effect.mapError((cause) => new SessionCredentialIssueError({ sessionId, cause }))); yield* emitUpsert( toAuthClientSession({ sessionId, @@ -604,7 +649,6 @@ export const make = Effect.gen(function* () { ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), } satisfies IssuedSession; }, - Effect.mapError((cause) => new SessionCredentialIssueError({ cause })), ); const verify: SessionStore["Service"]["verify"] = Effect.fn("SessionStore.verify")( @@ -623,22 +667,37 @@ export const make = Effect.gen(function* () { Effect.mapError((cause) => new InvalidSessionTokenPayloadError({ cause })), ); - const now = yield* Clock.currentTimeMillis; - if (claims.exp <= now) { - return yield* new SessionTokenExpiredError({}); + const observedAt = yield* DateTime.now; + const expiresAt = DateTime.make(claims.exp); + if (Option.isNone(expiresAt)) { + return yield* new InvalidSessionExpirationClaimError({ + sessionId: claims.sid, + expirationClaim: claims.exp, + }); + } + if (claims.exp <= observedAt.epochMilliseconds) { + return yield* new SessionTokenExpiredError({ + sessionId: claims.sid, + expiresAt: expiresAt.value, + observedAt, + }); } - const row = yield* authSessions.getById({ sessionId: claims.sid }); + const row = yield* authSessions + .getById({ sessionId: claims.sid }) + .pipe( + Effect.mapError( + (cause) => new SessionCredentialVerificationError({ sessionId: claims.sid, cause }), + ), + ); if (Option.isNone(row)) { - return yield* new UnknownSessionTokenError({}); + return yield* new UnknownSessionTokenError({ sessionId: claims.sid }); } if (row.value.revokedAt !== null) { - return yield* new SessionTokenRevokedError({}); - } - - const expiresAt = DateTime.make(claims.exp); - if (Option.isNone(expiresAt)) { - return yield* new InvalidSessionExpirationClaimError({}); + return yield* new SessionTokenRevokedError({ + sessionId: claims.sid, + revokedAt: row.value.revokedAt, + }); } return { @@ -652,95 +711,111 @@ export const make = Effect.gen(function* () { ...(claims.jkt ? { proofKeyThumbprint: claims.jkt } : {}), } satisfies VerifiedSession; }, - Effect.mapError((cause) => - isSessionCredentialInvalidError(cause) - ? cause - : new SessionCredentialVerificationError({ cause }), - ), ); const encodeWsClaims = Schema.encodeEffect(Schema.fromJsonString(WebSocketClaims)); const issueWebSocketToken: SessionStore["Service"]["issueWebSocketToken"] = Effect.fn( "SessionStore.issueWebSocketToken", - )( - function* (sessionId, input) { - const issuedAt = yield* DateTime.now; - const expiresAt = DateTime.add(issuedAt, { - milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL), - }); - const claims: WebSocketClaims = { - v: 1, - kind: "websocket", - sid: sessionId, - iat: issuedAt.epochMilliseconds, - exp: expiresAt.epochMilliseconds, - }; - const encodedPayload = yield* encodeWsClaims(claims).pipe( - Effect.map(base64UrlEncode), - Effect.mapError( - (cause) => - new SessionClaimsEncodingError({ operation: "encode_websocket_claims", cause }), - ), - ); - const signature = signPayload(encodedPayload, signingSecret); - return { - token: `${encodedPayload}.${signature}`, - expiresAt, - }; - }, - Effect.mapError((cause) => new WebSocketTokenIssueError({ cause })), - ); + )(function* (sessionId, input) { + const issuedAt = yield* DateTime.now; + const expiresAt = DateTime.add(issuedAt, { + milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL), + }); + const claims: WebSocketClaims = { + v: 1, + kind: "websocket", + sid: sessionId, + iat: issuedAt.epochMilliseconds, + exp: expiresAt.epochMilliseconds, + }; + const encodedPayload = yield* encodeWsClaims(claims).pipe( + Effect.map(base64UrlEncode), + Effect.mapError( + (cause) => + new WebSocketTokenIssueError({ + sessionId, + cause: new SessionClaimsEncodingError({ + sessionId, + operation: "encode_websocket_claims", + cause, + }), + }), + ), + ); + const signature = signPayload(encodedPayload, signingSecret); + return { + token: `${encodedPayload}.${signature}`, + expiresAt, + }; + }); const verifyWebSocketToken: SessionStore["Service"]["verifyWebSocketToken"] = Effect.fn( "SessionStore.verifyWebSocketToken", - )( - function* (token) { - const [encodedPayload, signature] = token.split("."); - if (!encodedPayload || !signature) { - return yield* new MalformedWebSocketTokenError({}); - } - - const expectedSignature = signPayload(encodedPayload, signingSecret); - if (!timingSafeEqualBase64Url(signature, expectedSignature)) { - return yield* new InvalidWebSocketTokenSignatureError({}); - } + )(function* (token) { + const [encodedPayload, signature] = token.split("."); + if (!encodedPayload || !signature) { + return yield* new MalformedWebSocketTokenError({}); + } - const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( - Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })), - ); + const expectedSignature = signPayload(encodedPayload, signingSecret); + if (!timingSafeEqualBase64Url(signature, expectedSignature)) { + return yield* new InvalidWebSocketTokenSignatureError({}); + } - const now = yield* Clock.currentTimeMillis; - if (claims.exp <= now) { - return yield* new WebSocketTokenExpiredError({}); - } + const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe( + Effect.mapError((cause) => new InvalidWebSocketTokenPayloadError({ cause })), + ); - const row = yield* authSessions.getById({ sessionId: claims.sid }); - if (Option.isNone(row)) { - return yield* new UnknownWebSocketSessionError({}); - } - if (row.value.expiresAt.epochMilliseconds <= now) { - return yield* new WebSocketSessionExpiredError({}); - } - if (row.value.revokedAt !== null) { - return yield* new WebSocketSessionRevokedError({}); - } + const observedAt = yield* DateTime.now; + const expiresAt = DateTime.make(claims.exp); + if (Option.isNone(expiresAt)) { + return yield* new InvalidSessionExpirationClaimError({ + sessionId: claims.sid, + expirationClaim: claims.exp, + }); + } + if (claims.exp <= observedAt.epochMilliseconds) { + return yield* new WebSocketTokenExpiredError({ + sessionId: claims.sid, + expiresAt: expiresAt.value, + observedAt, + }); + } - return { - sessionId: row.value.sessionId, - token, - method: row.value.method, - client: toClientMetadata(row.value.client), + const row = yield* authSessions + .getById({ sessionId: claims.sid }) + .pipe( + Effect.mapError( + (cause) => new WebSocketTokenVerificationError({ sessionId: claims.sid, cause }), + ), + ); + if (Option.isNone(row)) { + return yield* new UnknownWebSocketSessionError({ sessionId: claims.sid }); + } + if (row.value.expiresAt.epochMilliseconds <= observedAt.epochMilliseconds) { + return yield* new WebSocketSessionExpiredError({ + sessionId: claims.sid, expiresAt: row.value.expiresAt, - subject: row.value.subject, - scopes: row.value.scopes, - } satisfies VerifiedSession; - }, - Effect.mapError((cause) => - isSessionCredentialInvalidError(cause) - ? cause - : new WebSocketTokenVerificationError({ cause }), - ), - ); + observedAt, + }); + } + if (row.value.revokedAt !== null) { + return yield* new WebSocketSessionRevokedError({ + sessionId: claims.sid, + revokedAt: row.value.revokedAt, + }); + } + + return { + sessionId: row.value.sessionId, + token, + method: row.value.method, + client: toClientMetadata(row.value.client), + expiresAt: row.value.expiresAt, + subject: row.value.subject, + scopes: row.value.scopes, + } satisfies VerifiedSession; + }); const listActive: SessionStore["Service"]["listActive"] = Effect.fn("SessionStore.listActive")( function* () { @@ -768,10 +843,12 @@ export const make = Effect.gen(function* () { const revoke: SessionStore["Service"]["revoke"] = Effect.fn("SessionStore.revoke")( function* (sessionId) { const revokedAt = yield* DateTime.now; - const revoked = yield* authSessions.revoke({ - sessionId, - revokedAt, - }); + const revoked = yield* authSessions + .revoke({ + sessionId, + revokedAt, + }) + .pipe(Effect.mapError((cause) => new SessionRevocationError({ sessionId, cause }))); if (revoked) { yield* Ref.update(connectedSessionsRef, (current) => { const next = new Map(current); @@ -782,39 +859,41 @@ export const make = Effect.gen(function* () { } return revoked; }, - Effect.mapError((cause) => new SessionRevocationError({ cause })), ); const revokeAllExcept: SessionStore["Service"]["revokeAllExcept"] = Effect.fn( "SessionStore.revokeAllExcept", - )( - function* (sessionId) { - const revokedAt = yield* DateTime.now; - const revokedSessionIds = yield* authSessions.revokeAllExcept({ + )(function* (sessionId) { + const revokedAt = yield* DateTime.now; + const revokedSessionIds = yield* authSessions + .revokeAllExcept({ currentSessionId: sessionId, revokedAt, + }) + .pipe( + Effect.mapError( + (cause) => new OtherSessionsRevocationError({ currentSessionId: sessionId, cause }), + ), + ); + if (revokedSessionIds.length > 0) { + yield* Ref.update(connectedSessionsRef, (current) => { + const next = new Map(current); + for (const revokedSessionId of revokedSessionIds) { + next.delete(revokedSessionId); + } + return next; }); - if (revokedSessionIds.length > 0) { - yield* Ref.update(connectedSessionsRef, (current) => { - const next = new Map(current); - for (const revokedSessionId of revokedSessionIds) { - next.delete(revokedSessionId); - } - return next; - }); - yield* Effect.forEach( - revokedSessionIds, - (revokedSessionId) => emitRemoved(revokedSessionId), - { - concurrency: "unbounded", - discard: true, - }, - ); - } - return revokedSessionIds.length; - }, - Effect.mapError((cause) => new OtherSessionsRevocationError({ cause })), - ); + yield* Effect.forEach( + revokedSessionIds, + (revokedSessionId) => emitRemoved(revokedSessionId), + { + concurrency: "unbounded", + discard: true, + }, + ); + } + return revokedSessionIds.length; + }); return SessionStore.of({ cookieName,