Fix: capture Google avatar on returning sign-in (+ userinfo fallback)

find_or_create_user returned early when the identity already existed, so a
returning Google sign-in never refreshed the profile picture (the name had been
set earlier, at link time — which is why name worked but avatar stayed null).
Now profile bits refresh on every sign-in. Also fall back to the OIDC userinfo
endpoint for the picture if the ID token omits it. 119 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-03 14:57:44 +00:00
parent 15728c3bcb
commit a2765af3fc
4 changed files with 42 additions and 15 deletions
+2
View File
@@ -432,6 +432,8 @@ def create_app() -> FastAPI:
try:
tokens = oauth_google.exchange_code(code, _google_redirect_uri(), verifier)
info = oauth_google.verify_id_token(tokens["id_token"])
if not info.get("picture") and tokens.get("access_token"):
info["picture"] = oauth_google.fetch_userinfo(tokens["access_token"]).get("picture")
except Exception:
return fail
with get_conn() as conn:
+11 -9
View File
@@ -69,27 +69,29 @@ def find_or_create_user(
(provider, provider_subject),
).fetchone()
if existing:
return existing["user_id"]
user_id = existing["user_id"]
else:
user = conn.execute("SELECT id FROM users WHERE email = ?", (email,)).fetchone()
if user:
user_id = user["id"]
# Fill display name if missing; refresh avatar whenever the provider gives one.
conn.execute(
"UPDATE users SET display_name = COALESCE(display_name, ?), "
"avatar_url = COALESCE(?, avatar_url), updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(display_name, avatar_url, user_id),
)
else:
user_id = conn.execute(
"INSERT INTO users (email, display_name, avatar_url) VALUES (?, ?, ?)",
(email, display_name, avatar_url),
).lastrowid
conn.execute(
"INSERT OR IGNORE INTO identities (user_id, provider, provider_subject) VALUES (?, ?, ?)",
(user_id, provider, provider_subject),
)
# Always refresh provider-supplied profile bits (even for a returning identity):
# fill the name if missing, and keep the avatar current when the provider sends one.
if display_name or avatar_url:
conn.execute(
"UPDATE users SET display_name = COALESCE(display_name, ?), "
"avatar_url = COALESCE(?, avatar_url), updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(display_name, avatar_url, user_id),
)
return user_id
+14
View File
@@ -19,6 +19,7 @@ from datetime import datetime, timezone
AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
TOKEN_URL = "https://oauth2.googleapis.com/token"
USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo"
_VALID_ISS = {"accounts.google.com", "https://accounts.google.com"}
@@ -75,6 +76,19 @@ def exchange_code(code: str, redirect_uri: str, code_verifier: str) -> dict:
return json.loads(response.read())
def fetch_userinfo(access_token: str) -> dict:
"""Call the OIDC userinfo endpoint — a reliable source for the profile picture
if the ID token happens to omit it. Best-effort; returns {} on any error."""
request = urllib.request.Request(
USERINFO_URL, headers={"Authorization": f"Bearer {access_token}", "Accept": "application/json"}
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
return json.loads(response.read())
except Exception:
return {}
def _decode_jwt_payload(token: str) -> dict:
parts = token.split(".")
if len(parts) != 3:
+9
View File
@@ -33,6 +33,15 @@ def test_find_or_create_links_by_email_and_dedupes_identity():
assert auth.get_user(c, uid)["display_name"] == "A"
def test_returning_identity_refreshes_avatar():
c = _db()
uid = auth.find_or_create_user(c, "a@b.com", "google", "gsub", display_name="A", avatar_url="http://pic/1")
assert auth.get_user(c, uid)["avatar_url"] == "http://pic/1"
# a repeat sign-in with the SAME identity must still refresh the picture
assert auth.find_or_create_user(c, "a@b.com", "google", "gsub", avatar_url="http://pic/2") == uid
assert auth.get_user(c, uid)["avatar_url"] == "http://pic/2"
def test_magic_link_token_single_use():
c = _db()
raw = auth.create_login_token(c, "a@b.com")