connection->fetch($sql, [$userId, $provider]); return $row ? StoredOAuthToken::fromArray($row) : null; } /** * Find token by ID */ public function findById(int $id): ?StoredOAuthToken { $sql = "SELECT * FROM " . self::TABLE . " WHERE id = ? LIMIT 1"; $row = $this->connection->fetch($sql, [$id]); return $row ? StoredOAuthToken::fromArray($row) : null; } /** * Save or update token */ public function save(StoredOAuthToken $storedToken): StoredOAuthToken { if ($storedToken->id === null) { return $this->insert($storedToken); } return $this->update($storedToken); } /** * Save token for user (upsert by user_id + provider) */ public function saveForUser( string $userId, string $provider, OAuthToken $token ): StoredOAuthToken { $existing = $this->findForUser($userId, $provider); if ($existing !== null) { $updated = $existing->withRefreshedToken($token); return $this->update($updated); } $storedToken = StoredOAuthToken::create($userId, $provider, $token); return $this->insert($storedToken); } /** * Delete token for user and provider */ public function deleteForUser(string $userId, string $provider): bool { $sql = "DELETE FROM " . self::TABLE . " WHERE user_id = ? AND provider = ?"; $this->connection->execute($sql, [$userId, $provider]); return true; } /** * Delete expired tokens without refresh token (cleanup job) */ public function deleteExpiredWithoutRefreshToken(): int { $sql = "DELETE FROM " . self::TABLE . " WHERE expires_at < ? AND refresh_token IS NULL"; $this->connection->execute($sql, [time()]); return $this->connection->rowCount(); } /** * Get all tokens for a user * * @return array */ public function findAllForUser(string $userId): array { $sql = "SELECT * FROM " . self::TABLE . " WHERE user_id = ?"; $rows = $this->connection->fetchAll($sql, [$userId]); return array_map( fn (array $row) => StoredOAuthToken::fromArray($row), $rows ); } /** * Get all tokens expiring soon (for refresh worker) * * @return array */ public function findExpiringSoon(int $withinSeconds = 300): array { $expiryThreshold = time() + $withinSeconds; $sql = "SELECT * FROM " . self::TABLE . " WHERE expires_at < ? AND refresh_token IS NOT NULL"; $rows = $this->connection->fetchAll($sql, [$expiryThreshold]); return array_map( fn (array $row) => StoredOAuthToken::fromArray($row), $rows ); } /** * Insert new token */ private function insert(StoredOAuthToken $storedToken): StoredOAuthToken { $data = $storedToken->toArray(); unset($data['id']); // Don't insert ID $sql = "INSERT INTO " . self::TABLE . " (user_id, provider, access_token, refresh_token, token_type, scope, expires_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"; $this->connection->execute($sql, [ $data['user_id'], $data['provider'], $data['access_token'], $data['refresh_token'], $data['token_type'], $data['scope'], $data['expires_at'], $data['created_at'], $data['updated_at'], ]); $id = (int) $this->connection->lastInsertId(); return new StoredOAuthToken( id: $id, userId: $storedToken->userId, provider: $storedToken->provider, token: $storedToken->token, createdAt: $storedToken->createdAt, updatedAt: $storedToken->updatedAt, ); } /** * Update existing token */ private function update(StoredOAuthToken $storedToken): StoredOAuthToken { if ($storedToken->id === null) { throw FrameworkException::create( ErrorCode::ENTITY_NOT_FOUND, 'Cannot update token without ID' ); } $data = $storedToken->toArray(); $sql = "UPDATE " . self::TABLE . " SET access_token = ?, refresh_token = ?, token_type = ?, scope = ?, expires_at = ?, updated_at = ? WHERE id = ?"; $this->connection->execute($sql, [ $data['access_token'], $data['refresh_token'], $data['token_type'], $data['scope'], $data['expires_at'], $data['updated_at'], $storedToken->id, ]); return $storedToken; } }