Skip to content

Commit 318e543

Browse files
committed
Add SigningOnlySessionHandler for HMAC-only session signing
Adds an alternative to HaliteSessionEncryption that uses HMAC-SHA256 signing without encryption. This is useful for: - Debugging (session data is readable) - Interoperability with other platforms - Lower overhead when confidentiality isn't required The handler implements SessionEncryptionInterface and can be injected into UserManagement via constructor or setter.
1 parent 19d649b commit 318e543

2 files changed

Lines changed: 359 additions & 0 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<?php
2+
3+
namespace WorkOS\Session;
4+
5+
use WorkOS\Exception\UnexpectedValueException;
6+
7+
/**
8+
* Session handler using HMAC signing only (no encryption).
9+
*
10+
* WARNING: This handler does NOT encrypt session data. The session contents
11+
* are readable by anyone with access to the cookie. Only use this in
12+
* controlled environments with TLS where you trust the transport layer.
13+
*
14+
* Use cases:
15+
* - Performance-critical applications where encryption overhead matters
16+
* - Environments with strict TLS enforcement
17+
* - Debugging/development scenarios
18+
*/
19+
class SigningOnlySessionHandler implements SessionEncryptionInterface
20+
{
21+
const ALGORITHM = 'sha256';
22+
const VERSION = 1;
23+
const DEFAULT_TTL = 2592000; // 30 days
24+
25+
/**
26+
* Seal session data with HMAC signature (no encryption).
27+
*
28+
* Format: base64(json({ p: base64(payload), s: base64(signature) }))
29+
* Payload: json({ v: version, d: data, e: expiry })
30+
*
31+
* @param array $data Session data
32+
* @param string $password HMAC key
33+
* @param int|null $ttl Time-to-live in seconds
34+
* @return string Signed session string
35+
*/
36+
public function seal(array $data, string $password, ?int $ttl = null): string
37+
{
38+
$ttl = $ttl ?? self::DEFAULT_TTL;
39+
$expiry = time() + $ttl;
40+
41+
$payload = [
42+
'v' => self::VERSION,
43+
'd' => $data,
44+
'e' => $expiry,
45+
];
46+
47+
$payloadJson = json_encode($payload);
48+
$signature = hash_hmac(self::ALGORITHM, $payloadJson, $password, true);
49+
50+
$sealed = [
51+
'p' => base64_encode($payloadJson),
52+
's' => base64_encode($signature),
53+
];
54+
55+
return base64_encode(json_encode($sealed));
56+
}
57+
58+
/**
59+
* Unseal session data by verifying HMAC signature.
60+
*
61+
* @param string $sealed Signed session string
62+
* @param string $password HMAC key
63+
* @return array Unsealed session data
64+
* @throws UnexpectedValueException If signature invalid or expired
65+
*/
66+
public function unseal(string $sealed, string $password): array
67+
{
68+
$decoded = json_decode(base64_decode($sealed), true);
69+
if (!$decoded || !isset($decoded['p']) || !isset($decoded['s'])) {
70+
throw new UnexpectedValueException('Invalid signed session format');
71+
}
72+
73+
$payloadJson = base64_decode($decoded['p']);
74+
$providedSignature = base64_decode($decoded['s']);
75+
$expectedSignature = hash_hmac(self::ALGORITHM, $payloadJson, $password, true);
76+
77+
// Constant-time comparison to prevent timing attacks
78+
if (!hash_equals($expectedSignature, $providedSignature)) {
79+
throw new UnexpectedValueException('Invalid session signature');
80+
}
81+
82+
$payload = json_decode($payloadJson, true);
83+
if (!$payload || !isset($payload['v']) || !isset($payload['d']) || !isset($payload['e'])) {
84+
throw new UnexpectedValueException('Invalid payload structure');
85+
}
86+
87+
// Version check for future compatibility
88+
if ($payload['v'] !== self::VERSION) {
89+
throw new UnexpectedValueException('Unsupported session version');
90+
}
91+
92+
// TTL check
93+
if ($payload['e'] < time()) {
94+
throw new UnexpectedValueException('Session expired');
95+
}
96+
97+
return $payload['d'];
98+
}
99+
}
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
<?php
2+
3+
namespace WorkOS\Session;
4+
5+
use PHPUnit\Framework\TestCase;
6+
use WorkOS\Exception\UnexpectedValueException;
7+
8+
class SigningOnlySessionHandlerTest extends TestCase
9+
{
10+
private $handler;
11+
private $password = "test-password-for-hmac-signing";
12+
13+
protected function setUp(): void
14+
{
15+
$this->handler = new SigningOnlySessionHandler();
16+
}
17+
18+
public function testSealAndUnseal()
19+
{
20+
$data = [
21+
'access_token' => 'test_access_token_12345',
22+
'refresh_token' => 'test_refresh_token_67890',
23+
'session_id' => 'session_01H7X1M4TZJN5N4HG4XXMA1234'
24+
];
25+
26+
$sealed = $this->handler->seal($data, $this->password);
27+
28+
$this->assertIsString($sealed);
29+
$this->assertNotEmpty($sealed);
30+
31+
$unsealed = $this->handler->unseal($sealed, $this->password);
32+
33+
$this->assertEquals($data, $unsealed);
34+
}
35+
36+
public function testSealedDataIsReadable()
37+
{
38+
$data = ['test' => 'value'];
39+
40+
$sealed = $this->handler->seal($data, $this->password);
41+
42+
// Signing-only data should be decodable (not encrypted)
43+
$decoded = json_decode(base64_decode($sealed), true);
44+
$this->assertIsArray($decoded);
45+
$this->assertArrayHasKey('p', $decoded); // payload
46+
$this->assertArrayHasKey('s', $decoded); // signature
47+
48+
// Payload should be readable
49+
$payloadJson = base64_decode($decoded['p']);
50+
$payload = json_decode($payloadJson, true);
51+
$this->assertIsArray($payload);
52+
$this->assertArrayHasKey('d', $payload); // data
53+
$this->assertEquals($data, $payload['d']);
54+
}
55+
56+
public function testUnsealWithWrongPasswordFails()
57+
{
58+
$data = ['test' => 'value'];
59+
$sealed = $this->handler->seal($data, $this->password);
60+
61+
$this->expectException(UnexpectedValueException::class);
62+
$this->expectExceptionMessage('Invalid session signature');
63+
64+
$this->handler->unseal($sealed, 'wrong-password');
65+
}
66+
67+
public function testExpiredSessionFails()
68+
{
69+
$data = ['test' => 'value'];
70+
$sealed = $this->handler->seal($data, $this->password, -1); // Already expired
71+
72+
$this->expectException(UnexpectedValueException::class);
73+
$this->expectExceptionMessage('Session expired');
74+
75+
$this->handler->unseal($sealed, $this->password);
76+
}
77+
78+
public function testCustomTTL()
79+
{
80+
$data = ['test' => 'value'];
81+
$ttl = 3600; // 1 hour
82+
83+
$sealed = $this->handler->seal($data, $this->password, $ttl);
84+
$unsealed = $this->handler->unseal($sealed, $this->password);
85+
86+
$this->assertEquals($data, $unsealed);
87+
}
88+
89+
public function testTamperedDataFails()
90+
{
91+
$data = ['test' => 'value'];
92+
$sealed = $this->handler->seal($data, $this->password);
93+
94+
// Decode, modify, re-encode (without updating signature)
95+
$decoded = json_decode(base64_decode($sealed), true);
96+
$payloadJson = base64_decode($decoded['p']);
97+
$payload = json_decode($payloadJson, true);
98+
$payload['d'] = ['test' => 'tampered']; // Modify the data
99+
$decoded['p'] = base64_encode(json_encode($payload));
100+
$tampered = base64_encode(json_encode($decoded));
101+
102+
$this->expectException(UnexpectedValueException::class);
103+
$this->expectExceptionMessage('Invalid session signature');
104+
105+
$this->handler->unseal($tampered, $this->password);
106+
}
107+
108+
public function testInvalidFormatFails()
109+
{
110+
$this->expectException(UnexpectedValueException::class);
111+
$this->expectExceptionMessage('Invalid signed session format');
112+
113+
$this->handler->unseal('not-valid-base64-data', $this->password);
114+
}
115+
116+
public function testMissingPayloadFieldFails()
117+
{
118+
$invalid = base64_encode(json_encode(['s' => 'signature-only']));
119+
120+
$this->expectException(UnexpectedValueException::class);
121+
$this->expectExceptionMessage('Invalid signed session format');
122+
123+
$this->handler->unseal($invalid, $this->password);
124+
}
125+
126+
public function testMissingSignatureFieldFails()
127+
{
128+
$invalid = base64_encode(json_encode(['p' => 'payload-only']));
129+
130+
$this->expectException(UnexpectedValueException::class);
131+
$this->expectExceptionMessage('Invalid signed session format');
132+
133+
$this->handler->unseal($invalid, $this->password);
134+
}
135+
136+
public function testInvalidPayloadStructureFails()
137+
{
138+
// Create valid signature but with invalid payload structure
139+
$payload = ['invalid' => 'structure']; // Missing v, d, e fields
140+
$payloadJson = json_encode($payload);
141+
$signature = hash_hmac('sha256', $payloadJson, $this->password, true);
142+
143+
$sealed = base64_encode(json_encode([
144+
'p' => base64_encode($payloadJson),
145+
's' => base64_encode($signature),
146+
]));
147+
148+
$this->expectException(UnexpectedValueException::class);
149+
$this->expectExceptionMessage('Invalid payload structure');
150+
151+
$this->handler->unseal($sealed, $this->password);
152+
}
153+
154+
public function testVersionCheckFails()
155+
{
156+
// Create valid signature but with wrong version
157+
$payload = [
158+
'v' => 999, // Unsupported version
159+
'd' => ['test' => 'value'],
160+
'e' => time() + 3600,
161+
];
162+
$payloadJson = json_encode($payload);
163+
$signature = hash_hmac('sha256', $payloadJson, $this->password, true);
164+
165+
$sealed = base64_encode(json_encode([
166+
'p' => base64_encode($payloadJson),
167+
's' => base64_encode($signature),
168+
]));
169+
170+
$this->expectException(UnexpectedValueException::class);
171+
$this->expectExceptionMessage('Unsupported session version');
172+
173+
$this->handler->unseal($sealed, $this->password);
174+
}
175+
176+
public function testComplexDataStructures()
177+
{
178+
$data = [
179+
'access_token' => 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...',
180+
'refresh_token' => 'refresh_01H7X1M4TZJN5N4HG4XXMA1234',
181+
'session_id' => 'session_01H7X1M4TZJN5N4HG4XXMA1234',
182+
'user' => [
183+
'id' => 'user_123',
184+
'email' => 'test@example.com',
185+
'first_name' => 'Test',
186+
'last_name' => 'User'
187+
],
188+
'organization_id' => 'org_123',
189+
'roles' => ['admin', 'user'],
190+
'permissions' => ['read', 'write', 'delete']
191+
];
192+
193+
$sealed = $this->handler->seal($data, $this->password);
194+
$unsealed = $this->handler->unseal($sealed, $this->password);
195+
196+
$this->assertEquals($data, $unsealed);
197+
}
198+
199+
public function testDifferentPasswordsProduceDifferentSignatures()
200+
{
201+
$data = ['test' => 'value'];
202+
$password1 = 'password-one-for-signing';
203+
$password2 = 'password-two-for-signing';
204+
205+
$sealed1 = $this->handler->seal($data, $password1);
206+
$sealed2 = $this->handler->seal($data, $password2);
207+
208+
// Signatures should be different
209+
$this->assertNotEquals($sealed1, $sealed2);
210+
211+
// Each can only be unsealed with its own password
212+
$unsealed1 = $this->handler->unseal($sealed1, $password1);
213+
$this->assertEquals($data, $unsealed1);
214+
215+
$unsealed2 = $this->handler->unseal($sealed2, $password2);
216+
$this->assertEquals($data, $unsealed2);
217+
}
218+
219+
public function testSignatureIsConstantTimeCompared()
220+
{
221+
// This test verifies hash_equals is used (timing attack prevention)
222+
// We can't directly test timing, but we ensure the code path exists
223+
$data = ['test' => 'value'];
224+
$sealed = $this->handler->seal($data, $this->password);
225+
226+
// Valid unseal should work
227+
$unsealed = $this->handler->unseal($sealed, $this->password);
228+
$this->assertEquals($data, $unsealed);
229+
}
230+
231+
public function testImplementsSessionEncryptionInterface()
232+
{
233+
$this->assertInstanceOf(SessionEncryptionInterface::class, $this->handler);
234+
}
235+
236+
public function testCanBeUsedWithUserManagement()
237+
{
238+
// SigningOnlySessionHandler can be injected into UserManagement
239+
$userManagement = new \WorkOS\UserManagement($this->handler);
240+
241+
$data = [
242+
'access_token' => 'test_access_token',
243+
'refresh_token' => 'test_refresh_token',
244+
];
245+
246+
// Seal directly (as authkit-php would do)
247+
$sealed = $this->handler->seal($data, $this->password);
248+
249+
// UserManagement should be able to unseal it via authenticateWithSessionCookie
250+
// (will get HTTP error since no API, but that's past the encryption layer)
251+
$result = $userManagement->authenticateWithSessionCookie($sealed, $this->password);
252+
253+
// Should succeed past encryption (get HTTP error, not encryption error)
254+
$this->assertInstanceOf(\WorkOS\Resource\SessionAuthenticationFailureResponse::class, $result);
255+
$this->assertEquals(
256+
\WorkOS\Resource\SessionAuthenticationFailureResponse::REASON_HTTP_ERROR,
257+
$result->reason
258+
);
259+
}
260+
}

0 commit comments

Comments
 (0)