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).
5.6 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 configOAUTH_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) — stdliburllib.requestonly, no new dep. 8s timeouts, fail-closedOAuthError.- Google:
tokeninfo?id_token=→ requireaud == OAUTH_GOOGLE_CLIENT_ID,email_verified == "true", takesub+email. - Facebook: app token via
oauth/access_token(client creds) →me?fields=id,email,name→debug_token?input_token=..&access_token=apprequiringis_valid,app_idmatch, andme.id == user_id.
- Google:
backend/app/api/oauth_routes.py(new) + registered infactory.py(/api/authprefix):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 inOAUTH_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()mirroringlogin.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; FBFB.login{scope:email}for access token), monochrome single-color "G"/"f" SVG glyphs (no colored emoji, brand rule), i18n error handling,must_setupredirect.
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):
- 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.
- Account-takeover via email linking — safe: linking only after validated token whose email
is provider-verified;
test_invalid_or_unverified_email_token_rejectedconfirms no user on invalid token. - SSRF/injection — safe: fixed trusted URLs in constants; token only in query params via
urlencode, never path-interpolated. - Username safety — safe:
g_<sub>/fb_<id>(numeric → USERNAME_RE), collision suffix,id==usernamepreserved. - Secrets — safe: config endpoint returns booleans + public IDs only; tests assert no secret keys in the response.
- Rate limiting — present (per-IP + per-email), 429 test confirms.
- Error handling — fail-closed 400/401/404/429/503; only exception type logged.
- 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).