77Phase: EXPAND
88"""
99
10+ import os
1011from collections .abc import Sequence
1112from uuid import UUID
1213
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
4470def _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
103132def _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+
126198def _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 :
0 commit comments