diff --git a/apps/server/src/auth/SessionStore.test.ts b/apps/server/src/auth/SessionStore.test.ts index 72f91e21d16f..85cf899fe414 100644 --- a/apps/server/src/auth/SessionStore.test.ts +++ b/apps/server/src/auth/SessionStore.test.ts @@ -1,6 +1,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { EnvironmentId } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -114,6 +116,74 @@ const failingSessionLookupCredentialLayer = Layer.effect( ); it.layer(NodeServices.layer)("SessionStore.layer", (it) => { + it.effect.each([undefined, "90 days"])("uses the configured default session lifetime %s", (ttl) => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const now = yield* DateTime.now; + for (const method of ["browser-session-cookie", "bearer-access-token"] as const) { + const issued = yield* sessions.issue({ method }); + expect(issued.expiresAt.epochMilliseconds - now.epochMilliseconds).toBe( + Duration.toMillis(Duration.days(ttl === undefined ? 30 : 90)), + ); + expect((yield* sessions.verify(issued.token)).sessionId).toBe(issued.sessionId); + } + const relay = yield* sessions.issue(relaySessionInput); + expect(relay.expiresAt.epochMilliseconds - now.epochMilliseconds).toBe( + Duration.toMillis(Duration.hours(1)), + ); + const explicit = yield* sessions.issue({ ttl: Duration.minutes(10) }); + expect(explicit.expiresAt.epochMilliseconds - now.epochMilliseconds).toBe(600_000); + }).pipe( + Effect.provide( + makeSessionStoreLayer().pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown(ttl === undefined ? {} : { T3CODE_SESSION_TTL: ttl }), + ), + ), + ), + ), + ), + ); + + it.effect("expires sessions at the configured lifetime", () => + Effect.gen(function* () { + const sessions = yield* SessionStore.SessionStore; + const issued = yield* sessions.issue(); + yield* TestClock.adjust(Duration.seconds(3)); + expect((yield* Effect.flip(sessions.verify(issued.token)))._tag).toBe( + "SessionTokenExpiredError", + ); + }).pipe( + Effect.provide( + makeSessionStoreLayer().pipe( + Layer.provide( + ConfigProvider.layer(ConfigProvider.fromUnknown({ T3CODE_SESSION_TTL: "2 seconds" })), + ), + ), + ), + ), + ); + + it.effect.each(["0 seconds", "-1 day", "Infinity", "invalid"])( + "rejects invalid default session lifetime %s", + (ttl) => + Effect.gen(function* () { + const error = yield* Effect.flip( + SessionStore.SessionStore.pipe( + Effect.provide( + makeSessionStoreLayer().pipe( + Layer.provide( + ConfigProvider.layer(ConfigProvider.fromUnknown({ T3CODE_SESSION_TTL: ttl })), + ), + ), + ), + ), + ); + expect(error._tag).toBe("ConfigError"); + }), + ); + it.effect("keys remote cookies by environment identity instead of state directory", () => Effect.gen(function* () { const cookieName = (stateDir: string, environmentId: EnvironmentId) => diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 1526dd6705d7..92163a21e7de 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -9,6 +9,7 @@ import { type ClientSurface, type ServerAuthSessionMethod, } from "@t3tools/contracts"; +import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -420,7 +421,14 @@ export class SessionStore extends Context.Service< >()("t3/auth/SessionStore") {} const SIGNING_SECRET_NAME = "server-signing-key"; -const DEFAULT_SESSION_TTL = Duration.days(30); +const sessionTtlConfig = Config.schema( + Schema.DurationFromString.check( + Schema.makeFilter((ttl) => Duration.isFinite(ttl) && Duration.isPositive(ttl), { + message: "Session lifetime must be a finite, positive duration.", + }), + ), + "T3CODE_SESSION_TTL", +).pipe(Config.withDefault(Duration.days(30))); const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5); const SessionClaims = Schema.Struct({ v: Schema.Literal(1), @@ -479,6 +487,7 @@ function toAuthClientSession(input: Omit): AuthCli } export const make = Effect.gen(function* () { + const defaultSessionTtl = yield* sessionTtlConfig; const crypto = yield* Crypto.Crypto; const serverConfig = yield* ServerConfig.ServerConfig; const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity; @@ -655,7 +664,7 @@ export const make = Effect.gen(function* () { ); const issuedAt = yield* DateTime.now; const expiresAt = DateTime.add(issuedAt, { - milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL), + milliseconds: Duration.toMillis(input?.ttl ?? defaultSessionTtl), }); const claims: SessionClaims = { v: 1, diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index f3fb96b830f9..8d1e57190c9f 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -150,6 +150,12 @@ pairing links and revoke client sessions. Revoking an unused link prevents new pairings; revoke a device's session to remove its existing access. Command-line management is available through `t3 auth --help`. +Direct-pairing sessions expire after 30 days by default. To change the lifetime +for new sessions, set `T3CODE_SESSION_TTL` to a finite, positive duration such as +`90 days` in the server environment and restart the server. Existing sessions +keep their original expiry. Explicit session lifetimes, including T3 Connect's +short-lived credentials, take precedence. Activity does not extend expiry. + A session with an open connection stays listed after its access credential expires.