Accounts Phase 2: Google sign-in (OAuth 2.0 / OIDC)

- oauth_google.py (stdlib): PKCE, auth URL, code exchange, ID-token claim
  validation (iss/aud/exp/email_verified — token comes straight from Google's
  token endpoint over TLS, so no signature re-verify / JWKS needed).
- API: GET /api/auth/google/start (302 to Google, PKCE + signed state cookie
  binding the flow to the browser) and /callback (CSRF-checked state, exchange,
  find-or-create by verified email → links to an existing magic-link account,
  session cookie, redirect home). Errors land on /auth/verify?error=google.
- SignIn modal: "Continue with Google" + an "or email link" divider.
- 112 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-03 01:31:52 +00:00
parent 28dc79d0b7
commit b635d8f574
5 changed files with 280 additions and 5 deletions
+51
View File
@@ -0,0 +1,51 @@
import base64
import hashlib
import json
import pytest
from goodnews import oauth_google as g
def _seg(d: dict) -> str:
return base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b"=").decode()
def _token(claims: dict) -> str:
return f"{_seg({'alg': 'RS256'})}.{_seg(claims)}.sig"
def test_pkce_challenge_matches_verifier():
verifier, challenge = g.new_pkce()
expected = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
assert challenge == expected
def test_auth_url_has_required_params(monkeypatch):
monkeypatch.setenv("GOODNEWS_GOOGLE_CLIENT_ID", "cid")
url = g.auth_url("https://x/cb", "state123", "chal")
for needle in ["client_id=cid", "redirect_uri=https", "state=state123",
"code_challenge=chal", "code_challenge_method=S256", "scope=openid"]:
assert needle in url
def test_verify_id_token_happy(monkeypatch):
monkeypatch.setenv("GOODNEWS_GOOGLE_CLIENT_ID", "cid")
tok = _token({"iss": "https://accounts.google.com", "aud": "cid", "exp": 9999999999,
"sub": "g-123", "email": "a@b.com", "email_verified": True, "name": "A"})
info = g.verify_id_token(tok)
assert info == {"sub": "g-123", "email": "a@b.com", "name": "A"}
def test_verify_id_token_rejects(monkeypatch):
monkeypatch.setenv("GOODNEWS_GOOGLE_CLIENT_ID", "cid")
base = {"iss": "https://accounts.google.com", "aud": "cid", "exp": 9999999999,
"sub": "g", "email": "a@b.com", "email_verified": True}
with pytest.raises(ValueError): # wrong audience
g.verify_id_token(_token({**base, "aud": "other"}))
with pytest.raises(ValueError): # bad issuer
g.verify_id_token(_token({**base, "iss": "evil.com"}))
with pytest.raises(ValueError): # expired
g.verify_id_token(_token({**base, "exp": 1}))
with pytest.raises(ValueError): # email not verified
g.verify_id_token(_token({**base, "email_verified": False}))