Skip to content
Merged
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
15 changes: 11 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,14 @@ 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');
}
// Compare as float so fractional NumericDate values are not truncated
// before the boundary check.
if ((float) $decoded['exp'] <= time()) {
throw new \InvalidArgumentException('JWT has expired');
}

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;
}
61 changes: 61 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,64 @@ 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],
'expired float' => [['exp' => 1699999999.5], false],
'fractional second after now' => [['exp' => 1700000000.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