Skip to content

Commit e550bc0

Browse files
committed
fix(auth): keep refresh sessions alive on HTTP (#14252)
* fix(auth): keep refresh sessions alive on HTTP * fix(auth): use supported Vite token expiry injection * fix(auth): make Vite env injection Playwright-safe
1 parent 20a39b8 commit e550bc0

8 files changed

Lines changed: 122 additions & 45 deletions

File tree

.secrets.baseline

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2587,7 +2587,7 @@
25872587
"filename": "src/frontend/src/constants/constants.ts",
25882588
"hashed_secret": "c04f8fbf55c9096907a982750b1c6b0e4c1dd658",
25892589
"is_verified": false,
2590-
"line_number": 952,
2590+
"line_number": 962,
25912591
"is_secret": false
25922592
}
25932593
],

docs/docs/Develop/api-keys-and-authentication.mdx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -451,17 +451,20 @@ LANGFLOW_CORS_ALLOW_METHODS=["GET","POST","PUT"]
451451
```
452452
:::
453453

454-
### LANGFLOW_ACCESS_* {#session-cookie-hardening}
454+
### LANGFLOW_ACCESS_* and LANGFLOW_REFRESH_* {#session-cookie-hardening}
455455

456-
For a shared or public deployment served over HTTPS, harden the access-token cookie. These default to permissive values for local HTTP development and for the current frontend, which reads the access token in JavaScript.
456+
For a shared or public deployment served over HTTPS, harden the session cookies. These default to values that support local HTTP development and same-site deployments. The current frontend reads the access token in JavaScript, while the refresh token remains `HttpOnly`.
457457

458-
The refresh-token cookie is `HttpOnly` + `Secure` + `SameSite` by default.
458+
Set both `LANGFLOW_ACCESS_SECURE` and `LANGFLOW_REFRESH_SECURE` to `True` for HTTPS deployments. Cross-site HTTPS deployments must also set `LANGFLOW_REFRESH_SAME_SITE=none`.
459459

460460
| Variable | Format | Default | Description |
461461
|----------|--------|---------|-------------|
462462
| `LANGFLOW_ACCESS_SECURE` | Boolean | `False` | When `true`, the `access_token_lf` cookie is sent only over HTTPS. Recommended `true` for any HTTPS deployment. |
463463
| `LANGFLOW_ACCESS_HTTPONLY` | Boolean | `False` | When `true`, the `access_token_lf` cookie is not readable by JavaScript. The default is `false` because the bundled frontend currently reads this cookie in JavaScript. |
464464
| `LANGFLOW_ACCESS_SAME_SITE` | String | `lax` | The `SameSite` attribute of the access-token cookie (`lax`, `strict`, or `none`). |
465+
| `LANGFLOW_REFRESH_SECURE` | Boolean | `False` | When `true`, the `refresh_token_lf` cookie is sent only over HTTPS. Set this to `true` for any HTTPS deployment. |
466+
| `LANGFLOW_REFRESH_HTTPONLY` | Boolean | `True` | When `true`, the `refresh_token_lf` cookie is not readable by JavaScript. |
467+
| `LANGFLOW_REFRESH_SAME_SITE` | String | `lax` | The `SameSite` attribute of the refresh-token cookie (`lax`, `strict`, or `none`). |
465468

466469
### LANGFLOW_RATE_LIMIT_* {#login-rate-limiting}
467470

src/backend/tests/unit/test_security_cors.py

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -438,35 +438,30 @@ async def test_refresh_token_valid_flow(self):
438438
# user_id is converted to string in JWT payload, then back to UUID in service
439439
mock_create_tokens.assert_called_once_with(str(user_id), mock_db)
440440

441-
def test_refresh_token_samesite_setting_current_behavior(self):
442-
"""Test current refresh token SameSite settings (warns about security)."""
441+
def test_refresh_cookie_defaults_support_same_site_http(self):
442+
"""Refresh cookies use the same safe, HTTP-compatible defaults as access cookies."""
443443
from lfx.services.settings.auth import AuthSettings
444444

445445
with tempfile.TemporaryDirectory() as temp_dir, patch.dict(os.environ, {"LANGFLOW_CONFIG_DIR": temp_dir}):
446446
auth_settings = AuthSettings(CONFIG_DIR=temp_dir)
447-
# Current behavior: refresh token uses 'none' (allows cross-site)
448-
assert auth_settings.REFRESH_SAME_SITE == "none" # Current: allows cross-site (less secure)
449-
assert auth_settings.ACCESS_SAME_SITE == "lax" # Access token is already lax (good)
447+
assert auth_settings.REFRESH_SAME_SITE == "lax"
448+
assert auth_settings.REFRESH_SECURE is False
449+
assert auth_settings.ACCESS_SAME_SITE == "lax"
450+
assert auth_settings.ACCESS_SECURE is False
450451
assert auth_settings.ACCESS_HTTPONLY is True
451452

452-
# Warn about security implications
453-
warnings.warn(
454-
"SECURITY WARNING: Refresh tokens currently use SameSite=none which allows "
455-
"cross-site requests. This should be changed to 'lax' or 'strict' in production. "
456-
"In v1.7, this will default to 'lax' for better security.",
457-
UserWarning,
458-
stacklevel=2,
459-
)
453+
def test_refresh_cookie_cross_site_https_can_be_enabled(self):
454+
"""Operators can retain cross-site HTTPS refresh cookies explicitly."""
455+
from lfx.services.settings.auth import AuthSettings
460456

461-
@pytest.mark.skip(reason="Uncomment in v1.7 - represents future secure SameSite behavior")
462-
def test_refresh_token_samesite_setting_future_secure(self):
463-
"""Test future secure refresh token SameSite settings (skip until v1.7)."""
464-
# Future secure behavior (uncomment in v1.7):
465-
# from langflow.services.settings.auth import AuthSettings
466-
# with tempfile.TemporaryDirectory() as temp_dir, patch.dict(os.environ, {"LANGFLOW_CONFIG_DIR": temp_dir}):
467-
# auth_settings = AuthSettings(CONFIG_DIR=temp_dir)
468-
# assert auth_settings.REFRESH_SAME_SITE == "lax" # Secure default
469-
# assert auth_settings.ACCESS_SAME_SITE == "lax"
457+
with tempfile.TemporaryDirectory() as temp_dir:
458+
auth_settings = AuthSettings(
459+
CONFIG_DIR=temp_dir,
460+
REFRESH_SAME_SITE="none",
461+
REFRESH_SECURE=True,
462+
)
463+
assert auth_settings.REFRESH_SAME_SITE == "none"
464+
assert auth_settings.REFRESH_SECURE is True
470465

471466

472467
class TestCORSIntegration:
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import {
2+
ACCESS_TOKEN_EXPIRE_SECONDS_ENV_KEY,
3+
createAccessTokenExpireSecondsDefinition,
4+
} from "../../../vite-env-definitions";
5+
6+
describe("authentication token refresh timing", () => {
7+
const originalAccessTokenExpiry = process.env.ACCESS_TOKEN_EXPIRE_SECONDS;
8+
9+
afterEach(() => {
10+
if (originalAccessTokenExpiry === undefined) {
11+
delete process.env.ACCESS_TOKEN_EXPIRE_SECONDS;
12+
} else {
13+
process.env.ACCESS_TOKEN_EXPIRE_SECONDS = originalAccessTokenExpiry;
14+
}
15+
jest.resetModules();
16+
});
17+
18+
it("defaults to refreshing 10% before the one-hour backend expiry", async () => {
19+
expect(ACCESS_TOKEN_EXPIRE_SECONDS_ENV_KEY).toBe(
20+
"__LANGFLOW_ACCESS_TOKEN_EXPIRE_SECONDS__",
21+
);
22+
const definitions = createAccessTokenExpireSecondsDefinition();
23+
expect(definitions[ACCESS_TOKEN_EXPIRE_SECONDS_ENV_KEY]).toBe("3600");
24+
process.env.ACCESS_TOKEN_EXPIRE_SECONDS = String(
25+
JSON.parse(definitions[ACCESS_TOKEN_EXPIRE_SECONDS_ENV_KEY]),
26+
);
27+
jest.resetModules();
28+
29+
const { LANGFLOW_ACCESS_TOKEN_EXPIRE_SECONDS_ENV } = await import(
30+
"../constants"
31+
);
32+
33+
expect(LANGFLOW_ACCESS_TOKEN_EXPIRE_SECONDS_ENV).toBe(3240);
34+
});
35+
36+
it("refreshes 10% before a configured expiry", async () => {
37+
const definitions = createAccessTokenExpireSecondsDefinition("7200");
38+
expect(definitions[ACCESS_TOKEN_EXPIRE_SECONDS_ENV_KEY]).toBe('"7200"');
39+
process.env.ACCESS_TOKEN_EXPIRE_SECONDS = String(
40+
JSON.parse(definitions[ACCESS_TOKEN_EXPIRE_SECONDS_ENV_KEY]),
41+
);
42+
jest.resetModules();
43+
44+
const { LANGFLOW_ACCESS_TOKEN_EXPIRE_SECONDS_ENV } = await import(
45+
"../constants"
46+
);
47+
48+
expect(LANGFLOW_ACCESS_TOKEN_EXPIRE_SECONDS_ENV).toBe(6480);
49+
});
50+
});

src/frontend/src/constants/constants.ts

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,23 @@ import {
77
import { customDefaultShortcuts } from "../customization/constants";
88
import type { languageMap } from "../types/components";
99

10+
declare const __LANGFLOW_ACCESS_TOKEN_EXPIRE_SECONDS__: string | number;
11+
declare const __LANGFLOW_AUTO_LOGIN__: string | boolean;
12+
1013
export const DEFAULT_SESSION_NAME = "Default Session";
1114
export const NEW_SESSION_NAME = "New Session";
1215
export const SLIDING_TRANSITION_MS = 300;
1316

1417
const getEnvVar = <T = string | undefined>(
1518
key: string,
19+
viteValue: T | undefined,
1620
defaultValue?: T,
1721
): T | undefined => {
18-
if (typeof process !== "undefined" && process.env) {
19-
return (process.env[key] as T) ?? defaultValue;
20-
}
21-
try {
22-
const value = new Function(`return import.meta.env?.${key}`)() as T;
23-
return value ?? defaultValue;
24-
} catch {
25-
return defaultValue;
26-
}
22+
const processValue =
23+
typeof process !== "undefined" && process.env
24+
? (process.env[key] as T | undefined)
25+
: undefined;
26+
return processValue ?? viteValue ?? defaultValue;
2727
};
2828

2929
/**
@@ -881,9 +881,19 @@ export const LANGFLOW_AUTO_LOGIN_OPTION = "auto_login_lf";
881881
export const LANGFLOW_REFRESH_TOKEN = "refresh_token_lf";
882882

883883
export const LANGFLOW_ACCESS_TOKEN_EXPIRE_SECONDS = 60 * 60 - 60 * 60 * 0.1;
884+
const viteAccessTokenExpireSeconds =
885+
typeof __LANGFLOW_ACCESS_TOKEN_EXPIRE_SECONDS__ === "undefined"
886+
? undefined
887+
: __LANGFLOW_ACCESS_TOKEN_EXPIRE_SECONDS__;
888+
const configuredAccessTokenExpireSeconds = Number(
889+
getEnvVar<string | number>(
890+
"ACCESS_TOKEN_EXPIRE_SECONDS",
891+
viteAccessTokenExpireSeconds,
892+
60 * 60,
893+
),
894+
);
884895
export const LANGFLOW_ACCESS_TOKEN_EXPIRE_SECONDS_ENV =
885-
Number(getEnvVar("ACCESS_TOKEN_EXPIRE_SECONDS", 60)) -
886-
Number(getEnvVar("ACCESS_TOKEN_EXPIRE_SECONDS", 60)) * 0.1;
896+
configuredAccessTokenExpireSeconds - configuredAccessTokenExpireSeconds * 0.1;
887897
export const TEXT_FIELD_TYPES: string[] = ["str", "SecretStr"];
888898
export const NODE_WIDTH = 384;
889899
export const NODE_HEIGHT = NODE_WIDTH * 3;
@@ -958,9 +968,16 @@ export const POLLING_MESSAGES = {
958968

959969
export const BUILD_POLLING_INTERVAL = 25;
960970

971+
const viteAutoLogin =
972+
typeof __LANGFLOW_AUTO_LOGIN__ === "undefined"
973+
? undefined
974+
: __LANGFLOW_AUTO_LOGIN__;
975+
const autoLoginEnv = getEnvVar<string | boolean>(
976+
"LANGFLOW_AUTO_LOGIN",
977+
viteAutoLogin,
978+
);
961979
export const IS_AUTO_LOGIN =
962-
!getEnvVar("LANGFLOW_AUTO_LOGIN") ||
963-
String(getEnvVar("LANGFLOW_AUTO_LOGIN"))?.toLowerCase() !== "false";
980+
!autoLoginEnv || String(autoLoginEnv).toLowerCase() !== "false";
964981

965982
export const AUTO_LOGIN_RETRY_DELAY = 2000;
966983
export const AUTO_LOGIN_MAX_RETRY_DELAY = 60000;
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export const ACCESS_TOKEN_EXPIRE_SECONDS_ENV_KEY =
2+
"__LANGFLOW_ACCESS_TOKEN_EXPIRE_SECONDS__";
3+
export const DEFAULT_ACCESS_TOKEN_EXPIRE_SECONDS = 60 * 60;
4+
5+
export const createAccessTokenExpireSecondsDefinition = (
6+
configuredValue?: string,
7+
) => ({
8+
[ACCESS_TOKEN_EXPIRE_SECONDS_ENV_KEY]: JSON.stringify(
9+
configuredValue ?? DEFAULT_ACCESS_TOKEN_EXPIRE_SECONDS,
10+
),
11+
});

src/frontend/vite.config.mts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
PORT,
1212
PROXY_TARGET,
1313
} from "./src/customization/config-constants";
14+
import { createAccessTokenExpireSecondsDefinition } from "./vite-env-definitions";
1415

1516
export default defineConfig(({ mode }) => {
1617
const env = loadEnv(mode, process.cwd(), "");
@@ -44,14 +45,14 @@ export default defineConfig(({ mode }) => {
4445
outDir: "build",
4546
},
4647
define: {
48+
...createAccessTokenExpireSecondsDefinition(
49+
envLangflow.ACCESS_TOKEN_EXPIRE_SECONDS,
50+
),
4751
"import.meta.env.BACKEND_URL": JSON.stringify(
4852
envLangflow.BACKEND_URL ?? "http://localhost:7860",
4953
),
50-
"import.meta.env.ACCESS_TOKEN_EXPIRE_SECONDS": JSON.stringify(
51-
envLangflow.ACCESS_TOKEN_EXPIRE_SECONDS ?? 60,
52-
),
5354
"import.meta.env.CI": JSON.stringify(envLangflow.CI ?? false),
54-
"import.meta.env.LANGFLOW_AUTO_LOGIN": JSON.stringify(
55+
__LANGFLOW_AUTO_LOGIN__: JSON.stringify(
5556
envLangflow.LANGFLOW_AUTO_LOGIN ?? true,
5657
),
5758
"import.meta.env.LANGFLOW_MCP_COMPOSER_ENABLED": JSON.stringify(

src/lfx/src/lfx/services/settings/auth.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,9 @@ class AuthSettings(BaseSettings):
123123
# Store password as SecretStr to prevent accidental plaintext exposure
124124
SUPERUSER_PASSWORD: SecretStr = Field(default=DEFAULT_SUPERUSER_PASSWORD)
125125

126-
REFRESH_SAME_SITE: Literal["lax", "strict", "none"] = "none"
126+
REFRESH_SAME_SITE: Literal["lax", "strict", "none"] = "lax"
127127
"""The SameSite attribute of the refresh token cookie."""
128-
REFRESH_SECURE: bool = True
128+
REFRESH_SECURE: bool = False
129129
"""The Secure attribute of the refresh token cookie."""
130130
REFRESH_HTTPONLY: bool = True
131131
"""The HttpOnly attribute of the refresh token cookie."""

0 commit comments

Comments
 (0)