# Google Sign-In — Deeniyat Plus

**Date:** 2026-09-16
**Status:** Code complete. **Migration NOT yet applied.** Requires a rotated Firebase key and confirmation of the app's Firebase project before it can work.

---

## 1. What this adds

`POST /api/auth/google` — one idempotent endpoint handling both first-time signup and returning login. Google sign-in is idempotent by nature: the client sends the same kind of token either way and the server works out which case it is. There is deliberately no separate `/google/register`.

The response envelope is **identical to `/api/auth/login`**, so the mobile app reuses its existing parsing.

## 2. The Gmail dot-stripping trap — read this before touching the code

`helpers/validator.js:5,10` runs `normalizeEmail({ gmail_remove_dots: true })` on `mail_id` for signup and login. It is a **mutating sanitizer** — it rewrites `req.body.mail_id` before the controller sees it.

Measured against the live database: **2408 of 2464 users are on `@gmail.com`, and not one has a dot in the local part.** Real Gmail addresses contain dots constantly. Every signup has silently had its dots stripped.

| Input | Stored as |
|---|---|
| `First.Last@Gmail.com` | `firstlast@gmail.com` |
| `first.last+tag@gmail.com` | `firstlast@gmail.com` |
| `First.Last@googlemail.com` | `firstlast@gmail.com` |
| `First.Last@outlook.com` | `first.last@outlook.com` (dots kept) |

A Firebase ID token carries the user's **real** address. Matching it without the same normalisation finds nothing, creates a duplicate account, and orphans a long-standing user's data. With ~98% of the base on Gmail that would hit almost everyone who links.

`auth.controller.js` therefore runs every token email through `normaliseEmail()`, which calls the **same** `validator` function with the **same** options. `validator` was promoted to a direct dependency in `package.json` — it was previously only transitive via `express-validator`, and account-matching correctness now depends on it.

**If you ever change the validator chain's email options, change `normaliseEmail()` in the same commit.** They must stay identical.

## 3. Firebase: a second, named app

`controllers/admin.dashboard.js:124` already initialises the **default** Firebase app with the `serviceAccount_*` credentials for project **`dinyatplus`**, and all FCM push resolves against it.

Google Sign-In uses a different project, **`deeniyat-plus`**. `configs/firebase.auth.config.js` therefore registers a **named** app (`deeniyat-plus-auth`). Initialising the auth credential as the default would have broken push notifications for every user.

Verified against the installed `firebase-admin@11.10.1`: a named app registers alongside `[DEFAULT]`, `admin.auth(namedApp)` targets the named project, and `admin.auth()` still targets the default.

The config also applies `privateKey.replace(/\\n/g, '\n')`, which the existing FCM init omits. dotenv only unescapes `\n` inside double-quoted values — if the key ever arrives as a real process env var (Docker `-e`, PM2, systemd, CI secret) dotenv never touches it and `cert()` throws "Failed to parse private key". The replace is idempotent.

### New `.env` keys
```
googleAuth_PROJECT_ID="deeniyat-plus"
googleAuth_CLIENT_EMAIL="firebase-adminsdk-fbsvc@deeniyat-plus.iam.gserviceaccount.com"
googleAuth_PRIVATE_KEY="PASTE_ROTATED_PRIVATE_KEY_HERE"
```
Double quotes are required. The existing `serviceAccount_*` keys are untouched.

## 4. Schema

`migrations/20260916000001_users_google_signin.up.sql` — the **first migration file in this repo**, establishing the convention used by the sibling `deeniyat-self-learning-backend` (timestamped `.up.sql`/`.down.sql`, `BEGIN/COMMIT`, applied by hand).

- `password` → nullable (Google-only accounts have none)
- `mail_id` → `varchar(255)` (was 50; RFC allows 254, longest existing is 40)
- new: `google_id text`, `auth_provider text NOT NULL DEFAULT 'email'`, `is_email_verified boolean NOT NULL DEFAULT false`
- `uq_users_mail_id_lower` — unique on `lower(mail_id)`, **partial on `mail_id <> ''`** because `register()` stores `''` as the "no email" sentinel for mobile-only signups (`auth.controller.js:34-35`); a plain index would reject the second such row
- `uq_users_google_id` — partial unique on `google_id`

All verified safe against live data first: 0 duplicate `lower(mail_id)` groups, 0 rows with `mail_id = ''`, 0 empty or non-bcrypt passwords.

## 5. API

### `POST /api/auth/google`

Body: `{ "idToken": "<Firebase ID token>" }` — from the Firebase Auth SDK after a Google sign-in. **Not** a raw Google ID token.

**Success — 200** (identical shape to `/login`):
```json
{
  "result": true,
  "message": "User signed in!",
  "data": {
    "user_id": 2465,
    "user_name": "Aisha Ahmad",
    "mail_id": "aishaahmad@gmail.com",
    "user_mobile": "",
    "is_admin": false,
    "is_active": true,
    "google_id": "1150487...",
    "auth_provider": "google",
    "is_email_verified": true,
    "accessToken": "<jwt>"
  }
}
```

**Failures**

| Status | Trigger | Body |
|---|---|---|
| 400 | `idToken` missing/empty | `{ "errors": [ { "msg": "idToken is required", ... } ] }` |
| 400 | provider isn't Google | `{ "error": "Unsupported sign-in provider: password" }` |
| 400 | no Google identity claim | `{ "error": "Sign-in token has no Google identity." }` |
| 403 | `email_verified` is not true | `{ "error": "Google account email is not verified." }` |
| 400 | token carries no email | `{ "error": "Google account has no email address." }` |
| 409 | that email is already linked to a different `google_id` | `{ "error": "This email is already linked to a different Google account." }` |
| 401 | token invalid/expired/revoked, **or from the wrong Firebase project** | `{ "error": "Failed to verify sign-in token." }` |
| 500 | Firebase not configured | `{ "result": false, "message": "Firebase Auth admin failed to initialise...", "data": null }` |

### Account resolution order
1. `google_id` matches → sign in.
2. Normalised email matches → **link**: set `google_id`, `auth_provider='google'`, `is_email_verified=true`, keep their `user_id`, sign in. Their existing password keeps working. Admin accounts are included, by decision.
3. No match → create a new row (`password NULL`, `user_mobile ''`, `is_active true`).

A `23505` from step 3 means a concurrent sign-in won the race; the handler re-runs the precedence and resolves cleanly rather than returning 500.

**Two guards protect step 2 specifically**, because that step hands control of an existing account to whoever presents the token:

- **`email_verified` must be true**, else 403. Without it, anyone able to obtain a token for an unverified address matching a victim's account would take that account over outright. Google verifies addresses before issuing them for the `google.com` provider, so this should always pass — which is exactly why it is cheap to enforce rather than assume.
- **If the row already carries a *different* `google_id`, refuse with 409** rather than silently re-pointing an existing account at a new Google identity. Google addresses are unique per account so this should be unreachable, but a silent re-point would be an account takeover with no trace.

### `is_active`
In this codebase `is_active` is **not** an account-status flag — `login` sets it true (`:310`), `logout` sets it false (`:382`). It means "currently signed in". The Google path sets it true, matching `login`. New rows set it explicitly because the column defaults to `false`.

### Change to `/api/auth/login`
A guard was added before `bcrypt.compare` (`auth.controller.js`): once `password` can be NULL, a Google-only user submitting the password form would make bcrypt reject with "Illegal arguments" and return a meaningless 500. They now get:
```json
{ "error": "This account uses Google Sign-In. Please continue with Google." }
```

## 6. JWT payload

`login` signs `{...otherData}` — the **entire** users row minus password. So `google_id`, `auth_provider` and `is_email_verified` now appear in every token and in every `/login` and `/logout` response, from the moment the migration runs and before any Google code is called.

This is additive, so clients reading named fields are unaffected. The payload was deliberately **not** curated as part of this change: `middlewares/auth.middleware.js` reads `decoded.is_admin`, the admin panel may read others, and there is no test suite to catch a break. Curating it is a separate, deliberate change.

> **⚠ Verify with the mobile team.** Most JSON layers ignore unknown keys — Swift `Codable`, Gson/Retrofit, Dart map access all do. **`kotlinx.serialization` throws on unknown keys unless `ignoreUnknownKeys = true`.** If the Android app parses strictly, password login breaks for all 2464 users the moment the migration is applied — which it already has been. Ask the mobile team whether their decoder tolerates unknown keys. If it does not, the fix is to replace `SELECT *` in `login` with an explicit column list, which is a bigger change deserving its own regression pass.

## 7. Verification

No test framework exists (`npm test` is the placeholder that exits 1). Run `npm start` (nodemon, `LOCAL_PORT=3400`).

**Step 0 — before anything else.** Confirm the `project_id` in the app's `google-services.json` (Android) / `GoogleService-Info.plist` (iOS) reads **`deeniyat-plus`**. Firebase verification is hard-scoped to the project: `verifyIdToken` rejects unless `aud === projectId` and `iss === "https://securetoken.google.com/" + projectId`. If the app is on a different project every sign-in returns 401, and the library's own message is *"Make sure the ID token comes from the same Firebase project as the service account used to authenticate this SDK."*

| # | Step | Expect |
|---|---|---|
| 1 | Apply the migration | `\d public.users` shows `password` nullable, `mail_id varchar(255)`, 3 new columns; `\di` shows both indexes |
| 2 | **Regression, before any Google call** — `POST /api/auth/login` with known credentials | 200 + `accessToken`; response now also carries `google_id:null`, `auth_provider:"email"` |
| 3 | `POST /api/auth/google` with a real token, account not in DB | 200; row created with `auth_provider='google'`, `password IS NULL`, `google_id` set |
| 4 | Same account again | 200, **same `user_id`**, no second row |
| 5 | **Critical** — existing Gmail user (stored dotless) signs in with their dotted address | Returns their **existing `user_id`**; user count unchanged. If a second row appears, normalisation is broken — stop |
| 6 | That Google user tries `POST /api/auth/login` | 400 "uses Google Sign-In", **not** 500 |
| 7 | The linked user logs in with their original password | Still works |
| 8 | **Trigger an FCM push** (the `node-cron` daily_post job, or `POST /api/users/retrieve-token`) | Still sends — proves the named app didn't clobber the default `dinyatplus` app. **Do not skip** |
| 9 | `{}` / `{"idToken":"garbage"}` / non-Google token | 400 / 401 / 400 |

```sql
-- No duplicate emails. 0 rows.
SELECT lower(mail_id) FROM public.users WHERE mail_id <> '' GROUP BY 1 HAVING COUNT(*) > 1;
-- No duplicate google_id. 0 rows.
SELECT google_id FROM public.users WHERE google_id IS NOT NULL GROUP BY 1 HAVING COUNT(*) > 1;
-- Google rows must have NULL password and a google_id.
SELECT auth_provider, COUNT(*)::int, COUNT(password)::int AS with_password,
       COUNT(google_id)::int AS with_google_id FROM public.users GROUP BY 1;
-- Dot-stripping sanity: must stay 0, or normalisation has drifted.
SELECT COUNT(*)::int FROM public.users
 WHERE mail_id ILIKE '%@gmail.com' AND split_part(mail_id,'@',1) LIKE '%.%';
```

## 8. Known gaps

1. **Rotate the service-account key.** The one this was designed around was exposed in a chat transcript; `.env` carries a placeholder, so the endpoint returns 500 until a rotated key is pasted in.
2. **The app's Firebase project is unconfirmed** — step 0 above. Everything else is moot if it isn't `deeniyat-plus`.
3. **The committed `index.js` does not serve port 3010.** It listens on `LOCAL_PORT` (3400) over plain HTTP; the HTTPS block for `api.deeniyatplus.com:3010` is commented out at lines 70-90. The deployed server must have it uncommented, or the endpoint is unreachable at the URL the app calls.
4. **No transactions.** `configs/db.config.js` exports a single shared `pg.Client`, and there is no `BEGIN`/`COMMIT` anywhere in the repo. Creating a user then a session row is two autocommitted statements — a crash between them leaves a user with no session row. Already true of `login`; the Google path is no worse, but it is not atomic.
5. **`logout` doesn't invalidate the JWT** — it only flips `is_active`, never touching `users_session`, and the middleware is stateless. A token stays valid for its full 1 day after logout. Pre-existing.
6. **Subaddress stripping is a small mis-linking hazard on non-Gmail domains.** `normalizeEmail` also strips Yahoo `-subaddress` and Outlook/iCloud `+tags`: `ali-shop@yahoo.com` → `ali@yahoo.com`. So those two different mailboxes are one identity to this system, and a Google sign-in for one could link into an account registered with the other. Exposure is small — 56 of 2464 users are non-Gmail, of which 8 yahoo / 6 icloud / 3 hotmail / 2 outlook. This behaviour **already exists** in `/register` and `/login`, so diverging from it here would reintroduce the §2 mismatch; consistency is the lesser evil. Accept and document, or change it in *both* files plus a backfill.
7. **`verifyIdToken(idToken, true)` uses `checkRevoked`**, which needs the `firebaseauth.users.get` permission. The default Firebase Admin SDK Service Agent role has it. If the role was ever narrowed, every sign-in fails with a permission error. Dropping the `true` removes the extra RPC (~100–200 ms/sign-in) at the cost of no longer rejecting disabled or revoked Firebase users.
8. **Confirm the app sends a *Firebase* ID token, not a raw Google one.** This design assumes Firebase Auth with Google as a provider (`aud` = the Firebase project, `iss` = `securetoken.google.com/...`). `google-auth-library@8.7.0` is also installed in this repo — if the app instead uses the bare Google Sign-In SDK, its tokens have `aud` = an OAuth client ID and `iss` = `accounts.google.com`, and every one would be rejected. That would need a different verifier entirely.
9. **Pre-existing bugs left alone**, to keep this change reviewable: `auth.routes.js:4` imports `verifyToken` non-destructured (module object, not the function); `auth.controller.js` references an undefined `err` in register's dead branch; `login` passes `$1` for both the email and mobile comparison; `logout` sends no response when `rowCount == 0`; the admin guard's `is_admin` check is commented out in `middlewares/auth.middleware.js:11-17`.
