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).
73 lines
4.4 KiB
Markdown
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.
|