Unified · Split
| @@ -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; |
| @@ -26,6 +26,7 @@ import * as ServerSecretStore from "./ServerSecretStore.ts"; · 25 unmodified lines | |||
| 26 | import { | 26 | import { |
| 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, |
| @@ -171,6 +171,23 @@ export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, · 170 unmodified lines | |||
| 171 | }); | 171 | }); |
| 172 | } | 172 | } |
| 173 | 173 | ||
| 174 | const 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 | |||
| 174 | export const requireEnvironmentScope = Effect.fn("environment.auth.requireScope")(function* ( | 191 | export 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; |
| @@ -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 | ||
| 51 | export 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 | |||
| 51 | export function isRemoteReachableHost(host: string | undefined): boolean { | 61 | export 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; |
| @@ -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(); |