# Product Updates Admin — Google OAuth (@ordio.com)

**Last Updated:** 2026-04-08  
**Status:** Production

## Overview

The Product Updates CMS at `/produkt-updates-admin` uses **Google OAuth** (authorization code flow) for sign-in when credentials are configured. Only **verified** Google accounts with an email address whose domain is exactly **`ordio.com`** are accepted (enforced server-side after OpenID userinfo).

The **OAuth client secret** is the same class of secret as the Partner (“Ordio Loop”) integration: load from environment (`GOOGLE_OAUTH_CLIENT_SECRET`) or `v2/config/oauth-credentials.php` (gitignored). It is **never** sent to the browser.

Product Updates Admin OAuth is **not** gated by `AFFILIATE_OAUTH_GOOGLE_ENABLED` — if Client ID and Secret are set, the admin “Sign in with Google” path can work even when partner Google login is disabled.

## Google Cloud Console

1. Use the same OAuth client as Partner **or** a dedicated Web client.
2. **Authorized redirect URIs** must include **every** URL the app will send as `redirect_uri` — **byte-for-byte** the same (scheme, host, port, path, no trailing slash).
   - Production examples:
     - `https://www.ordio.com/produkt-updates-admin/oauth/callback`
     - `https://ordio.com/produkt-updates-admin/oauth/callback` (only if you use bare host in the browser)
   - Local / Docker: add **both** if you ever open the site as either hostname:
     - `http://localhost:8003/produkt-updates-admin/oauth/callback`
     - `http://127.0.0.1:8003/produkt-updates-admin/oauth/callback`
   - **Partner vs Product Updates:** `/partner/oauth/callback` and `/produkt-updates-admin/oauth/callback` are **different** — registering only the partner URI causes **`Error 400: redirect_uri_mismatch`** on Product Updates login.
3. Scopes requested: `openid email profile` (see `buildGoogleAuthUrl` in `v2/helpers/oauth-google.php`).

### `redirect_uri_mismatch` (Google error 400)

1. In Google Cloud Console → **APIs & Services** → **Credentials** → your **OAuth 2.0 Client ID** (Web application) → **Authorized redirect URIs** → **Add URI** using the exact value below.
2. To see what the app sends: start login, then in DevTools → **Network** open the request to `accounts.google.com/.../auth` and inspect the **`redirect_uri`** query parameter (URL-decoded). That string must appear **unchanged** in the Console list.
3. **CLI (SSH on production or simulate host):** run `php v2/scripts/dev-helpers/show-product-updates-admin-oauth-callback.php` (uses server env) or e.g. `php v2/scripts/dev-helpers/show-product-updates-admin-oauth-callback.php --host=www.ordio.com --https` — the script prints the callback URL to register.
4. Behind a trusted reverse proxy, set **`PRODUCT_UPDATES_ADMIN_OAUTH_USE_FORWARDED_HOST=1`** if `HTTP_HOST` is internal, or set **`PRODUCT_UPDATES_ADMIN_OAUTH_REDIRECT_URI`** to the full registered URI (see environment table).

## Environment variables

| Variable | Purpose |
|----------|---------|
| `GOOGLE_OAUTH_CLIENT_ID` | OAuth client ID |
| `GOOGLE_OAUTH_CLIENT_SECRET` | OAuth client secret (“API key” in ops language) |
| `PRODUCT_UPDATES_ADMIN_OAUTH_ENABLED` | Set to `0`, `false`, or `off` to **disable** Google login and use legacy password only (requires `PRODUCT_UPDATES_ADMIN_LEGACY_PASSWORD_HASH`) |
| `PRODUCT_UPDATES_ADMIN_LEGACY_PASSWORD_HASH` | Bcrypt hash for password login when OAuth is disabled or credentials missing. Generate: `php -r "echo password_hash('your_password', PASSWORD_BCRYPT);"` |
| `PRODUCT_UPDATES_ADMIN_REMEMBER_SECRET` | Optional but **recommended in production**: random string used to HMAC remember-me verifiers. If unset, a derived pepper is used from OAuth/legacy secrets (or a dev-only fallback). |
| `PRODUCT_UPDATES_ADMIN_OAUTH_REDIRECT_URI` | Optional **full** callback URL, e.g. `https://www.ordio.com/produkt-updates-admin/oauth/callback`. Use when auto-detection from `HTTP_HOST` does not match what you registered in Google Cloud (fixes **`redirect_uri_mismatch`**). |
| `PRODUCT_UPDATES_ADMIN_OAUTH_USE_FORWARDED_HOST` | Set to `1` / `true` to build the callback host from `X-Forwarded-Host` (first value) instead of `HTTP_HOST`. Only behind a **trusted** proxy that strips client-supplied forwarded headers. |

## Routes

| URL | File |
|-----|------|
| `/produkt-updates-admin/oauth/google` | `v2/pages/produkt-updates-oauth-google.php` |
| `/produkt-updates-admin/oauth/callback` | `v2/pages/produkt-updates-oauth-callback.php` |

Rewrite rules live in `.htaccess` (before the generic `produkt-updates-admin` rule).

## Security behavior

- **CSRF:** `state` token in session (`pu_admin_oauth_state`); validated with `hash_equals` on callback (distinct from partner `oauth_state` keys).
- **Session:** `v2/includes/produkt-updates-admin-session.php` sets secure cookie params (HttpOnly, SameSite=Lax, Secure on HTTPS) before `session_start()`.
- **Post-login:** `session_regenerate_id(true)`; `$_SESSION['produkt_updates_admin_authenticated'] = true`.
- **Domain:** `produkt_updates_admin_oauth_identity_allowed()` requires verified email and exact `@ordio.com` domain (normalized lowercase).
- **`hd=ordio.com`:** Sent on the authorize URL as a UX hint only; **not** a security boundary.

## Remember me (“stay signed in”)

When `ADMIN_REMEMBER_ME_ENABLED` is true (see `v2/config/admin_config.php`):

- **Google OAuth:** The login screen includes an optional checkbox (“Stay signed in on this device”). If checked, after successful OAuth the server issues a **selector + verifier** token: the cookie holds `selector.verifier` (hex); the SSOT data directory stores only an **HMAC hash** of the verifier (`v2/data/produkt-updates/.pu_admin_remember_tokens.json`, gitignored). **OAuth rows re-check** `@ordio.com` on each restore.
- **Legacy password:** Same checkbox on the password form; stored tokens are marked `legacy` (no email binding).
- **Rotation:** Each successful validation **consumes** the old token and issues a new one (limits replay if a cookie is copied once).
- **APIs:** `produkt-updates-upload.php`, `produkt-updates-autosave.php`, and `produkt-updates-seo-generate.php` call `produkt_updates_admin_try_remember_cookie_login()` so XHR continues to work with a valid remember cookie even when the PHP session was new.
- **Logout:** Revokes the current remember token server-side and clears the cookie.

Cookie flags: **HttpOnly**, **SameSite=Lax**, **Secure** on HTTPS (see `produkt_updates_admin_remember_send_cookie()`).

## Validation

```bash
php v2/scripts/dev-helpers/validate-oauth-setup.php
php v2/scripts/dev-helpers/show-product-updates-admin-oauth-callback.php --help
```

## Legacy admin tools (follow-up)

Scripts under `v2/admin/produkt-updates/` are **not** covered by Product Updates Admin OAuth. Many still use **`?password=`** with hardcoded strings or `password_verify` against `PRODUCT_UPDATES_ADMIN_LEGACY_PASSWORD_HASH`. **Rotate or remove** any shared secrets that were committed or documented in runbooks.

| Pattern | Files (non-exhaustive; re-grep before changes) |
|--------|--------------------------------------------------|
| Hardcoded `vXbTz6PqdY6UdexSxlp5` | `list-images.php`, `migrate-missing-images.php`, `test-post-load.php`, `test-specific-slug.php`, `production-diagnostic.php`, `production-comprehensive-diagnostic.php`, `production-cache-check.php`, `force-cache-clear.php`, `clear-cache.php`, `check-post-fix.php`, `check-json-data.php`, `check-deployment.php`, `check-admin-file.php` |
| Hardcoded `debug2024` | `debug-image-loading.php`, `test-image-api.php`, `scan-image-directories.php` |
| Session **or** `password_verify($admin_password_hash)` | `comprehensive-production-test.php`, `test-production-endpoints.php`, `discover-writable-directories.php`, `analyze-production-setup.php`, plus migration/init scripts that load `admin_config.php` for auth |

**Follow-up:** gate these with the same session as `/produkt-updates-admin` (`$_SESSION['produkt_updates_admin_authenticated']`) or remove from public web roots.

## Related files

- `v2/includes/produkt-updates-admin-remember.php` — remember-me tokens (OAuth + legacy)
- `v2/includes/produkt-updates-admin-oauth.php` — flags and domain check
- `v2/config/oauth-config.php` — `getProductUpdatesAdminOAuthCallbackUrl()`, `isGoogleOAuthCredentialsConfigured()`
- `v2/config/admin_config.php` — session tuning; legacy hash from env only
- `.cursor/rules/produkt-updates-admin-oauth.mdc` — agent pointer
