Files
sales-trainer/docs/plan-oauth.md
Macky d6e7cffc84 feat(auth): Google + Facebook OAuth login/register
Public social signup into OAUTH_DEFAULT_ORG (role user, seat-checked);
email-match links existing active user instead of duplicating. Server-side
provider token validation via stdlib urllib only (no new dep): Google
tokeninfo (aud + email_verified) and Facebook app/debug-token/me (is_valid,
app_id, me.id==user_id). Fail-closed when creds unconfigured, rate-limited
per-IP + per-email, /oauth/config leaks no secrets. Frontend: login buttons
(only enabled providers), GSI + FB SDK on-demand, monochrome glyphs, TH/EN.

Login page shows social buttons only when backend reports provider enabled.

348 backend tests pass (337 + 11 new OAuth), frontend build + 4/4 unit
clean, manual security review PASS. Not pushed (push auto-deploys).
2026-08-20 10:57:04 +07:00

4.4 KiB

OAuth (Google + Facebook) — Design & Plan

Status: in progress (2026-08-19) Owner decision (clarify): public signup — new users from Google/FB join a single default org (OAUTH_DEFAULT_ORG), role user, seat-checked. If the verified email matches an existing user, log them in (link) instead of creating a duplicate.

Model chosen

  • Supersedes "no self-registration" ONLY for social login. Username/password auth + admin provisioning remain unchanged.
  • New OAuth-registered users go to OAUTH_DEFAULT_ORG (configurable tenant id). Org is active (created if missing) so new signups pass the SaaS tenant gate. Seats apply via existing UserStore.create_user seat check.
  • Email-match linking: if by_email(verified_email) finds an active user, issue a token for them (OAuth sign-in); else create a new user.

Security invariants (MUST preserve)

  • Users stay keyed by username with id == username, USERNAME_RE [a-zA-Z0-9_.-]{2,64}, matching _token_identity + require_auth.
  • OAuth username derivation must satisfy USERNAME_RE and be collision-safe: e.g. g_<google_sub> / fb_<facebook_id> (subs are numeric → valid). Must not collide with an existing username; if it does, suffix with a counter until unique.
  • Server-side token validation only. Backend validates the provider token over HTTPS — never trusts a client-declared identity. Use stdlib urllib.request (no new dep, avoids requirements.lock.txt churn).
    • Google: verify ID token via https://oauth2.googleapis.com/tokeninfo?id_token=...; check email_verified == "true", audience == OAUTH_GOOGLE_CLIENT_ID, take sub + email.
    • Facebook: exchange client token for app token GET /oauth/access_token?client_id=..&client_secret=..&grant_type=client_credentials, then GET /me?fields=id,email,name&access_token=<user_token>, then verify the user token via GET /debug_token?input_token=<user_token>&access_token=<app_token> checking data.is_valid, data.app_id == OAUTH_FACEBOOK_APP_ID, data.user_id, and that the Graph me result's id == data.user_id. Only accept verified email.
  • Provider tokens are single-use inputs, never stored. Secret keys live in .env only, never logged or returned.
  • Rate-limit the OAuth exchange endpoint (per-IP + per-email) like login.
  • Reject tokens with invalid issuer/app/audience → ApiError 401/400, never 500.
  • All outbound provider calls must set a timeout and fail closed (AuthError → 401/400).

Config (backend/app/config.py)

New env vars (all optional; OAuth disabled unless configured):

  • OAUTH_GOOGLE_CLIENT_ID, OAUTH_GOOGLE_CLIENT_SECRET
  • OAUTH_FACEBOOK_APP_ID, OAUTH_FACEBOOK_APP_SECRET
  • OAUTH_DEFAULT_ORG (tenant id for new signups)
  • OAUTH_ENABLED-style toggles derived from presence of creds (fail closed: endpoint 404/disabled when creds absent).

API

  • POST /api/auth/oauth body {provider: "google"|"facebook", token: "..."} → validates token, resolves/creates user, returns {token, user, must_setup} (same shape as login).
  • No new deps.

Frontend

  • frontend/src/views/Login.vue + frontend/src/api/index.js: add api.oauth(provider, token).
  • Login screen adds "เข้าสู่ระบบด้วย Google / Facebook" buttons (single-color line icons, no colored emoji per brand rule).
  • Provider SDK: Google Identity Services (GID) loaded from CDN on demand → google.accounts.id to get an ID token; FB SDK (fbLogin FB.getLoginStatus / FB.login scope email) to get an access token. Send the token to /api/auth/oauth. Handle must_setup like login.
  • Keep OAuth buttons hidden/disabled when provider creds are not configured (backend drives via a small public config endpoint or by the presence of the button config in the login page response).

Tests (backend, mock/fake provider — no live network)

  • New backend/tests/test_oauth.py: fake provider responses via monkeypatched validation function; assert: new-user creation (org=OAUTH_DEFAULT_ORG, role user, seat-checked), email-match login-links existing user, invalid token → 401, unverified email rejected, disabled provider (creds absent) → 404/disabled, username collision gets a unique suffix, rate-limit enforced, response shape matches login.

Deliverable

Backend routes + validation + store integration + config; frontend buttons + api method + provider SDK wiring; tests; handoff docs.