@@ -193,8 +193,30 @@ def get_package(cls):
193193
194194# ── Helpers ───────────────────────────────────────────────────────────────────
195195
196+ def _claims (extra : dict ) -> dict :
197+ """Flatten allauth's OIDC extra_data to the actual claim dict.
198+
199+ The openid_connect provider stores extra_data as
200+ ``{"id_token": <jwt>, "userinfo": {...claims...}}`` — the OIDC claims
201+ (email, groups, preferred_username, sub) live under ``userinfo``, not at the
202+ top level. Other providers store the claims flat, so fall back to the dict
203+ itself when there's no nested ``userinfo``.
204+
205+ Gated on ``"id_token"`` (which the openid_connect provider always puts at the
206+ top level) so the function is idempotent: calling it on already-flattened
207+ claims — or on a flat provider's data — returns them unchanged even if they
208+ happen to carry their own ``userinfo`` key.
209+ """
210+ if isinstance (extra , dict ) and "id_token" in extra :
211+ ui = extra .get ("userinfo" )
212+ if isinstance (ui , dict ) and ui :
213+ return ui
214+ return extra or {}
215+
216+
196217def _extract_groups (extra : dict ) -> set :
197218 """Return the set of IdP group names from token extra data."""
219+ extra = _claims (extra )
198220 oidc_cfg = getattr (settings , "OIDC_CFG" , None ) or {}
199221 claim = oidc_cfg .get ("groups_claim" ) or "groups"
200222 raw = extra .get (claim ) or []
@@ -233,6 +255,7 @@ def _apply_idp_roles_and_email(user, extra: dict) -> bool:
233255 but empty claim is honoured (the user really is in no groups → demote).
234256 """
235257 changed = False
258+ extra = _claims (extra )
236259
237260 email = extra .get ("email" ) or ""
238261 if email and user .email != email :
@@ -303,7 +326,11 @@ def pre_social_login(self, request, sociallogin):
303326 Raises ImmediateHttpResponse — caught by allauth's complete_login
304327 wrapper and rendered as a user-facing error page (a bare
305328 ValidationError here would bubble up as a 500)."""
306- user_email = sociallogin .account .extra_data .get ("email" ) or ""
329+ user_email = (
330+ _claims (sociallogin .account .extra_data or {}).get ("email" )
331+ or (sociallogin .user .email if sociallogin .user else "" )
332+ or ""
333+ )
307334 if settings .SOCIAL_AUTH_EMAIL_DOMAIN :
308335 if not user_email :
309336 # Fail closed: a domain allowlist is configured but the IdP sent
@@ -359,7 +386,7 @@ def save_user(self, request, sociallogin, form=None):
359386 if no subject is present — is appended to guarantee uniqueness.
360387 """
361388 user = super ().save_user (request , sociallogin , form )
362- extra = sociallogin .account .extra_data or {}
389+ extra = _claims ( sociallogin .account .extra_data or {})
363390
364391 # ── username (provisioning only — kept stable across later logins) ──
365392 identifier = (
0 commit comments