History

fix(server): migrate legacy web sessions

Bil0000 committed 2 months ago

Commit 2ca78081 parent a0e7e225 files · +73 −15Browse files

Unified · Split

5 files
  1. apps/server/src/auth/EnvironmentAuth.ts +3 −1
  2. apps/server/src/auth/SessionStore.ts +7 −2
  3. apps/server/src/auth/http.ts +35 −12
  4. apps/server/src/auth/utils.ts +10 −0
  5. apps/server/src/server.test.ts +18 −0
apps/server/src/auth/EnvironmentAuth.ts+3−1
@@ -600,7 +600,9 @@ export const make = Effect.gen(function* () { · 599 unmodified lines
600 const authenticateRequest = (600 const authenticateRequest = (
601 request: HttpServerRequest.HttpServerRequest,601 request: HttpServerRequest.HttpServerRequest,
602 ): Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError> => {602 ): Effect.Effect<AuthenticatedSession, ServerAuthCredentialError | ServerAuthInternalError> => {
603 const cookieToken = request.cookies[sessions.cookieName];603 const cookieToken =
604 request.cookies[sessions.cookieName] ??
605 (sessions.legacyCookieName ? request.cookies[sessions.legacyCookieName] : undefined);
604 const bearerToken = parseBearerToken(request);606 const bearerToken = parseBearerToken(request);
605 const dpopToken = parseDpopToken(request);607 const dpopToken = parseDpopToken(request);
606 const credential = cookieToken ?? bearerToken ?? dpopToken;608 const credential = cookieToken ?? bearerToken ?? dpopToken;
apps/server/src/auth/SessionStore.ts+7−2
@@ -26,6 +26,7 @@ import * as ServerSecretStore from "./ServerSecretStore.ts"; · 25 unmodified lines
26import {26import {
27 base64UrlDecodeUtf8,27 base64UrlDecodeUtf8,
28 base64UrlEncode,28 base64UrlEncode,
29 resolveLegacySessionCookieName,
29 resolveSessionCookieName,30 resolveSessionCookieName,
30 signPayload,31 signPayload,
31 timingSafeEqualBase64Url,32 timingSafeEqualBase64Url,
@@ -360,6 +361,7 @@ export class SessionStore extends Context.Service< · 328 unmodified lines
360 SessionStore,361 SessionStore,
361 {362 {
362 readonly cookieName: string;363 readonly cookieName: string;
364 readonly legacyCookieName: string | undefined;
363 readonly issue: (input?: {365 readonly issue: (input?: {
364 readonly ttl?: Duration.Duration;366 readonly ttl?: Duration.Duration;
365 readonly subject?: string;367 readonly subject?: string;
@@ -475,13 +477,15 @@ export const make = Effect.gen(function* () { · 109 unmodified lines
475 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);477 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);
476 const connectedSessionsRef = yield* Ref.make(new Map<string, number>());478 const connectedSessionsRef = yield* Ref.make(new Map<string, number>());
477 const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>();479 const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>();
478 const cookieName = resolveSessionCookieName({480 const cookieInput = {
479 mode: serverConfig.mode,481 mode: serverConfig.mode,
480 port: serverConfig.port,482 port: serverConfig.port,
481 host: serverConfig.host,483 host: serverConfig.host,
482 instanceKey: serverConfig.stateDir,484 instanceKey: serverConfig.stateDir,
483 development: serverConfig.devUrl !== undefined,485 development: serverConfig.devUrl !== undefined,
484 });486 } as const;
487 const cookieName = resolveSessionCookieName(cookieInput);
488 const legacyCookieName = resolveLegacySessionCookieName(cookieInput);
485 489
486 const emitUpsert = (clientSession: AuthClientSession) =>490 const emitUpsert = (clientSession: AuthClientSession) =>
487 PubSub.publish(changesPubSub, {491 PubSub.publish(changesPubSub, {
@@ -930,6 +934,7 @@ export const make = Effect.gen(function* () { · 442 unmodified lines
930 934
931 return SessionStore.of({935 return SessionStore.of({
932 cookieName,936 cookieName,
937 legacyCookieName,
933 issue,938 issue,
934 verify,939 verify,
935 issueWebSocketToken,940 issueWebSocketToken,
apps/server/src/auth/http.ts+35−12
@@ -171,6 +171,23 @@ export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, · 170 unmodified lines
171 });171 });
172}172}
173 173
174const appendSessionCookie = (cookieName: string, token: string, expiresAt: DateTime.DateTime) =>
175 Effect.fromResult(
176 Cookies.set(Cookies.empty, cookieName, token, {
177 expires: DateTime.toDate(expiresAt),
178 httpOnly: true,
179 path: "/",
180 sameSite: "lax",
181 }),
182 ).pipe(
183 Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed")),
184 Effect.flatMap((cookies) =>
185 HttpEffect.appendPreResponseHandler((_request, response) =>
186 Effect.succeed(HttpServerResponse.mergeCookies(response, cookies)),
187 ),
188 ),
189 );
190
174export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope")(function* (191export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope")(function* (
175 scope: AuthEnvironmentScope,192 scope: AuthEnvironmentScope,
176) {193) {
@@ -224,7 +241,20 @@ export const authHttpApiLayer = HttpApiBuilder.group( · 47 unmodified lines
224 function* (args) {241 function* (args) {
225 yield* annotateEnvironmentRequest(args.endpoint.name);242 yield* annotateEnvironmentRequest(args.endpoint.name);
226 const request = yield* HttpServerRequest.HttpServerRequest;243 const request = yield* HttpServerRequest.HttpServerRequest;
227 return yield* serverAuth.getSessionState(request);244 const result = yield* serverAuth.getSessionState(request);
245 const legacyToken =
246 sessions.legacyCookieName && !request.cookies[sessions.cookieName]
247 ? request.cookies[sessions.legacyCookieName]
248 : undefined;
249 if (
250 legacyToken &&
251 result.authenticated &&
252 result.sessionMethod === "browser-session-cookie" &&
253 result.expiresAt
254 ) {
255 yield* appendSessionCookie(sessions.cookieName, legacyToken, result.expiresAt);
256 }
257 return result;
228 },258 },
229 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>259 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
230 failEnvironmentInternal("internal_error", error),260 failEnvironmentInternal("internal_error", error),
@@ -241,17 +271,10 @@ export const authHttpApiLayer = HttpApiBuilder.group( · 10 unmodified lines
241 args.payload.credential,271 args.payload.credential,
242 deriveAuthClientMetadata({ request }),272 deriveAuthClientMetadata({ request }),
243 );273 );
244 const sessionCookies = yield* Effect.fromResult(274 yield* appendSessionCookie(
245 Cookies.set(Cookies.empty, sessions.cookieName, result.sessionToken, {275 sessions.cookieName,
246 expires: DateTime.toDate(result.response.expiresAt),276 result.sessionToken,
247 httpOnly: true,277 result.response.expiresAt,
248 path: "/",
249 sameSite: "lax",
250 }),
251 ).pipe(Effect.catch(() => failEnvironmentInternal("browser_session_cookie_failed")));
252
253 yield* HttpEffect.appendPreResponseHandler((_request, response) =>
254 Effect.succeed(HttpServerResponse.mergeCookies(response, sessionCookies)),
255 );278 );
256 yield* appendCredentialResponseHeaders;279 yield* appendCredentialResponseHeaders;
257 return result.response;280 return result.response;
apps/server/src/auth/utils.ts+10−0
@@ -48,6 +48,16 @@ export function resolveSessionCookieName(input: { · 47 unmodified lines
48 return `${SESSION_COOKIE_NAME}_${input.port}_${instanceHash}`;48 return `${SESSION_COOKIE_NAME}_${input.port}_${instanceHash}`;
49}49}
50 50
51export function resolveLegacySessionCookieName(input: {
52 readonly mode: "web" | "desktop";
53 readonly host: string | undefined;
54 readonly development: boolean;
55}): string | undefined {
56 return input.mode === "web" && !input.development && isRemoteReachableHost(input.host)
57 ? SESSION_COOKIE_NAME
58 : undefined;
59}
60
51export function isRemoteReachableHost(host: string | undefined): boolean {61export function isRemoteReachableHost(host: string | undefined): boolean {
52 if (host === "0.0.0.0" || host === "::" || host === "[::]") {62 if (host === "0.0.0.0" || host === "::" || host === "[::]") {
53 return true;63 return true;
apps/server/src/server.test.ts+18−0
@@ -1638,6 +1638,24 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 1637 unmodified lines
1638 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1638 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1639 );1639 );
1640 1640
1641 it.effect("migrates a valid legacy remote-web session cookie", () =>
1642 Effect.gen(function* () {
1643 yield* buildAppUnderTest({ config: { mode: "web", host: "192.168.1.50" } });
1644
1645 const { cookie } = yield* bootstrapBrowserSession();
1646 const currentCookie = cookie?.split(";")[0] ?? "";
1647 const legacyCookie = currentCookie.replace(/^t3_session_[^=]+=/, "t3_session=");
1648 const sessionUrl = yield* getHttpServerUrl("/api/auth/session");
1649 const response = yield* fetchEffect(sessionUrl, {
1650 headers: { cookie: legacyCookie },
1651 });
1652 const body = yield* responseJsonEffect<{ readonly authenticated: boolean }>(response);
1653
1654 assert.equal(body.authenticated, true);
1655 assert.match(response.headers["set-cookie"] ?? "", /^t3_session_[a-f0-9]{12}=/);
1656 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1657 );
1658
1641 it.effect("exchanges a bootstrap grant for a scoped bearer access token", () =>1659 it.effect("exchanges a bootstrap grant for a scoped bearer access token", () =>
1642 Effect.gen(function* () {1660 Effect.gen(function* () {
1643 yield* buildAppUnderTest();1661 yield* buildAppUnderTest();