# OTP + OIDC Seamless Login — Complete Implementation Guide

This document describes, end-to-end, the **mobile-OTP-gated OpenID Connect (OIDC) login flow** implemented in this project, with every request, response, status code, config value, and edge case, so it can be reproduced in another project.

It has two halves that are chained together:

1. **OTP verification** — proves the user owns a phone number.
2. **OIDC Authorization Code flow (with PKCE)** — this app acts as a full **OIDC Identity Provider (IdP)**, issuing signed JWTs (`id_token`) that a **Relying Party (RP)** — in this project, Shopify — trusts, without the user ever typing a password.

The trick that stitches the two together is a **short-lived cache token passed through a cookie** ("the seamless-login bridge") — see [§6](#6-the-seamless-login-bridge-the-key-trick). Everything else is a textbook OIDC Authorization Code + PKCE flow.

---

## Table of Contents

1. [Overview & Why This Design](#1-overview--why-this-design)
2. [Actors & Terminology](#2-actors--terminology)
3. [Architecture Diagram](#3-architecture-diagram)
4. [Full End-to-End Sequence Diagram](#4-full-end-to-end-sequence-diagram)
5. [Database Schema](#5-database-schema)
6. [The Seamless-Login Bridge (the key trick)](#6-the-seamless-login-bridge-the-key-trick)
7. [Environment Variables & Config](#7-environment-variables--config)
8. [API Reference — full request/response/status codes](#8-api-reference--full-requestresponsestatus-codes)
   - [8.1 POST /api/send-otp](#81-post-apisend-otp)
   - [8.2 POST /api/verify-otp](#82-post-apiverify-otp)
   - [8.3 POST /api/tse-login-as-dealer (optional "login as" bridge)](#83-post-apitse-login-as-dealer-optional-login-as-bridge)
   - [8.4 GET /auth/bridge (bridge redirect — sets the cookie)](#84-get-authbridge-bridge-redirect--sets-the-cookie)
   - [8.5 GET /.well-known/openid-configuration](#85-get-well-knownopenid-configuration)
   - [8.6 GET /jwks.json](#86-get-jwksjson)
   - [8.7 GET/POST /authorize](#87-getpost-authorize)
   - [8.8 POST /token](#88-post-token)
   - [8.9 GET /userinfo](#89-get-userinfo)
   - [8.10 GET /logout](#810-get-logout)
   - [8.11 GET /auth/callback (Relying Party side)](#811-get-authcallback-relying-party-side)
9. [id_token (JWT) Structure & Signing](#9-id_token-jwt-structure--signing)
10. [Error Reference](#10-error-reference)
11. [Security Checklist](#11-security-checklist)
12. [Full Walkthrough — one real login, start to finish](#12-full-walkthrough--one-real-login-start-to-finish)
13. [Porting Checklist — implementing this in another project](#13-porting-checklist--implementing-this-in-another-project)
14. [Shopify Admin API Dependencies (phone lookup + metafields)](#14-shopify-admin-api-dependencies-phone-lookup--metafields)
15. [Registering This App as Shopify's External OIDC Provider](#15-registering-this-app-as-shopifys-external-oidc-provider)

---

## 1. Overview & Why This Design

- A customer-facing storefront (or any third-party app) needs to know **who the user is**, but shouldn't manage passwords itself.
- This Laravel app owns the source-of-truth user table (`customer_details`) and acts as the **OIDC Identity Provider**.
- Instead of a traditional password login, the user authenticates by **WhatsApp/SMS OTP** to a mobile number already on file.
- Once the OTP is verified, the IdP **auto-issues an OIDC authorization code** for that user without showing a login form or asking for a password again — this is the "seamless" part.
- The Relying Party (Shopify, or any other app) then completes a **standard OAuth2/OIDC Authorization Code + PKCE exchange** to get a signed `id_token` (JWT), `access_token`, and `refresh_token`.

Two user types are supported at the OTP step:

| Type | Behavior after OTP verified |
|---|---|
| **Dealer** (self login) | Immediately gets an `oidc_redirect_url` — no further input needed. |
| **TSE** (agent logging in *as* a dealer) | Gets back a list of dealers to choose from; picking one calls a second endpoint (`/tse-login-as-dealer`) that produces the same kind of `oidc_redirect_url`. |

Both paths converge on the same bridge mechanism described in [§6](#6-the-seamless-login-bridge-the-key-trick).

---

## 2. Actors & Terminology

| Term | Meaning in this project | Generic OIDC term |
|---|---|---|
| Laravel app (this repo) | Verifies OTP, issues JWTs | **IdP** / Authorization Server |
| Shopify | Consumes the `id_token`, establishes its own customer session | **RP** / Relying Party / OAuth Client |
| `customer_details` table | Local user record used as JWT subject | User store |
| `oidc_clients` table | Registered RPs (client_id/secret/redirect URIs) | OAuth client registry |
| `oidc_auth_codes` table | One-time codes, 5–60 min TTL | Authorization codes |
| `oidc_refresh_tokens` table | 30-day rotating refresh tokens | Refresh tokens |
| `id_token` | RS256-signed JWT with user claims | OIDC ID Token |
| `login_token` / `oidc_login_token` cookie | Short-lived pointer into cache, used only to skip the login form after OTP | *(non-standard — this project's bridge mechanism)* |

---

## 3. Architecture Diagram

```
┌─────────────┐        ┌───────────────────────────────────────────┐        ┌───────────────┐
│   Browser   │        │              Laravel App (IdP)             │        │  Relying Party │
│  (customer) │        │                                             │        │   (Shopify)    │
└──────┬──────┘        │  ┌───────────────┐   ┌──────────────────┐  │        └───────┬────────┘
       │               │  │ LoginController│   │  OidcController   │  │                │
       │  1. OTP send  │  │ (send/verify)  │   │ (authorize/token/  │  │                │
       │──────────────►│  └───────┬───────┘   │  userinfo/logout)  │  │                │
       │               │          │           └─────────┬──────────┘  │                │
       │  2. OTP verify│          ▼                     ▲             │                │
       │──────────────►│  DialogOtpService      OidcService (RS256    │                │
       │               │  → seeds Cache          JWT sign/verify)     │                │
       │               │    "oidc_login_<tok>"                        │                │
       │               │          │                     │             │                │
       │  3. redirect  │          ▼                     │             │                │
       │◄──────────────│  ShopifyAuthController::redirectToShopify    │                │
       │               │  → sets oidc_login_token cookie, 302s to     │                │
       │               │    Shopify's customer_authentication/login   │                │
       │  4. ──────────┼───────────────────────────────────────────────────────────────►│
       │               │                                               Shopify starts   │
       │               │                                               its OIDC flow    │
       │  5. ◄─────────┼── GET /api/oidc/authorize?... (cookie sent)  ◄──────────────────│
       │               │  reads cookie → cache hit → issues auth code │                │
       │  6. 302 code+state ────────────────────────────────────────────────────────────►│
       │               │                                               RP calls /token  │
       │               │◄──────────────────────────────────────────── POST /token ──────│
       │               │  validates code+PKCE, signs id_token,        │                │
       │               │  returns access/id/refresh tokens             │                │
       │               │────────────────────────────────────────────► RP stores session │
       └───────────────┴───────────────────────────────────────────────┴────────────────┘
```

---

## 4. Full End-to-End Sequence Diagram

```mermaid
sequenceDiagram
    autonumber
    participant U as User (Browser)
    participant API as IdP API (LoginController)
    participant OTP as DialogOtpService
    participant Bridge as Auth Bridge (ShopifyAuthController)
    participant RP as Relying Party (Shopify)
    participant OIDC as OidcController/Service (IdP)

    U->>API: POST /api/send-otp {mobile, customer_code}
    API->>OTP: sendOtp()
    OTP-->>U: 200 {message: "OTP sent successfully"}

    U->>API: POST /api/verify-otp {mobile, otp, customer_code}
    API->>OTP: verifyOtp()
    OTP->>OTP: mark OtpSession.is_verified = true
    OTP->>OTP: Cache::put("oidc_login_<token>", email, ttl=600s)
    OTP-->>U: 200 {oidc_redirect_url: "APP_URL/shopify/login?login_token=..."}

    U->>Bridge: GET /shopify/login?login_token=...
    Bridge->>Bridge: Set-Cookie oidc_login_token=<token> (10 min, HttpOnly, Secure, SameSite=None)
    Bridge-->>U: 302 -> RP's customer_authentication/login

    U->>RP: follows redirect
    RP-->>U: 302 -> IdP /api/oidc/authorize?response_type=code&client_id=...&redirect_uri=...&state=...&code_challenge=...

    U->>OIDC: GET /api/oidc/authorize (cookie oidc_login_token attached)
    OIDC->>OIDC: validateClient(client_id, redirect_uri)
    OIDC->>OIDC: Cache::pull("oidc_login_" + cookie value) -> email
    alt cache hit (seamless)
        OIDC->>OIDC: issueAuthCode({username: email, password: "seamless-login"})
        OIDC-->>U: 302 redirect_uri?code=...&state=...
    else cache miss
        OIDC-->>U: 200 HTML login form (auto-submits)
    end

    U->>RP: follows redirect with ?code&state
    RP->>OIDC: POST /api/oidc/token {grant_type=authorization_code, code, redirect_uri, client_id, client_secret, code_verifier}
    OIDC->>OIDC: validate client, code, PKCE (S256)
    OIDC->>OIDC: sign id_token (RS256), create access_token + refresh_token
    OIDC-->>RP: 200 {access_token, id_token, refresh_token, token_type, expires_in}

    RP->>OIDC: GET /api/oidc/userinfo (Authorization: Bearer <access_token or id_token>)
    OIDC-->>RP: 200 {sub, email, name, phone, ...}

    RP-->>U: RP session established, user logged in
```

---

## 5. Database Schema

### `oidc_clients` — registered Relying Parties

| Column | Type | Notes |
|---|---|---|
| id | INT PK | |
| client_id | VARCHAR unique | public identifier |
| client_secret | VARCHAR | shared secret, sent at `/token` |
| redirect_uris | JSON | allow-list, exact match required |
| post_logout_redirect_uris | JSON | allow-list for `/logout` |
| created_at / updated_at | TIMESTAMP | |

Example seed row:
```
client_id     : 759b2c61f3db684d86f30e6de8b1965c
client_secret : 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
redirect_uris : ["https://apg-uae.myshopify.com/authorization"]
```

### `oidc_auth_codes` — one-time authorization codes

| Column | Type | Notes |
|---|---|---|
| id | INT PK | |
| code | VARCHAR(32) | `Str::random(32)` |
| client_id | VARCHAR | |
| user_id | INT | FK → `customer_details.id` |
| redirect_uri | VARCHAR | must match exactly at `/token` |
| scope | VARCHAR nullable | e.g. `openid email` |
| nonce | VARCHAR nullable | echoed into `id_token` |
| state | VARCHAR nullable | used as the upsert key in the interactive `POST /authorize` path |
| code_challenge | VARCHAR nullable | PKCE, base64url(SHA256(code_verifier)) |
| code_challenge_method | VARCHAR nullable | `S256` |
| expires_at | TIMESTAMP | 5 min (seamless path) / 60 min (form-post path — see note in §8.7) |
| created_at | TIMESTAMP | |

Codes are **deleted immediately** after successful exchange at `/token` (one-time use).

### `oidc_refresh_tokens`

| Column | Type | Notes |
|---|---|---|
| id | INT PK | |
| token | VARCHAR(40) | `Str::random(40)` |
| client_id | VARCHAR | |
| user_id | INT | |
| expires_at | TIMESTAMP | default 30 days, `OIDC_REFRESH_TOKEN_TTL_DAYS` |
| created_at | TIMESTAMP | |

Refresh tokens **rotate**: `refresh_token` grant deletes the old row and issues a brand new refresh token alongside the new access/id tokens.

### `otp_sessions` — OTP attempts

| Column | Type | Notes |
|---|---|---|
| id | INT PK | |
| mobile | VARCHAR | |
| otp | VARCHAR(6) | plaintext (compared via `hash_equals`) |
| is_verified | BOOLEAN | flips to `true` on success |
| created_at | TIMESTAMP | |

Every `send-otp` call inserts a **new row** (history of attempts). `verify-otp` locks the **latest unverified row for that mobile** (`lockForUpdate`) inside a DB transaction — this makes verification idempotent and race-safe.

### `customer_details` — the user table (existing)

| Column | Used as |
|---|---|
| id | JWT `sub` |
| customer_email | login identifier / JWT `email` |
| first_name, last_name | JWT `name` |
| customer_mobile_no | JWT `phone` |
| shopify_customer_id | JWT `shopify_customer_id` (project-specific extra claim) |

---

## 6. The Seamless-Login Bridge (the key trick)

Standard OIDC has no concept of "the user already proved who they are 30 seconds ago via OTP, skip the login form." This project bridges the gap with a **one-shot cache token carried in a cookie**:

1. After OTP verification succeeds, the server picks a random 40-char token and stores:
   ```
   Cache::put('oidc_login_' . $loginToken, $email, ttl = 600 seconds)
   ```
2. It returns a URL to the client: `{APP_URL}/shopify/login?login_token={token}`.
3. The client (browser) navigates to that URL. The bridge controller (`ShopifyAuthController::redirectToShopify`) reads `login_token` from the query string and sets it as a **cookie** named `oidc_login_token`:
   ```php
   $response->cookie('oidc_login_token', $token, 10 /*minutes*/, '/', null, true, true, false, 'None');
   // Cookie flags: TTL=10min, path=/, domain=null, secure=true, httpOnly=true, raw=false, sameSite=None
   ```
   Then it 302-redirects the browser to the Relying Party's own login-initiation URL (Shopify's `customer_authentication/login`).
4. The Relying Party runs its own OIDC-initiation logic and eventually redirects the browser to **this app's** `GET /api/oidc/authorize?...` — and because it's the *same browser*, the `oidc_login_token` cookie rides along.
5. `OidcController::authorize` (GET branch) reads the cookie, does `Cache::pull('oidc_login_' . $cookieValue)` (pull = read-and-delete, so it's single-use), and if it finds a cached email:
   - Deletes the cookie immediately (`Cookie::forget`).
   - Calls `issueAuthCode()` with `username = cachedEmail` and a dummy password (`'seamless-login'`) — **password verification is bypassed entirely** in this flow; only presence of the cached email (which only exists because OTP succeeded) authorizes the user.
   - 302-redirects straight to the RP's `redirect_uri?code=...&state=...` — **no login form is ever shown**.
6. If the cookie/cache lookup misses (expired, wrong device, or the RP started its own flow without going through the OTP bridge), the endpoint falls back to rendering an **auto-submitting HTML login form** that posts back to `/authorize` — this project's form has a hardcoded static credential injected server-side (`Cache::get('user_email')` + a fixed password), which is a workaround specific to this project and **should be replaced in a port with a real credential/session check** if you ever need the non-seamless path to be genuinely interactive.

Both the **direct OTP/dealer path** and the **TSE "login as dealer" path** (§8.3) seed the *exact same* `oidc_login_<token>` cache key/shape — they're interchangeable producers for step 1 above.

**Why a cookie and not just a query param on the `/authorize` redirect?** Because step 4's redirect to `/authorize` is constructed and issued by the *Relying Party* (Shopify), not by this app — this app has no way to inject its own query param into that URL. A cookie is the only channel this app controls that will still be present on the browser's next request to it.

---

## 7. Environment Variables & Config

`config/constants.php` (trimmed to what this flow needs):

```php
'urls' => [
    'shopify_login_url'  => "https://{$shopDomain}/pages/login",              // generic fallback / error redirect
    'customer_auth_url'  => "https://{$shopDomain}/customer_authentication/login?", // RP's OIDC-initiation URL
    'oidc_token'          => rtrim($baseUrl, '/') . '/api/oidc/token',        // used by the RP-callback controller
],
'dialog' => [
    'otp_url' => 'https://orailap.azurewebsites.net/api/cloud/Dialog',       // WhatsApp/SMS OTP gateway
],
'oidc' => [
    'token_ttl'              => (int) env('OIDC_TOKEN_TTL', 7200),           // seconds, id_token/access_token lifetime
    'refresh_token_ttl_days' => (int) env('OIDC_REFRESH_TOKEN_TTL_DAYS', 30),
],
'http' => [
    'status_ok' => 200, 'status_not_found' => 404,
    'status_unprocessable' => 422, 'status_server_error' => 500,
],
```

Environment variables:

| Variable | Purpose | Default |
|---|---|---|
| `APP_URL` | Used as JWT `iss` and to build the bridge URL | — |
| `DIALOG_API_KEY` | OTP gateway auth | — |
| `DIALOG_UAE_NAMESPACE` / `DIALOG_OMAN_NAMESPACE` | WhatsApp template namespace per region | hardcoded fallbacks in code |
| `SHOPIFY_SHOP_NAME` | Shop domain; used to pick UAE vs Oman OTP template based on request `Origin` header, and to build every Shopify Admin/Storefront URL | `apg-uae.myshopify.com` |
| `SHOPIFY_API_KEY` / `SHOPIFY_API_SECRET` | RP client_id/secret used by the callback controller (also stored as the `oidc_clients` row's `client_id`/`client_secret`) | — |
| `SHOPIFY_API_VERSION` | Admin/Storefront GraphQL API version, e.g. `2024-01` | `2024-01` (code default; **not actually set in `.env`/`.env.example` in this project**) |
| `SHOPIFY_ADMIN_ACCESS_TOKEN` | Admin API token used by `ShopifyService::getCustomerEmailByPhone()`/`getCustomerMetafields()` — needs `read_customers` scope | — |
| `SHOPIFY_STOREFRONT_TOKEN` | Used post-login to mint a Shopify customer access token (Storefront API) | — |
| `SHOPIFY_REDIRECT_URI` | Must exactly match an entry in `oidc_clients.redirect_uris` | — |
| `OIDC_TOKEN_TTL` | id_token/access_token lifetime, seconds | 7200 |
| `OIDC_REFRESH_TOKEN_TTL_DAYS` | refresh_token lifetime, days | 30 |

> ⚠️ **Live bug found in this project, worth fixing before/while porting:** `config/constants.php` reads `env('SHOPIFY_ADMIN_ACCESS_TOKEN', ...)` for the Admin API token, but this project's actual `.env` only defines `SHOPIFY_ADMIN_API_TOKEN` (different name) — `SHOPIFY_ADMIN_ACCESS_TOKEN` is never set. That means `ShopifyService::getCustomerEmailByPhone()` throws `RuntimeException('Shopify Admin credentials not configured properly.')` at runtime unless something else populates that exact env key. Since `getCustomerEmailByPhone()` is called at the very top of both `send-otp` and `verify-otp` (§14), **this can silently break the entire OTP flow in an environment where only `SHOPIFY_ADMIN_API_TOKEN` was set**. When porting: pick one env var name and use it consistently everywhere the Admin token is read.

RSA keypair (generated once, kept out of git):

```
keys/private.pem   # sign id_token — RS256
keys/public.pem    # verify id_token
keys/jwks.json     # public key published at /jwks.json
```

Generate with:
```bash
openssl genrsa -out keys/private.pem 2048
openssl rsa -in keys/private.pem -pubout -out keys/public.pem
```
(`jwks.json` is derived from the public key's modulus/exponent — build a small script that base64url-encodes `n`/`e` into the JWKS shape shown in §8.6.)

---

## 8. API Reference — full request/response/status codes

All non-OIDC endpoints wrap responses in this envelope (helper functions `successResponse()` / `errorResponse()`):
```json
{ "success": true|false, "status_code": 200, "message": "...", "data": { ... } }
```
OIDC endpoints (`/api/oidc/*`) follow the **standard OAuth2/OIDC JSON shapes** instead (no envelope), per spec.

### 8.1 `POST /api/send-otp`

Middleware: `throttle:api`, `ApiLoggerMiddleware`, `SapTokenMiddleware`.

**Request body** (`application/json` or form):
| Field | Type | Rules |
|---|---|---|
| `mobile` | string | required, digits only, 10–15 chars |
| `customer_code` | string | required, max 50 |

Header used: `Origin` (determines which regional OTP template/gateway is used).

**cURL:**
```bash
curl -X POST 'https://your-app.com/api/send-otp' \
  -H 'Content-Type: application/json' \
  -d '{"mobile": "0771234567", "customer_code": "AER10001"}'
```

**Success — 200:**
```json
{
  "success": true,
  "status_code": 200,
  "message": "OTP sent successfully",
  "data": {
    "is_verified": 0,
    "status_code": 200,
    "message": "OTP sent successfully"
  }
}
```

**Failure — validation, 422** (invalid mobile/customer_code against the on-file record):
```json
{
  "success": false,
  "status_code": 500,
  "message": "Invalid mobile or dealer code.",
  "data": { "is_verified": 0, "status_code": 500, "message": "Invalid mobile or dealer code." }
}
```
> Note: this project's business-rule mismatch ("mobile doesn't belong to this customer_code") is surfaced as HTTP `500` in the underlying service, not `422`/`404` — a known inconsistency worth fixing in a port; prefer `422 Unprocessable Entity` for a business validation failure.

**Failure — malformed request, 422:**
```json
{ "success": false, "status_code": 422, "message": "The given data was invalid.", "errors": { "mobile": ["..."] } }
```

Behavior:
1. **Calls Shopify Admin API** (`ShopifyService::getCustomerEmailByPhone()`) to resolve the mobile number to a customer, then **calls Shopify Admin API again** (`getCustomerMetafields()`) to read that customer's `dealer_code` metafield and cross-checks it against the submitted `customer_code` — see [§14](#14-shopify-admin-api-dependencies-phone-lookup--metafields) for the exact GraphQL and required scopes. If either call fails or the codes don't match, this returns an error before any OTP is generated.
2. Generates a 6-digit numeric OTP (`random_int(100000, 999999)`).
3. Sends it via a WhatsApp template through an SAP CPI-backed gateway (`Dialog`), selecting a UAE or Oman template based on the request `Origin` header vs. the configured shop domain.
4. Persists a new `otp_sessions` row (`is_verified = false`) regardless of gateway success/failure, for audit + rate-limiting history.

### 8.2 `POST /api/verify-otp`

**Request body:**
| Field | Type | Rules |
|---|---|---|
| `mobile` | string | required, digits only, 10–15 chars |
| `otp` | string | required, exactly 6 digits |
| `customer_code` | string | required, max 50 |

**cURL:**
```bash
curl -X POST 'https://your-app.com/api/verify-otp' \
  -H 'Content-Type: application/json' \
  -d '{"mobile": "0771234567", "otp": "482913", "customer_code": "AER10001"}'
```

**Success — Dealer (direct login) — 200:**
```json
{
  "success": true,
  "status_code": 200,
  "message": "OTP verified successfully.",
  "data": {
    "is_verified": 1,
    "status_code": 200,
    "message": "OTP verified successfully.",
    "oidc_redirect_url": "https://your-app.com/shopify/login?login_token=aB3xQ...40chars",
    "userType": "DEALER",
    "requireDealerSelection": false
  }
}
```
→ Client should immediately `window.location.href = oidc_redirect_url`.

**Success — TSE (must choose a dealer) — 200:**
```json
{
  "success": true,
  "status_code": 200,
  "message": "OTP verified successfully.",
  "data": {
    "is_verified": 1,
    "status_code": 200,
    "message": "OTP verified successfully.",
    "userType": "TSE",
    "requireDealerSelection": true,
    "dealers": [
      { "customer_code": "AER10001", "customer_name": "..." }
    ]
  }
}
```
→ Client shows a dealer picker, then calls §8.3 with the chosen `dealer_customer_code`.

**Failure — wrong/expired OTP — 422:**
```json
{
  "success": false,
  "status_code": 422,
  "message": "Invalid OTP or mobile number.",
  "data": { "is_verified": 0, "status_code": 422, "message": "Invalid OTP or mobile number." }
}
```

**Failure — internal error — 500:**
```json
{ "success": false, "status_code": 500, "message": "Verification failed due to internal error.", "data": { ... } }
```

Behavior (inside a DB transaction with row locking):
1. Finds the latest **unverified** `otp_sessions` row for the mobile; if none, checks whether an **already-verified** row with this exact OTP exists and returns an idempotent success (`OTP is already verified.`) rather than an error — safe to retry.
2. Compares OTP with `hash_equals()` (timing-safe).
3. On match, flips `is_verified = true`.
4. **Calls Shopify Admin API a second time** (phone lookup + metafields, [§14](#14-shopify-admin-api-dependencies-phone-lookup--metafields)) to read `customer_type` → routes to DEALER or TSE branch described above.
5. For DEALER: seeds the `oidc_login_<token>` cache (§6) and returns the bridge URL.
6. For TSE: fetches the dealer list from SAP CPI and returns it — **does not** auto-login.

### 8.3 `POST /api/tse-login-as-dealer` (optional "login as" bridge)

Only needed if your project has an agent/impersonation use case (support staff logging in as a specific customer). Skip this endpoint if you don't need it.

**Request body** (validated inline, not a FormRequest):
| Field | Type | Rules |
|---|---|---|
| `dealer_customer_code` | string | required |
| `customer_code` | string | required (the TSE's own code, for audit) |

**cURL:**
```bash
curl -X POST 'https://your-app.com/api/tse-login-as-dealer' \
  -H 'Content-Type: application/json' \
  -d '{"dealer_customer_code": "AER10001", "customer_code": "TSE5001"}'
```

**Success — 200:**
```json
{
  "success": true,
  "status_code": 200,
  "message": "Direct login URL generated successfully",
  "data": {
    "oidc_redirect_url": "https://your-app.com/shopify/login?login_token=k9Zp...40chars",
    "tse_data": {
      "customer_email": "tse@example.com",
      "customer_mobile_no": "0779999999",
      "first_name": "Agent",
      "last_name": "Name",
      "customer_code": "TSE5001"
    }
  }
}
```

**Failure — dealer not found / no email on file — 404:**
```json
{ "success": false, "status_code": 404, "message": "Dealer email not found for the provided customer code." }
```

**Failure — malformed request — 422** (Laravel's default validation-exception shape, since this endpoint uses `$request->validate()` inline rather than a FormRequest):
```json
{ "message": "The dealer customer code field is required. (and 1 more error)", "errors": { "dealer_customer_code": ["..."], "customer_code": ["..."] } }
```
> Note the response shape difference vs. the FormRequest-based endpoints — worth normalizing in a port.

### 8.4 `GET /auth/bridge` (bridge redirect — sets the cookie)

*In this project: `GET /shopify/login`.* Not part of the `/api` prefix — a plain web route, because it needs to set a cookie and issue a browser redirect (not JSON).

**Query params:**
| Param | Required | Notes |
|---|---|---|
| `login_token` | optional | if absent, no cookie is set and the RP will show its own interactive login |

**cURL (illustrative — this is meant to be a browser navigation, not an API call):**
```bash
curl -i 'https://your-app.com/shopify/login?login_token=aB3xQ...40chars'
```

**Response — 302:**
```
HTTP/1.1 302 Found
Set-Cookie: oidc_login_token=aB3xQ...40chars; Max-Age=600; Path=/; Secure; HttpOnly; SameSite=None
Location: https://{shop_domain}/customer_authentication/login?
```

On any exception, redirects to the fallback `shopify_login_url` instead.

### 8.5 `GET /.well-known/openid-configuration`

No auth required.

```bash
curl 'https://your-app.com/api/oidc/.well-known/openid-configuration'
```

**200:**
```json
{
  "issuer": "https://your-app.com",
  "authorization_endpoint": "https://your-app.com/api/oidc/authorize",
  "token_endpoint": "https://your-app.com/api/oidc/token",
  "jwks_uri": "https://your-app.com/api/oidc/jwks.json",
  "userinfo_endpoint": "https://your-app.com/api/oidc/userinfo",
  "end_session_endpoint": "https://your-app.com/api/oidc/logout",
  "scopes_supported": ["openid", "email", "profile"],
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "id_token_signing_alg_values_supported": ["RS256"]
}
```

### 8.6 `GET /jwks.json`

```bash
curl 'https://your-app.com/api/oidc/jwks.json'
```

**200:**
```json
{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "alg": "RS256",
      "kid": "key-1",
      "n": "base64url-modulus...",
      "e": "AQAB"
    }
  ]
}
```

### 8.7 `GET`/`POST` `/authorize`

**GET — query params:**
| Param | Required | Notes |
|---|---|---|
| `response_type` | yes | must be `code` |
| `client_id` | yes | must exist in `oidc_clients` |
| `redirect_uri` | yes | must be in that client's `redirect_uris` list (exact match) |
| `state` | recommended | CSRF protection, echoed back verbatim |
| `nonce` | recommended | echoed into `id_token` claims |
| `scope` | optional | e.g. `openid email` |
| `code_challenge` / `code_challenge_method` | recommended (PKCE) | `code_challenge_method` must be `S256` if present |

**GET behavior:**
- Invalid `response_type`, or `validateClient()` fails → **302 redirect to fallback `shopify_login_url`** (this project intentionally never returns a raw 400 JSON error to the browser on this endpoint — everything degrades to a redirect).
- Valid: checks `oidc_login_token` cookie → cache. **Hit** → seamless auth code issued, **302** straight to `redirect_uri?code=...&state=...`. **Miss** → **200 HTML** auto-submitting login form.

```bash
# GET, browser-only — shows form or seamlessly redirects
curl -i 'https://your-app.com/api/oidc/authorize?response_type=code&client_id=759b2c61f3db684d86f30e6de8b1965c&redirect_uri=https%3A%2F%2Fyour-rp.com%2Fauthorization&state=xyz123&scope=openid%20email&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256'
```

**POST — body params:** same fields as GET, plus:
| Param | Required | Notes |
|---|---|---|
| `username` | yes | matched against `customer_details.customer_email` |
| `password` | accepted but **not verified** in this implementation | see §11 |

```bash
curl -i -X POST 'https://your-app.com/api/oidc/authorize' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'response_type=code' \
  --data-urlencode 'client_id=759b2c61f3db684d86f30e6de8b1965c' \
  --data-urlencode 'redirect_uri=https://your-rp.com/authorization' \
  --data-urlencode 'state=xyz123' \
  --data-urlencode 'scope=openid email' \
  --data-urlencode 'username=customer@example.com' \
  --data-urlencode 'password=anything'
```

**Response — 302** on success:
```
Location: https://your-rp.com/authorization?code=CbyPYm1JkIVOurMyzOjLBggVMg4H6TOG&state=xyz123
```
On error (user not found, client invalid) → **302 to fallback URL**, not a JSON error.

> **TTL inconsistency to note:** the interactive `POST /authorize` path (`issueAuthCode()`) sets the code's `expires_at` to **60 minutes**, while the seamless-cache path (`generateAuthRedirect()`, used by an alternate integration point) uses **5 minutes**. Pick one TTL (5–10 minutes is standard for OIDC auth codes) when porting — 60 minutes is unusually long for a one-time code.

### 8.8 `POST /token`

**Client authentication — either:**
- HTTP Basic: `Authorization: Basic base64(client_id:client_secret)`, or
- Body params `client_id` + `client_secret`.

**Body params — `grant_type=authorization_code`:**
| Param | Required |
|---|---|
| `grant_type` | `authorization_code` |
| `code` | yes |
| `redirect_uri` | yes — must match what was used at `/authorize` |
| `client_id` / `client_secret` | yes (or Basic header) |
| `code_verifier` | required only if `code_challenge` was sent at `/authorize` |

```bash
curl -X POST 'https://your-app.com/api/oidc/token' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=authorization_code' \
  --data-urlencode 'code=CbyPYm1JkIVOurMyzOjLBggVMg4H6TOG' \
  --data-urlencode 'redirect_uri=https://your-rp.com/authorization' \
  --data-urlencode 'client_id=759b2c61f3db684d86f30e6de8b1965c' \
  --data-urlencode 'client_secret=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' \
  --data-urlencode 'code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
```

**Body params — `grant_type=refresh_token`:**
| Param | Required |
|---|---|
| `grant_type` | `refresh_token` |
| `refresh_token` | yes |
| `client_id` / `client_secret` | yes |

**Success — 200:**
```json
{
  "access_token": "33PvglScw4SUlAzBJaYmtTnuS4XnQRqNWk9FDmiT",
  "token_type": "Bearer",
  "expires_in": 7200,
  "id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0xIn0.eyJpc3MiOiJodHRwczovL3lvdXItYXBwLmNvbSIsImF1ZCI6Ijc1OWIyYzYxZjNkYjY4NGQ4NmYzMGU2ZGU4YjE5NjVjIiwic3ViIjoiMyIsImlhdCI6MTc3NjA3MTI3OSwiZXhwIjoxNzc2MDc4NDc5LCJlbWFpbCI6ImN1c3RvbWVyQGV4YW1wbGUuY29tIn0.signature",
  "refresh_token": "87MuEl4A7864lFx8rcBHnHYqTrKQTIMiXWkL46C5"
}
```

**Errors — all returned as `{"error": "...", "error_description"?: "..."}` with the status codes below** (the controller in this project currently redirects instead of returning this JSON on error — see §11 for why that's worth fixing in a port; the **service layer** already produces the correct standard shape):

| `error` | HTTP status | Cause |
|---|---|---|
| `invalid_client` | 401 | client_id/secret don't match a row in `oidc_clients` |
| `invalid_grant` | 400 | redirect_uri mismatch, code not found/expired, or PKCE verification failed |
| `unsupported_grant_type` | 400 | `grant_type` not one of `authorization_code`/`refresh_token` |
| `server_error` | 400 | user record behind the code/refresh token vanished |

### 8.9 `GET /userinfo`

**Header:** `Authorization: Bearer <id_token or access_token>`

```bash
curl 'https://your-app.com/api/oidc/userinfo' \
  -H 'Authorization: Bearer eyJ0eXAiOiJKV1Qi...'
```

**Success — 200:**
```json
{
  "sub": "3",
  "email": "customer@example.com",
  "email_verified": true,
  "name": "Nimesh Mishra",
  "phone": "919099868381",
  "shopify_customer_id": "9312912310334"
}
```

**Failure — 401** conceptually (`invalid_token` — missing/invalid Bearer header, expired JWT, bad signature, or issuer mismatch); this project's controller currently redirects to the fallback URL instead of returning JSON 401 — flag this for a port if the RP expects a proper 401.

### 8.10 `GET /logout`

**Query params:**
| Param | Required |
|---|---|
| `post_logout_redirect_uri` | yes — must be in the client's `post_logout_redirect_uris` allow-list |

```bash
curl -i --max-redirs 0 'https://your-app.com/api/oidc/logout?post_logout_redirect_uri=https://your-rp.com'
```

**Success — 302:**
```
Location: https://your-rp.com
```
**Failure (URI not allow-listed) — 302 to fallback URL** (again, not a 400 JSON — consistent with this project's "always redirect, never surface raw errors to the browser" pattern on `/authorize`, `/logout`).

There's also an **RP-initiated logout** helper (`ShopifyAuthController::logout`, exposed at `GET /api/oidc/shopify/logout`) that clears the local session and forwards to the RP's own end-session endpoint with `id_token_hint` — needed only if the RP (like Shopify) itself exposes an OIDC end-session endpoint you need to also hit.

### 8.11 `GET /auth/callback` (Relying Party side)

*In this project: `GET /shopify/callback`.* This is the **other half** of the flow — code that runs *as the Relying Party*, not the IdP. Include it in a port only if your "other project" also needs to consume an external OIDC IdP (e.g., if the roles are reversed there).

**Query params (from the IdP's redirect):** `code`, `state`.

**Behavior:**
1. Verifies `state` matches a value stored in session (`shopify_state`) at flow-start — CSRF protection.
2. POSTs to the IdP's `/token` endpoint with `code`, `client_id`, `client_secret`, `redirect_uri`, and the `code_verifier` stashed in session at flow-start (PKCE).
3. Decodes the returned `id_token` (payload segment only — **does not verify the signature here**, since it trusts the token endpoint's TLS channel; verify the signature too if the callback and token endpoint are ever on different trust boundaries).
4. `firstOrCreate`s a local user record keyed by the token's `email` claim.
5. Establishes its own session/cookie for the now-authenticated user and redirects to the final destination.

On any failure (state mismatch, missing code, token exchange failure, missing email) → redirects to the fallback login URL rather than showing an error page.

---

## 9. id_token (JWT) Structure & Signing

- Algorithm: **RS256** (RSA-signed, asymmetric — the RP only needs the public key/JWKS, never the private key).
- Header: `{"typ":"JWT","alg":"RS256","kid":"key-1"}`
- Payload:
```json
{
  "iss": "https://your-app.com",
  "aud": "759b2c61f3db684d86f30e6de8b1965c",
  "sub": "3",
  "iat": 1776071279,
  "exp": 1776078479,
  "nonce": "n-0S6_WzA2Mj",
  "email": "customer@example.com",
  "email_verified": true,
  "name": "Nimesh Mishra",
  "phone": "919099868381",
  "shopify_customer_id": "9312912310334"
}
```

| Claim | Meaning |
|---|---|
| `iss` | Issuer — must equal `APP_URL`, checked by the RP and by `/userinfo` |
| `aud` | Audience — the `client_id` this token was issued for |
| `sub` | Subject — local user's primary key, stable identifier |
| `iat` / `exp` | Issued-at / expiry, unix seconds; `exp - iat` = `OIDC_TOKEN_TTL` (default 7200s) |
| `nonce` | Echoes the `nonce` sent at `/authorize`, replay protection |

Token lifetimes:

| Token | Lifetime |
|---|---|
| Authorization code (interactive POST path) | 60 minutes *(consider shortening to 5–10 min)* |
| Authorization code (seamless cache path) | 5 minutes |
| `id_token` / `access_token` | `OIDC_TOKEN_TTL`, default 7200s (2h) |
| `refresh_token` | `OIDC_REFRESH_TOKEN_TTL_DAYS`, default 30 days, **rotates** on use |
| `oidc_login_<token>` cache entry | 600 seconds (10 min) |
| `oidc_login_token` cookie | 10 minutes, `HttpOnly`, `Secure`, `SameSite=None` |

---

## 10. Error Reference

| Endpoint | Status | Error / message | Cause | Fix |
|---|---|---|---|---|
| `/send-otp` | 422 | validation errors | malformed mobile/customer_code | fix input |
| `/send-otp` | 500* | `Invalid mobile or dealer code.` | mobile/customer_code don't match records | verify against real customer data (*should be 422/404 in a clean port*) |
| `/verify-otp` | 422 | `Invalid OTP or mobile number.` | wrong/expired OTP, or no pending session | request a fresh OTP |
| `/verify-otp` | 500 | `Verification failed due to internal error.` | exception during Shopify/SAP lookups | check logs, retry |
| `/authorize` (GET/POST) | 302→fallback | *(no JSON body)* | invalid `response_type`, unknown `client_id`, `redirect_uri` not allow-listed, or `username` not found | check `oidc_clients` row, check user exists |
| `/token` | 401 | `invalid_client` | wrong client_id/secret | check `oidc_clients` row |
| `/token` | 400 | `invalid_grant` | code expired/used/wrong redirect_uri, or PKCE mismatch | get a fresh code |
| `/token` | 400 | `unsupported_grant_type` | grant_type not `authorization_code`/`refresh_token` | fix request |
| `/userinfo` | 401 (conceptually) | `invalid_token` | expired/bad-signature/wrong-issuer JWT, or missing Bearer header | get a fresh `id_token` |
| `/logout` | 302→fallback | *(no JSON body)* | `post_logout_redirect_uri` not allow-listed | register the URI on the client |
| `/tse-login-as-dealer` | 404 | `Dealer email not found for the provided customer code.` | bad `dealer_customer_code` | verify dealer exists |

\* See the TTL/status-code inconsistencies flagged inline above — worth normalizing when porting to a fresh project rather than copying verbatim.

---

## 11. Security Checklist

Carry these forward when porting — some are already correct in this implementation, some are gaps worth closing:

- ✅ **PKCE (S256)** is supported and enforced when `code_challenge` is present — keep this, and consider making it **mandatory** for all clients (public clients especially) rather than optional.
- ✅ **Auth codes are one-time-use** (deleted immediately after exchange) and **short-lived**.
- ✅ **Refresh tokens rotate** on every use (old one deleted, new one issued) — good practice, keep it.
- ✅ **OTP comparison uses `hash_equals`** (timing-safe) — keep this for any secret comparison.
- ✅ **RS256 asymmetric signing** — RP only ever needs the public key (JWKS); private key never leaves the IdP.
- ⚠️ **Password field at `/authorize` is accepted but never checked** (`issueAuthCode()` looks the user up by email only). In a port, either remove the password field entirely and make the seamless-cookie/cache path the *only* login path, or actually enforce a `Hash::check()` against a real password column if you need a fallback interactive login.
- ⚠️ **Client-error paths on `/authorize`, `/token`(controller-level), `/userinfo`, `/logout` redirect to a fallback URL instead of returning proper HTTP error codes/JSON.** This is convenient for a browser-only flow but breaks machine clients (e.g., a mobile app calling `/token` directly) that expect a real `400`/`401` JSON body per spec. The **service layer already returns the correct `{error, error_description}` shape** — in a port, have the controller pass that straight through with the right status code instead of swallowing it into a redirect.
- ⚠️ **Hardcoded API keys** in `httpSendOTPUAE`/`httpSendOTPOman` (`'API-KEY' => 'ks1Uvh8V0TEpZVUylylfesoVAK'` etc.) — move these to env vars in a port; don't hardcode secrets in source.
- ⚠️ **Hardcoded, inconsistent Shopify customer passwords.** `ShopifyAuthController` uses the literal string `'Admin@123'` when minting a Storefront `customerAccessToken` after the OIDC callback; `ShopifyService::shopifyLogin()` uses a *different* literal, `'BinaryApgUae@12345'`, for the same kind of call elsewhere. Neither is derived from anything OTP-related — they're static shared secrets baked into source. In a port, don't use a shared static password for `customerAccessTokenCreate` at all if you can avoid it (prefer a Storefront **Customer Account API** flow, or a signed one-time multipass/token if your commerce platform supports it); at minimum, use one consistent value pulled from config/env, not two different hardcoded literals.
- ⚠️ **`SHOPIFY_ADMIN_ACCESS_TOKEN` vs `SHOPIFY_ADMIN_API_TOKEN` env-var mismatch** — see the callout in [§7](#7-environment-variables--config). This isn't a theoretical port concern, it's a bug in *this* environment right now: verify which name is actually set before assuming OTP send/verify works end-to-end.
- ⚠️ Cookie `oidc_login_token` uses `SameSite=None; Secure` (correct for a cross-site redirect flow) — **keep both flags together**; `SameSite=None` without `Secure` is rejected by modern browsers anyway, but double-check your TLS termination sets `Secure` correctly behind any load balancer/proxy.
- ⚠️ Validate `nonce` on the RP side against what was sent at `/authorize` — the IdP echoes it into the `id_token`, but confirm your RP-side callback code actually **checks** it (replay protection), not just decodes it.
- ⚠️ Auth-code TTL of 60 minutes (interactive path) is too long — bring it down to 5–10 minutes, matching the seamless path and OIDC norms.
- ✅ **RP-callback state verification** (`shopify_state` session comparison) is implemented — keep this CSRF check in a port.

---

## 12. Full Walkthrough — one real login, start to finish

```bash
BASE=https://your-app.com
CLIENT_ID=759b2c61f3db684d86f30e6de8b1965c
CLIENT_SECRET=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
REDIRECT_URI=https://your-rp.com/authorization

# 1) Send OTP
curl -s -X POST "$BASE/api/send-otp" \
  -H 'Content-Type: application/json' \
  -d '{"mobile":"0771234567","customer_code":"AER10001"}'
# -> {"success":true,"status_code":200,"message":"OTP sent successfully",...}

# 2) User receives OTP via WhatsApp, submits it
curl -s -X POST "$BASE/api/verify-otp" \
  -H 'Content-Type: application/json' \
  -d '{"mobile":"0771234567","otp":"482913","customer_code":"AER10001"}'
# -> {"data":{"oidc_redirect_url":"https://your-app.com/shopify/login?login_token=aB3xQ...","userType":"DEALER","requireDealerSelection":false}}

# 3) Browser follows oidc_redirect_url (sets cookie, 302s to RP)
curl -si "$BASE/shopify/login?login_token=aB3xQ...40chars" | grep -i 'set-cookie\|location'
# Set-Cookie: oidc_login_token=aB3xQ...; Max-Age=600; ...
# Location: https://{shop_domain}/customer_authentication/login?

# 4) RP redirects browser back to IdP /authorize (cookie included by browser automatically)
#    IdP recognizes cookie -> cache hit -> seamless 302 with ?code&state to REDIRECT_URI
#    (simulated here directly, since real cookie-jar handling needs a browser or curl -b/-c)
curl -si -b cookies.txt -c cookies.txt \
  "$BASE/api/oidc/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$REDIRECT_URI&state=xyz123&scope=openid%20email&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256" \
  | grep -i location
# Location: https://your-rp.com/authorization?code=CbyPYm1JkIVOurMyzOjLBggVMg4H6TOG&state=xyz123

# 5) RP exchanges the code for tokens
curl -s -X POST "$BASE/api/oidc/token" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=authorization_code' \
  --data-urlencode "code=CbyPYm1JkIVOurMyzOjLBggVMg4H6TOG" \
  --data-urlencode "redirect_uri=$REDIRECT_URI" \
  --data-urlencode "client_id=$CLIENT_ID" \
  --data-urlencode "client_secret=$CLIENT_SECRET" \
  --data-urlencode 'code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
# -> {"access_token":"...","id_token":"eyJ...","refresh_token":"...","expires_in":7200,"token_type":"Bearer"}

# 6) RP fetches user profile
curl -s "$BASE/api/oidc/userinfo" -H 'Authorization: Bearer <id_token>'
# -> {"sub":"3","email":"customer@example.com","name":"...", ...}

# 7) Later: refresh the session without a new OTP
curl -s -X POST "$BASE/api/oidc/token" \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data-urlencode 'grant_type=refresh_token' \
  --data-urlencode "refresh_token=<refresh_token from step 5>" \
  --data-urlencode "client_id=$CLIENT_ID" \
  --data-urlencode "client_secret=$CLIENT_SECRET"

# 8) Logout
curl -si --max-redirs 0 "$BASE/api/oidc/logout?post_logout_redirect_uri=https://your-rp.com"
```

The `code_challenge`/`code_verifier` pair above corresponds to the standard PKCE recipe:
```
code_verifier  = random 43-128 char URL-safe string
code_challenge = base64url( SHA256(code_verifier) )   # no padding
```

---

## 13. Porting Checklist — implementing this in another project

1. **User table** — pick/adapt a table with at least: primary key, email (or another unique login identifier), display name, and any extra claims you want in the `id_token`. Update the JWT payload builder (§9) accordingly.
2. **OTP provider** — swap the Dialog WhatsApp integration for whatever SMS/WhatsApp/email OTP gateway the new project uses. Keep: random 6-digit OTP, `hash_equals` comparison, `otp_sessions`-style table with row-locking on verify, idempotent "already verified" handling.
3. **`oidc_clients` table + seed row** — register every Relying Party you'll support (client_id, client_secret, allowed `redirect_uris`, allowed `post_logout_redirect_uris`).
4. **RSA keypair** — generate fresh `private.pem`/`public.pem`/`jwks.json`; never reuse keys across projects/environments.
5. **The bridge route** (§6, §8.4) — implement a plain (non-JSON) route that: reads a one-time token from the query string, sets it as an `HttpOnly; Secure; SameSite=None` cookie with a short TTL, and redirects to wherever your RP's own login-initiation flow begins. If there is no separate RP-initiation step (e.g., you're building both IdP and RP in the same app, or the "RP" is just your own SPA calling `/authorize` directly), you can skip the bridge and have the SPA call `/authorize` with the cookie already set by your OTP-verify endpoint's `Set-Cookie` header directly — simpler, fewer redirects.
6. **`/authorize`, `/token`, `/userinfo`, `/jwks.json`, `/.well-known/openid-configuration`, `/logout`** — copy the controller/service logic largely as-is; it's already spec-compliant OIDC. Fix the two flagged gaps first (§11): return real HTTP status codes/JSON on error instead of always redirecting, and decide what to do with the unchecked password field.
7. **PKCE** — keep mandatory for any public/browser client; optional only for confidential server-to-server clients if you have any.
8. **Config** — recreate the `urls`, `oidc`, and gateway sections of `config/constants.php` (§7) with your new project's domains/timeouts; move any hardcoded API keys into env vars while you're at it.
9. **Multi-tenant/impersonation flow (§8.3)** — only port this if you have an agent-logs-in-as-customer use case; otherwise the two-endpoint OTP flow (§8.1–8.2) alone is a complete login system.
10. **Test the full loop** with the curl walkthrough in §12 before wiring up a real frontend — it exercises every endpoint and will surface config mistakes (wrong redirect_uri, wrong client_secret, clock skew on `exp`, etc.) early.
