Skip to content

Commit 85d4d4d

Browse files
committed
feat(sso): enhance SSO identity management and secure client secret handling
1 parent a1c7b1b commit 85d4d4d

5 files changed

Lines changed: 155 additions & 10 deletions

File tree

src/backend/base/langflow/alembic/versions/e8f1a2b3c4d5_allow_multiple_sso_identities_per_user.py

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
Phase: EXPAND
88
"""
99

10+
import os
1011
from collections.abc import Sequence
1112
from uuid import UUID
1213

@@ -39,6 +40,31 @@
3940
"issuer",
4041
"client_id",
4142
)
43+
# Structural prefix of the current client-secret envelope. Duplicated from
44+
# ``models/auth/sso_secret.py`` on purpose: a migration must not import
45+
# application models, whose shape changes independently of this revision.
46+
_ENVELOPE_HEADER = "lf-sso:v1:hkdf-sha256-v1:aes-256-gcm:"
47+
_ENVELOPE_PART_COUNT = 6
48+
49+
50+
def _external_auth_provider() -> str | None:
51+
"""Return the provider key owned by OSS EXTERNAL_AUTH, if configured.
52+
53+
``sso_user_profile.sso_provider`` has two independent writers: the SSO plugin
54+
(which this revision re-keys onto ``sso_config.slug``) and the OSS
55+
EXTERNAL_AUTH flow in ``services/auth/service.py``, which writes
56+
``EXTERNAL_AUTH_PROVIDER`` verbatim. Those rows belong to a different feature
57+
and must never be re-keyed here — rewriting one silently breaks that user's
58+
login and JIT-provisions a duplicate account on their next sign-in.
59+
"""
60+
return os.environ.get("LANGFLOW_EXTERNAL_AUTH_PROVIDER", "").strip() or None
61+
62+
63+
def _is_secret_envelope(value: object) -> bool:
64+
"""Return whether a stored secret is already a versioned ciphertext envelope."""
65+
return (
66+
isinstance(value, str) and value.startswith(_ENVELOPE_HEADER) and len(value.split(":")) == _ENVELOPE_PART_COUNT
67+
)
4268

4369

4470
def _indexes(conn: sa.Connection, table_name: str) -> dict[str, dict]:
@@ -92,15 +118,23 @@ def _backfill_profile_connection_slugs(conn: sa.Connection) -> None:
92118
sa.column("provider_name"),
93119
)
94120
profile = sa.table(_PROFILE_TABLE, sa.column("sso_provider"))
121+
external_provider = _external_auth_provider()
95122
rows = conn.execute(sa.select(config.c.slug, config.c.provider_name).order_by(config.c.id)).all()
96123
for row in rows:
97-
if row.slug and row.provider_name:
98-
conn.execute(
99-
profile.update().where(profile.c.sso_provider == row.provider_name).values(sso_provider=row.slug)
100-
)
124+
if not (row.slug and row.provider_name):
125+
continue
126+
# Leave EXTERNAL_AUTH-owned identities alone; see _external_auth_provider.
127+
if external_provider is not None and row.provider_name == external_provider:
128+
continue
129+
conn.execute(profile.update().where(profile.c.sso_provider == row.provider_name).values(sso_provider=row.slug))
101130

102131

103132
def _restore_profile_connection_names(conn: sa.Connection) -> None:
133+
"""Reverse of :func:`_backfill_profile_connection_slugs`.
134+
135+
No EXTERNAL_AUTH guard is needed here: that flow never writes a slug, so a
136+
config skipped on upgrade simply matches no rows on the way back down.
137+
"""
104138
if not migration.table_exists(_PROFILE_TABLE, conn):
105139
return
106140
config_columns = _column_names(conn, _CONFIG_TABLE)
@@ -123,6 +157,44 @@ def _restore_profile_connection_names(conn: sa.Connection) -> None:
123157
)
124158

125159

160+
def _sanitize_legacy_client_secrets(conn: sa.Connection) -> None:
161+
"""Clear pre-encryption plaintext client secrets and disable those connections.
162+
163+
Before this revision ``client_secret_encrypted`` held the raw secret despite
164+
its name. From this revision on the model rejects any value that is not a
165+
versioned envelope, so a legacy row would be unwritable through the ORM and
166+
undecryptable at login — a failure that would surface long after upgrade.
167+
168+
Re-encrypting in place is not possible here: the key is derived from the
169+
application's ``SECRET_KEY``, which alembic has no reliable access to, and a
170+
migration that fails on a missing key would block the deploy. Removing the
171+
plaintext and disabling the connection fails safe instead, and it also clears
172+
a secret that was stored unencrypted. An administrator re-enters it through
173+
the admin UI and re-enables the connection.
174+
175+
This is deliberately one-way; ``downgrade`` cannot restore a secret that has
176+
been deleted.
177+
"""
178+
columns = _column_names(conn, _CONFIG_TABLE)
179+
if not {"id", "client_secret_encrypted"} <= columns:
180+
return
181+
182+
has_enabled = "enabled" in columns
183+
selected = [sa.column("id"), sa.column("client_secret_encrypted")]
184+
if has_enabled:
185+
selected.append(sa.column("enabled"))
186+
table = sa.table(_CONFIG_TABLE, *selected)
187+
188+
for row in conn.execute(sa.select(table.c.id, table.c.client_secret_encrypted)).mappings():
189+
secret = row["client_secret_encrypted"]
190+
if secret is None or _is_secret_envelope(secret):
191+
continue
192+
values: dict[str, object] = {"client_secret_encrypted": None}
193+
if has_enabled:
194+
values["enabled"] = False
195+
conn.execute(table.update().where(table.c.id == row["id"]).values(**values))
196+
197+
126198
def _backfill_provider_settings(conn: sa.Connection) -> None:
127199
columns = _column_names(conn, _CONFIG_TABLE)
128200
if not {"id", "protocol", "provider_settings"} <= columns:
@@ -302,6 +374,7 @@ def _upgrade_sso_config(conn: sa.Connection) -> None:
302374
_backfill_connection_identity(conn)
303375
_backfill_profile_connection_slugs(conn)
304376
_backfill_provider_settings(conn)
377+
_sanitize_legacy_client_secrets(conn)
305378
columns = _column_names(conn, _CONFIG_TABLE)
306379
indexes = _indexes(conn, _CONFIG_TABLE)
307380
with op.batch_alter_table(_CONFIG_TABLE, schema=None) as batch_op:

src/backend/tests/unit/alembic/test_sso_protocol_settings_migration.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313
_PRIOR_REVISION = "b7d5f9a3c2e4" # pragma: allowlist secret
1414
_REVISION = "e8f1a2b3c4d5" # pragma: allowlist secret
1515
_TEST_PASSWORD = "hashed" # noqa: S105
16-
_TEST_ENCRYPTED_SECRET = "encrypted-secret" # noqa: S105
16+
# Must be a structurally valid client-secret envelope. The revision clears any
17+
# value that is not one: pre-encryption rows held plaintext, and the model now
18+
# rejects non-envelope values. See _sanitize_legacy_client_secrets.
19+
_TEST_ENCRYPTED_SECRET = "lf-sso:v1:hkdf-sha256-v1:aes-256-gcm:AAAAAAAAAAAAAAAA:BBBBBBBBBBBBBBBBBBBBBBBB" # noqa: S105
1720
_PROVIDER_SETTING_COLUMNS = {
1821
"discovery_url",
1922
"redirect_uri",

src/backend/tests/unit/alembic/test_sso_secret_migration.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,65 @@ def test_sso_secret_upgrade_and_downgrade_preserve_seeded_ciphertext(db_url): #
132132
)
133133
finally:
134134
engine.dispose()
135+
136+
137+
def test_sso_upgrade_clears_pre_encryption_plaintext_secret(db_url): # noqa: F811
138+
"""A legacy plaintext secret is removed and its connection disabled.
139+
140+
Before this revision ``client_secret_encrypted`` held the raw secret. The
141+
model now rejects non-envelope values, so leaving one in place would make the
142+
row unwritable through the ORM and undecryptable at login. The migration
143+
fails safe instead: clear the plaintext, disable the connection, and require
144+
an administrator to re-enter it.
145+
"""
146+
alembic_cfg = _make_alembic_cfg(db_url)
147+
command.upgrade(alembic_cfg, _PRIOR_REVISION)
148+
149+
timestamp = datetime.now(timezone.utc)
150+
config_id = str(uuid4())
151+
152+
engine = sa.create_engine(_engine_url(db_url))
153+
try:
154+
metadata = sa.MetaData()
155+
with engine.begin() as connection:
156+
sso_config = sa.Table("sso_config", metadata, autoload_with=connection)
157+
connection.execute(
158+
sso_config.insert(),
159+
{
160+
"id": config_id,
161+
"provider": "oidc",
162+
"provider_name": "Legacy Plaintext OIDC",
163+
"enabled": True,
164+
"enforce_sso": False,
165+
"client_secret_encrypted": _PLAINTEXT_SECRET,
166+
"email_claim": "email",
167+
"username_claim": "preferred_username",
168+
"user_id_claim": "sub",
169+
"created_at": timestamp,
170+
"updated_at": timestamp,
171+
},
172+
)
173+
finally:
174+
engine.dispose()
175+
176+
command.upgrade(alembic_cfg, _REVISION)
177+
178+
engine = sa.create_engine(_engine_url(db_url))
179+
try:
180+
metadata = sa.MetaData()
181+
with engine.connect() as connection:
182+
sso_config = sa.Table("sso_config", metadata, autoload_with=connection)
183+
row = (
184+
connection.execute(
185+
sa.select(sso_config.c.client_secret_encrypted, sso_config.c.enabled).where(
186+
sso_config.c.id == config_id
187+
)
188+
)
189+
.mappings()
190+
.one()
191+
)
192+
193+
assert row["client_secret_encrypted"] is None
194+
assert not row["enabled"]
195+
finally:
196+
engine.dispose()

src/backend/tests/unit/test_login_rate_limiting.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@
1111
def enable_rate_limiting(monkeypatch):
1212
"""Enable rate limiting for tests that need to verify rate limit behavior."""
1313
monkeypatch.setenv("LANGFLOW_RATE_LIMIT_ENABLED", "true")
14+
monkeypatch.setenv("LANGFLOW_RATE_LIMIT_PER_MINUTE", "5")
15+
16+
# The settings singleton may have been initialized during test collection or
17+
# by an earlier test, before the environment overrides above were applied.
18+
from langflow.services.deps import get_settings_service
19+
20+
settings = get_settings_service().settings
21+
monkeypatch.setattr(settings, "rate_limit_enabled", True)
22+
monkeypatch.setattr(settings, "rate_limit_per_minute", 5)
1423

1524

1625
@pytest.fixture

src/backend/tests/unit/test_messages.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import asyncio
2-
import base64
32
from datetime import datetime, timezone
43
from types import SimpleNamespace
54
from unittest.mock import AsyncMock
@@ -348,11 +347,10 @@ def test_to_lc_message_keeps_supported_csv_attachments_as_text(tmp_path):
348347

349348

350349
def test_to_lc_message_keeps_supported_image_attachments(tmp_path):
350+
from PIL import Image as PILImage
351+
351352
image_path = tmp_path / "image.png"
352-
image_content = base64.b64decode(
353-
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg=="
354-
)
355-
image_path.write_bytes(image_content)
353+
PILImage.new("RGB", (1, 1)).save(image_path)
356354

357355
message = Message(
358356
text="Hello",

0 commit comments

Comments
 (0)