diff --git a/composer.json b/composer.json
index e3d9eb5731d8ef22a7d47ccb3bfc84e0fb634d5c..4249162c1c211421f1685d83674d460993a13bd7 100644
--- a/composer.json
+++ b/composer.json
@@ -23,16 +23,18 @@
         "thecodingmachine/safe": "^1.0|^2.0"
     },
     "require-dev": {
+        "ekino/phpstan-banned-code": "^1.0",
         "infection/infection": "^0.26",
-        "phpunit/phpunit": "^9.5",
         "phpstan/phpstan": "^1.0",
         "phpstan/phpstan-beberlei-assert": "^1.0",
         "phpstan/phpstan-deprecation-rules": "^1.0",
         "phpstan/phpstan-phpunit": "^1.0",
         "phpstan/phpstan-strict-rules": "^1.0",
-        "thecodingmachine/phpstan-safe-rule": "^1.0",
+        "phpunit/phpunit": "^9.5",
         "rector/rector": "^0.12.11",
-        "symplify/easy-coding-standard": "^10.0"
+        "symfony/phpunit-bridge": "^6.0",
+        "symplify/easy-coding-standard": "^10.0",
+        "thecodingmachine/phpstan-safe-rule": "^1.0"
     },
     "autoload": {
         "psr-4": { "OTPHP\\": "src/" }
diff --git a/ecs.php b/ecs.php
index 0454311c1a3abf2b5b57852481ffc240a2ec51c0..cccdf766486789582628b8b03e954fd4cb90f476 100644
--- a/ecs.php
+++ b/ecs.php
@@ -14,6 +14,7 @@ use PhpCsFixer\Fixer\Import\OrderedImportsFixer;
 use PhpCsFixer\Fixer\LanguageConstruct\CombineConsecutiveIssetsFixer;
 use PhpCsFixer\Fixer\LanguageConstruct\CombineConsecutiveUnsetsFixer;
 use PhpCsFixer\Fixer\Phpdoc\AlignMultilineCommentFixer;
+use PhpCsFixer\Fixer\Phpdoc\GeneralPhpdocAnnotationRemoveFixer;
 use PhpCsFixer\Fixer\Phpdoc\NoSuperfluousPhpdocTagsFixer;
 use PhpCsFixer\Fixer\Phpdoc\PhpdocOrderFixer;
 use PhpCsFixer\Fixer\Phpdoc\PhpdocTrimConsecutiveBlankLineSeparationFixer;
@@ -105,20 +106,13 @@ return static function (ContainerConfigurator $containerConfigurator): void {
         ]])
     ;
 
+    $services->remove(GeneralPhpdocAnnotationRemoveFixer::class);
     $services->remove(PhpUnitTestClassRequiresCoversFixer::class);
 
     $parameters = $containerConfigurator->parameters();
     $parameters
         ->set(Option::PARALLEL, true)
         ->set(Option::PATHS, [__DIR__])
-        ->set(Option::SKIP, [
-            __DIR__ . '/src/Kernel.php',
-            __DIR__ . '/assets',
-            __DIR__ . '/bin',
-            __DIR__ . '/config',
-            __DIR__ . '/heroku',
-            __DIR__ . '/public',
-            __DIR__ . '/var',
-        ])
+        ->set(Option::SKIP, [__DIR__ . '/.github', __DIR__ . '/doc', __DIR__ . '/vendor'])
     ;
 };
diff --git a/phpstan.neon b/phpstan.neon
index 4b0aa288751dcd108a62a197e086e3aacedb4358..53210dd7205f727c3b6200e9fb594382a07a47ab 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -13,3 +13,4 @@ includes:
     - vendor/thecodingmachine/phpstan-safe-rule/phpstan-safe-rule.neon
     - vendor/phpstan/phpstan-beberlei-assert/extension.neon
     - vendor/phpstan/phpstan-phpunit/rules.neon
+    - vendor/ekino/phpstan-banned-code/extension.neon
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 52370048ae694c783b7a7133a71d0b53924faa2a..6b57145444ff66ce98a9f629da5f55a94433a485 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -15,4 +15,7 @@
       <directory suffix="Test.php">./tests</directory>
     </testsuite>
   </testsuites>
+  <listeners>
+    <listener class="Symfony\Bridge\PhpUnit\SymfonyTestsListener"/>
+  </listeners>
 </phpunit>
diff --git a/src/Factory.php b/src/Factory.php
index 4b917749f33e120869b9bcf6ce4ea0c6aaa21d5e..eba1a4e73c0b1f3a4f74b03f0223d8ec8a5eac8f 100644
--- a/src/Factory.php
+++ b/src/Factory.php
@@ -19,6 +19,7 @@ final class Factory implements FactoryInterface
     {
         try {
             $parsed_url = Url::fromString($uri);
+            Assertion::eq('otpauth', $parsed_url->getScheme());
         } catch (Throwable $throwable) {
             throw new InvalidArgumentException('Not a valid OTP provisioning URI', $throwable->getCode(), $throwable);
         }
diff --git a/src/TOTP.php b/src/TOTP.php
index bba3f436becf3b332487636f81c0814fe17ce909..ea9c0affcca3e95af03acc3f6d43dec07467a9d3 100644
--- a/src/TOTP.php
+++ b/src/TOTP.php
@@ -29,7 +29,7 @@ final class TOTP extends OTP implements TOTPInterface
     public function getPeriod(): int
     {
         $value = $this->getParameter('period');
-        Assertion::integer($value, 'Invalid "epoch" period.');
+        Assertion::integer($value, 'Invalid "period" parameter.');
 
         return $value;
     }
@@ -42,6 +42,13 @@ final class TOTP extends OTP implements TOTPInterface
         return $value;
     }
 
+    public function expiresIn(): int
+    {
+        $period = $this->getPeriod();
+
+        return $period - (time() % $this->getPeriod());
+    }
+
     public function at(int $timestamp): string
     {
         return $this->generateOTP($this->timecode($timestamp));
diff --git a/src/TOTPInterface.php b/src/TOTPInterface.php
index 2d492f941b8180e8eaaee8bfec559b767d871f80..3a6f3e7be593d39dc719fd5d0f588caf673bcb91 100644
--- a/src/TOTPInterface.php
+++ b/src/TOTPInterface.php
@@ -28,5 +28,7 @@ interface TOTPInterface extends OTPInterface
      */
     public function getPeriod(): int;
 
+    public function expiresIn(): int;
+
     public function getEpoch(): int;
 }
diff --git a/tests/TOTPTest.php b/tests/TOTPTest.php
index 59cf1160aff6002b6ec0145c903fd5172f78a534..d97d0eff0e3e7039b068587055407791bbdee333 100644
--- a/tests/TOTPTest.php
+++ b/tests/TOTPTest.php
@@ -11,9 +11,11 @@ use OTPHP\TOTPInterface;
 use ParagonIE\ConstantTime\Base32;
 use PHPUnit\Framework\TestCase;
 use RuntimeException;
+use Symfony\Bridge\PhpUnit\ClockMock;
 
 /**
  * @internal
+ * @group time-sensitive
  */
 final class TOTPTest extends TestCase
 {
@@ -26,7 +28,6 @@ final class TOTPTest extends TestCase
         $this->expectExceptionMessage('The label is not set.');
         $hotp = TOTP::create();
         $hotp->getProvisioningUri();
-        var_dump($hotp->getProvisioningUri());
     }
 
     /**
@@ -101,6 +102,19 @@ final class TOTPTest extends TestCase
         );
     }
 
+    /**
+     * @test
+     * @dataProvider dataRemainingTimeBeforeExpiration
+     */
+    public function getRemainingTimeBeforeExpiration(int $timespamp, int $period, int $expectedRemainder): void
+    {
+        ClockMock::register(TOTP::class);
+        ClockMock::withClockMock($timespamp);
+        $otp = $this->createTOTP(6, 'sha1', $period);
+
+        static::assertSame($expectedRemainder, $otp->expiresIn());
+    }
+
     /**
      * @test
      */
@@ -314,6 +328,27 @@ final class TOTPTest extends TestCase
         );
     }
 
+    /**
+     * @return int[][]
+     */
+    public function dataRemainingTimeBeforeExpiration(): array
+    {
+        return [
+            [1644926810, 90, 40],
+            [1644926810, 30, 10],
+            [1644926810, 20, 10],
+            [1577833199, 90, 1],
+            [1577833199, 30, 1],
+            [1577833199, 20, 1],
+            [1577833200, 90, 90],
+            [1577833200, 30, 30],
+            [1577833200, 20, 20],
+            [1577833201, 90, 89],
+            [1577833201, 30, 29],
+            [1577833201, 20, 19],
+        ];
+    }
+
     private function createTOTP(
         int $digits,
         string $digest,