# Solution Design Document: Mobile JWT Authentication

## Validation Checklist

- [x] All required sections are complete
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] All context sources are listed with relevance ratings
- [x] Project commands are discovered from actual project files
- [x] Constraints → Strategy → Design → Implementation path is logical
- [x] Architecture pattern is clearly stated with rationale
- [x] Every component in diagram has directory mapping
- [x] Every interface has specification
- [x] Error handling covers all error types
- [x] Quality requirements are specific and measurable
- [x] Every quality requirement has test coverage
- [x] **All architecture decisions confirmed by user**
- [x] Component names consistent across diagrams
- [x] A developer could implement from this design

---

## Constraints

**CON-1 Technology Stack**
- PHP 8.x with Slim 2.6.2 framework
- MySQL multi-database architecture (kiosk_users central, kiosk_[typeNum] per-store)
- Must use Firebase JWT library (already installed via composer)
- HS256 algorithm (confirmed by user)
- Redis via Predis\Client using `$_ENV['REDIS_URL']` for rate limiting

**CON-2 Security Requirements**
- Access token TTL: 15 minutes (900 seconds)
- Refresh token TTL: 30 days
- Refresh token rotation on each use (confirmed by user)
- Rate limiting: 5 failed login attempts per email per 15 minutes
- Device fingerprinting for audit trail
- **Store-level authorization required**: JWT-authenticated requests to store-scoped endpoints must validate `userStoreAssignments(userId, typeNum)` before processing

**CON-3 Compatibility**
- Backward compatibility with existing APIKey authentication during transition
- JWT takes priority when both auth methods present
- Log deprecated APIKey usage for migration tracking
- Login response schema must match existing `/api/mobile/verify` format (storeName, storeCity, storeType fields)

**CON-4 Multi-Store Architecture**
- Users can access multiple stores
- Single JWT works across all stores (user-scoped tokens)
- Store context resolved per-request via URL typeNum
- **Store access validation required**: Middleware must verify user has assignment to requested store
- Multiple simultaneous sessions allowed (confirmed by user)

## Implementation Context

### Required Context Sources

```yaml
# Existing JWT infrastructure (REUSE)
- file: userfrosting/src/BuyerKiosk/MobileScheduling/Services/JwtAuthService.php
  relevance: CRITICAL
  why: "Existing JWT service with HS256, token generation, validation - REUSE directly"

- file: userfrosting/src/BuyerKiosk/MobileScheduling/Repositories/RefreshTokenRepository.php
  relevance: CRITICAL
  why: "Existing refresh token storage with revocation support - REUSE directly"

- file: userfrosting/src/BuyerKiosk/MobileScheduling/Middleware/JwtAuthMiddleware.php
  relevance: CRITICAL
  why: "Existing JWT middleware for Bearer token validation - REUSE directly"

- file: userfrosting/src/BuyerKiosk/MobileScheduling/Controllers/MobileAuthController.php
  relevance: HIGH
  why: "Reference implementation for login/refresh/logout endpoints"

- file: userfrosting/src/BuyerKiosk/MobileScheduling/Models/RefreshToken.php
  relevance: HIGH
  why: "Existing refresh token entity model"

# Current mobile API (MODIFY)
- file: userfrosting/routes/groups/mobile.php
  relevance: CRITICAL
  why: "Routes requiring auth modification - add Bearer token support"

- file: userfrosting/src/BuyerKiosk/MobileApi/MobileApiController.php
  relevance: HIGH
  why: "Controller with verifyApiKey() - understand current auth flow"

# Database migrations
- file: userfrosting/migrations/input/20251220_005_oauth_refresh_tokens.json
  relevance: HIGH
  why: "Existing oauthRefreshTokens table schema - already created"
```

### Implementation Boundaries

- **Must Preserve**:
  - Existing `/api/mobile/scheduling/*` JWT auth (works independently)
  - APIKey validation flow (deprecated but supported during transition)
  - All existing mobile.php route functionality

- **Can Modify**:
  - Add new `/api/mobile/auth/*` routes
  - Modify existing `/api/mobile/*` routes to accept Bearer tokens
  - Create hybrid middleware supporting both auth methods

- **Must Not Touch**:
  - `JwtAuthService.php` - reuse as-is
  - `RefreshTokenRepository.php` - reuse as-is
  - `oauthRefreshTokens` table schema - already exists

### External Interfaces

#### System Context Diagram

```mermaid
graph TB
    MobileApp[Mobile App<br/>iOS/Android] --> |JWT Bearer Token| MobileAPI[Mobile API<br/>/api/mobile/*]
    MobileApp --> |Login/Refresh/Logout| AuthEndpoints[Auth Endpoints<br/>/api/mobile/auth/*]

    AuthEndpoints --> |Verify Credentials| UsersDB[(kiosk_users<br/>users table)]
    AuthEndpoints --> |Store/Validate Tokens| TokensDB[(kiosk_users<br/>oauthRefreshTokens)]

    MobileAPI --> |Get Employee Links| UserEmployeeLinks[UserEmployeeLinkManager]
    MobileAPI --> |Store Operations| StoreDB[(kiosk_[typeNum]<br/>Store DB)]

    UserEmployeeLinks --> UsersDB
    UserEmployeeLinks --> StoreDB
```

#### Interface Specifications

```yaml
# Inbound Interfaces
inbound:
  - name: "Mobile Auth API"
    type: HTTPS
    format: REST/JSON
    authentication: None (login), JWT Bearer (logout)
    endpoints:
      - POST /api/mobile/auth/login
      - POST /api/mobile/auth/refresh
      - POST /api/mobile/auth/logout
    data_flow: "Authentication credentials in, JWT tokens out"

  - name: "Mobile API (Protected)"
    type: HTTPS
    format: REST/form-urlencoded
    authentication: JWT Bearer OR APIKey (deprecated)
    endpoints: /api/mobile/* (all existing endpoints)
    data_flow: "Business operations with authenticated context"

# Data Interfaces
data:
  - name: "Central Users Database"
    type: MySQL
    database: kiosk_users
    tables:
      - users: User accounts (id, email, password, enabled, active)
      - userStoreAssignments: User-to-store role mappings
      - oauthRefreshTokens: JWT refresh token storage
    data_flow: "Authentication and authorization"

  - name: "Store Databases"
    type: MySQL
    database: kiosk_[typeNum]
    tables:
      - employees: Employee records for user context
    data_flow: "Employee context per store"
```

### Project Commands

```bash
# Testing
./test.sh --testsuite unit          # Run unit tests
./test.sh --testsuite integration   # Run integration tests
./test.sh --stan                    # Tests + PHPStan analysis

# Development
cd userfrosting && composer install  # Install dependencies

# Deployment
./deploy.sh                          # Test + deploy
```

## Solution Strategy

### Architecture Pattern: **Service-Based Extension**

We extend the existing MobileScheduling JWT infrastructure to the general Mobile API, maintaining a clean separation while maximizing code reuse.

**Integration Approach:**
1. **Reuse existing services** - JwtAuthService, RefreshTokenRepository, RefreshToken model work unchanged
2. **Create hybrid middleware** - New middleware that accepts BOTH Bearer token and APIKey
3. **Add auth routes** - New `/api/mobile/auth/*` endpoints following MobileScheduling patterns
4. **Minimal disruption** - Existing mobile.php routes modified only for auth header extraction

**Justification:**
- 90% of JWT infrastructure already exists and is battle-tested
- Hybrid auth enables gradual migration without breaking existing apps
- User-scoped tokens already implemented (single token works across all stores)

**Key Decisions:**
- ADR-1: Reuse existing JWT infrastructure (HS256, 15-min/30-day TTLs)
- ADR-2: Hybrid middleware for backward compatibility
- ADR-3: User-scoped tokens (store context resolved per-request)
- ADR-4: Refresh token rotation for enhanced security

## Building Block View

### Components

```mermaid
graph LR
    subgraph "Mobile App"
        App[iOS/Android App]
    end

    subgraph "Auth Layer"
        Login[LoginController]
        HybridMW[HybridAuthMiddleware]
    end

    subgraph "Shared Services"
        JwtSvc[JwtAuthService]
        TokenRepo[RefreshTokenRepository]
    end

    subgraph "Mobile API"
        MobileRoutes[mobile.php Routes]
        MobileCtrl[MobileApiController]
    end

    subgraph "Databases"
        UsersDB[(kiosk_users)]
        StoreDB[(kiosk_typeNum)]
    end

    App -->|POST /auth/login| Login
    App -->|Bearer Token| HybridMW
    App -->|APIKey deprecated| HybridMW

    Login --> JwtSvc
    JwtSvc --> TokenRepo
    TokenRepo --> UsersDB

    HybridMW --> MobileRoutes
    MobileRoutes --> MobileCtrl
    MobileCtrl --> StoreDB
```

### Directory Map

```
userfrosting/
├── src/BuyerKiosk/
│   ├── MobileApi/
│   │   ├── Controllers/
│   │   │   ├── MobileApiController.php    # EXISTING - no changes needed
│   │   │   └── MobileAuthController.php   # NEW: Login/refresh/logout endpoints
│   │   ├── Middleware/
│   │   │   ├── HybridAuthMiddleware.php   # NEW: JWT + APIKey hybrid auth
│   │   │   └── StoreAccessMiddleware.php  # NEW: Validates user store assignment
│   │   └── Models/
│   │       └── AuthContext.php            # NEW: Standardized auth context contract
│   │
│   └── MobileScheduling/
│       ├── Services/
│       │   └── JwtAuthService.php         # EXISTING - reuse as-is
│       ├── Repositories/
│       │   └── RefreshTokenRepository.php # EXISTING - reuse as-is
│       ├── Models/
│       │   └── RefreshToken.php           # EXISTING - reuse as-is
│       └── Middleware/
│           └── JwtAuthMiddleware.php      # EXISTING - reference for HybridAuth
│
├── routes/
│   └── groups/
│       ├── mobile.php                     # MODIFY: Add HybridAuthMiddleware + StoreAccessMiddleware
│       └── mobile-auth.php                # NEW: Auth endpoints
│
└── tests/Unit/
    └── MobileApi/
        ├── MobileAuthControllerTest.php   # NEW: Auth endpoint tests
        ├── HybridAuthMiddlewareTest.php   # NEW: Middleware tests
        └── StoreAccessMiddlewareTest.php  # NEW: Store authorization tests
```

### Interface Specifications

#### Data Storage (EXISTING - No Changes)

```yaml
# oauthRefreshTokens table - Already exists in kiosk_users
Table: oauthRefreshTokens
  id: int unsigned AUTO_INCREMENT PRIMARY KEY
  userId: int unsigned NOT NULL (FK to users.id)
  token: varchar(255) NOT NULL UNIQUE (SHA256 hash)
  clientId: varchar(100) NULL
  deviceName: varchar(255) NULL
  deviceFingerprint: varchar(255) NULL
  ipAddress: varchar(45) NULL
  userAgent: text NULL
  expiresAt: timestamp NOT NULL
  revokedAt: timestamp NULL
  lastUsedAt: timestamp NULL
  createdAt: timestamp DEFAULT CURRENT_TIMESTAMP

Indexes:
  - uk_token (token) UNIQUE
  - idx_userId (userId)
  - idx_userExpires (userId, expiresAt)
  - idx_revokedAt (revokedAt)
```

#### NEW API: Login Endpoint

```yaml
Endpoint: Mobile Login
  Method: POST
  Path: /api/mobile/auth/login
  Content-Type: application/json

  Request:
    email: string, required, valid email format
    password: string, required, min 6 characters
    deviceName: string, required, device model/name
    deviceFingerprint: string, required, UUID v4

  Response (200):
    accessToken: string (JWT, 15-min TTL)
    refreshToken: string (random token, 30-day TTL)
    expiresIn: int (900 seconds)
    user:
      id: int
      email: string
      displayName: string
    stores: array  # Matches existing /api/mobile/verify format
      - typeNum: string (e.g., "ou00")
        storeName: string          # Note: storeName not name (existing format)
        storeCity: string          # Included for consistency with existing format
        storeType: string (PC, OUAC, etc.)
        employee:
          id: int
          fullName: string
          role: int (1=Owner, 2=Manager, 3=ShiftLead, 4=Employee)

  Error Responses:
    400 validation_error: Missing/invalid request fields
    401 invalid_credentials: Email/password don't match
    401 account_disabled: User account is disabled
    429 rate_limited: Too many failed attempts (Retry-After header)
```

#### NEW API: Refresh Token Endpoint

```yaml
Endpoint: Token Refresh
  Method: POST
  Path: /api/mobile/auth/refresh
  Content-Type: application/json

  Request:
    refreshToken: string, required

  Response (200):
    accessToken: string (new JWT)
    refreshToken: string (new rotated token)
    expiresIn: int (900 seconds)

  Error Responses:
    401 invalid_token: Token malformed or invalid signature
    401 expired_token: Refresh token has expired
    401 revoked_token: Token was revoked (user logged out)
```

#### NEW API: Logout Endpoint

```yaml
Endpoint: Mobile Logout
  Method: POST
  Path: /api/mobile/auth/logout
  Content-Type: application/json
  Authorization: Bearer <accessToken> (optional)

  Request:
    refreshToken: string, required
    deviceFingerprint: string, required

  Response (200):
    message: "Logged out successfully"

  Notes:
    - Access token validation is optional for logout
    - Even with invalid access token, valid refresh token should be revoked
```

#### MODIFIED: Existing Mobile API Endpoints

```yaml
Authentication Change:
  Before:
    POST body contains: APIKey=abc123xyz789...

  After (preferred):
    Header: Authorization: Bearer <JWT>

  Fallback (deprecated):
    POST body contains: APIKey=abc123xyz789...

  Priority:
    1. Check Authorization header for Bearer token
    2. If no Bearer token, check POST body for APIKey
    3. Log APIKey usage for migration tracking

  401 Response:
    WWW-Authenticate: Bearer error="invalid_token"
    {
      "error": "Token expired or invalid",
      "error_code": "unauthorized"
    }
```

### Implementation Examples

#### Example: Hybrid Authentication Middleware

**Why this example**: Demonstrates the core backward-compatibility logic that determines whether to use JWT or fall back to APIKey.

```php
// HybridAuthMiddleware - Core authentication logic
class HybridAuthMiddleware extends \Slim\Middleware
{
    private JwtAuthService $jwtService;

    public function call()
    {
        // 1. Try JWT Bearer token first (preferred)
        $authHeader = $this->app->request->headers->get('Authorization');
        $bearerToken = JwtAuthService::extractBearerToken($authHeader);

        if ($bearerToken !== null) {
            try {
                $payload = $this->jwtService->validateAccessToken($bearerToken);
                $this->app->jwtUser = (object)[
                    'userId' => $payload['userId'],
                    'authMethod' => 'jwt',
                ];
                $this->next->call();
                return;
            } catch (RuntimeException $e) {
                // Invalid JWT - return 401, don't fall back to APIKey
                $this->sendUnauthorizedResponse($e->getMessage());
                return;
            }
        }

        // 2. Fall back to APIKey (deprecated)
        $apiKey = $this->app->request->post('APIKey');
        if (!empty($apiKey)) {
            // Log deprecated usage for migration tracking
            $this->logDeprecatedApiKeyUsage($apiKey);

            $result = $this->validateApiKey($apiKey);
            if ($result !== null) {
                $this->app->jwtUser = (object)[
                    'userId' => $result['userId'],
                    'authMethod' => 'apikey_deprecated',
                ];
                $this->next->call();
                return;
            }
        }

        // 3. No valid authentication
        $this->sendUnauthorizedResponse('Authentication required');
    }
}
```

#### Example: Auth Context Contract

**Why this example**: Defines the standardized auth context object that all downstream controllers can rely on.

```php
// AuthContext - Standardized authentication context
class AuthContext
{
    public function __construct(
        public readonly int $userId,
        public readonly string $authMethod,      // 'jwt' | 'apikey_deprecated'
        public readonly ?string $typeNum = null, // Set by StoreAccessMiddleware
        public readonly ?int $employeeId = null, // Set by StoreAccessMiddleware
        public readonly ?int $roleLevel = null   // 1=Owner, 2=Manager, 3=ShiftLead, 4=Employee
    ) {}

    public function hasStoreAccess(): bool
    {
        return $this->typeNum !== null && $this->employeeId !== null;
    }

    public function isManager(): bool
    {
        return $this->roleLevel !== null && $this->roleLevel <= 2;
    }
}

// Usage in middleware chain:
// 1. HybridAuthMiddleware sets: $app->authContext = new AuthContext(userId, authMethod)
// 2. StoreAccessMiddleware enriches: $app->authContext = new AuthContext(..., typeNum, employeeId, roleLevel)
// 3. Controllers access: $app->authContext->userId, $app->authContext->typeNum, etc.
```

#### Example: Store Access Middleware

**Why this example**: Shows the critical store-level authorization that prevents JWT holders from accessing stores they're not assigned to.

```php
// StoreAccessMiddleware - Validates user has access to requested store
class StoreAccessMiddleware extends \Slim\Middleware
{
    public function call()
    {
        $typeNum = $this->app->request->params('typeNum');
        $authContext = $this->app->authContext;

        if ($typeNum === null) {
            // Route doesn't require store context, proceed
            $this->next->call();
            return;
        }

        // Query userStoreAssignments to verify access
        $db = dbConnectByName('kiosk_users');
        $stmt = $db->prepare(
            "SELECT usa.roleId, e.id as employeeId
             FROM userStoreAssignments usa
             JOIN kiosk_{$typeNum}.employees e ON e.userId = usa.userId
             WHERE usa.userId = :userId
               AND usa.typeNum = :typeNum
               AND usa.isActive = 1"
        );
        $stmt->execute([':userId' => $authContext->userId, ':typeNum' => $typeNum]);
        $assignment = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!$assignment) {
            $this->sendForbiddenResponse('User does not have access to this store');
            return;
        }

        // Enrich auth context with store info
        $this->app->authContext = new AuthContext(
            userId: $authContext->userId,
            authMethod: $authContext->authMethod,
            typeNum: $typeNum,
            employeeId: (int) $assignment['employeeId'],
            roleLevel: (int) $assignment['roleId']
        );

        $this->next->call();
    }

    private function sendForbiddenResponse(string $message): void
    {
        $this->app->response->setStatus(403);
        $this->app->response->headers->set('Content-Type', 'application/json');
        $this->app->response->setBody(json_encode([
            'error' => $message,
            'error_code' => 'store_access_denied'
        ]));
    }
}
```

#### Example: Refresh Token Rotation (Atomic)

**Why this example**: Shows the security-enhanced token rotation pattern with atomic transaction to handle concurrent refresh race conditions.

```php
// Token rotation on refresh - with atomic transaction for race condition safety
public function refreshWithRotation(string $oldRefreshToken): array
{
    $tokenHash = hash('sha256', $oldRefreshToken);
    $db = dbConnectByName('kiosk_users');

    try {
        $db->beginTransaction();

        // 1. Validate and lock the token row (SELECT FOR UPDATE prevents concurrent refresh)
        $stmt = $db->prepare(
            "SELECT id, userId, deviceFingerprint, deviceName
             FROM oauthRefreshTokens
             WHERE token = :tokenHash
               AND expiresAt > NOW()
               AND revokedAt IS NULL
             FOR UPDATE"
        );
        $stmt->execute([':tokenHash' => $tokenHash]);
        $storedToken = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!$storedToken) {
            $db->rollBack();
            throw new RuntimeException('Invalid or expired refresh token');
        }

        // 2. Revoke old token immediately (within transaction)
        $stmt = $db->prepare(
            "UPDATE oauthRefreshTokens SET revokedAt = NOW() WHERE id = :id"
        );
        $stmt->execute([':id' => $storedToken['id']]);

        // 3. Generate new token pair
        $deviceInfo = [
            'deviceFingerprint' => $storedToken['deviceFingerprint'],
            'deviceName' => $storedToken['deviceName'],
            'ipAddress' => $this->getCurrentIpAddress(),
        ];
        $newTokens = $this->generateTokenPair($storedToken['userId'], $deviceInfo);

        $db->commit();
        return $newTokens;

    } catch (PDOException $e) {
        $db->rollBack();
        throw new RuntimeException('Token refresh failed: ' . $e->getMessage());
    }
}

// Note: If two devices attempt concurrent refresh with same token,
// only one will succeed (first to acquire FOR UPDATE lock).
// The second will find revokedAt IS NOT NULL and fail with "Invalid or expired".
// App should handle this by retrying login.
```

#### Example: Rate Limiting for Login

**Why this example**: Demonstrates the rate limiting logic to prevent brute-force attacks.

```php
// Rate limiting implementation using Predis\Client
class LoginRateLimiter
{
    private const MAX_ATTEMPTS = 5;
    private const WINDOW_SECONDS = 900; // 15 minutes

    private ?\Predis\Client $redis;

    public function __construct()
    {
        try {
            $this->redis = new \Predis\Client($_ENV['REDIS_URL']);
        } catch (\Exception $e) {
            // Log warning - rate limiting will be disabled
            error_log('Redis unavailable for rate limiting: ' . $e->getMessage());
            $this->redis = null;
        }
    }

    public function checkRateLimit(string $email): ?int
    {
        // Fail OPEN when Redis unavailable (user decision: prioritize availability)
        if ($this->redis === null) {
            return null;
        }

        try {
            $key = 'login_attempts:' . hash('sha256', strtolower($email));
            $attempts = (int) ($this->redis->get($key) ?? 0);

            if ($attempts >= self::MAX_ATTEMPTS) {
                $ttl = $this->redis->ttl($key);
                return $ttl > 0 ? $ttl : self::WINDOW_SECONDS;
            }

            return null; // Not rate limited
        } catch (\Exception $e) {
            error_log('Rate limit check failed: ' . $e->getMessage());
            return null; // Fail open
        }
    }

    public function recordFailedAttempt(string $email): void
    {
        if ($this->redis === null) {
            return;
        }

        try {
            $key = 'login_attempts:' . hash('sha256', strtolower($email));
            $this->redis->incr($key);
            $this->redis->expire($key, self::WINDOW_SECONDS);
        } catch (\Exception $e) {
            error_log('Failed to record login attempt: ' . $e->getMessage());
        }
    }

    public function resetAttempts(string $email): void
    {
        if ($this->redis === null) {
            return;
        }

        try {
            $key = 'login_attempts:' . hash('sha256', strtolower($email));
            $this->redis->del([$key]);
        } catch (\Exception $e) {
            error_log('Failed to reset login attempts: ' . $e->getMessage());
        }
    }
}
```

## Runtime View

### Primary Flow: User Login

```mermaid
sequenceDiagram
    actor User
    participant App as Mobile App
    participant Auth as MobileAuthController
    participant JWT as JwtAuthService
    participant DB as kiosk_users DB
    participant TokenRepo as RefreshTokenRepository

    User->>App: Enter email/password
    App->>Auth: POST /api/mobile/auth/login

    Auth->>Auth: Check rate limit
    alt Rate limited
        Auth-->>App: 429 + Retry-After header
    end

    Auth->>DB: Find user by email
    DB-->>Auth: User record

    Auth->>Auth: Verify password
    alt Invalid credentials
        Auth->>Auth: Record failed attempt
        Auth-->>App: 401 invalid_credentials
    end

    Auth->>Auth: Check enabled, active, canLogin
    Auth->>DB: Get userStoreAssignments
    DB-->>Auth: Store list with roles

    Auth->>JWT: generateTokenPair(userId, deviceInfo)
    JWT->>JWT: Generate access token (HS256)
    JWT->>TokenRepo: Save refresh token (hashed)
    TokenRepo->>DB: INSERT oauthRefreshTokens
    JWT-->>Auth: {accessToken, refreshToken, expiresIn}

    Auth->>Auth: Reset rate limit attempts
    Auth-->>App: 200 {tokens, user, stores}
    App-->>User: Login successful
```

### Primary Flow: API Request with JWT

```mermaid
sequenceDiagram
    actor App as Mobile App
    participant MW as HybridAuthMiddleware
    participant JWT as JwtAuthService
    participant Route as Mobile API Route
    participant Ctrl as MobileApiController

    App->>MW: POST /api/mobile/currentQueue/ou00<br/>Authorization: Bearer <token>

    MW->>JWT: extractBearerToken(header)
    JWT-->>MW: token string

    MW->>JWT: validateAccessToken(token)
    alt Token valid
        JWT-->>MW: {userId, type: "access"}
        MW->>MW: Set app.jwtUser = {userId}
        MW->>Route: Continue to route handler
        Route->>Ctrl: getCurrentQueue("ou00")
        Ctrl-->>Route: Queue data
        Route-->>App: 200 {queue data}
    else Token expired
        JWT-->>MW: RuntimeException
        MW-->>App: 401 + WWW-Authenticate: Bearer
    end
```

### Error Handling

```yaml
# Error Classification (Standardized)
# All error responses use consistent envelope: {error: string, error_code: string}

Validation Errors (400):
  error_codes:
    - validation_error: Missing required fields (email, password, deviceName, deviceFingerprint)
    - invalid_email: Invalid email format
    - password_too_short: Password under minimum length

Authentication Errors (401):
  error_codes:
    - invalid_credentials: Email/password mismatch (login)
    - invalid_token: JWT malformed or signature invalid
    - expired_token: JWT or refresh token expired
    - revoked_token: Refresh token was revoked
    - account_disabled: User.enabled = false (cannot authenticate)
    - account_not_activated: User.active = false (cannot authenticate)
  headers:
    - WWW-Authenticate: Bearer error="<error_code>"

Authorization Errors (403):
  error_codes:
    - store_access_denied: User does not have assignment to requested store
    - no_store_access: User has no store assignments at all

Rate Limiting (429):
  error_codes:
    - rate_limited: Too many failed login attempts
  headers:
    - Retry-After: <seconds remaining>

Server Errors (500):
  error_codes:
    - internal_error: Generic server error
  behavior:
    - Log full error with stack trace
    - Return generic "Internal error occurred" message (no internal details)
```

## Deployment View

### Single Application Deployment

- **Environment**: Production servers behind load balancer
- **Configuration**:
  ```
  JWT_SECRET: [256-bit secret in environment]
  REDIS_HOST: [For rate limiting]
  ```
- **Dependencies**:
  - Firebase JWT library (already installed)
  - Redis (for rate limiting)
- **Performance**:
  - Token validation is stateless (no DB hit)
  - Refresh token lookup indexed by token hash

### Migration Rollout

**Phase 1: Deploy Backend (Week 1)**
- Deploy new auth endpoints
- Deploy HybridAuthMiddleware
- All existing apps continue working via APIKey

**Phase 2: Mobile App Update (Week 2-4)**
- Release app update with JWT support
- New users get JWT, existing users migrated on next login
- Monitor APIKey usage via logs

**Phase 3: Deprecation Warning (Week 5-8)**
- Log warnings for APIKey usage
- Notify remaining APIKey users

**Phase 4: APIKey Removal (Week 12+)**
- Remove APIKey fallback from middleware
- Full JWT-only authentication

## Cross-Cutting Concepts

### Pattern Documentation

```yaml
# Existing patterns used
- pattern: JWT Bearer Token Authentication
  relevance: CRITICAL
  why: "Standard pattern for stateless API authentication"

- pattern: Refresh Token Rotation
  relevance: HIGH
  why: "Security enhancement - stolen tokens become invalid after one use"

- pattern: Backward-Compatible Migration
  relevance: HIGH
  why: "Support old auth while transitioning to new system"
```

### Security Patterns

- **Password Verification**: Use PHP's `password_verify()` with bcrypt hashes
- **Token Storage**: Store only SHA256 hash of refresh token, never raw token
- **Rate Limiting**: Redis-based sliding window counter per email
- **Audit Trail**: Device fingerprint stored with each refresh token

### Error Handling Pattern

```pseudocode
FUNCTION: handle_auth_error(exception)
  CLASSIFY: error_type from exception message
  LOG: full exception for debugging
  RESPOND:
    401 + WWW-Authenticate header for auth errors
    429 + Retry-After header for rate limits
    generic message (no internal details exposed)
```

## Architecture Decisions

- [x] **ADR-1 Reuse Existing JWT Infrastructure**
  - Choice: Reuse JwtAuthService, RefreshTokenRepository from MobileScheduling
  - Rationale: Code is battle-tested, reduces implementation time by 80%
  - Trade-offs: Couples Mobile API to MobileScheduling namespace
  - User confirmed: ✅ Yes

- [x] **ADR-2 HS256 Algorithm**
  - Choice: Use HS256 (shared secret) for JWT signing
  - Rationale: Simpler key management, sufficient for single-server architecture
  - Trade-offs: All servers need access to secret (not suitable for public key distribution)
  - User confirmed: ✅ Yes

- [x] **ADR-3 Refresh Token Rotation**
  - Choice: Issue new refresh token on each refresh request
  - Rationale: Enhanced security - stolen tokens become invalid after one use
  - Trade-offs: Slightly more complex; concurrent refreshes can cause issues
  - User confirmed: ✅ Yes

- [x] **ADR-4 Multi-Device Sessions**
  - Choice: Allow multiple simultaneous sessions per user
  - Rationale: User convenience - can be logged in on phone and tablet
  - Trade-offs: Harder to force single-session logout; more tokens to manage
  - User confirmed: ✅ Yes

- [x] **ADR-5 Hybrid Authentication Middleware**
  - Choice: Support both JWT and APIKey in same middleware
  - Rationale: Backward compatibility enables gradual migration
  - Trade-offs: Slightly more complex middleware; need to deprecate APIKey eventually
  - User confirmed: ✅ Implicit (migration requirement)

- [x] **ADR-6 Store Access Middleware (Security Critical)**
  - Choice: Separate middleware validates userStoreAssignments for store-scoped routes
  - Rationale: Prevents JWT holder from accessing arbitrary stores; separates concerns from auth
  - Trade-offs: Additional DB query per request (mitigate with short TTL cache if needed)
  - User confirmed: ✅ Implicit (security requirement)

- [x] **ADR-7 Rate Limiting Fail-Open**
  - Choice: Allow logins when Redis unavailable (fail open)
  - Rationale: User prioritized availability over absolute security
  - Trade-offs: Brute-force protection disabled during Redis outage
  - User confirmed: ✅ Yes

## Quality Requirements

| Metric | Target | Measurement |
|--------|--------|-------------|
| Login Response Time | < 200ms p95 | Load test with 100 concurrent logins |
| Token Validation Time | < 10ms | JWT decode is CPU-only (stateless) |
| Failed Login Rate Limit | Max 5 per 15 min | Redis counter per email hash |
| Token Expiry Accuracy | ±5 seconds | Clock sync via NTP |
| Backward Compatibility | 100% | All existing apps work unchanged |

## Risks and Technical Debt

### Known Technical Issues

- **Redis dependency for rate limiting**: If Redis is down, rate limiting fails open (allows requests)
- **Clock skew**: JWT expiration relies on server clock; NTP must be configured

### Technical Debt

- **Namespace location**: New MobileAuthController in MobileApi namespace, but imports from MobileScheduling
- **Consider future refactor**: Move shared JWT classes to `BuyerKiosk\Auth\` namespace
- **Route prefix resolved**: Auth endpoints use `/api/mobile/auth/*` (matches existing `/api/mobile/*` pattern). README Context section should be updated to reflect this.

### Implementation Gotchas

- **Concurrent refresh race condition**: If two devices refresh same token simultaneously, one will fail after rotation. App should retry login on refresh failure.
- **APIKey vs Bearer priority**: Bearer token errors should NOT fall back to APIKey (security risk)
- **Form-urlencoded content type**: Existing mobile routes use form-urlencoded, not JSON. Don't change this.

## Test Specifications

### Critical Test Scenarios

**Scenario 1: Successful Login**
```gherkin
Given: User exists with valid credentials
And: User has store assignments
When: POST /api/mobile/auth/login with email, password, deviceName, deviceFingerprint
Then: Response 200 with accessToken, refreshToken, expiresIn
And: Response includes user object with id, email, displayName
And: Response includes stores array with typeNum, name, storeType, employee info
```

**Scenario 2: Invalid Credentials**
```gherkin
Given: User exists in database
When: POST /api/mobile/auth/login with wrong password
Then: Response 401 with error_code "invalid_credentials"
And: Failed attempt is recorded for rate limiting
```

**Scenario 3: Rate Limiting**
```gherkin
Given: 5 failed login attempts for same email in last 15 minutes
When: POST /api/mobile/auth/login
Then: Response 429 with error_code "rate_limited"
And: Retry-After header contains seconds until reset
```

**Scenario 4: Token Refresh with Rotation**
```gherkin
Given: Valid refresh token exists in database
When: POST /api/mobile/auth/refresh with refreshToken
Then: Response 200 with new accessToken
And: Response includes new refreshToken (rotated)
And: Old refresh token is revoked in database
```

**Scenario 5: JWT Protected Endpoint**
```gherkin
Given: Valid access token
When: POST /api/mobile/currentQueue/ou00 with Authorization: Bearer <token>
Then: Request proceeds to route handler
And: app.jwtUser.userId is set from token payload
```

**Scenario 6: Expired Token Rejected**
```gherkin
Given: Expired access token
When: POST /api/mobile/currentQueue/ou00 with Authorization: Bearer <expired>
Then: Response 401 with WWW-Authenticate: Bearer header
And: Request does NOT fall back to APIKey
```

**Scenario 7: APIKey Backward Compatibility**
```gherkin
Given: Valid APIKey in mobileAPI table
And: No Authorization header present
When: POST /api/mobile/currentQueue/ou00 with APIKey in body
Then: Request proceeds to route handler
And: Deprecated usage is logged for migration tracking
```

**Scenario 8: Store Access Denied (CRITICAL SECURITY TEST)**
```gherkin
Given: Valid access token for user with access to store "ou00" only
When: POST /api/mobile/currentQueue/pa00 with Authorization: Bearer <token>
Then: Response 403 with error_code "store_access_denied"
And: Request does NOT reach the route handler
And: Unauthorized store access attempt is logged
```

**Scenario 9: Concurrent Refresh Token Rotation**
```gherkin
Given: Valid refresh token for user
When: Two devices simultaneously POST /api/mobile/auth/refresh with same refreshToken
Then: Only one request succeeds with new tokens
And: Second request receives 401 with error_code "invalid_token" or "revoked_token"
And: App on failed device should prompt user to login again
```

### Test Coverage Requirements

- **Unit Tests**:
  - MobileAuthController: login, refresh, logout methods
  - HybridAuthMiddleware: JWT path, APIKey path, no auth path
  - StoreAccessMiddleware: valid access, denied access, no typeNum
  - AuthContext: construction, helper methods
  - Rate limiting logic (with Redis mock)

- **Integration Tests**:
  - Full login flow with database
  - Token refresh with rotation (atomic transaction)
  - Logout and session revocation
  - Store-scoped endpoint access validation

- **Security Tests (CRITICAL)**:
  - Expired token rejection
  - Revoked token rejection
  - Rate limiting enforcement
  - Invalid signature rejection
  - **Store access denial** (JWT valid but user lacks store assignment)
  - Concurrent refresh token rotation race condition

---

## Glossary

### Domain Terms

| Term | Definition | Context |
|------|------------|---------|
| typeNum | Store identifier pattern (e.g., "ou00", "pa00") | Used in URLs and database names |
| Employee | Store-level worker record | Linked to user for store context |
| Role | Employee permission level (1=Owner, 2=Manager, etc.) | Determines API access |

### Technical Terms

| Term | Definition | Context |
|------|------------|---------|
| Access Token | Short-lived JWT (15 min) for API authentication | Sent in Authorization header |
| Refresh Token | Long-lived token (30 days) to get new access tokens | Stored hashed in database |
| Token Rotation | Issuing new refresh token on each use | Security enhancement |
| Bearer Token | Token type in Authorization header | "Bearer xyz123..." |
| HS256 | HMAC-SHA256 JWT signing algorithm | Symmetric key signing |

### API Terms

| Term | Definition | Context |
|------|------------|---------|
| APIKey | Legacy static authentication key | Being deprecated for JWT |
| HybridAuth | Middleware supporting both JWT and APIKey | Transition period support |
| deviceFingerprint | UUID identifying specific device | Audit trail and session management |
