Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions lib/SessionManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -361,8 +361,9 @@ private function getCachedJwks(string $clientId, bool $forceRefresh = false): ar
* Decode and validate an access token JWT.
*
* Verifies the JWS signature against the JWKS published for `$clientId`,
* enforces an algorithm allow-list, and rejects expired tokens. This is
* the only path used by {@see authenticate()}; callers must not bypass it.
* enforces an algorithm allow-list, and requires a numeric, unexpired exp
* claim. This is the only path used by {@see authenticate()}; callers must
* not bypass it.
*
* @param string $accessToken The JWT access token.
* @param string $clientId The WorkOS client ID (used to fetch JWKS).
Expand Down Expand Up @@ -436,8 +437,12 @@ private function decodeAccessToken(
throw new \InvalidArgumentException('JWT signature verification failed');
}

// Expiration check (after signature verification).
if (isset($decoded['exp']) && is_numeric($decoded['exp']) && (int) $decoded['exp'] < time()) {
// Require expiration after signature verification; missing or malformed
// claims must not bypass the expiry check.
if (!isset($decoded['exp']) || !is_numeric($decoded['exp'])) {
throw new \InvalidArgumentException('JWT exp claim is missing or invalid');
}
if ((int) $decoded['exp'] <= time()) {
throw new \InvalidArgumentException('JWT has expired');
}
Comment on lines +445 to 447

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Fractional expirations end early

Casting exp to an integer before comparing it rejects valid fractional expirations during their final second. For example, when time() is 1700000000, an exp of 1700000000.5 is still in the future but is truncated to 1700000000 and returned as invalid_jwt. This matters because the test matrix explicitly treats floating-point expiration claims as supported.

Suggested change
if ((int) $decoded['exp'] <= time()) {
throw new \InvalidArgumentException('JWT has expired');
}
if ((float) $decoded['exp'] <= time()) {
throw new \InvalidArgumentException('JWT has expired');
}

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/SessionManager.php
Line: 445-447

Comment:
**Fractional expirations end early**

Casting `exp` to an integer before comparing it rejects valid fractional expirations during their final second. For example, when `time()` is `1700000000`, an `exp` of `1700000000.5` is still in the future but is truncated to `1700000000` and returned as `invalid_jwt`. This matters because the test matrix explicitly treats floating-point expiration claims as supported.

```suggestion
        if ((float) $decoded['exp'] <= time()) {
            throw new \InvalidArgumentException('JWT has expired');
        }
```

**Knowledge Base Used:**
- [SSO and session management](https://app.greptile.com/workos/-/custom-context/knowledge-base/workos/workos-php/-/docs/sso-and-session-management.md)
- [Authentication and sessions](https://app.greptile.com/workos/-/custom-context/knowledge-base/workos/workos-php/-/docs/authentication.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.


Expand Down
11 changes: 11 additions & 0 deletions tests/Fixtures/session_expiration_clock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace WorkOS;

// Loaded only by isolated session expiration tests.
function time(): int
{
return 1700000000;
}
59 changes: 59 additions & 0 deletions tests/SessionManagerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

namespace Tests;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\PreserveGlobalState;
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
use PHPUnit\Framework\TestCase;
use WorkOS\SessionManager;
use WorkOS\TestHelper;
Expand Down Expand Up @@ -203,6 +206,62 @@ public function testAuthenticateValidatesSignedJwt(): void
$this->assertSame('org_test', $result['organization_id']);
}

/**
* @return array<string, array{0: array<string, mixed>, 1: bool}>
*/
public static function expirationClaimsProvider(): array
{
// The isolated test clock is fixed at 1700000000.
return [
'missing' => [[], false],
'null' => [['exp' => null], false],
'non-numeric string' => [['exp' => 'never'], false],
'empty string' => [['exp' => ''], false],
'true' => [['exp' => true], false],
'false' => [['exp' => false], false],
'array' => [['exp' => [1700003600]], false],
'object' => [['exp' => (object) ['value' => 1700003600]], false],
'expired' => [['exp' => 1699999999], false],
'expired numeric string' => [['exp' => '1699999999'], false],
'exactly now' => [['exp' => 1700000000], false],
'exactly now numeric string' => [['exp' => '1700000000'], false],
'future' => [['exp' => 1700000001], true],
'future numeric string' => [['exp' => '1700000001'], true],
'future float' => [['exp' => 1700000001.5], true],
];
}

#[DataProvider('expirationClaimsProvider')]
#[RunInSeparateProcess]
#[PreserveGlobalState(false)]
public function testAuthenticateRequiresUnexpiredNumericExp(array $claims, bool $authenticated): void
{
// Keep the exact expiry boundary deterministic without changing the
// production clock or leaking the clock override into other tests.
require __DIR__ . '/Fixtures/session_expiration_clock.php';

[$jwks, $jwt] = $this->buildSignedJwt(['sid' => 'session_test'] + $claims);
$sealed = SessionManager::sealSessionFromAuthResponse(
accessToken: $jwt,
refreshToken: 'ref_test',
cookiePassword: $this->cookiePassword,
);

$client = $this->createMockClient([['status' => 200, 'body' => $jwks]]);
$result = $client->sessionManager()->authenticate(
sessionData: $sealed,
cookiePassword: $this->cookiePassword,
clientId: 'client_123',
);

$this->assertSame($authenticated, $result['authenticated']);
if ($authenticated) {
$this->assertSame('session_test', $result['session_id']);
} else {
$this->assertSame('invalid_jwt', $result['reason']);
}
}

public function testAuthenticateRejectsTamperedSignature(): void
{
[$jwks, $jwt] = $this->buildSignedJwt([
Expand Down
Loading