Files
sales-trainer/docs/engineering-log/2026-08-20-oauth-google-facebook.md

6.3 KiB

2026-08-20 — OAuth (Google + Facebook) login/register

Date: 2026-08-20 Status: implemented + locally verified (tests/build clean, manual security review); deploy pending operator push (auto-deploys to EasyPanel)

Context

User asked (Thai): (1) split the marketing website out of this repo into its own project (done — see the website/ removal), and (2) add OAuth login/register with Google + Facebook.

Decision confirmed via clarify: public signup — new social users join a single default org (OAUTH_DEFAULT_ORG), role user, seat-checked. If the provider's verified email already belongs to an active user, log them in (email-match linking) instead of creating a duplicate. The prior "no self-registration (admin provisions)" model is superseded ONLY for social login.

What was done (subagent deleg_ddfbc58b + parent verification)

Backend:

  • backend/app/config.py — optional env config OAUTH_GOOGLE_CLIENT_ID/SECRET, OAUTH_FACEBOOK_APP_ID/SECRET, OAUTH_DEFAULT_ORG; Config.oauth_provider_enabled(provider) returns True only when every cred + default org present and non-placeholder (fail closed).
  • backend/app/services/oauth.py (new) — stdlib urllib.request only, no new dep. 8s timeouts, fail-closed OAuthError.
    • Google: tokeninfo?id_token= → require aud == OAUTH_GOOGLE_CLIENT_ID, email_verified == "true", take sub+email.
    • Facebook: app token via oauth/access_token (client creds) → me?fields=id,email,namedebug_token?input_token=..&access_token=app requiring is_valid, app_id match, and me.id == user_id.
  • backend/app/api/oauth_routes.py (new) + registered in factory.py (/api/auth prefix):
    • POST /api/auth/oauth — provider+token required (400), fail-closed disabled (404), rate-limit per-IP (15/300) + per-email (8/300), server-side validate, then email-match link OR create in OAUTH_DEFAULT_ORG (org created once, active, seat-checked, role user, random strong password, must_setup=False). Returns login shape {token, user, must_setup}.
    • GET /api/auth/oauth/config — booleans + public client_id/app_id only, no secrets.
  • backend/.env.example — documented new vars.

Frontend:

  • api/index.js: oauth(provider, token) + oauthConfig().
  • store/auth.js: loginOAuth() mirroring login.
  • i18n/index.js: TH + EN strings.
  • views/Login.vue: fetches oauth config on mount, renders enabled provider buttons only, on-demand SDK loading (Google GSI for ID token; FB FB.login{scope:email} for access token), monochrome single-color "G"/"f" SVG glyphs (no colored emoji, brand rule), i18n error handling, must_setup redirect.

Tests — backend/tests/test_oauth.py (11, monkeypatched validators, no live network): new-user in default org (role/org active/seat), email-match no-duplicate, invalid/unverified→401 (no user), disabled→404, collision suffix, rate-limit 429, facebook path (validator ran), config-no-secret-leak, config-disabled, missing-field 400, seat-limit rejection.

Verification evidence

Check Result (parent re-ran independently)
Full backend suite 348 passed (337 original + 11 new) — parent re-ran, green
Frontend vite build clean (parent re-ran)
Frontend vitest 4/4 (parent re-ran)
Static secret scan no hardcoded secrets (frontend diff + backend new files)
requirements.lock.txt unchanged (stdlib only)

Security review

An independent reviewer subagent (deleg_683b043f) was dispatched but timed out (tried to run pytest in its own env; "no tests collected" — an invocation/environment issue, not a finding; no verdict returned). The parent completed the adversarial review manually instead (read every line of oauth.py + oauth_routes.py + test_oauth.py):

  1. Impersonation / arbitrary-user creation — safe: user creation reached only after server-side provider validation; real validator enforces aud/app_id + verified email; test proves invalid token → 401 with no user created.
  2. Account-takeover via email linking — safe: linking only after validated token whose email is provider-verified; test_invalid_or_unverified_email_token_rejected confirms no user on invalid token.
  3. SSRF/injection — safe: fixed trusted URLs in constants; token only in query params via urlencode, never path-interpolated.
  4. Username safety — safe: g_<sub>/fb_<id> (numeric → USERNAME_RE), collision suffix, id==username preserved.
  5. Secrets — safe: config endpoint returns booleans + public IDs only; tests assert no secret keys in the response.
  6. Rate limiting — present (per-IP + per-email), 429 test confirms.
  7. Error handling — fail-closed 400/401/404/429/503; only exception type logged.
  8. Test adequacy — good, security properties genuinely asserted.

Manual verdict: safe.

Files changed

M backend/.env.example
M backend/app/config.py
M backend/app/factory.py
M frontend/src/api/index.js
M frontend/src/i18n/index.js
M frontend/src/store/auth.js
M frontend/src/views/Login.vue
A backend/app/api/oauth_routes.py
A backend/app/services/oauth.py
A backend/tests/test_oauth.py
A docs/plan-oauth.md   (design/plan doc)
M docs/engineering-log.md  (this entry)

Next action

DEPLOY — SUCCESSFUL (pushed + live, verified 2026-08-20)

  • Operator approved push. ece187b..d6e7cff main -> main pushed → Gitea→EasyPanel webhook auto-deployed (~3 min). Live verification:
    • Backend booted: POST /api/auth/login → 400 (Flask alive, no crash-loop).
    • GET /api/auth/oauth/config200 {"google":false,"facebook":false,"google_client_id":"","facebook_app_id":""} — OAuth routes live + fail-closed disabled (creds not set, correct/safe).
    • Live bundle index-CyyCBLha.js = the freshly-built OAuth bundle (hash matches local build).
  • Deploy status: code LIVE. OAuth stays disabled until creds configured (safe default).

To ENABLE OAuth in production (operator)

  • Operator approves push → deploy to EasyPanel (oauth disabled by default until creds set).
  • To ENABLE OAuth in production, set in EasyPanel env: OAUTH_GOOGLE_CLIENT_ID + OAUTH_GOOGLE_CLIENT_SECRET (or FB equivalents) + OAUTH_DEFAULT_ORG. Until then the login page shows no social buttons (fail-closed).
  • Also pending (same session): push of 74de0d4 (website/ removal — repo now app-only).