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).
This commit is contained in:
@@ -24,6 +24,15 @@ filesystem JSON storage (no SQL). i18n TH/EN. No self-registration (admin provis
|
||||
- **user** (trainee) — trains against personas, own board.
|
||||
|
||||
## Current state — local code/security gate passed; production-operation gate pending
|
||||
> **2026-08-20:** OAuth login/register (Google + Facebook) added. Public social signup into a
|
||||
> single default org (`OAUTH_DEFAULT_ORG`, role user, seat-checked); email-match links existing
|
||||
> users. Server-side token validation via stdlib urllib (no new dep; Google tokeninfo + Facebook
|
||||
> app/debug-token/me), fail-closed when creds absent (login page shows no social buttons),
|
||||
> rate-limited per-IP + per-email. **348 backend tests pass (11 new), frontend build clean, manual
|
||||
> security review PASS.** Committed locally (see `docs/engineering-log/2026-08-20-oauth-google-facebook.md`).
|
||||
> Also: the `website/` marketing site was **moved out of this repo** into its own project
|
||||
> `~/Gitea/Sales Trainer Website/` (own git repo, `7e2b74d`, not pushed) — this repo is now
|
||||
> **app-only** (commit `74de0d4`, not yet pushed).
|
||||
> **2026-08-19:** app UX/UI redesign (8 files, 100% presentational: global design-token system
|
||||
> rebuild in `style.css` + polish of App/Login/Chat/MyBoard/Personas/Setup/Training) AND a new
|
||||
> self-contained marketing landing site (`website/index.html` + `main.css`, TH-primary with EN
|
||||
@@ -125,6 +134,13 @@ cd backend && uv run python run.py # Flask :5001
|
||||
Responsive CSS (640px, single-column, `flex-wrap`, `.btn-back`) is present + deployed.
|
||||
|
||||
## Next actions / backlog (also docs/FUTURE_WORK.md)
|
||||
- **2026-08-20 pending pushes (operator-approved):** two local commits on `main` not yet pushed:
|
||||
(1) `74de0d4` — `website/` removed from repo (app-only now; marketing site lives in its own repo
|
||||
`~/Gitea/Sales Trainer Website`, not pushed). (2) OAuth Google+FB feature + docs. Each push to
|
||||
`main` auto-deploys to EasyPanel. **To ENABLE OAuth in production**, set in EasyPanel env:
|
||||
`OAUTH_GOOGLE_CLIENT_ID` + `OAUTH_GOOGLE_CLIENT_SECRET` (or `OAUTH_FACEBOOK_APP_ID` +
|
||||
`OAUTH_FACEBOOK_APP_SECRET`) and `OAUTH_DEFAULT_ORG` (a tenant id). Until creds are set, OAuth
|
||||
is disabled (fail-closed; no social buttons shown).
|
||||
- **2026-08-19 UX + marketing-site push (operator-approved):** the app UX/UI redesign (8 files)
|
||||
and new `website/` landing site are uncommitted on `main`. Commit + push ships the redesign to
|
||||
the live EasyPanel app AND adds the marketing site to the repo. **Confirm production
|
||||
|
||||
@@ -31,6 +31,7 @@ Informed by MiroFish (CrowdSight engine) + the hermes-brain-and-tools CrowdSight
|
||||
| S4.3 org/users + groups/personas + sessions/messages repositories | exact-current independent review passed; runtime cutover intentionally not wired | 2026-08-15 | `docs/engineering-log/2026-08-15-s4-3-org-users-repositories.md`, `docs/engineering-log/2026-08-15-s4-3-offline-dialect-remediation.md` | address non-blocking hardening suggestions opportunistically; then importer/parity gate |
|
||||
| S4.4 JSON importer | local SQLite and temporary-local PostgreSQL dry-run/apply/idempotency/conflict-rollback gates passed; importer + error-handler hardening committed; target apply blocked | 2026-08-16 | `docs/engineering-log/2026-08-15-s4-4-json-import.md`, `docs/engineering-log/2026-08-15-postgresql-import-gate.md`, `docs/engineering-log/2026-08-16-s4-4-importer-errorhandler-commit.md`, `docs/test-evidence/2026-08-15-postgresql-import.md` | target snapshot checksum/count comparison, retained backup, and operator-approved rollback rehearsal |
|
||||
| UX/UI redesign + marketing website | implemented + locally verified (build clean, 4/4 unit tests, independent review PASS, responsive verified); uncommitted; deploy pending operator approval | 2026-08-19 | `docs/engineering-log/2026-08-19-ux-redesign-and-marketing-site.md`, `website/`, `git diff` | operator approves push (auto-deploys); confirm production `JWT_SECRET` before deploy |
|
||||
| OAuth Google + Facebook login/register | implemented + locally verified (348 backend tests incl. 11 new, frontend build + 4/4 unit, manual security review PASS, no new deps); uncommitted | 2026-08-20 | `docs/engineering-log/2026-08-20-oauth-google-facebook.md`, `backend/app/services/oauth.py`, `backend/app/api/oauth_routes.py`, `backend/tests/test_oauth.py` | operator approves push; set OAUTH_* creds + OAUTH_DEFAULT_ORG in EasyPanel env to enable (disabled by default, fail-closed) |
|
||||
|
||||
## Guardrails
|
||||
- No self-registration; admin provisions users. (Verified: register => 404.)
|
||||
@@ -69,3 +70,4 @@ Informed by MiroFish (CrowdSight engine) + the hermes-brain-and-tools CrowdSight
|
||||
- `2026-08-16-s4-4-importer-errorhandler-commit.md` — re-verified from clean lock env (330 tests) and committed the staged S4.4 importer + error-handler hardening increment.
|
||||
- `2026-08-18-live-qa-ux-fixes.md` — live-QA UX fixes: persona JSON never leaks into bubble, natural greeting openers (wrong_text cools off only after first reply), auto-close + auto-summarize on buy/walk/try, no manual "สรุปผล" button (336 tests).
|
||||
- `2026-08-19-ux-redesign-and-marketing-site.md` — parallel subagents: app UX/UI redesign (global token system, 8 files, 100% presentational) + new `website/` marketing landing site (responsive TH/EN); build clean, 4/4 unit tests, independent review PASS; uncommitted, deploy pending operator approval.
|
||||
- `2026-08-20-oauth-google-facebook.md` — OAuth login/register (Google + FB): public signup into OAUTH_DEFAULT_ORG, email-match linking, stdlib server-side token validation (no new dep), fail-closed, rate-limited; 348 backend tests (11 new), frontend clean; manual security review PASS; deploy pending operator push.
|
||||
|
||||
108
docs/engineering-log/2026-08-20-oauth-google-facebook.md
Normal file
108
docs/engineering-log/2026-08-20-oauth-google-facebook.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# 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,name` →
|
||||
`debug_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
|
||||
- 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).
|
||||
72
docs/plan-oauth.md
Normal file
72
docs/plan-oauth.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user