diff --git a/docs/backends/google.rst b/docs/backends/google.rst index 7e1caca9..4f993309 100644 --- a/docs/backends/google.rst +++ b/docs/backends/google.rst @@ -154,8 +154,11 @@ User identification Google OAuth2, OpenID Connect, and One Tap use the stable ``sub`` claim for account association. The legacy OAuth1 backend uses Google's stable ``id``. -Associations created by older social-core releases used the email address and -migrate to the stable identifier on the next successful authentication. +Older default setups used email addresses. Historical associations can migrate +through indexed email lookups, subject to the policy in +:doc:`../configuration/identifier-migration`. Google retains the default +unverified compatibility allowance only for its audited email-to-stable-ID +transitions; conflicting stored evidence always blocks migration. The following legacy settings remain accepted, but stable identifiers are now the default:: @@ -166,7 +169,10 @@ or:: SOCIAL_AUTH_GOOGLE_OAUTH2_USE_UNIQUE_USER_ID = True -depending on the backends in use. +depending on the backends in use. If these settings were already enabled before +identifier-key tracking, configure the Django backfill's +``SOCIAL_AUTH_OLD_ID_KEYS`` with ``sub`` for OAuth2, OpenID Connect, and One Tap, +or ``id`` for OAuth1. The historical default backfill otherwise uses ``email``. See `Configurable User ID Key`_ for migration controls and custom identifier settings. diff --git a/docs/backends/implementation.rst b/docs/backends/implementation.rst index 32b562f7..0b8bcb12 100644 --- a/docs/backends/implementation.rst +++ b/docs/backends/implementation.rst @@ -389,6 +389,22 @@ overridden to customize behavior. Here are some key methods: special logic for determining the ID key. Instead, use the ``SOCIAL_AUTH__ID_KEY`` setting to configure it. +``get_legacy_user_identifiers(details, response)`` + Returns previous ``(id_key, uid)`` pairs from built-in ``LEGACY_ID_KEYS`` and + backend-scoped configuration. Missing claims are omitted; other provider + response errors propagate. Override this hook when historical identifiers + need special extraction or scoping. ``get_legacy_user_ids()`` remains a + compatibility wrapper returning values only. The pipeline invokes both + hooks and uses additional values from existing custom overrides to find + empty-key associations, subject to the same evidence requirements. Prefer + the keyed hook for new overrides and associations with recorded keys. + +``get_stored_user_id_keys(id_key)`` + Returns stored extra-data fields containing evidence for the current + identifier. The default returns ``(id_key,)``. Historical OIDC subjects may + also be stored under ``id``. Only declare aliases known to represent the + same identifier; every present evidence field must agree. + ``get_user_id(details, response)`` Returns a unique ID for the current user from the provider's response or from the details dict. This method uses ``id_key()`` to determine which diff --git a/docs/backends/keycloak.rst b/docs/backends/keycloak.rst index 306fb5af..f4ecf894 100644 --- a/docs/backends/keycloak.rst +++ b/docs/backends/keycloak.rst @@ -136,3 +136,12 @@ External memberships See :doc:`/groups` for opt-in extraction, group-based login restrictions, and local group synchronization. No separate extraction pipeline step is needed. + + +Changing account identifier configuration +----------------------------------------- + +For explicit identifier changes, configure previous ``LEGACY_ID_KEYS`` and +ensure the new stable identifier was stored before changing ``ID_KEY``. +See :doc:`../configuration/identifier-migration`. Merely recording the old key +or presenting a matching username does not prove ownership of the association. diff --git a/docs/configuration/django.rst b/docs/configuration/django.rst index 78c68438..0206f00d 100644 --- a/docs/configuration/django.rst +++ b/docs/configuration/django.rst @@ -104,11 +104,38 @@ or applying your initial migrations. See :ref:`django-user-models`. python manage.py migrate -Upgrades that add identifier-key tracking must install compatible releases of -both ``social-auth-core`` and ``social-auth-app-django`` before running this -command. The Django migration adds a blank ``id_key`` to existing social -associations; social-core then migrates those rows according to the policy in -:ref:`the configurable user ID key documentation `. +Install compatible releases of both ``social-auth-core`` and +``social-auth-app-django`` before running this command. Indexed identifier +migration requires social-auth-core 6.1 or later. A data migration backfills +empty ``id_key`` values using frozen pre-v7 backend defaults; it preserves UIDs, +existing keys, extra data, and timestamps. + +If your previous setup used custom identifiers, configure +``SOCIAL_AUTH_OLD_ID_KEYS`` **before running migrations**:: + + SOCIAL_AUTH_OLD_ID_KEYS = { + "google-oauth2": "sub", # Previously used USE_UNIQUE_USER_ID=True. + "custom-provider": "old_subject", + "trello": None, # Leave this provider unchanged. + } + +Each entry describes the historical setup, not the current configuration. +Unlisted known providers use frozen defaults. Unknown providers remain +unchanged. A nonempty key must fit the model's ``id_key`` field; ``None`` skips +that provider. Invalid overrides fail before any updates. + +The migration executes one SQL update per provider without loading associations +into Python. It commits each provider separately and can resume after failure +without overwriting completed backfills. Each large update can still hold locks +and generate substantial transaction logs; schedule it during your deployment +maintenance window. Reversing the migration preserves identifier metadata. +Changing overrides after it has completed does not rerun it or rewrite populated +keys; review incorrect historical mappings before serving authentication. + +Backfilling a key does not prove that a mutable identifier still belongs to the +same account. Read :doc:`identifier-migration` for the migration policy, +configuration changes, and recovery requirements. Run migrations before serving +upgraded authentication traffic. **8. Add login form in template**:: diff --git a/docs/configuration/identifier-migration.rst b/docs/configuration/identifier-migration.rst new file mode 100644 index 00000000..bd50723e --- /dev/null +++ b/docs/configuration/identifier-migration.rst @@ -0,0 +1,239 @@ +.. _identifier-migration: + +Identifier migration +==================== + +Authentication looks up the current ``(provider, id_key, uid)`` first. If it +does not exist, it looks up previous keys and their values from the authenticated +provider response. These queries use the existing provider/UID index; they do +not search every association's JSON data. Empty-key historical rows are also +considered using the same indexed UID lookups. + +An old email or username locates a candidate, but does not prove that the +candidate belongs to the current provider account. The candidate's saved current +identifier must match the authenticated identifier. Multiple candidates, +conflicting evidence, and invalid evidence stop authentication. Missing evidence +also stops authentication unless the compatibility policy below permits it. +Migration updates both identifier fields atomically and revalidates evidence +under the association lock. + +Configuration changes +--------------------- + +Before switching identifiers, configure extra data to save the proposed new +stable field while the old key is still active. Existing users must authenticate +in this configuration, or receive an independently verified administrative +backfill, before their associations contain that evidence. Adding an extra-data +setting does not populate historical rows automatically. + +For example, while Google still uses email:: + + SOCIAL_AUTH_GOOGLE_OAUTH2_ID_KEY = "email" + SOCIAL_AUTH_GOOGLE_OAUTH2_EXTRA_DATA = [("sub", "sub")] + +Then switch the active key and retain the historical key:: + + SOCIAL_AUTH_GOOGLE_OAUTH2_ID_KEY = "sub" + SOCIAL_AUTH_GOOGLE_OAUTH2_LEGACY_ID_KEYS = ["email"] + SOCIAL_AUTH_GOOGLE_OAUTH2_ALLOW_UNVERIFIED_LEGACY_UID_MIGRATION = False + +Configured historical keys supplement the backend's built-in keys, and work +with an explicit current ``ID_KEY``. Custom backends must implement extraction +for their historical keys when identifiers require transformations or scoping. + +If an email or username has changed and its old value is no longer returned, +indexed lookup cannot recover the old association. There is no global JSON +search fallback. Use account recovery or an authenticated linking flow. +Presenting a reclaimed mutable identifier with conflicting stored evidence +always fails, including when unverified migration is enabled. + +Compatibility policy +-------------------- + +``ALLOW_UNVERIFIED_LEGACY_UID_MIGRATION`` defaults to enabled only for the +specific built-in transitions marked "Yes" below. It requires the audited +backend class and current built-in key. Custom subclasses, newly added legacy +keys, and other configuration-driven transitions require evidence by default. +Explicitly selecting the same built-in current key retains that transition's +default. Other current keys do not. + +The global ``SOCIAL_AUTH_ALLOW_UNVERIFIED_LEGACY_UID_MIGRATION`` setting and its +backend-specific variants override that default. Explicit ``False`` always +requires evidence. Explicit ``True`` permits missing evidence even outside the +audited list, but cannot override conflicting or invalid evidence. + +.. warning:: + + Allowing missing evidence accepts the risk that a new owner of an old email, + username, or other mutable identifier can claim its association. The + compatibility policy preserves that behavior for known historical setups; + it does not make those migrations safe. Disable it before upgrading if this + risk is unacceptable. + +.. _historical-identifier-audit: + +Historical backend audit +------------------------ + +This table describes default storage declarations in social-core 5.2.0 for the +25 built-in backend variants that now declare historical identifier keys. +Three stored the new identifier directly; Fence and CAS stored ``sub`` under +``id``. The remaining 20 lacked a directly usable stable identifier field. +Stored ID tokens are not decoded as migration evidence. Actual data can differ +because of older releases, custom pipelines, or extra-data configuration. + +.. list-table:: Historical transitions and default compatibility allowances + :header-rows: 1 + :widths: 25 18 18 22 17 + + * - Provider + - Old key + - Current key + - Stored evidence + - Unverified default + * - ``arcgis`` + - ``username`` + - ``id`` + - Absent + - Yes + * - ``azuread-oauth2`` + - ``upn`` + - ``sub`` + - Absent + - Yes + * - ``azuread-oauth2-v2`` + - ``upn`` + - ``sub`` + - Absent + - Yes + * - ``azuread-v2-tenant-oauth2`` + - ``preferred_username`` + - ``sub`` + - Absent + - Yes + * - ``cas`` + - ``username`` + - ``sub`` + - Alias ``id`` + - No + * - ``cognito`` + - ``username`` + - ``sub`` + - Absent + - Yes + * - ``dailymotion`` + - ``username`` + - ``id`` + - ``id`` + - No + * - ``deezer`` + - ``name`` + - ``id`` + - Absent + - Yes + * - ``discourse`` + - ``email`` + - ``external_id`` + - Absent + - Yes + * - ``fence`` + - ``username`` + - ``sub`` + - Alias ``id`` + - No + * - ``google-oauth`` + - ``email`` + - ``id`` + - Absent + - Yes + * - ``google-oauth2`` + - ``email`` + - ``sub`` + - Absent + - Yes + * - ``google-onetap`` + - ``email`` + - ``sub`` + - Absent + - Yes + * - ``google-openidconnect`` + - ``email`` + - ``sub`` + - Absent + - Yes + * - ``keycloak`` + - ``username`` + - ``sub`` + - Absent + - Yes + * - ``mailru`` + - ``email`` + - ``id`` + - Absent + - Yes + * - ``okta-oauth2`` + - ``preferred_username`` + - ``sub`` + - Absent + - Yes + * - ``okta-openidconnect`` + - ``preferred_username`` + - ``sub`` + - Absent + - Yes + * - ``opensuse`` + - ``nickname`` + - ``identity_url`` + - Absent + - Yes + * - ``qiita`` + - ``id`` + - ``permanent_id`` + - ``permanent_id`` + - No + * - ``scistarter`` + - ``email`` + - ``profile_id`` + - ``profile_id`` + - No + * - ``trello`` + - ``username`` + - ``id`` + - Absent + - Yes + * - ``tumblr`` + - ``name`` + - ``uuid`` + - Absent + - Yes + * - ``ubuntu`` + - ``nickname`` + - ``identity_url`` + - Absent + - Yes + * - ``yandex-openid`` + - ``email`` + - ``identity_url`` + - Absent + - Yes + +Django historical backfill +-------------------------- + +The Django data migration records historical keys using frozen pre-v7 defaults. +It changes neither UIDs nor account ownership. Configure +``SOCIAL_AUTH_OLD_ID_KEYS`` before running it if previous settings selected a +different identifier. This includes Google ``USE_UNIQUE_USER_ID`` and Qiita +``IDENTIFIED_BY_PERMANENT_ID``. For example:: + + SOCIAL_AUTH_OLD_ID_KEYS = { + "google-oauth2": "sub", + "qiita": "permanent_id", + "custom-provider": "old_subject", + "trello": None, + } + +Unknown and skipped providers retain empty keys. Authentication considers them +only through indexed UID candidates and the same evidence policy. A historical +key describes how existing associations were created; current settings alone +cannot establish it. See :doc:`django` for deployment order and migration costs. diff --git a/docs/configuration/index.rst b/docs/configuration/index.rst index 92dc283f..663db040 100644 --- a/docs/configuration/index.rst +++ b/docs/configuration/index.rst @@ -16,6 +16,7 @@ Contents: :maxdepth: 2 settings + identifier-migration django flask pyramid diff --git a/docs/configuration/settings.rst b/docs/configuration/settings.rst index 7e4d26f6..d22dd3c4 100644 --- a/docs/configuration/settings.rst +++ b/docs/configuration/settings.rst @@ -307,28 +307,36 @@ per-IdP ``attr_user_permanent_id`` mapping instead. See the :doc:`OpenID <../backends/saml>` backend documentation. Associations store both the identifier value and the name of the provider field -that supplied it. When a bundled backend changes to a more stable default, -associations created by older versions have a blank identifier key. On the next -successful authentication, social-core first attempts to prove the association -using stable data stored in ``extra_data``. By default it then falls back to the -current value of the backend's historical identifier and updates the association -to the stable identifier. - -``SOCIAL_AUTH_ALLOW_UNVERIFIED_LEGACY_UID_MIGRATION = True`` - Allow the compatibility fallback for legacy associations whose stable - identity cannot be proved from stored provider data. This is enabled by - default so unchanged existing users continue to authenticate. - - Set this to ``False`` to require stable stored data, an administrative data - migration, or an authenticated reconnection. Backend-specific variants of - the setting are supported. +that supplied it. Authentication first looks up the current identity, then +previous ``(id_key, uid)`` pairs using indexed queries. It does not search all +associations' JSON data. Candidates are migrated only after applying the stored +evidence and compatibility policy described in :doc:`identifier-migration`. + +``SOCIAL_AUTH__LEGACY_ID_KEYS = []`` + Additional previous identifier keys to extract from the provider response. + They supplement the backend's built-in historical keys and work when + ``ID_KEY`` is explicitly configured. This enables candidate discovery; + it does not authorize migration without evidence. + + Before changing keys, store the proposed new identifier through + ``EXTRA_DATA`` settings while associations still use their previous key. + +``SOCIAL_AUTH_ALLOW_UNVERIFIED_LEGACY_UID_MIGRATION`` + Permit migration when a candidate lacks stored current-identifier evidence. + The default is transition-specific: enabled only for the audited built-in + transitions in :ref:`historical-identifier-audit`, and disabled for custom + backends and configuration-driven changes. Backend-specific settings take + precedence over the global setting. + + Explicit ``False`` requires stored evidence. Explicit ``True`` enables the + unsafe compatibility fallback even outside the default transition list. + Neither setting permits conflicting or invalid stored evidence. .. warning:: - A legacy association remains keyed by its old identifier until it is - migrated. With compatibility fallback enabled, the first provider identity - to present that value can claim the migration. Deployments that cannot - accept this one-time risk should disable unverified migration before - upgrading and migrate associations administratively. + Unverified migration through a mutable identifier can let its new owner + claim an old association. A backfilled identifier key does not remove this + risk. Disable unverified migration if this is unacceptable and use account + recovery or authenticated linking for associations without evidence. Explicitly overriding ``ID_KEY`` with a mutable username, email address, UPN, or ``preferred_username`` keeps the corresponding backend vulnerable. diff --git a/docs/pipeline.rst b/docs/pipeline.rst index d1699575..a5e980ca 100644 --- a/docs/pipeline.rst +++ b/docs/pipeline.rst @@ -734,3 +734,20 @@ Log pipeline execution to understand the flow:: .. _python-social-auth: https://github.com/python-social-auth .. _example applications: https://github.com/python-social-auth/social-examples + + +Identifier migration parameters +------------------------------- + +``social_uid`` supplies the current ``uid`` and ``id_key``, plus +``legacy_identifiers`` containing previous ``(id_key, uid)`` pairs. +``legacy_uids`` remains available as an unkeyed compatibility parameter; new +custom pipelines should pass keyed pairs. Persisted partial pipelines may +restore pairs as lists, which are accepted. + +``social_user`` performs indexed candidate lookup and applies the evidence +policy before account creation. Missing or conflicting proof raises +``AuthAssociationError`` with code ``identifier_migration_conflict`` unless +missing proof is explicitly permitted for that transition. The association +retry path passes the same identifiers and applies the same checks. +See :doc:`configuration/identifier-migration`. diff --git a/docs/security.rst b/docs/security.rst index 06d91316..d1340e05 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -16,12 +16,20 @@ ensuring the selected claim has the same stability properties. Backends whose provider responses expose no stable identifier, including Drip, Last.fm, and Mixcloud, are association-only and require an authenticated local user. -When upgrading an existing deployment, read :ref:`the configurable user ID -key documentation ` before authenticating users. The -default compatibility migration preserves existing logins but accepts a -one-time first-login race for associations that lack stored stable identity -data. Security-sensitive deployments should disable that fallback and migrate -the affected records administratively. +When upgrading an existing deployment, read +:doc:`configuration/identifier-migration` before authenticating users. Candidate +lookup uses historical keys and values; authorization to migrate requires +matching stored current-identifier evidence. Conflicting evidence always blocks +migration. + +Only audited historical transitions that lacked evidence retain default +unverified migration. This compatibility allowance accepts a first-login race +through mutable identifiers. Security-sensitive deployments should set +``SOCIAL_AUTH_ALLOW_UNVERIFIED_LEGACY_UID_MIGRATION = False`` and use recovery +or authenticated linking where evidence is unavailable. Explicit ``True`` is an +unsafe escape hatch, not a verification mechanism. Configuration-driven changes +and custom backends require evidence by default. + Host header validation ---------------------- diff --git a/docs/storage.rst b/docs/storage.rst index e17e01b1..706590f9 100644 --- a/docs/storage.rst +++ b/docs/storage.rst @@ -13,8 +13,9 @@ This model associates a social account data with a user in the system, it contains the provider name, the user ID (``uid``) which identifies the social account in the remote provider, and ``id_key`` naming the provider field from which that ID was obtained. It also contains JSON-encoded ``extra_data`` with -additional provider information. Existing rows created before ``id_key`` was -introduced use an empty string until social-core migrates them. +additional provider information. Django backfills historical identifier keys +through a data migration; unknown or explicitly skipped setups retain an empty +key. See :doc:`configuration/identifier-migration`. When implementing this model, it must inherits from UserMixin_ and extend the needed methods: @@ -70,7 +71,7 @@ needed methods: @classmethod def get_social_auth_by_extra_data(cls, provider, key, value, id_key=''): - """Return one unambiguous association matching stable provider data""" + """Compatibility API; no longer used by the authentication pipeline""" raise NotImplementedError('Implement in subclass') @classmethod @@ -84,13 +85,22 @@ needed methods: raise NotImplementedError('Implement in subclass') @classmethod - def migrate_social_auth(cls, social, uid, id_key): - """Atomically replace the association identifier and its key""" + def migrate_social_auth(cls, social, uid, id_key, *, evidence_key=None): + """Atomically replace identifiers and revalidate supplied evidence""" raise NotImplementedError('Implement in subclass') -Identifier migration must preserve the storage's provider/UID uniqueness -guarantee, lock the association while updating it, and fail rather than select -an arbitrary row when stored provider data matches multiple associations. +Identifier migration must preserve provider/UID uniqueness, lock the association, +and reject concurrent identifier changes. When ``evidence_key`` is supplied, +require its stored scalar value to match the new UID, and recheck that evidence +under the lock before updating. Nulls, booleans, and compound values are not +identifier evidence. Missing evidence must fail rather than silently bypassing +the proof requested by the pipeline. Django also rejects concurrent changes to +extra data, including new contradictory evidence during an unverified migration. + +Authentication finds candidates using ``get_social_auth()`` and rejects multiple +distinct matches. The public extra-data lookup remains available for callers, +but is no longer part of automatic authentication. Custom storage implementations +must accept the new keyword-only argument before upgrading social-core. * Social disconnection::