Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
4b39cb9
feat: add enterprise feature flag and custom admin menu item
deon-sanchez Jul 27, 2026
71fee19
Merge branch 'release-1.12.0' of https://github.com/langflow-ai/langf…
deon-sanchez Jul 29, 2026
a1c7b1b
feat(sso): implement multi-identity support for users and enhance SSO…
deon-sanchez Jul 29, 2026
85d4d4d
feat(sso): enhance SSO identity management and secure client secret h…
deon-sanchez Jul 29, 2026
f3c127c
Merge branch 'release-1.12.0' of https://github.com/langflow-ai/langf…
deon-sanchez Jul 30, 2026
c08092a
Merge branch 'release-1.12.0' of https://github.com/langflow-ai/langf…
deon-sanchez Jul 30, 2026
823d9fb
Merge branch 'release-1.12.0' of https://github.com/langflow-ai/langf…
deon-sanchez Jul 30, 2026
ccfabe1
feat(auth): refresh login and signup pages
deon-sanchez Jul 30, 2026
dd09fed
refactor(LoginPage): update layout and accessibility of login options…
deon-sanchez Jul 31, 2026
804b311
feat(port): enhance port configuration by prioritizing LANGFLOW_PORT …
deon-sanchez Jul 31, 2026
8fdcb06
feat(auth): introduce customizable login components and enhance authe…
deon-sanchez Aug 3, 2026
8da998a
Merge branch 'release-1.12.0' of https://github.com/langflow-ai/langf…
deon-sanchez Aug 3, 2026
9000c81
fix(alembic): resolve duplicate e8f1a2b3c4d5 revision after release m…
deon-sanchez Aug 3, 2026
13e3d90
clean up
deon-sanchez Aug 4, 2026
256cc7d
clean up 2
deon-sanchez Aug 4, 2026
e4d2f7f
fix superuser test
deon-sanchez Aug 4, 2026
b36efa0
fix a11y test
deon-sanchez Aug 4, 2026
ac5aeaa
codeRabbit fixes
deon-sanchez Aug 4, 2026
660c74f
Merge branch 'release-1.12.0' of https://github.com/langflow-ai/langf…
deon-sanchez Aug 4, 2026
29cb0c1
erics suggestions
deon-sanchez Aug 4, 2026
aa96368
Merge branch 'release-1.12.0' of https://github.com/langflow-ai/langf…
deon-sanchez Aug 4, 2026
4c9193e
Implement SSO client secret migration and validation checks
deon-sanchez Aug 5, 2026
c27fcff
Merge branch 'release-1.12.0' of https://github.com/langflow-ai/langf…
deon-sanchez Aug 5, 2026
992b9b2
Merge branch 'release-1.12.0' of https://github.com/langflow-ai/langf…
deon-sanchez Aug 5, 2026
46558c8
fixed playwright tests
deon-sanchez Aug 5, 2026
e792d3e
Merge branch 'release-1.12.0' of https://github.com/langflow-ai/langf…
deon-sanchez Aug 5, 2026
d4a1401
fix migration
deon-sanchez Aug 5, 2026
1c2c6de
fixed db migrations
deon-sanchez Aug 5, 2026
8038c51
fix playwright tests
deon-sanchez Aug 5, 2026
ef6f5e0
Merge branch 'release-1.12.0' of https://github.com/langflow-ai/langf…
deon-sanchez Aug 5, 2026
5f42a64
refactor: enhance accessibility and structure of login and signup pages
deon-sanchez Aug 5, 2026
c902a22
fix: address SSO review findings
erichare Aug 5, 2026
01af799
fix: allow SSO expand schema drift
erichare Aug 5, 2026
52f0022
fix: address SSO review follow-ups
erichare Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
668 changes: 330 additions & 338 deletions .secrets.baseline

Large diffs are not rendered by default.

5 changes: 0 additions & 5 deletions scripts/a11y/a11y_routes.json
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,6 @@
}
],
"gated": [
{
"path": "/admin",
"surface": "Admin page",
"currentBehavior": "Redirects to /flows for current user/session. Scan with an admin user."
},
{
"path": "/login",
"surface": "Login page",
Expand Down
123 changes: 117 additions & 6 deletions scripts/migrate_secret_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- user.store_api_key: Langflow Store API keys
- variable.value: All encrypted variable values
- folder.auth_settings: MCP oauth_client_secret and api_key fields
- sso_config.client_secret_encrypted: SSO/OIDC client secrets

Usage:
uv run python scripts/migrate_secret_key.py --help
Expand All @@ -20,6 +21,7 @@

import argparse
import base64
import binascii
import json
import os
import platform
Expand All @@ -29,14 +31,27 @@
from datetime import datetime, timezone
from pathlib import Path

from cryptography.exceptions import InvalidTag
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from platformdirs import user_cache_dir
from sqlalchemy import create_engine, text
from sqlalchemy import create_engine, inspect, text

MINIMUM_KEY_LENGTH = 32
SENSITIVE_AUTH_FIELDS = ["oauth_client_secret", "api_key"]
# Must match langflow.services.variable.constants.CREDENTIAL_TYPE
CREDENTIAL_TYPE = "Credential"
SSO_ENVELOPE_HEADER = "lf-sso:v1:hkdf-sha256-v1:aes-256-gcm"
SSO_AAD = SSO_ENVELOPE_HEADER.encode()
SSO_HKDF_SALT = b"langflow/sso/client-secret/hkdf-salt/v1"
SSO_HKDF_INFO = b"langflow/sso/client-secret/encryption"
SSO_NONCE_BYTES = 12
SSO_TAG_BYTES = 16
SSO_ENVELOPE_PARTS = 6
SSO_HEADER_PARTS = 4
SSO_BASE64URL_ALPHABET = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")


def get_default_config_dir() -> Path:
Expand Down Expand Up @@ -140,6 +155,62 @@ def migrate_value(encrypted: str, old_key: str, new_key: str) -> str | None:
return None


def _derive_sso_key(master_key: str) -> bytes:
return HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=SSO_HKDF_SALT,
info=SSO_HKDF_INFO,
).derive(master_key.encode())


def _decode_sso_envelope(envelope: str) -> tuple[bytes, bytes]:
parts = envelope.split(":")
if len(parts) != SSO_ENVELOPE_PARTS or ":".join(parts[:SSO_HEADER_PARTS]) != SSO_ENVELOPE_HEADER:
msg = "Unsupported SSO client-secret envelope"
raise ValueError(msg)
decoded_payloads: list[bytes] = []
for value in parts[4:]:
if not value or any(character not in SSO_BASE64URL_ALPHABET for character in value):
msg = "Invalid base64url data in SSO client-secret envelope"
raise ValueError(msg)
try:
decoded_payloads.append(base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True))
except (binascii.Error, ValueError) as exc:
msg = "Invalid base64url data in SSO client-secret envelope"
raise ValueError(msg) from exc
nonce, ciphertext = decoded_payloads
if len(nonce) != SSO_NONCE_BYTES or len(ciphertext) < SSO_TAG_BYTES:
msg = "Invalid SSO client-secret envelope payload"
raise ValueError(msg)
return nonce, ciphertext
Comment thread
erichare marked this conversation as resolved.


def decrypt_sso_secret_with_key(envelope: str, key: str) -> str:
"""Decrypt an SSO client-secret envelope with an explicit master key."""
nonce, ciphertext = _decode_sso_envelope(envelope)
plaintext = AESGCM(_derive_sso_key(key)).decrypt(nonce, ciphertext, SSO_AAD)
return plaintext.decode()


def encrypt_sso_secret_with_key(plaintext: str, key: str) -> str:
"""Encrypt an SSO client secret using the application's current envelope format."""
nonce = os.urandom(SSO_NONCE_BYTES)
ciphertext = AESGCM(_derive_sso_key(key)).encrypt(nonce, plaintext.encode(), SSO_AAD)
encoded_nonce = base64.urlsafe_b64encode(nonce).rstrip(b"=").decode()
encoded_ciphertext = base64.urlsafe_b64encode(ciphertext).rstrip(b"=").decode()
return f"{SSO_ENVELOPE_HEADER}:{encoded_nonce}:{encoded_ciphertext}"


def migrate_sso_secret(envelope: str, old_key: str, new_key: str) -> str | None:
"""Rewrap an SSO client-secret envelope under a replacement master key."""
try:
plaintext = decrypt_sso_secret_with_key(envelope, old_key)
return encrypt_sso_secret_with_key(plaintext, new_key)
except (InvalidTag, UnicodeDecodeError, ValueError):
return None


def migrate_auth_settings(auth_settings: dict, old_key: str, new_key: str) -> tuple[dict, list[str]]:
"""Re-encrypt sensitive fields in auth_settings dict.

Expand Down Expand Up @@ -208,6 +279,17 @@ def verify_migration(conn, new_key: str) -> tuple[int, int]:
except (InvalidToken, json.JSONDecodeError):
failed += 1

if inspect(conn).has_table("sso_config"):
configs = conn.execute(
text("SELECT id, client_secret_encrypted FROM sso_config WHERE client_secret_encrypted IS NOT NULL LIMIT 3")
).fetchall()
for _, encrypted_secret in configs:
try:
decrypt_sso_secret_with_key(encrypted_secret, new_key)
verified += 1
except (InvalidTag, UnicodeDecodeError, ValueError):
failed += 1

return verified, failed


Expand Down Expand Up @@ -364,9 +446,38 @@ def migrate(
total_migrated += migrated
total_failed += failed

# Migrate sso_config.client_secret_encrypted when the optional SSO schema exists.
print("\n4. Migrating SSO client secrets...")
migrated, failed = 0, 0
if inspect(conn).has_table("sso_config"):
configs = conn.execute(
text("SELECT id, client_secret_encrypted FROM sso_config WHERE client_secret_encrypted IS NOT NULL")
).fetchall()
for config_id, encrypted_secret in configs:
new_encrypted = migrate_sso_secret(encrypted_secret, old_key, new_key)
if new_encrypted:
if not dry_run:
conn.execute(
text("UPDATE sso_config SET client_secret_encrypted = :secret WHERE id = :id"),
{"secret": new_encrypted, "id": config_id},
)
migrated += 1
else:
failed += 1
print(f" Warning: Could not decrypt SSO config {config_id}")
print(f" {'Would migrate' if dry_run else 'Migrated'}: {migrated}, Failed: {failed}")
total_migrated += migrated
total_failed += failed

if total_failed > 0 and not dry_run:
print(f"\nERROR: {total_failed} values could not be migrated.")
print("Rolling back all database changes; the secret key was not changed.")
conn.rollback()
sys.exit(1)

# Verify migrated data can be decrypted with new key
if total_migrated > 0:
print("\n4. Verifying migration...")
print("\n5. Verifying migration...")
verified, verify_failed = verify_migration(conn, new_key)
if verify_failed > 0:
print(f" ERROR: {verify_failed} records failed verification!")
Expand All @@ -386,12 +497,12 @@ def migrate(
if not dry_run:
backup_file = config_dir / f"secret_key.backup.{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}"
write_secret_key_to_file(config_dir, old_key, backup_file.name)
print(f"\n5. Backed up old key to: {backup_file}")
print(f"\n6. Backed up old key to: {backup_file}")
write_secret_key_to_file(config_dir, new_key)
print(f"6. Saved new secret key to: {config_dir / 'secret_key'}")
print(f"7. Saved new secret key to: {config_dir / 'secret_key'}")
else:
print("\n5. [DRY RUN] Would backup old key")
print(f"6. [DRY RUN] Would save new key to: {config_dir / 'secret_key'}")
print("\n6. [DRY RUN] Would backup old key")
print(f"7. [DRY RUN] Would save new key to: {config_dir / 'secret_key'}")

# Summary
print("\n" + "=" * 50)
Expand Down
3 changes: 3 additions & 0 deletions src/backend/base/langflow/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from sqlalchemy.exc import SAWarning
from sqlalchemy.ext.asyncio import async_engine_from_config

from langflow.alembic.expand_compat import filter_expand_revision_directives
from langflow.services.database.service import SQLModel

# this is the Alembic Config object, which provides
Expand Down Expand Up @@ -61,6 +62,7 @@ def run_migrations_offline() -> None:
"literal_binds": True,
"dialect_opts": {"paramstyle": "named"},
"render_as_batch": True,
"process_revision_directives": filter_expand_revision_directives,
}

# Only add prepare_threshold for PostgreSQL
Expand Down Expand Up @@ -93,6 +95,7 @@ def _do_run_migrations(connection):
"connection": connection,
"target_metadata": target_metadata,
"render_as_batch": True,
"process_revision_directives": filter_expand_revision_directives,
}

# Only add prepare_threshold for PostgreSQL
Expand Down
109 changes: 109 additions & 0 deletions src/backend/base/langflow/alembic/expand_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""Autogenerate compatibility rules for active EXPAND migration windows."""

from alembic.operations import ops

# ``sso_config`` is deliberately in an EXPAND window: released N-1 services
# still need the scalar columns, while N reads the typed JSON representation.
# Alembic therefore sees the retained DB-only columns and nullable typed columns
# as a future CONTRACT migration. Keep this list exact and remove it with that
# contract revision; rolling-compatibility migration tests assert that the
# temporary physical schema remains present and synchronized.
SSO_EXPAND_LEGACY_COLUMNS = frozenset(
{
"provider",
"provider_name",
"enforce_sso",
"client_id",
"discovery_url",
"redirect_uri",
"scopes",
"token_endpoint",
"authorization_endpoint",
"jwks_uri",
"issuer",
}
)
SSO_EXPAND_NULLABLE_COLUMNS = frozenset({"slug", "display_name", "protocol", "provider_settings"})
_REMOVE_COLUMN_DIFF_LENGTH = 4
_MODIFY_NULLABLE_DIFF_LENGTH = 7


def filter_sso_expand_diffs(diffs: list) -> list:
"""Suppress only schema diffs intentionally deferred to SSO CONTRACT."""
significant_diffs = []
for diff in diffs:
# Alembic can group multiple alter-column operations in a nested list.
if isinstance(diff, list):
filtered_group = filter_sso_expand_diffs(diff)
if filtered_group:
significant_diffs.append(filtered_group)
continue
if not isinstance(diff, tuple):
significant_diffs.append(diff)
continue

if (
len(diff) >= _REMOVE_COLUMN_DIFF_LENGTH
and diff[0] == "remove_column"
and diff[2] == "sso_config"
and getattr(diff[3], "name", None) in SSO_EXPAND_LEGACY_COLUMNS
):
continue
if (
len(diff) >= _MODIFY_NULLABLE_DIFF_LENGTH
and diff[0] == "modify_nullable"
and diff[2] == "sso_config"
and diff[3] in SSO_EXPAND_NULLABLE_COLUMNS
and diff[5] is True
and diff[6] is False
):
continue
significant_diffs.append(diff)

return significant_diffs


def _filter_sso_expand_operations(container: ops.OpContainer) -> None:
filtered_operations = []
for operation in container.ops:
if isinstance(operation, ops.OpContainer):
_filter_sso_expand_operations(operation)
if operation.ops:
filtered_operations.append(operation)
continue

if (
isinstance(operation, ops.DropColumnOp)
and operation.table_name == "sso_config"
and operation.column_name in SSO_EXPAND_LEGACY_COLUMNS
):
continue

if (
isinstance(operation, ops.AlterColumnOp)
and operation.table_name == "sso_config"
and operation.column_name in SSO_EXPAND_NULLABLE_COLUMNS
and operation.existing_nullable is True
and operation.modify_nullable is False
):
# Preserve any type/default/comment change Alembic grouped with the
# expected nullable diff so real schema drift remains visible.
operation.modify_nullable = None
if not operation.has_changes():
continue

filtered_operations.append(operation)

container.ops[:] = filtered_operations


def filter_expand_revision_directives(_context, _revision, directives: list[ops.MigrationScript]) -> None:
"""Apply active EXPAND allowlists to Alembic autogenerate/check output."""
for directive in directives:
for upgrade_ops, downgrade_ops in zip(
directive.upgrade_ops_list,
directive.downgrade_ops_list,
strict=True,
):
_filter_sso_expand_operations(upgrade_ops)
upgrade_ops.reverse_into(downgrade_ops)
Loading
Loading