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:
@@ -432,6 +432,8 @@ def create_app() -> FastAPI:
|
|||||||
try:
|
try:
|
||||||
tokens = oauth_google.exchange_code(code, _google_redirect_uri(), verifier)
|
tokens = oauth_google.exchange_code(code, _google_redirect_uri(), verifier)
|
||||||
info = oauth_google.verify_id_token(tokens["id_token"])
|
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:
|
except Exception:
|
||||||
return fail
|
return fail
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|||||||
+17
-15
@@ -69,27 +69,29 @@ def find_or_create_user(
|
|||||||
(provider, provider_subject),
|
(provider, provider_subject),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if existing:
|
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"]
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
user = conn.execute("SELECT id FROM users WHERE email = ?", (email,)).fetchone()
|
# Always refresh provider-supplied profile bits (even for a returning identity):
|
||||||
if user:
|
# fill the name if missing, and keep the avatar current when the provider sends one.
|
||||||
user_id = user["id"]
|
if display_name or avatar_url:
|
||||||
# Fill display name if missing; refresh avatar whenever the provider gives one.
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE users SET display_name = COALESCE(display_name, ?), "
|
"UPDATE users SET display_name = COALESCE(display_name, ?), "
|
||||||
"avatar_url = COALESCE(?, avatar_url), updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
"avatar_url = COALESCE(?, avatar_url), updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||||
(display_name, avatar_url, user_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),
|
|
||||||
)
|
|
||||||
return user_id
|
return user_id
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||||
TOKEN_URL = "https://oauth2.googleapis.com/token"
|
TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||||
|
USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo"
|
||||||
_VALID_ISS = {"accounts.google.com", "https://accounts.google.com"}
|
_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())
|
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:
|
def _decode_jwt_payload(token: str) -> dict:
|
||||||
parts = token.split(".")
|
parts = token.split(".")
|
||||||
if len(parts) != 3:
|
if len(parts) != 3:
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ def test_find_or_create_links_by_email_and_dedupes_identity():
|
|||||||
assert auth.get_user(c, uid)["display_name"] == "A"
|
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():
|
def test_magic_link_token_single_use():
|
||||||
c = _db()
|
c = _db()
|
||||||
raw = auth.create_login_token(c, "a@b.com")
|
raw = auth.create_login_token(c, "a@b.com")
|
||||||
|
|||||||
Reference in New Issue
Block a user