Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 108
0.00% covered (danger)
0.00%
0 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
TokenService
0.00% covered (danger)
0.00%
0 / 108
0.00% covered (danger)
0.00%
0 / 16
650
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 createAccessToken
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
 createRefreshToken
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
2
 validateAccessToken
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 validateRefreshToken
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
6
 revokeRefreshToken
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 revokeAllUserTokens
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 rotateRefreshToken
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 getUserActiveTokens
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 revokeTokenById
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 cleanupExpiredTokens
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getPrivateKey
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getPublicKey
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 generateTokenId
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAccessTokenTTL
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRefreshTokenTTL
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Token Service
4 *
5 * Handles JWT access token creation/validation and refresh token management.
6 * Uses RS256 signing for access tokens and opaque hashed refresh tokens.
7 *
8 * @package BuyerKiosk\Auth\Services
9 */
10
11namespace BuyerKiosk\Auth\Services;
12
13use Firebase\JWT\JWT;
14use Firebase\JWT\Key;
15
16class TokenService
17{
18    /**
19     * @var string Path to private key file
20     */
21    private $privateKeyPath;
22
23    /**
24     * @var string Path to public key file
25     */
26    private $publicKeyPath;
27
28    /**
29     * @var string|null Cached private key
30     */
31    private $privateKey = null;
32
33    /**
34     * @var string|null Cached public key
35     */
36    private $publicKey = null;
37
38    /**
39     * @var string Key ID for JWT header
40     */
41    private $keyId = 'key-2025-01';
42
43    /**
44     * @var int Access token TTL in seconds (15 minutes)
45     */
46    private $accessTokenTTL = 900;
47
48    /**
49     * @var int Refresh token TTL in seconds (7 days)
50     */
51    private $refreshTokenTTL = 604800;
52
53    /**
54     * @var string JWT issuer
55     */
56    private $issuer = 'buyerkiosk';
57
58    /**
59     * @var \PDO Database connection
60     */
61    private $db;
62
63    /**
64     * Constructor
65     *
66     * @param \PDO $db Database connection for refresh token storage
67     * @param string|null $privateKeyPath Path to private key file
68     * @param string|null $publicKeyPath Path to public key file
69     */
70    public function __construct(\PDO $db, ?string $privateKeyPath = null, ?string $publicKeyPath = null)
71    {
72        $this->db = $db;
73        $this->privateKeyPath = $privateKeyPath ?? __DIR__ . '/../../../../config/keys/jwt_private.pem';
74        $this->publicKeyPath = $publicKeyPath ?? __DIR__ . '/../../../../config/keys/jwt_public.pem';
75    }
76
77    /**
78     * Create an access token (JWT) for a user
79     *
80     * @param array $user User data (id, username, email, displayName, accountType)
81     * @param array $stores Array of store typeNums the user can access
82     * @param array $permissions Array of permission hooks
83     * @return string JWT access token
84     */
85    public function createAccessToken(array $user, array $stores = [], array $permissions = []): string
86    {
87        $now = time();
88        $payload = [
89            'iss' => $this->issuer,
90            'sub' => (string) $user['id'],
91            'iat' => $now,
92            'exp' => $now + $this->accessTokenTTL,
93            'jti' => $this->generateTokenId(),
94            'user' => [
95                'id' => (int) $user['id'],
96                'username' => $user['username'] ?? null,
97                'email' => $user['email'] ?? null,
98                'displayName' => $user['displayName'] ?? null,
99                'accountType' => $user['accountType'] ?? 'user'
100            ],
101            'stores' => $stores,
102            'permissions' => $permissions
103        ];
104
105        return JWT::encode($payload, $this->getPrivateKey(), 'RS256', $this->keyId);
106    }
107
108    /**
109     * Create a refresh token for a user
110     *
111     * @param int $userId User ID
112     * @param array $deviceInfo Device information (name, fingerprint, ip, userAgent)
113     * @return string Plaintext refresh token (to be sent to client)
114     */
115    public function createRefreshToken(int $userId, array $deviceInfo = []): string
116    {
117        // Generate cryptographically secure token
118        $plaintextToken = bin2hex(random_bytes(32)); // 64 hex characters
119
120        // Hash before storing
121        $hashedToken = hash('sha256', $plaintextToken);
122
123        $expiresAt = date('Y-m-d H:i:s', time() + $this->refreshTokenTTL);
124
125        // Store hashed token in database
126        $stmt = $this->db->prepare("
127            INSERT INTO oauthRefreshTokens
128            (userId, token, deviceName, deviceFingerprint, ipAddress, userAgent, expiresAt, createdAt)
129            VALUES (:userId, :token, :deviceName, :fingerprint, :ip, :userAgent, :expiresAt, NOW())
130        ");
131        $stmt->execute([
132            'userId' => $userId,
133            'token' => $hashedToken,
134            'deviceName' => $deviceInfo['deviceName'] ?? null,
135            'fingerprint' => $deviceInfo['fingerprint'] ?? null,
136            'ip' => $deviceInfo['ip'] ?? null,
137            'userAgent' => $deviceInfo['userAgent'] ?? null,
138            'expiresAt' => $expiresAt
139        ]);
140
141        return $plaintextToken;
142    }
143
144    /**
145     * Validate an access token (JWT)
146     *
147     * @param string $token JWT access token
148     * @return array|null Decoded payload or null if invalid
149     */
150    public function validateAccessToken(string $token): ?array
151    {
152        try {
153            $decoded = JWT::decode($token, new Key($this->getPublicKey(), 'RS256'));
154            return (array) $decoded;
155        } catch (\Firebase\JWT\ExpiredException $e) {
156            error_log("TokenService: Access token expired");
157            return null;
158        } catch (\Firebase\JWT\SignatureInvalidException $e) {
159            error_log("TokenService: Invalid token signature");
160            return null;
161        } catch (\Exception $e) {
162            error_log("TokenService: Token validation failed - " . $e->getMessage());
163            return null;
164        }
165    }
166
167    /**
168     * Validate a refresh token and return associated data
169     *
170     * @param string $plaintextToken The plaintext refresh token
171     * @return array|null Token data (userId, etc.) or null if invalid
172     */
173    public function validateRefreshToken(string $plaintextToken): ?array
174    {
175        $hashedToken = hash('sha256', $plaintextToken);
176
177        $stmt = $this->db->prepare("
178            SELECT id, userId, deviceName, ipAddress, expiresAt, lastUsedAt
179            FROM oauthRefreshTokens
180            WHERE token = :token
181              AND expiresAt > NOW()
182              AND revokedAt IS NULL
183        ");
184        $stmt->execute(['token' => $hashedToken]);
185        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
186
187        if (!$row) {
188            return null;
189        }
190
191        // Update last used timestamp
192        $updateStmt = $this->db->prepare("
193            UPDATE oauthRefreshTokens SET lastUsedAt = NOW() WHERE id = :id
194        ");
195        $updateStmt->execute(['id' => $row['id']]);
196
197        return [
198            'tokenId' => (int) $row['id'],
199            'userId' => (int) $row['userId'],
200            'deviceName' => $row['deviceName'],
201            'ipAddress' => $row['ipAddress'],
202            'expiresAt' => $row['expiresAt'],
203            'lastUsedAt' => $row['lastUsedAt']
204        ];
205    }
206
207    /**
208     * Revoke a specific refresh token
209     *
210     * @param string $plaintextToken The plaintext refresh token
211     * @return bool True if revoked, false if not found
212     */
213    public function revokeRefreshToken(string $plaintextToken): bool
214    {
215        $hashedToken = hash('sha256', $plaintextToken);
216
217        $stmt = $this->db->prepare("
218            UPDATE oauthRefreshTokens
219            SET revokedAt = NOW()
220            WHERE token = :token AND revokedAt IS NULL
221        ");
222        $stmt->execute(['token' => $hashedToken]);
223
224        return $stmt->rowCount() > 0;
225    }
226
227    /**
228     * Revoke all refresh tokens for a user
229     *
230     * @param int $userId User ID
231     * @return int Number of tokens revoked
232     */
233    public function revokeAllUserTokens(int $userId): int
234    {
235        $stmt = $this->db->prepare("
236            UPDATE oauthRefreshTokens
237            SET revokedAt = NOW()
238            WHERE userId = :userId AND revokedAt IS NULL
239        ");
240        $stmt->execute(['userId' => $userId]);
241
242        return $stmt->rowCount();
243    }
244
245    /**
246     * Rotate a refresh token (revoke old, create new)
247     *
248     * @param string $oldToken The current refresh token
249     * @param array $deviceInfo Device information for new token
250     * @return array|null New token pair or null if old token invalid
251     */
252    public function rotateRefreshToken(string $oldToken, array $deviceInfo = []): ?array
253    {
254        $tokenData = $this->validateRefreshToken($oldToken);
255
256        if (!$tokenData) {
257            return null;
258        }
259
260        // Revoke old token
261        $this->revokeRefreshToken($oldToken);
262
263        // Create new refresh token
264        $newRefreshToken = $this->createRefreshToken($tokenData['userId'], $deviceInfo);
265
266        return [
267            'userId' => $tokenData['userId'],
268            'refreshToken' => $newRefreshToken
269        ];
270    }
271
272    /**
273     * Get user's active refresh tokens (for session management UI)
274     *
275     * @param int $userId User ID
276     * @return array List of active tokens with device info
277     */
278    public function getUserActiveTokens(int $userId): array
279    {
280        $stmt = $this->db->prepare("
281            SELECT id, deviceName, deviceFingerprint, ipAddress, userAgent, createdAt, lastUsedAt, expiresAt
282            FROM oauthRefreshTokens
283            WHERE userId = :userId
284              AND revokedAt IS NULL
285              AND expiresAt > NOW()
286            ORDER BY lastUsedAt DESC
287        ");
288        $stmt->execute(['userId' => $userId]);
289
290        return $stmt->fetchAll(\PDO::FETCH_ASSOC);
291    }
292
293    /**
294     * Revoke a specific token by ID (for session management)
295     *
296     * @param int $userId User ID (for authorization check)
297     * @param int $tokenId Token ID to revoke
298     * @return bool True if revoked
299     */
300    public function revokeTokenById(int $userId, int $tokenId): bool
301    {
302        $stmt = $this->db->prepare("
303            UPDATE oauthRefreshTokens
304            SET revokedAt = NOW()
305            WHERE id = :tokenId AND userId = :userId AND revokedAt IS NULL
306        ");
307        $stmt->execute(['tokenId' => $tokenId, 'userId' => $userId]);
308
309        return $stmt->rowCount() > 0;
310    }
311
312    /**
313     * Clean up expired tokens (should be run periodically)
314     *
315     * @return int Number of tokens cleaned up
316     */
317    public function cleanupExpiredTokens(): int
318    {
319        $stmt = $this->db->prepare("
320            DELETE FROM oauthRefreshTokens
321            WHERE expiresAt < DATE_SUB(NOW(), INTERVAL 7 DAY)
322               OR (revokedAt IS NOT NULL AND revokedAt < DATE_SUB(NOW(), INTERVAL 7 DAY))
323        ");
324        $stmt->execute();
325
326        return $stmt->rowCount();
327    }
328
329    /**
330     * Get private key contents
331     *
332     * @return string Private key PEM
333     * @throws \RuntimeException If key file not found
334     */
335    private function getPrivateKey(): string
336    {
337        if ($this->privateKey === null) {
338            if (!file_exists($this->privateKeyPath)) {
339                throw new \RuntimeException("JWT private key not found at: " . $this->privateKeyPath);
340            }
341            $this->privateKey = file_get_contents($this->privateKeyPath);
342        }
343        return $this->privateKey;
344    }
345
346    /**
347     * Get public key contents
348     *
349     * @return string Public key PEM
350     * @throws \RuntimeException If key file not found
351     */
352    private function getPublicKey(): string
353    {
354        if ($this->publicKey === null) {
355            if (!file_exists($this->publicKeyPath)) {
356                throw new \RuntimeException("JWT public key not found at: " . $this->publicKeyPath);
357            }
358            $this->publicKey = file_get_contents($this->publicKeyPath);
359        }
360        return $this->publicKey;
361    }
362
363    /**
364     * Generate a unique token ID for JWT jti claim
365     *
366     * @return string Unique ID
367     */
368    private function generateTokenId(): string
369    {
370        return bin2hex(random_bytes(16));
371    }
372
373    /**
374     * Get the access token TTL
375     *
376     * @return int TTL in seconds
377     */
378    public function getAccessTokenTTL(): int
379    {
380        return $this->accessTokenTTL;
381    }
382
383    /**
384     * Get the refresh token TTL
385     *
386     * @return int TTL in seconds
387     */
388    public function getRefreshTokenTTL(): int
389    {
390        return $this->refreshTokenTTL;
391    }
392}