# Nafath Authentication Integration - Complete Developer Guide

This document explains the Nafath integration in this Laravel 11 project from the first browser click to the final authenticated Laravel session or REST API token. It explains what every important class does, why it exists, the patterns used, how the database tables relate, how to troubleshoot the integration, and how to reuse the same design in another Laravel application.

The guide describes the code as it exists today. Recommendations that are not implemented are clearly marked as **Production hardening** so a developer can distinguish current behavior from future improvements.

## 1. Who should read this guide

This guide is written for:

- Laravel developers maintaining this project;
- backend developers integrating Nafath into another project;
- frontend developers consuming the REST API;
- DevOps engineers deploying the callback and Docker stack;
- security reviewers checking token, callback, and logging behavior;
- testers using the included Postman collection.

You do not need deep knowledge of OpenID Connect. The important terms are explained below.

## 2. What the implementation provides

The project supports two consumers and two Nafath scenarios using one shared local authentication core:

1. **Blade web login**
   - The browser starts a Nafath attempt.
   - The user opens the provider URL and authenticates at Nafath.
   - The original page polls until the callback completes.
   - Laravel creates a normal authenticated web session.

2. **REST API login**
   - An API client starts a Nafath attempt.
   - The client opens the returned provider URL in a browser or web view.
   - The client polls using a secret attempt token.
   - After success, it exchanges the attempt for a Bearer API token.

Provider scenarios:

- **Mobile MFA without redirect:** submit National ID, display the returned two-digit number, poll status, and log in after the user approves in the Nafath mobile app.
- **Browser OIDC redirect:** open a provider URL, receive a callback state, retrieve and verify a rich identity JWT.

The implementation also includes:

- local verification of the signed identity JWT;
- provider-side JWT validation;
- user creation or update by national ID;
- PostgreSQL persistence;
- external-call monitoring with a Blade dashboard;
- Docker services for PHP, Nginx, PostgreSQL, and pgAdmin;
- feature tests with fake Nafath responses;
- a Postman collection for the full REST flow.

Passwords are never collected or stored by this application. The user enters any Nafath credentials only on the Nafath-owned page.

## 3. Essential vocabulary

| Term | Simple meaning |
| --- | --- |
| Nafath | The external Saudi identity provider used to verify a person. |
| ELM | The service provider operating the documented Nafath integration endpoints. |
| OIDC | OpenID Connect, the identity protocol behind the web authentication flow. |
| `requestId` | A UUID generated by this application to identify one login attempt. |
| `state` | A short-lived value posted by ELM to the callback after authentication. It is exchanged for the JWT. |
| JWT | A signed token containing verified identity claims. |
| JWK | Public-key data used to verify the JWT signature locally. |
| Claim | One verified identity field inside the JWT, such as `nationalId` or `englishName`. |
| Login attempt | The local database record connecting the initiating browser/client to the asynchronous callback. |
| Attempt token | A random local secret proving that a caller is allowed to inspect or exchange a login attempt. |
| API token | The Bearer token used after authentication to call `/api/v1/auth/me` and other protected APIs. |

## 4. Architecture at a glance

```mermaid
flowchart LR
    Browser["Blade browser"]
    Mobile["API client or mobile app"]
    Nginx["Nginx"]
    Controllers["Web and API controllers"]
    AuthService["NafathAuthenticationService"]
    Client["NafathClient"]
    Resolver["NafathUserResolver"]
    Verifier["NafathJwtVerifier"]
    Nafath["ELM Nafath STG APIs"]
    DB[("PostgreSQL")]
    Monitor["ExternalCallMonitor"]

    Browser --> Nginx
    Mobile --> Nginx
    Nginx --> Controllers
    Controllers --> AuthService
    AuthService --> Client
    Client --> Nafath
    Client --> Monitor
    Monitor --> DB
    AuthService --> Verifier
    AuthService --> Resolver
    AuthService --> DB
    Resolver --> DB
    Verifier --> Client
```

The central design decision is that controllers do not implement Nafath protocol details. They validate HTTP input and delegate to `NafathAuthenticationService`. That service orchestrates the use case, while `NafathClient` owns all provider HTTP calls.

## 5. Container architecture

```mermaid
flowchart TB
    User["Browser or API client"] -->|"localhost:8000"| Nginx["nginx:1.27-alpine"]
    Nginx -->|"FastCGI port 9000"| App["Laravel 11 / PHP 8.3 FPM"]
    App -->|"PostgreSQL port 5432"| DB[("postgres:16-alpine")]
    Admin["Developer"] -->|"localhost:5050"| PgAdmin["pgAdmin 4"]
    PgAdmin --> DB
    App -->|"HTTPS"| Nafath["rabet-nafath.api.elm.sa"]
```

The application image contains the Laravel source and Composer dependencies. Nginx serves only the `public` directory and forwards PHP requests to the `app` service. PostgreSQL and pgAdmin data are stored in named Docker volumes.

## 6. Complete web authentication sequence

```mermaid
sequenceDiagram
    autonumber
    actor U as User
    participant B as Blade browser
    participant W as Web controller
    participant S as Authentication service
    participant N as Nafath API
    participant D as PostgreSQL
    participant C as Callback controller

    U->>B: Click Continue with Nafath
    B->>W: POST /auth/nafath/start
    W->>S: start(locale, endUserIp)
    S->>S: Generate requestId and attempt token
    S->>N: GET /stg/api/v2/oidc/session
    N-->>S: Signed authorization URL and correlation data
    S->>D: Insert pending login attempt
    S-->>W: Attempt, local token, provider URL
    W->>W: Store local token in Laravel session
    W-->>B: URL, status URL, expiry
    U->>N: Open URL and authenticate
    N->>C: POST /auth/nafath/callback with state
    C->>S: complete(state, callbackIp)
    S->>N: POST /stg/api/v2/oidc/jwt
    N-->>S: Signed identity JWT
    S->>N: POST /stg/api/v2/oidc/jwt/valid
    N-->>S: true
    S->>N: GET /stg/api/v1/mfa/jwk
    N-->>S: Public signing keys
    S->>S: Verify JWT signature and parse claims
    S->>D: Lock attempt, update user, mark authenticated
    C-->>N: JSON true
    loop Every 2.5 seconds
        B->>W: GET status
        W->>D: Read attempt
    end
    W->>W: Auth::login and regenerate session
    W-->>B: Redirect URL
    B->>W: GET /dashboard
```

Why polling is used: the callback arrives from ELM, not from the original browser tab. The application needs a small bridge between the server-to-server callback and the waiting browser. A database attempt plus polling is simple, works without WebSockets, and is easy for Laravel developers to maintain.

## 7. Complete REST authentication sequence

```mermaid
sequenceDiagram
    autonumber
    participant A as API client
    participant API as API controller
    participant S as Authentication service
    participant N as Nafath
    participant D as PostgreSQL

    A->>API: POST /api/v1/auth/nafath/start
    API->>S: start(locale, endUserIp)
    S->>N: Create OIDC session
    N-->>S: Authorization URL
    S->>D: Store hash of attempt token
    API-->>A: attempt_id, plain attempt_token, URL
    A->>N: Open URL for the user
    N->>API: Callback is processed by shared web callback
    loop Until authenticated
        A->>API: GET status with X-Nafath-Attempt-Token
        API-->>A: pending or authenticated
    end
    A->>API: POST token exchange
    API->>D: Verify attempt token hash and status
    API->>D: Store hash of new API token
    API-->>A: Plain Bearer token, returned once
    A->>API: GET /me with Authorization: Bearer
    API-->>A: Verified user profile
```

The API never places the attempt token in the URL. It is returned in the start response, sent in a header for polling, and sent in a JSON body for token exchange. This reduces accidental leakage through URL history, proxy logs, and analytics tools.

## 8. Login-attempt state machine

```mermaid
stateDiagram-v2
    [*] --> pending: Session created
    pending --> processing: Valid callback begins
    processing --> authenticated: JWT verified and user saved
    pending --> expired: Local attempt TTL elapsed
    pending --> failed: Optional failure handling
    processing --> failed: Optional failure handling
    authenticated --> authenticated: Duplicate valid callback is idempotent
```

Current status constants live in `NafathLoginAttempt`:

- `pending`: waiting for ELM callback;
- `processing`: callback is inside the database transaction;
- `authenticated`: verified user is attached;
- `failed`: provider rejection or a verified callback identity that does not match the requested MFA National ID;
- `expired`: a pending attempt was observed after its local TTL.

The transaction uses `lockForUpdate()` so two callbacks cannot update the same attempt at the same time. If an already authenticated attempt is processed again, the service returns it without creating another user or changing the successful result.

## 9. Database model

```mermaid
erDiagram
    USERS ||--o{ NAFATH_LOGIN_ATTEMPTS : "completes"
    USERS ||--o{ API_TOKENS : "owns"

    USERS {
        bigint id PK
        string national_id UK
        string name
        string arabic_name
        string english_name
        string preferred_locale
        string nationality
        string gender
        string assurance_level
        json nafath_claims
        timestamp last_login_at
    }

    NAFATH_LOGIN_ATTEMPTS {
        uuid id PK
        string flow
        string attempt_token_hash
        string hashed_state
        string nafath_session_id
        text mfa_national_id_encrypted
        uuid mfa_trans_id
        string mfa_random
        string status
        string provider_status
        bigint user_id FK
        text error
        timestamp expires_at
        timestamp authenticated_at
        timestamp api_token_issued_at
    }

    API_TOKENS {
        bigint id PK
        bigint user_id FK
        string name
        string token_hash UK
        timestamp last_used_at
        timestamp expires_at
    }

    EXTERNAL_API_LOGS {
        bigint id PK
        string service
        uuid request_id
        string method
        string endpoint
        smallint status_code
        int duration_ms
        boolean successful
        json request_payload
        json response_payload
        text error
        timestamp created_at
    }
```

### Why `national_id` is unique

Nafath identifies the person using a verified national ID or Iqama claim. `updateOrCreate(['national_id' => ...])` makes repeated logins update the same local account. The unique database constraint protects this rule even under concurrent requests.

### Why email and password are nullable

Nafath login does not require a local password or email address. Keeping those columns nullable allows the standard Laravel `users` table to support identity-provider-only accounts. A project may still add email collection or local password login later.

### Why raw claims are retained

`nafath_claims` stores the complete verified claim set for debugging and future mapping. This is convenient, but identity claims are sensitive personal data.

**Production hardening:** define a retention policy, encrypt sensitive columns where appropriate, restrict who can view raw claims, and store only claims the business actually needs.

## 10. Project file map

```text
app/
  Exceptions/NafathException.php
  Http/
    Controllers/
      Api/NafathAuthController.php
      Api/NafathMfaAuthController.php
      Web/NafathAuthController.php
      Web/NafathMfaAuthController.php
      NafathCallbackController.php
      MonitoringController.php
    Middleware/AuthenticateApiToken.php
  Models/
    User.php
    NafathLoginAttempt.php
    ApiToken.php
    ExternalApiLog.php
  Services/
    Nafath/
      NafathClient.php
      NafathAuthenticationService.php
      NafathMfaAuthenticationService.php
      NafathJwtVerifier.php
      NafathUserResolver.php
    Monitoring/ExternalCallMonitor.php
  Support/ClientIp.php
config/services.php
database/migrations/2026_07_18_*.php
resources/views/
routes/web.php
routes/api.php
postman/Nafath-Authentication.postman_collection.json
compose.yaml
Dockerfile
```

## 11. Design patterns used

### 11.1 Thin Controller pattern

Controllers validate HTTP input, translate exceptions into HTTP responses, and choose whether the caller receives a Blade view or JSON. They do not know how to call Nafath or verify JWTs.

Why: protocol logic stays testable and reusable. The web and API controllers can both call the same authentication service.

### 11.2 Application Service pattern

`NafathAuthenticationService` implements the two main use cases: `start()` and `complete()`.

Why: a login flow crosses HTTP calls, database writes, token generation, JWT verification, and user mapping. Keeping that orchestration in one service makes the business sequence visible without placing everything in a controller or model.

### 11.3 Gateway or API Client pattern

`NafathClient` is the only class that knows provider endpoints, headers, timeouts, and response shapes.

Why: if ELM changes an endpoint or header, one class changes. Tests can fake Laravel HTTP calls without contacting the provider.

### 11.4 Mapper or Resolver pattern

`NafathUserResolver` translates provider claims into the local `users` schema.

Why: external identity fields should not leak throughout the application. When a claim name changes or the project wants a different display-name rule, the mapping has one home.

### 11.5 Middleware pattern

`AuthenticateApiToken` resolves a Bearer token into a Laravel user before protected controller actions execute.

Why: authentication is a cross-cutting HTTP concern. Controllers can use `$request->user()` without repeating token lookup code.

### 11.6 Dependency Injection

Laravel automatically injects services into constructors and controller methods.

Why: dependencies are explicit, classes remain small, and tests can replace `NafathJwtVerifier` with a mock.

### 11.7 Correlation record pattern

`nafath_login_attempts` connects three asynchronous actors: the initiating client, ELM's callback, and the status poller.

Why: neither the browser nor callback can safely rely on in-memory PHP state. PostgreSQL provides durable cross-request correlation.

### 11.8 Hash-at-rest pattern

Attempt tokens and API tokens are stored as SHA-256 hashes. Plain tokens are returned only when created.

Why: a database leak should not immediately reveal usable Bearer tokens. Token verification hashes the presented secret and performs an exact lookup or constant-time comparison.

## 12. Class-by-class explanation

### `NafathClient`

File: [`app/Services/Nafath/NafathClient.php`](../app/Services/Nafath/NafathClient.php)

Responsibilities:

- build the common HTTP client;
- attach `APP-ID`, `APP-KEY`, and `X-Forwarded-For` headers;
- call the four documented Nafath endpoints;
- validate minimum response shapes;
- apply connection and total timeouts;
- record every external call;
- convert transport/provider failures into `NafathException`.

Endpoint methods:

| Method | Provider call | Result |
| --- | --- | --- |
| `createSession()` | `GET api/v2/oidc/session` | Authorization URL and provider correlation values. |
| `retrieveJwt()` | `POST api/v2/oidc/jwt` | Signed identity JWT for callback state. |
| `validateJwt()` | `POST api/v2/oidc/jwt/valid` | Provider validation boolean. |
| `getJwk()` | `GET api/v1/mfa/jwk` | Public signing-key set. |

`request()` is deliberately private. All calls pass through the same monitoring and exception behavior.

### `NafathAuthenticationService`

File: [`app/Services/Nafath/NafathAuthenticationService.php`](../app/Services/Nafath/NafathAuthenticationService.php)

`start()`:

1. generates a UUID `requestId`;
2. generates a 64-character local attempt token;
3. asks Nafath for a signed authorization URL;
4. checks that a returned `requestId` matches;
5. stores only the SHA-256 hash of the local token;
6. saves provider correlation fields and local expiry;
7. returns the plain token to the controller once.

`complete()`:

1. exchanges callback `state` for a JWT;
2. asks Nafath to validate that JWT;
3. retrieves JWK public keys;
4. verifies the JWT signature locally;
5. finds the original local attempt;
6. locks it inside a database transaction;
7. creates or updates the user;
8. marks the attempt authenticated.

The order matters. No user record is trusted until both provider validation and local signature verification succeed.

### `NafathJwtVerifier`

File: [`app/Services/Nafath/NafathJwtVerifier.php`](../app/Services/Nafath/NafathJwtVerifier.php)

This service uses `firebase/php-jwt`:

```php
$keys = JWK::parseKeySet($jwk);
$decoded = JWT::decode($token, $keys);
```

It converts the decoded object to a plain associative array for predictable mapping and JSON storage. Any parsing, key, algorithm, or signature error becomes a domain-specific `NafathException`.

### `NafathUserResolver`

File: [`app/Services/Nafath/NafathUserResolver.php`](../app/Services/Nafath/NafathUserResolver.php)

The resolver accepts both simple claim keys such as `nationalId` and URI-style keys ending in `/nationalId`. This makes the mapper tolerant of the two common claim naming styles in identity systems.

The local identity key is `national_id`. The display name preference is:

1. English name;
2. Arabic name;
3. a safe fallback containing the national ID.

### `ClientIp`

File: [`app/Support/ClientIp.php`](../app/Support/ClientIp.php)

It uses the request IP only when it is public. For Docker, localhost, and private proxy addresses it falls back to `NAFATH_CLIENT_PUBLIC_IP`.

Why: ELM expects `X-Forwarded-For` in this form:

```text
end-user public IP, registered client public IP
```

**Production hardening:** configure Laravel trusted proxies correctly. Otherwise `$request->ip()` may be the load balancer's internal address rather than the real end-user IP.

### `NafathException`

File: [`app/Exceptions/NafathException.php`](../app/Exceptions/NafathException.php)

A dedicated exception separates expected integration failures from unrelated programming errors. Controllers can return a safe public message while `report($exception)` keeps internal details in server logs.

### Web `NafathAuthController`

File: [`app/Http/Controllers/Web/NafathAuthController.php`](../app/Http/Controllers/Web/NafathAuthController.php)

| Action | Purpose |
| --- | --- |
| `login()` | Displays the Blade page or redirects an already authenticated user. |
| `start()` | Validates locale, starts the shared flow, and stores the attempt token in the Laravel session. |
| `status()` | Proves ownership using the session-held attempt token, checks status, and logs in the user. |
| `dashboard()` | Displays verified identity fields. |
| `logout()` | Logs out, invalidates the session, and regenerates the CSRF token. |

The browser never receives its attempt token in visible HTML or a URL. The controller stores it under `nafath_attempts.{uuid}` in the server-side Laravel session.

When authentication finishes, the controller calls `Auth::login()`, regenerates the session ID to prevent session fixation, and removes the used attempt secret from the session.

### API `NafathAuthController`

File: [`app/Http/Controllers/Api/NafathAuthController.php`](../app/Http/Controllers/Api/NafathAuthController.php)

The REST controller exposes the same asynchronous flow without depending on cookies:

- `start()` returns the attempt ID and plain attempt token;
- `status()` expects `X-Nafath-Attempt-Token`;
- `token()` exchanges an authenticated attempt for a Bearer token;
- `me()` returns normalized user data;
- `logout()` deletes the current API token only.

Before issuing a token, the controller deletes an existing token with the same `device_name`. This gives one current token per user/device label, such as `iPhone` or `Postman`.

### `NafathCallbackController`

File: [`app/Http/Controllers/NafathCallbackController.php`](../app/Http/Controllers/NafathCallbackController.php)

This invokable controller has one responsibility: accept a `state` string and complete the shared authentication service. On success it returns JSON boolean `true`, matching the provider contract.

The route is intentionally exempt from Laravel CSRF validation because ELM is an external server and cannot possess the user's Laravel CSRF cookie/token. Safety comes from the signed provider state, server-to-server JWT exchange, JWT validation, local signature verification, and attempt correlation.

### `AuthenticateApiToken`

File: [`app/Http/Middleware/AuthenticateApiToken.php`](../app/Http/Middleware/AuthenticateApiToken.php)

The middleware:

1. reads the Bearer token;
2. hashes it with SHA-256;
3. loads the matching non-expired database record and its user;
4. sets Laravel's user resolver;
5. attaches the token model to request attributes for logout;
6. updates `last_used_at` no more than once every five minutes.

The five-minute write throttle avoids updating the database on every authenticated API request.

### `ExternalCallMonitor`

File: [`app/Services/Monitoring/ExternalCallMonitor.php`](../app/Services/Monitoring/ExternalCallMonitor.php)

Each provider request records method, endpoint, correlation ID, status, duration, success, redacted payloads, and errors. It writes to both PostgreSQL and the `nafath` daily log channel.

Database logging failures are swallowed after a warning is written. Monitoring should never cause a successful identity request to fail.

Exact array keys including `token`, `id_token`, `state`, app keys, and national IDs are replaced with `[REDACTED]`. The redactor also sanitizes sensitive query parameters embedded inside returned URL strings, including authorization URLs containing `state`.

### Eloquent models

| Model | Main role |
| --- | --- |
| [`User`](../app/Models/User.php) | Local authenticated identity and verified claims. |
| [`NafathLoginAttempt`](../app/Models/NafathLoginAttempt.php) | Asynchronous login correlation and status. |
| [`ApiToken`](../app/Models/ApiToken.php) | Hashed 30-day Bearer tokens. |
| [`ExternalApiLog`](../app/Models/ExternalApiLog.php) | Structured provider-call monitoring records. |

`NafathLoginAttempt::hasValidToken()` uses `hash_equals()` for constant-time comparison. `ApiToken::issue()` returns `[model, plainToken]`, allowing the controller to send the secret once while retaining only its hash.

## 13. Configuration reference

Nafath configuration is mapped in [`config/services.php`](../config/services.php):

```dotenv
NAFATH_BASE_URL=https://rabet-nafath.api.elm.sa/stg/
# Optional; use the exact MFA base URL confirmed by ELM for this subscription.
# NAFATH_MFA_BASE_URL=https://provider-confirmed-mfa-base.example/
NAFATH_APP_ID=your-app-id
NAFATH_APP_KEY=your-app-key
NAFATH_CLIENT_PUBLIC_IP=your-registered-egress-ip
NAFATH_SERVICE=Login
NAFATH_TIMEOUT=30
NAFATH_ATTEMPT_TTL_MINUTES=10
NAFATH_LOG_LEVEL=info
```

| Variable | Meaning | Important notes |
| --- | --- | --- |
| `NAFATH_BASE_URL` | Prefix used for OIDC provider endpoints. | The supplied STG OpenAPI places OIDC under `/stg/api/v2`. Using `/nafath-sandbox/` for session creation produced authorization URLs rejected with “Authentication parameters missing.” |
| `NAFATH_MFA_BASE_URL` | Optional separate prefix for Nafath App MFA and JWK endpoints. | Defaults to `NAFATH_BASE_URL`. The supplied OpenAPI lists MFA under root `/api/v1`, so use the exact sandbox or production base URL provisioned by ELM rather than guessing. |
| `NAFATH_APP_ID` | Consumer application identifier issued by ELM. | Secret-like configuration; do not commit real values. |
| `NAFATH_APP_KEY` | Consumer application key issued by ELM. | A credential; never log or expose it to frontend code. |
| `NAFATH_CLIENT_PUBLIC_IP` | Public outbound IPv4 registered with ELM. | Do not use `127.0.0.1`, Docker `172.x`, or LAN `192.168.x`. |
| `NAFATH_SERVICE` | Service value sent when creating mobile MFA requests. | Defaults to `Login`; use the exact value provisioned by ELM. |
| `NAFATH_TIMEOUT` | Total external HTTP timeout in seconds. | Connection timeout is separately fixed at 10 seconds. |
| `NAFATH_ATTEMPT_TTL_MINUTES` | Local pending-attempt lifetime. | This is a local application TTL, not a provider URL guarantee. |
| `NAFATH_LOG_LEVEL` | Minimum severity for the daily Nafath log. | Usually `info` in staging and `warning` or `error` in high-volume production. |

### Finding the client public IP

Run the check from the same server or container network that will call ELM:

```bash
curl https://api.ipify.org
```

For Docker, checking from inside the application container is more accurate:

```bash
docker compose exec app php -r "echo file_get_contents('https://api.ipify.org');"
```

Register that stable egress IP with ELM. If production traffic exits through a NAT gateway, firewall, proxy, or cloud egress service, use that component's public IP.

### Why the returned authorization URL can still contain `nafath-sandbox`

The application calls the valid STG API at `/stg/api/v2/oidc/session`. ELM generates the final browser URL and may use its own internal `/nafath-sandbox/authservice/authorize` route. Do not rewrite the returned URL. The environment of the session is determined by the API used to create it and the signed state, not by manually changing the browser URL.

## 14. Provider headers

Every external request uses:

```http
APP-ID: <configured application ID>
APP-KEY: <configured application key>
X-Forwarded-For: <end-user public IP>, <registered client public IP>
Accept: application/json
Content-Type: application/json
```

The two IP values have different meanings:

- the first represents the human user's public address when available;
- the second represents the registered public egress address of this application.

In local development both may be the same public NAT address. The comma-and-space formatting follows the supplied documentation.

## 15. Route reference

### Web routes

Defined in [`routes/web.php`](../routes/web.php):

| Method | Route | Middleware | Purpose |
| --- | --- | --- | --- |
| `GET` | `/` | web | Redirect to login. |
| `GET` | `/login` | web | Render Blade login page. |
| `POST` | `/auth/nafath/start` | web, throttle 10/min | Start a browser attempt. |
| `GET` | `/auth/nafath/status/{attempt}` | web, throttle 120/min | Poll the browser attempt. |
| `POST` | `/auth/nafath/mfa/start` | web, throttle 10/min | Start mobile approval with National ID. |
| `GET` | `/auth/nafath/mfa/status/{attempt}` | web, throttle 60/min | Poll Nafath and log in after approval. |
| `POST` | `/auth/nafath/callback` | web, CSRF-exempt, throttle 60/min | Receive ELM callback state. |
| `GET` | `/dashboard` | `auth` | Display verified identity. |
| `POST` | `/logout` | `auth` | End Laravel session. |
| `GET` | `/monitoring/external-calls` | Public, throttle 120/min | Inspect redacted provider calls during integration testing. |

### REST routes

Defined in [`routes/api.php`](../routes/api.php). Laravel automatically prefixes this file with `/api`.

| Method | Route | Authentication | Purpose |
| --- | --- | --- | --- |
| `GET` | `/api/v1/health` | Public | Container and application health. |
| `POST` | `/api/v1/auth/nafath/start` | Public, throttled | Start API attempt. |
| `GET` | `/api/v1/auth/nafath/status/{attempt}` | Attempt token header | Poll attempt. |
| `POST` | `/api/v1/auth/nafath/mfa/start` | Public, throttled | Start non-redirect mobile approval. |
| `GET` | `/api/v1/auth/nafath/mfa/status/{attempt}` | Attempt token header | Poll approval and return the user on completion. |
| `POST` | `/api/v1/auth/nafath/token/{attempt}` | Attempt token body | Issue API token after callback. |
| `GET` | `/api/v1/auth/me` | Bearer token | Read current user. |
| `POST` | `/api/v1/auth/logout` | Bearer token | Revoke current token. |

## 16. REST API examples

Use `Accept: application/json` on every API request.

### 16.1 Start authentication

```http
POST /api/v1/auth/nafath/start
Content-Type: application/json

{
  "locale": "ar"
}
```

Success: HTTP `201 Created`

```json
{
  "data": {
    "attempt_id": "3a18bde6-07e4-49bb-af8e-c49d60a6783d",
    "attempt_token": "plain-secret-returned-once",
    "authorization_url": "https://rabet-nafath.api.elm.sa/...",
    "expires_at": "2026-07-18T19:10:00+03:00"
  }
}
```

The client must securely retain both `attempt_id` and `attempt_token` until the flow completes.

### 16.2 Poll status

```http
GET /api/v1/auth/nafath/status/3a18bde6-07e4-49bb-af8e-c49d60a6783d
X-Nafath-Attempt-Token: plain-secret-returned-once
```

Pending response:

```json
{
  "data": {
    "attempt_id": "3a18bde6-07e4-49bb-af8e-c49d60a6783d",
    "status": "pending",
    "message": null,
    "token_ready": false
  }
}
```

Authenticated response changes `status` to `authenticated` and `token_ready` to `true`.

### 16.3 Exchange for Bearer token

```http
POST /api/v1/auth/nafath/token/3a18bde6-07e4-49bb-af8e-c49d60a6783d
Content-Type: application/json

{
  "attempt_token": "plain-secret-returned-once",
  "device_name": "iPhone 15"
}
```

Success:

```json
{
  "data": {
    "token": "plain-api-token-returned-once",
    "token_type": "Bearer",
    "expires_at": "2026-08-17T19:00:00+03:00",
    "user": {
      "id": 1,
      "national_id": "...",
      "name": "Verified User",
      "arabic_name": "...",
      "english_name": "Verified User",
      "preferred_locale": "ar",
      "nationality": "Saudi Arabia",
      "gender": "Male",
      "assurance_level": "HIGH"
    }
  }
}
```

### 16.4 Current user

```http
GET /api/v1/auth/me
Authorization: Bearer plain-api-token-returned-once
```

### 16.5 Logout

```http
POST /api/v1/auth/logout
Authorization: Bearer plain-api-token-returned-once
```

This deletes only the token used for that request. Other device tokens remain active.

## 17. HTTP status and error behavior

| Status | When it occurs |
| --- | --- |
| `200` | Successful status, token exchange, profile, logout, or callback. |
| `201` | API login attempt created. |
| `401` | Missing, unknown, or expired Bearer API token. |
| `404` | Attempt ID/token combination is invalid. A generic not-found response avoids revealing valid attempt IDs. |
| `409` | Attempt exists but Nafath authentication is not complete. |
| `422` | Laravel input validation failed. |
| `429` | Route throttle exceeded. |
| `502` | Provider connection, response, JWT, or callback processing failed. |

Public error messages are intentionally generic. Detailed causes should be read from application logs and monitoring records, not returned to an unauthenticated caller.

## 18. Blade frontend behavior

### Shared layout

File: [`resources/views/layouts/app.blade.php`](../resources/views/layouts/app.blade.php)

The layout provides:

- responsive page width and navigation;
- CSS variables for consistent colors;
- reusable card, button, alert, and form styles;
- an inline SVG brand mark;
- the CSRF token meta tag used by JavaScript;
- authenticated links to the account and monitoring pages;
- no frontend build system or JavaScript framework.

Keeping the CSS inline is reasonable for this small integration example. In a larger product, move design tokens and components into versioned CSS assets.

### Login page

File: [`resources/views/auth/login.blade.php`](../resources/views/auth/login.blade.php)

The vanilla JavaScript follows this small state flow:

```mermaid
flowchart TD
    Idle["Idle"] -->|"Click"| Starting["Starting session"]
    Starting -->|"201 or 200"| Waiting["Show provider link and poll"]
    Starting -->|"Error"| Error["Show safe error"]
    Waiting -->|"pending"| Waiting
    Waiting -->|"authenticated"| Redirect["Redirect to dashboard"]
    Waiting -->|"failed or expired"| Error
```

Important implementation details:

- the start request includes the Laravel CSRF token;
- the provider link uses `target="_blank"` and `rel="noopener noreferrer"`;
- status polling runs every 2.5 seconds;
- polling stops on success, failure, expiry, or a network error;
- the button is disabled while the start request is active;
- messages use `aria-live` and `role="alert"` for accessibility;
- no Nafath credential is requested by the page.

### Dashboard

File: [`resources/views/dashboard.blade.php`](../resources/views/dashboard.blade.php)

The dashboard displays normalized identity fields and exposes the full verified claim JSON inside a collapsed `<details>` element.

**Production hardening:** only show claims the signed-in user is allowed to see, and consider removing the raw-claims block entirely for normal users.

### Monitoring page

File: [`resources/views/monitoring/index.blade.php`](../resources/views/monitoring/index.blade.php)

The page provides success/failure filters, pagination, durations, request IDs, errors, and expandable redacted request/response payloads. It is intentionally simple and uses Eloquent pagination rather than a monitoring platform. The route is public in this integration-testing project, so developers can inspect a failed Nafath exchange before any user can sign in.

**Production hardening:** do not expose this diagnostics page publicly in production. Add authentication plus an administrator policy, an IP allow-list, or disable the route before internet deployment. Sensitive identity and credential fields stay redacted even in development.

## 19. Monitoring and observability

### Data path

```mermaid
flowchart LR
    Client["NafathClient request"] --> Timer["Measure duration"]
    Timer --> Record["ExternalCallMonitor.record"]
    Record --> Redact["Redact sensitive array keys"]
    Redact --> DB[("external_api_logs")]
    Redact --> File["storage/logs/nafath-YYYY-MM-DD.log"]
    DB --> Page["Public development monitoring page"]
```

Recorded fields:

- service name;
- local request ID when available;
- method and relative endpoint;
- response status or connection failure;
- duration in milliseconds;
- success boolean;
- redacted request and response payloads;
- exception message;
- creation timestamp.

The file channel retains 14 daily files by default. The database currently has no automatic retention job.

**Production hardening:** schedule pruning, for example:

```php
ExternalApiLog::where('created_at', '<', now()->subDays(30))->delete();
```

Run pruning through Laravel's scheduler, not during a user request.

### What monitoring does not replace

This lightweight monitor is useful for development and support but is not a complete production observability system. It does not provide distributed tracing, alerts, dashboards across servers, or tamper-resistant audit logs. A production platform can forward structured logs to tools such as OpenSearch, Grafana Loki, Datadog, or an approved SIEM.

## 20. Security model

### Trust boundaries

```mermaid
flowchart LR
    UntrustedClient["Untrusted browser or API caller"]
    Laravel["Laravel validation and attempt-token checks"]
    Provider["Trusted ELM endpoints over HTTPS"]
    JWT["Provider validation plus local signature verification"]
    User["Trusted local identity"]

    UntrustedClient --> Laravel
    Laravel --> Provider
    Provider --> JWT
    JWT --> User
```

The application trusts identity data only after all of these checks:

1. callback contains a string `state`;
2. ELM exchanges that state for a token;
3. ELM's validation endpoint accepts the token;
4. the token signature verifies against ELM's JWK;
5. the signed token `requestId` or OIDC state correlates to a pending local attempt;
6. the attempt has not expired at callback completion;
7. the JWT contains a usable national ID;
8. for mobile MFA, the signed JWT National ID exactly matches the encrypted National ID submitted when the attempt started.

### Token storage

| Secret | Plain value location | Database value |
| --- | --- | --- |
| Web attempt token | Laravel session | SHA-256 hash |
| API attempt token | API client secure storage | SHA-256 hash |
| Bearer API token | API client secure storage | SHA-256 hash |
| App key | Server `.env` only | Not stored in application tables |
| Callback state | Sent to ELM APIs during callback | OIDC attempts retain only provider `hashedState`; MFA attempts correlate using the signed JWT `requestId`. The plain callback state is not persisted and monitoring redacts it. |

### CSRF decision

Normal web POST routes retain Laravel CSRF protection. Only `/auth/nafath/callback` is exempt because it is called by an external provider. Do not exempt the start or logout routes.

### Rate limiting

- start: 10 requests per minute;
- status: 120 requests per minute;
- callback: 60 requests per minute;
- API token exchange: 20 requests per minute.

These limits reduce abuse while allowing 2.5-second browser polling. Tune them for the real proxy topology and user volume.

### Session fixation protection

After a successful web login, the Laravel session is regenerated. On logout the session is invalidated and the CSRF token is regenerated.

### Important current limitations

The following are current behaviors, not hidden guarantees:

1. An authenticated attempt token is not consumed after API token exchange. A caller holding it can request another token later, subject to status and route limits.
2. The token exchange action does not currently reject an authenticated attempt merely because its `expires_at` timestamp has passed.
3. `api_token_issued_at` is audit information; it is not enforced as a one-time rule.
4. API tokens use a hard-coded 30-day lifetime.
5. Raw verified claims are retained and visible on the dashboard.
6. The monitoring dashboard is deliberately public for integration testing.

**Production hardening options:** consume attempt tokens after first exchange, add an exchange deadline, make token TTL configurable, add token scopes, protect monitoring with authentication and administrator authorization, encrypt or minimize claims, and add scheduled pruning.

## 21. Docker setup explained

### `compose.yaml`

File: [`compose.yaml`](../compose.yaml)

Services:

| Service | Why it exists |
| --- | --- |
| `app` | Runs PHP 8.3 FPM and Laravel. Waits for healthy PostgreSQL. |
| `nginx` | Public web server on `${APP_PORT:-8000}`. Serves `public/` and forwards PHP to `app:9000`. |
| `db` | PostgreSQL 16 database with a persistent named volume. |
| `pgadmin` | Browser database administration on `${PGADMIN_PORT:-5050}`. |

### `Dockerfile`

File: [`Dockerfile`](../Dockerfile)

It uses a multi-stage build:

1. Composer image installs dependencies and optimizes autoloading.
2. PHP FPM Alpine image installs PostgreSQL extensions.
3. Application files are copied with `www-data` ownership.
4. writable Laravel directories are created.
5. the process runs as non-root `www-data`.

`INSTALL_DEV=true` includes development packages such as PHPUnit in a disposable test image. Production builds omit development dependencies.

### Entrypoint

File: [`docker/php/entrypoint.sh`](../docker/php/entrypoint.sh)

On every container start it:

1. refuses to start when `APP_KEY` is empty;
2. clears Laravel configuration cache;
3. runs idempotent production migrations;
4. starts the requested command, normally PHP FPM.

### Nginx

File: [`docker/nginx/default.conf`](../docker/nginx/default.conf)

Nginx applies Laravel's standard front-controller pattern: real public files are served directly and all other routes go to `public/index.php`. Dotfiles are denied and the PHP version header is hidden.

## 22. Starting and operating the stack

### Initial start

```bash
docker compose up --build -d
```

### Rebuild after code or `.env` changes

Because the Laravel source is copied into the application image rather than mounted as a development volume:

```bash
docker compose up -d --build --force-recreate app nginx
```

### Useful addresses

| Service | Address |
| --- | --- |
| Login | `http://localhost:8000/login` |
| API health | `http://localhost:8000/api/v1/health` |
| API base | `http://localhost:8000/api/v1` |
| pgAdmin | `http://localhost:5050` |
| PostgreSQL host port | `localhost:5432` |

### Container logs

```bash
docker compose logs -f app nginx
```

### Laravel route inspection

```bash
docker compose exec app php artisan route:list
```

### Database migrations

The entrypoint runs migrations automatically. To inspect status:

```bash
docker compose exec app php artisan migrate:status
```

## 23. Callback deployment requirements

ELM must be able to reach:

```text
https://your-public-domain.example/auth/nafath/callback
```

Requirements from the supplied integration context include:

- public HTTPS;
- provider-side callback registration;
- hosting/location requirements agreed with ELM, including Saudi hosting where required;
- correct DNS and a valid TLS certificate;
- firewall access from the provider;
- no interactive login, Basic Auth, CAPTCHA, or CSRF challenge on the callback;
- the application must preserve POST bodies through Nginx and any upstream proxy.

Localhost cannot receive an internet callback. A local developer can test session creation and unit/feature logic, but full end-to-end authentication needs an approved public staging domain or an approved secure tunnel when permitted by ELM policy.

## 24. Testing strategy

### Feature tests

File: [`tests/Feature/NafathAuthenticationTest.php`](../tests/Feature/NafathAuthenticationTest.php)

The tests cover:

1. the login page renders;
2. starting web login creates a correlated attempt;
3. headers and session endpoint are sent correctly;
4. external-call monitoring persists the call;
5. callback creates/updates the user and authenticates the attempt;
6. a completed attempt issues an API token;
7. the token authenticates `/me` and can be revoked.

`Http::fake()` prevents provider network calls. The JWT verifier is mocked in the callback test because signature verification belongs to the verifier library and would require a real signed fixture/key pair.

### Why fake external calls

- tests are deterministic;
- no real identity transaction is created;
- provider downtime does not break CI;
- app credentials are not required in CI;
- edge cases can be created cheaply.

### Run tests

```bash
docker build --build-arg INSTALL_DEV=true -t laravel-api-test .
docker run --rm --entrypoint php \
  -e APP_ENV=testing \
  -e APP_KEY=base64:khKlThaPgddOLAxireqRPaoL/oEswdylttqxMnsjO8Q= \
  -e CACHE_STORE=array \
  -e SESSION_DRIVER=array \
  laravel-api-test vendor/bin/phpunit
```

Current verified result: 11 tests and 72 assertions pass, including public monitoring, URL-secret redaction, mobile MFA approval, OIDC callback/JWT completion, National ID mismatch rejection, rich user creation, and returned user data.

### Recommended additional tests

- invalid attempt token returns 404;
- pending token exchange returns 409;
- expired Bearer token returns 401;
- missing national ID rejects callback;
- provider validation returns false;
- local signature verification fails;
- duplicate callback is idempotent;
- state correlation fallback matches each supported hash form;
- monitoring redacts nested sensitive fields;
- rate limits behave correctly;
- transaction concurrency does not create duplicate users.

## 25. Postman collection

File: [`postman/Nafath-Authentication.postman_collection.json`](../postman/Nafath-Authentication.postman_collection.json)

Run requests in order:

1. **Health** - verifies the local API is reachable.
2. **Start Nafath Login** - saves `attempt_id`, `attempt_token`, and `authorization_url` as collection variables.
3. Open the saved authorization URL in a browser and complete Nafath authentication.
4. **Check Login Status** - rerun until status is `authenticated`.
5. **Exchange Completed Attempt for API Token** - saves `access_token`.
6. **Current User** - verifies Bearer authentication.
7. **Logout** - deletes the current token and clears the Postman variable.

The collection uses scripts to carry values between requests so a tester does not manually copy secrets into every request.

Do not export a Postman environment containing real access tokens or credentials into source control.

## 26. How to reuse this integration in another Laravel project

This section is a practical migration order. Following the order reduces broken dependencies.

### Step 1: install the JWT library

```bash
composer require firebase/php-jwt
```

### Step 2: add configuration

Copy the `nafath` block from [`config/services.php`](../config/services.php) and add the documented `.env` variables. Do not call `env()` directly from services or controllers; use `config()` so Laravel configuration caching continues to work.

### Step 3: add database migrations

Copy or adapt:

- Nafath fields on users;
- `nafath_login_attempts`;
- `api_tokens` if not using Laravel Sanctum;
- `external_api_logs` if keeping the included monitor.

If the destination project already has identity fields, map into those instead of duplicating columns.

### Step 4: copy models

Add relationships and casts to the destination `User`. Copy `NafathLoginAttempt`, and choose one API-token strategy:

- copy the custom `ApiToken` model and middleware; or
- use Laravel Sanctum and replace the custom token issuance/middleware.

Sanctum is a good choice when the destination already uses it. The custom model is intentionally small and readable for this standalone project.

### Step 5: copy provider services

Copy:

- `NafathClient`;
- `NafathAuthenticationService`;
- `NafathJwtVerifier`;
- `NafathUserResolver`;
- `NafathException`;
- `ClientIp`.

Keep namespaces consistent with the destination project. Laravel's service container can resolve these concrete classes automatically; no custom provider binding is required.

### Step 6: customize claim mapping

Edit only `NafathUserResolver` for application-specific mapping. Examples:

```php
'date_of_birth' => $this->stringClaim($claims, 'dob'),
'family_name' => $this->stringClaim($claims, 'familyName'),
```

Add matching fillable fields, casts, and migrations. Never trust unmapped frontend data in place of verified JWT claims.

### Step 7: copy controllers and routes

Choose the surfaces needed by the new application:

- Blade-only: web controller, callback, web routes, and views;
- API-only: API controller, callback, API routes, token middleware;
- both: copy the shared structure used here.

Keep the callback route stable after ELM registers it. Changing a Laravel route without updating provider registration breaks the flow.

### Step 8: configure callback CSRF exception

In Laravel 11, add only the callback path to `validateCsrfTokens(except: [...])` in `bootstrap/app.php`. Do not disable CSRF globally.

### Step 9: add monitoring

Copy `ExternalCallMonitor`, its model/migration, the log channel, and optionally the Blade page. If the destination already uses centralized logging, adapt `record()` to emit structured logs there.

### Step 10: copy tests before customization

The feature tests provide a safety net while changing routes, schemas, or claims. Update fake URLs and expected fields, then keep all provider calls faked in normal CI.

### Reuse dependency diagram

```mermaid
flowchart TD
    Config["Config and environment"] --> Client["NafathClient"]
    Monitor["Monitoring implementation"] --> Client
    Client --> Service["NafathAuthenticationService"]
    Verifier["NafathJwtVerifier"] --> Service
    Resolver["NafathUserResolver"] --> Service
    Models["Attempt and User models"] --> Service
    Service --> Web["Optional web controller"]
    Service --> API["Optional API controller"]
    Web --> Callback["Shared callback"]
    API --> Callback
```

## 27. Common customization recipes

### Use Laravel Sanctum instead of custom API tokens

1. Install/configure Sanctum.
2. Add `HasApiTokens` to `User`.
3. Replace `ApiToken::issue()` with `$user->createToken($deviceName)`.
4. Replace `AuthenticateApiToken` with `auth:sanctum`.
5. Revoke the current Sanctum token on logout.
6. Remove the custom `api_tokens` migration/model after migrating existing tokens.

The Nafath session, callback, JWT, and user-resolution layers do not need to change.

### Add roles or permissions

Nafath verifies identity, not application authorization. After resolving the user, assign or preserve application roles separately. Do not infer administrator access from a name, nationality, or assurance claim unless the business and security design explicitly requires it.

### Support multiple frontend applications

Keep one callback and attempt table. Add a validated `client_type` or `return_url_key` to the attempt, then map that key to server-configured destinations. Never accept an arbitrary return URL without an allowlist, or the application can become an open redirect.

### Cache JWKs

JWKs change less often than login requests, so caching can reduce external calls:

```php
$jwk = Cache::remember('nafath.jwk', now()->addMinutes(30),
    fn () => $this->client->getJwk($endUserIp)
);
```

Use a short TTL and retry once with a fresh JWK when signature verification fails, because key rotation can make a cached set stale.

### Make token lifetime configurable

Add `NAFATH_API_TOKEN_TTL_DAYS`, map it in config, and replace the hard-coded `addDays(30)` in `ApiToken::issue()`.

### Make API attempt exchange one-time

Inside a transaction:

1. lock the attempt;
2. reject if `api_token_issued_at` is already set;
3. reject if its exchange deadline passed;
4. issue the token;
5. set `api_token_issued_at`.

Whether one-time exchange is correct depends on mobile recovery requirements. Document the chosen behavior for client developers.

## 28. Troubleshooting guide

### “Authentication parameters missing” on a blank Nafath page

Confirmed cause in this project: the signed session was created through the wrong API prefix.

Wrong for the supplied STG credentials:

```dotenv
NAFATH_BASE_URL=https://rabet-nafath.api.elm.sa/stg//
```

Correct:

```dotenv
NAFATH_BASE_URL=https://rabet-nafath.api.elm.sa/stg/
```

Important details:

- always create a new URI for every attempt;
- never manually edit the provider-returned URL;
- rebuild/recreate the app after `.env` changes;
- clear Laravel configuration cache;
- the returned browser URL may itself contain `/nafath-sandbox/authservice/authorize`; that provider-generated path is normal and should not be rewritten.

The corrected session flow was verified live: the authorization endpoint returned HTTP `302` accepted instead of HTTP `403` with the error text.

### Session endpoint returns 401 or 403

Check:

- `NAFATH_APP_ID` and `NAFATH_APP_KEY` have no extra whitespace;
- the application uses the environment where those credentials were provisioned;
- the outbound public IP is registered;
- a firewall or proxy is not replacing required headers;
- configuration cache was rebuilt after changing values.

Never print the app key while debugging. Compare hashes or lengths if necessary.

### Session endpoint returns 200 but browser URL fails

Check the exact API path used to create the URL. A 200 response alone does not prove that the signed authorization state is usable for the provisioned environment. Record the session request endpoint and immediately test a newly returned URL.

### Callback never arrives

Check:

- callback URL registered at ELM exactly matches the deployed route;
- DNS resolves publicly;
- TLS certificate is trusted and unexpired;
- Nginx/proxy allows POST and preserves the request body;
- firewall rules allow provider traffic;
- the route is not behind `auth` middleware;
- the callback is exempt only from CSRF, not disabled by another security layer;
- hosting/location requirements are satisfied.

Use access logs and the `api/v2/oidc/jwt` monitoring record to determine whether Laravel received the callback.

### Callback returns 422

Laravel did not receive a valid `state` string. Inspect the callback request content type and body at the edge proxy without logging the state value itself.

### Callback returns 502

Possible causes:

- state-to-JWT exchange failed;
- provider JWT validation returned false;
- JWK endpoint failed;
- local signature verification failed;
- no attempt matched the verified request ID or state hash;
- national ID claim was missing;
- database transaction failed.

Inspect application logs and monitoring. The public 502 message intentionally hides details.

### “No pending login attempt matches the Nafath callback”

Check:

- PostgreSQL persistence is shared by all app instances;
- the start and callback point to the same environment/database;
- attempts are not being pruned too early;
- `requestId` returned by Nafath matches the generated UUID;
- claim naming still matches `requestId` or URI suffix `/requestId`;
- `hashedState` format has not changed.

### API status returns 404

The attempt ID is wrong, the `X-Nafath-Attempt-Token` header is missing, or the token does not match. A generic 404 is intentional so an attacker cannot enumerate valid IDs.

### API token exchange returns 409

The callback has not marked the attempt authenticated. Keep polling status and verify the callback path first.

### Bearer requests return 401

Check:

- header uses `Authorization: Bearer <token>`;
- the client did not accidentally store quotes around the token;
- token was not revoked by logout;
- token is not older than 30 days;
- another token with the same `device_name` did not replace it;
- the correct database is connected.

### Monitoring table is empty

Check:

- migrations created `external_api_logs`;
- the tested action actually called `NafathClient`;
- database connection works;
- daily `nafath` log contains a persistence warning;
- the requested `status` filter is `success`, `failed`, or empty.

### Wrong end-user IP behind a load balancer

Configure trusted proxies and confirm Laravel's `$request->ip()` returns the actual client. Do not trust arbitrary forwarded headers directly from the public internet; only trust known proxy addresses.

## 29. Maintenance playbook

### When ELM changes an endpoint

1. update only `NafathClient` endpoint method;
2. update base configuration if the environment prefix changed;
3. update HTTP fakes and assertions;
4. run tests;
5. perform one controlled staging session test;
6. update this guide and README.

### When ELM adds or renames claims

1. capture a redacted verified claim sample;
2. update `NafathUserResolver`;
3. add database fields only if the application needs them;
4. update user serialization and dashboard intentionally;
5. add mapping tests;
6. review privacy/retention impact.

### When credentials rotate

1. place the new values in the secret manager or `.env`;
2. recreate the app container;
3. run a controlled STG session request;
4. verify the monitoring status without exposing credentials;
5. revoke old credentials with ELM.

### When public egress IP changes

1. reserve a stable replacement IP;
2. register it with ELM before cutover;
3. update `NAFATH_CLIENT_PUBLIC_IP`;
4. recreate application containers;
5. verify from inside the container with an IP echo service;
6. run a controlled session test.

## 30. Production readiness checklist

### Provider and network

- [ ] Correct STG or production base path is configured.
- [ ] App ID/key are stored in a secret manager.
- [ ] Stable egress IP is registered with ELM.
- [ ] Callback URL is registered exactly.
- [ ] Public HTTPS and certificate are valid.
- [ ] Saudi hosting/location requirements are satisfied.
- [ ] Server clock is synchronized.
- [ ] Timeouts and retry policy are approved.

### Laravel security

- [ ] `APP_ENV=production` and `APP_DEBUG=false`.
- [ ] Strong `APP_KEY` is configured.
- [ ] Only callback path is CSRF-exempt.
- [ ] Trusted proxy configuration is correct.
- [ ] Monitoring page is administrator-only.
- [ ] Raw claims display is restricted or removed.
- [ ] Sensitive URL query parameters are redacted from logs.
- [ ] Token and attempt retention rules are implemented.
- [ ] API token lifetime and scopes meet policy.
- [ ] Rate limits match expected scale and proxy behavior.

### Database and operations

- [ ] PostgreSQL backups and restore tests exist.
- [ ] External log pruning is scheduled.
- [ ] Login-attempt pruning is scheduled.
- [ ] Expired/revoked token pruning is scheduled.
- [ ] Alerts exist for provider failure rate and latency.
- [ ] Logs do not expose app keys, national IDs, tokens, or state.
- [ ] Deployment runs migrations safely.

### Testing

- [ ] Feature suite passes.
- [ ] Real STG session URL reaches Nafath login.
- [ ] Full public callback completes.
- [ ] Web login creates a Laravel session.
- [ ] REST flow issues, uses, and revokes a token.
- [ ] Duplicate callback is harmless.
- [ ] Failure cases produce safe public messages.

## 31. Key invariants developers must preserve

These rules are more important than the exact class names:

1. Never create a local authenticated user from unverified frontend data.
2. Never expose the app key to JavaScript, Postman collection variables, or API responses.
3. Create a new Nafath URI for every authentication attempt.
4. Never rewrite a provider-returned signed authorization URL.
5. Correlate callbacks to a durable local attempt.
6. Verify the JWT before using any identity claim.
7. Store hashes of local secrets, not reusable plain tokens.
8. Regenerate the Laravel session after login.
9. Keep external-call diagnostics useful but redacted.
10. Keep protocol code out of controllers so web and API flows remain consistent.

## 32. Suggested reading order for a new maintainer

1. Read sections 2 through 8 to understand the flow visually.
2. Open `routes/web.php` and `routes/api.php` to see the public surface.
3. Read `NafathAuthenticationService` from top to bottom.
4. Read `NafathClient` to understand provider calls.
5. Read `NafathUserResolver` to understand local identity mapping.
6. Read the login-attempt migration/model to understand correlation.
7. Read the feature test to see the whole flow in executable form.
8. Use the Postman collection only after the callback environment is ready.
9. Review the security limitations and production checklist before release.

## 33. Mobile MFA login without redirect

This is the second login scenario implemented after the original OIDC browser flow. It uses the `Nafath App` endpoints from the supplied OpenAPI JSON.

### Provider contract

Create request:

```http
POST /api/v1/mfa/request?local=ar&requestId=<uuid>

{
  "nationalId": "<10-digit ID>",
  "service": "Login"
}
```

Provider response:

```json
{
  "transId": "provider-transaction-uuid",
  "random": "42"
}
```

Status request:

```http
POST /api/v1/mfa/request/status

{
  "nationalId": "<10-digit ID>",
  "transId": "provider-transaction-uuid",
  "random": "42"
}
```

Documented statuses are `WAITING`, `COMPLETED`, `REJECTED`, and `EXPIRED`.

### Application sequence

```mermaid
sequenceDiagram
    autonumber
    actor U as User
    participant P as Blade page or API client
    participant L as Laravel
    participant N as Nafath App API
    participant D as PostgreSQL
    participant C as Shared callback

    U->>P: Enter National ID
    P->>L: Start MFA request
    L->>N: POST mfa/request
    N-->>L: transId and random
    L->>D: Save encrypted National ID and attempt
    L-->>P: Display random number
    U->>N: Match number and approve in mobile app
    loop Every 3 seconds
        P->>L: Poll local MFA status
        L->>N: POST mfa/request/status
        N-->>L: WAITING or terminal status
    end
    L->>D: On COMPLETED, mark mobile step approved
    N->>C: POST one-time state
    C->>N: Exchange state for JWT
    C->>N: Validate JWT and retrieve JWK
    C->>C: Verify signature, requestId, and National ID
    C->>D: Create/update rich user and authenticate attempt
    L-->>P: Authenticated status and user object
    P->>L: Browser session continues or API token is exchanged
```

### Local status mapping

| Provider status | Local status | Result |
| --- | --- | --- |
| `WAITING` | `pending` | Continue polling. |
| `COMPLETED` | `pending` | Mobile approval is complete; continue polling while waiting for the verified callback JWT. |
| `REJECTED` | `failed` | Stop polling and show rejection. |
| `EXPIRED` | `expired` | Stop polling and require a new request. |

The local status changes to `authenticated` only after the shared callback exchanges `state` for a JWT, validates it with ELM, verifies its signature using JWK, correlates the signed `requestId`, and confirms that the signed `nationalId` matches the encrypted National ID on the MFA attempt.

### Why MFA and OIDC are merged

The documented MFA status response contains only `status`. It does not contain the rich OIDC JWT user claims such as Arabic name, English name, nationality, gender, or assurance level.

Therefore `COMPLETED` alone is not used to create a local identity. The safe hybrid behavior is:

- use MFA request/status for the random-number mobile approval;
- use the same client-generated UUID `requestId` as the durable correlation key;
- receive the provider's one-time callback `state` without redirecting the user's browser;
- exchange `state` through the OIDC JWT endpoint;
- validate the token with ELM and verify its signature locally with JWK;
- require the signed JWT `requestId` and `nationalId` to match the pending MFA attempt;
- create/update the user only from signed JWT claims, never from frontend data or MFA status alone.

If the mobile status becomes `COMPLETED` before the callback arrives, polling returns `pending`, `provider_status: COMPLETED`, and the message `Mobile approval completed. Waiting for the verified Nafath identity callback.`

### MFA-specific files

- [`app/Services/Nafath/NafathMfaAuthenticationService.php`](../app/Services/Nafath/NafathMfaAuthenticationService.php)
- [`app/Http/Controllers/Web/NafathMfaAuthController.php`](../app/Http/Controllers/Web/NafathMfaAuthController.php)
- [`app/Http/Controllers/Api/NafathMfaAuthController.php`](../app/Http/Controllers/Api/NafathMfaAuthController.php)
- [`database/migrations/2026_07_18_000005_add_mfa_fields_to_nafath_authentication.php`](../database/migrations/2026_07_18_000005_add_mfa_fields_to_nafath_authentication.php)

The temporary National ID is stored using Laravel's `encrypted` Eloquent cast. Provider transaction ID, random number, and National ID fields are hidden when an attempt model is serialized. Monitoring redacts `nationalId`, `transId`, and `random` keys.

### MFA REST examples

Start:

```http
POST /api/v1/auth/nafath/mfa/start
Content-Type: application/json

{
  "national_id": "<10-digit ID>",
  "locale": "ar"
}
```

Success: HTTP `201`

```json
{
  "data": {
    "attempt_id": "local-request-uuid",
    "attempt_token": "plain-secret-returned-once",
    "random": "42",
    "expires_at": "2026-07-18T20:00:00+03:00"
  }
}
```

Poll while the mobile step is approved but the callback is still processing:

```http
GET /api/v1/auth/nafath/mfa/status/<attempt-id>
X-Nafath-Attempt-Token: <attempt-token>
```

```json
{
  "data": {
    "status": "pending",
    "provider_status": "COMPLETED",
    "message": "Mobile approval completed. Waiting for the verified Nafath identity callback.",
    "token_ready": false,
    "user": null
  }
}
```

After the verified callback, the same poll includes:

```json
{
  "data": {
    "status": "authenticated",
    "provider_status": "COMPLETED",
    "token_ready": true,
    "user": {
      "id": 1,
      "national_id": "<verified ID>",
      "name": "<name from signed JWT>",
      "arabic_name": "<signed Arabic name>",
      "english_name": "<signed English name>",
      "assurance_level": "<signed assurance level>",
      "last_auth_method": "mfa"
    }
  }
}
```

Use the existing `/api/v1/auth/nafath/token/{attempt}` endpoint to exchange either a completed MFA attempt or completed OIDC attempt for a Bearer token.

### Provider returns `422-016-023`

The provider message `Couldn't find the requested record` is a lookup failure, not evidence that Laravel omitted the JSON body. Check the new monitoring record: it shows the full provider URL plus separate `query` and `body` sections while redacting the National ID. Confirm all three provider-side mappings with ELM:

1. the National ID is enabled as a test identity in the selected environment;
2. the APP-ID subscription is provisioned for the Nafath App MFA service;
3. `NAFATH_SERVICE` exactly matches the service type assigned to that subscription.

OIDC and mobile MFA may use different base URLs. Set `NAFATH_MFA_BASE_URL` only to the exact sandbox or production URL supplied by ELM. Do not guess or switch a sandbox test to the production root, because a successful create request sends a real mobile approval notification.

### Testing note

Normal automated tests fake both MFA endpoints plus the OIDC JWT/validation/JWK endpoints and never notify a real Nafath user. Tests prove that `COMPLETED` does not create a user, a valid signed callback does create a rich user, and a callback for a different National ID is rejected. A live `mfa/request` call creates an external mobile approval notification for the submitted National ID, so run it only with an authorized test identity and explicit test approval.

## 34. Source index

- [Project README](../README.md)
- [Web routes](../routes/web.php)
- [API routes](../routes/api.php)
- [Laravel middleware and CSRF configuration](../bootstrap/app.php)
- [Nafath API client](../app/Services/Nafath/NafathClient.php)
- [Authentication orchestration](../app/Services/Nafath/NafathAuthenticationService.php)
- [Mobile MFA orchestration](../app/Services/Nafath/NafathMfaAuthenticationService.php)
- [JWT verifier](../app/Services/Nafath/NafathJwtVerifier.php)
- [Claim mapper](../app/Services/Nafath/NafathUserResolver.php)
- [External-call monitor](../app/Services/Monitoring/ExternalCallMonitor.php)
- [Web controller](../app/Http/Controllers/Web/NafathAuthController.php)
- [Mobile MFA web controller](../app/Http/Controllers/Web/NafathMfaAuthController.php)
- [API controller](../app/Http/Controllers/Api/NafathAuthController.php)
- [Mobile MFA API controller](../app/Http/Controllers/Api/NafathMfaAuthController.php)
- [Callback controller](../app/Http/Controllers/NafathCallbackController.php)
- [API token middleware](../app/Http/Middleware/AuthenticateApiToken.php)
- [Login page](../resources/views/auth/login.blade.php)
- [Database migrations](../database/migrations)
- [Feature tests](../tests/Feature/NafathAuthenticationTest.php)
- [Postman collection](../postman/Nafath-Authentication.postman_collection.json)
- [Docker Compose stack](../compose.yaml)

---

When modifying the integration, update code, tests, and this guide together. The safest change is one whose behavior is visible in a feature test and whose operational impact is explained here.
