# Mobile Auth Integration Guide (React Native)

This document describes how to integrate authentication in the React Native mobile app. The backend serves both web and mobile clients from the same endpoints — the difference is how tokens are delivered.

## How It Works

- **Web clients** receive refresh tokens via httpOnly secure cookies (automatic browser handling).
- **Mobile clients** receive refresh tokens in the JSON response body and must send them back in the request body.

The backend distinguishes clients by the `X-Client-Type` header.

---

## Required Headers

### All Requests

```
X-Client-Type: mobile
Content-Type: application/json
```

### Authenticated Requests (additionally)

```
Authorization: Bearer <accessToken>
```

---

## Endpoints

### POST /api/auth/register

Create a new account.

**Request:**

```json
{
  "email": "kid@example.com",
  "username": "coolkid",
  "password": "SecurePass1",
  "dateOfBirth": "2014-05-15",
  "displayName": "Cool Kid"
}
```

- `displayName` is optional.
- Password must meet validation rules (min 8 chars, at least 1 uppercase, 1 number).

**Response (201):**

```json
{
  "success": true,
  "data": {
    "user": {
      "id": "clx...",
      "email": "kid@example.com",
      "username": "coolkid",
      "isMinor": true,
      "requiresParentalConsent": true,
      "createdAt": "2026-04-17T..."
    },
    "accessToken": "<jwt>",
    "refreshToken": "<jwt>"
  }
}
```

**Note:** `refreshToken` is only present when `X-Client-Type: mobile` header is sent.

---

### POST /api/auth/login

Authenticate with email or username.

**Request:**

```json
{
  "identifier": "coolkid",
  "password": "SecurePass1"
}
```

- `identifier` accepts either email or username.

**Response (200):**

```json
{
  "success": true,
  "data": {
    "user": {
      "id": "clx...",
      "email": "kid@example.com",
      "username": "coolkid"
    },
    "accessToken": "<jwt>",
    "refreshToken": "<jwt>"
  }
}
```

---

### POST /api/auth/refresh

Exchange a valid refresh token for new access + refresh tokens. This implements token rotation — the old refresh token is invalidated on use.

**Request:**

```json
{
  "refreshToken": "<current-refresh-token>"
}
```

**Response (200):**

```json
{
  "success": true,
  "data": {
    "accessToken": "<new-jwt>",
    "refreshToken": "<new-jwt>"
  }
}
```

**Important edge case:** If `refreshToken` is absent in the response (but `accessToken` is present), it means another concurrent request already rotated the token. Keep using your stored refresh token — if that also fails with 401, the user must re-login.

---

### POST /api/auth/logout

Revoke the refresh token and destroy the session.

**Request:**

```json
{
  "refreshToken": "<current-refresh-token>"
}
```

**Response (200):**

```json
{
  "success": true,
  "message": "Logged out successfully"
}
```

---

## Token Expiry

| Token | Expiry |
|-------|--------|
| Access token | 15 minutes |
| Refresh token (JWT `exp` + server-side) | 2 days |

The access token expires every 15 minutes. When a 401 response is received, silently refresh using the stored refresh token before retrying the request.

---

## Error Responses

All errors follow this shape:

```json
{
  "success": false,
  "error": "Error message here"
}
```

Key status codes:

| Code | Meaning | Action |
|------|---------|--------|
| 401 | Token expired, invalid, or revoked | Attempt refresh; if refresh fails, navigate to login |
| 409 | Email or username already taken | Show registration error |
| 400 | Validation error | Show field-level errors |

---

## Secure Token Storage

**Never use AsyncStorage for tokens.** Use encrypted storage:

### With Expo

```typescript
import * as SecureStore from 'expo-secure-store';

async function saveTokens(accessToken: string, refreshToken: string) {
  await SecureStore.setItemAsync('accessToken', accessToken);
  await SecureStore.setItemAsync('refreshToken', refreshToken);
}

async function getAccessToken(): Promise<string | null> {
  return SecureStore.getItemAsync('accessToken');
}

async function getRefreshToken(): Promise<string | null> {
  return SecureStore.getItemAsync('refreshToken');
}

async function clearTokens() {
  await SecureStore.deleteItemAsync('accessToken');
  await SecureStore.deleteItemAsync('refreshToken');
}
```

### With Bare React Native

```typescript
import * as Keychain from 'react-native-keychain';

async function saveTokens(accessToken: string, refreshToken: string) {
  await Keychain.setInternetCredentials('accessToken', 'token', accessToken);
  await Keychain.setInternetCredentials('refreshToken', 'token', refreshToken);
}

async function getAccessToken(): Promise<string | null> {
  const creds = await Keychain.getInternetCredentials('accessToken');
  return creds ? creds.password : null;
}

async function getRefreshToken(): Promise<string | null> {
  const creds = await Keychain.getInternetCredentials('refreshToken');
  return creds ? creds.password : null;
}

async function clearTokens() {
  await Keychain.resetInternetCredentials('accessToken');
  await Keychain.resetInternetCredentials('refreshToken');
}
```

---

## Axios Setup with Auto-Refresh

Complete interceptor pattern with queue for concurrent requests:

```typescript
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';

const API_URL = __DEV__ ? 'http://<local-ip>:3001' : 'https://<prod-domain>';

const api = axios.create({
  baseURL: `${API_URL}/api`,
  headers: {
    'X-Client-Type': 'mobile',
    'Content-Type': 'application/json',
  },
});

// --- Request interceptor: attach access token ---
api.interceptors.request.use(async (config) => {
  const token = await getAccessToken();
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// --- Response interceptor: handle 401 with silent refresh ---
let isRefreshing = false;
let failedQueue: Array<{
  resolve: (token: string) => void;
  reject: (error: unknown) => void;
}> = [];

function processQueue(error: unknown, token: string | null) {
  failedQueue.forEach(({ resolve, reject }) => {
    error ? reject(error) : resolve(token!);
  });
  failedQueue = [];
}

api.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };

    // Only intercept 401s, skip if already retried or if it's an auth endpoint
    if (
      error.response?.status !== 401 ||
      originalRequest._retry ||
      originalRequest.url?.includes('/auth/')
    ) {
      return Promise.reject(error);
    }

    // Queue concurrent requests while refresh is in progress
    if (isRefreshing) {
      return new Promise<string>((resolve, reject) => {
        failedQueue.push({ resolve, reject });
      }).then((token) => {
        originalRequest.headers.Authorization = `Bearer ${token}`;
        return api(originalRequest);
      });
    }

    originalRequest._retry = true;
    isRefreshing = true;

    try {
      const refreshToken = await getRefreshToken();
      if (!refreshToken) throw new Error('No refresh token');

      // Use a fresh axios instance (not `api`) to avoid interceptor loop
      const { data } = await axios.post(
        `${API_URL}/api/auth/refresh`,
        { refreshToken },
        {
          headers: {
            'X-Client-Type': 'mobile',
            'Content-Type': 'application/json',
          },
        },
      );

      const newAccessToken: string = data.data.accessToken;
      await saveTokens(newAccessToken, data.data.refreshToken ?? refreshToken);

      processQueue(null, newAccessToken);
      originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
      return api(originalRequest);
    } catch (refreshError) {
      processQueue(refreshError, null);
      await clearTokens();
      // Navigate to login screen — use your navigation ref here
      // e.g., navigationRef.current?.navigate('Login');
      return Promise.reject(refreshError);
    } finally {
      isRefreshing = false;
    }
  },
);

export default api;
```

---

## App Lifecycle

### On App Launch

Attempt a silent refresh to restore the session:

```typescript
async function initializeAuth(): Promise<boolean> {
  const refreshToken = await getRefreshToken();
  if (!refreshToken) return false;

  try {
    const { data } = await axios.post(
      `${API_URL}/api/auth/refresh`,
      { refreshToken },
      {
        headers: {
          'X-Client-Type': 'mobile',
          'Content-Type': 'application/json',
        },
      },
    );

    await saveTokens(data.data.accessToken, data.data.refreshToken ?? refreshToken);
    return true;
  } catch {
    await clearTokens();
    return false;
  }
}
```

### On Foreground Resume

When the app returns from background, check if the access token is still valid. If the app was backgrounded for more than 15 minutes, trigger a refresh:

```typescript
import { AppState, AppStateStatus } from 'react-native';

let lastActiveTime = Date.now();

AppState.addEventListener('change', async (state: AppStateStatus) => {
  if (state === 'active') {
    const elapsed = Date.now() - lastActiveTime;
    // If backgrounded for more than 14 minutes, proactively refresh
    if (elapsed > 14 * 60 * 1000) {
      const refreshToken = await getRefreshToken();
      if (refreshToken) {
        try {
          const { data } = await axios.post(
            `${API_URL}/api/auth/refresh`,
            { refreshToken },
            {
              headers: {
                'X-Client-Type': 'mobile',
                'Content-Type': 'application/json',
              },
            },
          );
          await saveTokens(data.data.accessToken, data.data.refreshToken ?? refreshToken);
        } catch {
          await clearTokens();
          // Navigate to login
        }
      }
    }
  } else if (state === 'background') {
    lastActiveTime = Date.now();
  }
});
```

---

## Testing with curl

```bash
# Register (mobile)
curl -X POST http://localhost:3001/api/auth/register \
  -H "Content-Type: application/json" \
  -H "X-Client-Type: mobile" \
  -d '{"email":"mobiletest@test.com","username":"mobileuser","password":"Password123","dateOfBirth":"2014-01-01"}'

# Login (mobile)
curl -X POST http://localhost:3001/api/auth/login \
  -H "Content-Type: application/json" \
  -H "X-Client-Type: mobile" \
  -d '{"identifier":"mobileuser","password":"Password123"}'

# Refresh (mobile) — use refreshToken from login response
curl -X POST http://localhost:3001/api/auth/refresh \
  -H "Content-Type: application/json" \
  -H "X-Client-Type: mobile" \
  -d '{"refreshToken":"<token-from-login>"}'

# Logout (mobile)
curl -X POST http://localhost:3001/api/auth/logout \
  -H "Content-Type: application/json" \
  -H "X-Client-Type: mobile" \
  -d '{"refreshToken":"<current-refresh-token>"}'

# Authenticated request example
curl http://localhost:3001/api/users/me \
  -H "Authorization: Bearer <accessToken>" \
  -H "X-Client-Type: mobile"
```

---

## Security Notes

1. **Always use SecureStore/Keychain** for token storage. AsyncStorage is not encrypted.
2. **Refresh tokens rotate on every use** — after refreshing, the old token is invalidated. Always store the new refresh token from the response.
3. **Token reuse detection** — if a revoked refresh token is used (possible token theft), ALL of the user's sessions are revoked for safety. The user must re-login on all devices.
4. **Access tokens are short-lived (15 min)** — even if intercepted, the window of exposure is small.
5. **Send a proper User-Agent header** — the backend uses it for device tracking and session management. Example: `KSN-Mobile/1.0 (iOS 17.0)` or `KSN-Mobile/1.0 (Android 14)`.
