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

73 lines
4.4 KiB
Markdown

# 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.