Skip to content

Commit f7fa90f

Browse files
authored
Machine secret support (#50)
* Lint issues * Impl * Update ClerkBeforeRequestHooksTest.php * PR Comments
1 parent ab62dfd commit f7fa90f

11 files changed

+407
-25
lines changed

Tests/Helpers/Jwks/M2MAuthenticationTest.php

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ public function test_microservice_to_microservice_scenario()
4848
// Scenario: Microservice that only accepts M2M tokens
4949
$arOptions = new AuthenticateRequestOptions(
5050
secretKey: 'sk_test_secret',
51-
acceptsToken: ['machine_token']
51+
acceptsToken: ['m2m_token']
5252
);
5353

5454
// Should accept M2M token
@@ -91,7 +91,7 @@ public function test_hybrid_api_scenario()
9191
// Scenario: API that accepts both session tokens and M2M tokens
9292
$arOptions = new AuthenticateRequestOptions(
9393
secretKey: 'sk_test_secret',
94-
acceptsToken: ['session_token', 'machine_token']
94+
acceptsToken: ['session_token', 'm2m_token']
9595
);
9696

9797
// Should accept M2M token
@@ -115,7 +115,7 @@ public function test_machine_token_requires_secret_key()
115115
// Machine tokens should require secret key for verification
116116
$arOptions = new AuthenticateRequestOptions(
117117
jwtKey: 'jwt_key_only',
118-
acceptsToken: ['machine_token']
118+
acceptsToken: ['m2m_token']
119119
);
120120

121121
$m2mContext = $this->createHttpContextWithToken('mt_service_token_123');
@@ -125,9 +125,27 @@ public function test_machine_token_requires_secret_key()
125125
$this->assertEquals(AuthErrorReason::$SECRET_KEY_MISSING, $m2mState->getErrorReason());
126126
}
127127

128+
public function test_machine_token_accepts_machine_secret_key()
129+
{
130+
// Machine tokens should accept machine secret key for verification
131+
$arOptions = new AuthenticateRequestOptions(
132+
machineSecretKey: 'msk_test_machine_secret',
133+
acceptsToken: ['m2m_token']
134+
);
135+
136+
$m2mContext = $this->createHttpContextWithToken('mt_service_token_123');
137+
$m2mState = AuthenticateRequest::authenticateRequest($m2mContext, $arOptions);
138+
139+
// Should not fail due to missing secret key (will fail on actual verification, but that's expected)
140+
$this->assertNotEquals(AuthErrorReason::$SECRET_KEY_MISSING, $m2mState->getErrorReason());
141+
}
142+
128143
public function test_token_type_detection()
129144
{
145+
// Test new mt_ prefix
130146
$this->assertEquals(TokenTypes::MACHINE_TOKEN, TokenTypes::getTokenType('mt_token_123'));
147+
// Test legacy m2m_ prefix
148+
$this->assertEquals(TokenTypes::MACHINE_TOKEN, TokenTypes::getTokenType('m2m_token_123'));
131149
$this->assertEquals(TokenTypes::OAUTH_TOKEN, TokenTypes::getTokenType('oat_token_123'));
132150
$this->assertEquals(TokenTypes::API_KEY, TokenTypes::getTokenType('ak_key_123'));
133151
$this->assertEquals(TokenTypes::SESSION_TOKEN, TokenTypes::getTokenType('eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.session'));
@@ -137,22 +155,30 @@ public function test_token_type_detection()
137155

138156
public function test_token_type_validation_methods()
139157
{
158+
// Test new mt_ prefix
140159
$this->assertTrue(TokenTypes::isMachineToken('mt_token_123'));
160+
// Test legacy m2m_ prefix
161+
$this->assertTrue(TokenTypes::isMachineToken('m2m_token_123'));
141162
$this->assertFalse(TokenTypes::isMachineToken('eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.session'));
142163

143164
$this->assertTrue(TokenTypes::isOAuthToken('oat_token_123'));
144165
$this->assertFalse(TokenTypes::isOAuthToken('mt_token_123'));
166+
$this->assertFalse(TokenTypes::isOAuthToken('m2m_token_123'));
145167

146168
$this->assertTrue(TokenTypes::isApiKey('ak_key_123'));
147169
$this->assertFalse(TokenTypes::isApiKey('oat_token_123'));
148170

149171
$this->assertTrue(TokenTypes::isSessionToken('eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.session'));
150172
$this->assertFalse(TokenTypes::isSessionToken('mt_token_123'));
173+
$this->assertFalse(TokenTypes::isSessionToken('m2m_token_123'));
151174
}
152175

153176
public function test_token_type_name_generation()
154177
{
155-
$this->assertEquals('machine_token', TokenTypes::getTokenTypeName('mt_token_123'));
178+
// Test new mt_ prefix
179+
$this->assertEquals('m2m_token', TokenTypes::getTokenTypeName('mt_token_123'));
180+
// Test legacy m2m_ prefix
181+
$this->assertEquals('m2m_token', TokenTypes::getTokenTypeName('m2m_token_123'));
156182
$this->assertEquals('oauth_token', TokenTypes::getTokenTypeName('oat_token_123'));
157183
$this->assertEquals('api_key', TokenTypes::getTokenTypeName('ak_key_123'));
158184
$this->assertEquals('session_token', TokenTypes::getTokenTypeName('eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.session'));
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Clerk\Backend\Tests\Helpers\Jwks;
6+
7+
use Clerk\Backend\Helpers\Jwks\AuthenticateRequest;
8+
use Clerk\Backend\Helpers\Jwks\AuthenticateRequestOptions;
9+
use Clerk\Backend\Helpers\Jwks\AuthErrorReason;
10+
use Clerk\Backend\Helpers\Jwks\TokenTypes;
11+
use Clerk\Backend\Helpers\Jwks\TokenVerificationException;
12+
use Clerk\Backend\Helpers\Jwks\VerifyToken;
13+
use Clerk\Backend\Helpers\Jwks\VerifyTokenOptions;
14+
use GuzzleHttp\Client;
15+
use GuzzleHttp\Handler\MockHandler;
16+
use GuzzleHttp\HandlerStack;
17+
use GuzzleHttp\Psr7\Request;
18+
use GuzzleHttp\Psr7\Response;
19+
use PHPUnit\Framework\TestCase;
20+
21+
final class M2MTokenVerificationTest extends TestCase
22+
{
23+
private JwksHelpersFixture $fixture;
24+
25+
protected function setUp(): void
26+
{
27+
parent::setUp();
28+
$this->fixture = new JwksHelpersFixture();
29+
}
30+
31+
public function test_token_type_detection_supports_both_prefixes()
32+
{
33+
// Test old m2m_ prefix
34+
$this->assertEquals(TokenTypes::MACHINE_TOKEN, TokenTypes::getTokenType('m2m_old_style_token'));
35+
$this->assertTrue(TokenTypes::isMachineToken('m2m_old_style_token'));
36+
$this->assertEquals('m2m_token', TokenTypes::getTokenTypeName('m2m_old_style_token'));
37+
38+
// Test new mt_ prefix
39+
$this->assertEquals(TokenTypes::MACHINE_TOKEN, TokenTypes::getTokenType('mt_new_style_token'));
40+
$this->assertTrue(TokenTypes::isMachineToken('mt_new_style_token'));
41+
$this->assertEquals('m2m_token', TokenTypes::getTokenTypeName('mt_new_style_token'));
42+
}
43+
44+
public function test_verify_token_options_supports_machine_secret_key()
45+
{
46+
// Test with only machine secret key
47+
$options = new VerifyTokenOptions(
48+
machineSecretKey: 'msk_test_machine_secret'
49+
);
50+
51+
$this->assertNull($options->getSecretKey());
52+
$this->assertEquals('msk_test_machine_secret', $options->getMachineSecretKey());
53+
$this->assertNull($options->getJwtKey());
54+
55+
// Test with both secret key and machine secret key
56+
$options = new VerifyTokenOptions(
57+
secretKey: 'sk_test_secret',
58+
machineSecretKey: 'msk_test_machine_secret'
59+
);
60+
61+
$this->assertEquals('sk_test_secret', $options->getSecretKey());
62+
$this->assertEquals('msk_test_machine_secret', $options->getMachineSecretKey());
63+
}
64+
65+
public function test_verify_token_options_requires_at_least_one_key()
66+
{
67+
$this->expectException(TokenVerificationException::class);
68+
69+
new VerifyTokenOptions();
70+
}
71+
72+
public function test_authenticate_request_options_supports_machine_secret_key()
73+
{
74+
// Test with only machine secret key
75+
$options = new AuthenticateRequestOptions(
76+
machineSecretKey: 'msk_test_machine_secret'
77+
);
78+
79+
$this->assertNull($options->getSecretKey());
80+
$this->assertEquals('msk_test_machine_secret', $options->getMachineSecretKey());
81+
$this->assertNull($options->getJwtKey());
82+
83+
// Test with both secret key and machine secret key
84+
$options = new AuthenticateRequestOptions(
85+
secretKey: 'sk_test_secret',
86+
machineSecretKey: 'msk_test_machine_secret'
87+
);
88+
89+
$this->assertEquals('sk_test_secret', $options->getSecretKey());
90+
$this->assertEquals('msk_test_machine_secret', $options->getMachineSecretKey());
91+
}
92+
93+
public function test_machine_token_verification_with_secret_key()
94+
{
95+
// Test that machine tokens can be verified with regular secret key
96+
$arOptions = new AuthenticateRequestOptions(
97+
secretKey: 'sk_test_secret',
98+
acceptsToken: ['m2m_token']
99+
);
100+
101+
// Should accept mt_ token with secret key
102+
$m2mContext = $this->createHttpContextWithToken('mt_new_token_123');
103+
$m2mState = AuthenticateRequest::authenticateRequest($m2mContext, $arOptions);
104+
105+
// Should not fail due to missing keys (will fail on actual verification, but that's expected)
106+
$this->assertNotEquals(AuthErrorReason::$SECRET_KEY_MISSING, $m2mState->getErrorReason());
107+
108+
// Should accept m2m_ token with secret key
109+
$oldM2mContext = $this->createHttpContextWithToken('m2m_old_token_123');
110+
$oldM2mState = AuthenticateRequest::authenticateRequest($oldM2mContext, $arOptions);
111+
112+
// Should not fail due to missing keys
113+
$this->assertNotEquals(AuthErrorReason::$SECRET_KEY_MISSING, $oldM2mState->getErrorReason());
114+
}
115+
116+
public function test_machine_token_verification_with_machine_secret_key()
117+
{
118+
// Test that machine tokens can be verified with machine secret key
119+
$arOptions = new AuthenticateRequestOptions(
120+
machineSecretKey: 'msk_test_machine_secret',
121+
acceptsToken: ['m2m_token']
122+
);
123+
124+
// Should accept mt_ token with machine secret key
125+
$m2mContext = $this->createHttpContextWithToken('mt_new_token_123');
126+
$m2mState = AuthenticateRequest::authenticateRequest($m2mContext, $arOptions);
127+
128+
// Should not fail due to missing keys
129+
$this->assertNotEquals(AuthErrorReason::$SECRET_KEY_MISSING, $m2mState->getErrorReason());
130+
}
131+
132+
public function test_machine_token_verification_with_both_keys()
133+
{
134+
// Test that machine tokens work when both keys are provided
135+
$arOptions = new AuthenticateRequestOptions(
136+
secretKey: 'sk_test_secret',
137+
machineSecretKey: 'msk_test_machine_secret',
138+
acceptsToken: ['m2m_token']
139+
);
140+
141+
$m2mContext = $this->createHttpContextWithToken('mt_new_token_123');
142+
$m2mState = AuthenticateRequest::authenticateRequest($m2mContext, $arOptions);
143+
144+
// Should not fail due to missing keys
145+
$this->assertNotEquals(AuthErrorReason::$SECRET_KEY_MISSING, $m2mState->getErrorReason());
146+
}
147+
148+
public function test_machine_token_verification_requires_secret_or_machine_key()
149+
{
150+
// Test that machine tokens require either secret key or machine secret key
151+
$arOptions = new AuthenticateRequestOptions(
152+
jwtKey: 'jwt_key_only',
153+
acceptsToken: ['m2m_token']
154+
);
155+
156+
$m2mContext = $this->createHttpContextWithToken('mt_new_token_123');
157+
$m2mState = AuthenticateRequest::authenticateRequest($m2mContext, $arOptions);
158+
159+
// Should fail due to missing secret key or machine secret key
160+
$this->assertEquals(AuthErrorReason::$SECRET_KEY_MISSING, $m2mState->getErrorReason());
161+
}
162+
163+
public function test_verify_machine_token_api_call_with_secret_key()
164+
{
165+
// Mock the HTTP client to test the API call behavior
166+
$mockResponse = new Response(200, [], json_encode([
167+
'object' => 'machine_to_machine_token',
168+
'id' => 'mt_2xhFjEI5X2qWRvtV13BzSj8H6Dk',
169+
'subject' => 'mch_2xhFjEI5X2qWRvtV13BzSj8H6Dk',
170+
'claims' => ['important_metadata' => 'Some useful data'],
171+
'scopes' => [
172+
'mch_2xhFjEI5X2qWRvtV13BzSj8H6Dk',
173+
'mch_2yGkLpQ7Y3rXSwtU24CzTk9I7Em',
174+
],
175+
'name' => 'MY_M2M_TOKEN',
176+
]));
177+
178+
$mockHandler = new MockHandler([$mockResponse]);
179+
$handlerStack = HandlerStack::create($mockHandler);
180+
181+
// We need to test this at the VerifyToken level, but since it's a static method,
182+
// we'll need to test the API call indirectly through mocking
183+
$this->assertInstanceOf(Response::class, $mockResponse);
184+
$this->assertEquals(200, $mockResponse->getStatusCode());
185+
186+
$responseData = json_decode($mockResponse->getBody()->getContents(), true);
187+
$this->assertEquals('machine_to_machine_token', $responseData['object']);
188+
$this->assertEquals('mt_2xhFjEI5X2qWRvtV13BzSj8H6Dk', $responseData['id']);
189+
}
190+
191+
public function test_verify_machine_token_api_call_with_machine_secret_key()
192+
{
193+
// Mock the HTTP client to test the API call behavior with machine secret key
194+
$mockResponse = new Response(200, [], json_encode([
195+
'object' => 'machine_to_machine_token',
196+
'id' => 'mt_2xhFjEI5X2qWRvtV13BzSj8H6Dk',
197+
'subject' => 'mch_2xhFjEI5X2qWRvtV13BzSj8H6Dk',
198+
'claims' => ['important_metadata' => 'Some useful data'],
199+
'scopes' => [
200+
'mch_2xhFjEI5X2qWRvtV13BzSj8H6Dk',
201+
'mch_2yGkLpQ7Y3rXSwtU24CzTk9I7Em',
202+
],
203+
'name' => 'MY_M2M_TOKEN',
204+
]));
205+
206+
$this->assertInstanceOf(Response::class, $mockResponse);
207+
$this->assertEquals(200, $mockResponse->getStatusCode());
208+
209+
$responseData = json_decode($mockResponse->getBody()->getContents(), true);
210+
$this->assertEquals('machine_to_machine_token', $responseData['object']);
211+
$this->assertEquals('MY_M2M_TOKEN', $responseData['name']);
212+
}
213+
214+
public function test_hybrid_scenario_with_machine_secret_key()
215+
{
216+
// Test scenario where API accepts both session tokens and machine tokens
217+
// and machine tokens are verified with machine secret key
218+
$arOptions = new AuthenticateRequestOptions(
219+
jwtKey: 'jwt_key_for_sessions',
220+
machineSecretKey: 'msk_test_machine_secret',
221+
acceptsToken: ['session_token', 'm2m_token']
222+
);
223+
224+
// Machine token should use machine secret key
225+
$m2mContext = $this->createHttpContextWithToken('mt_new_token_123');
226+
$m2mState = AuthenticateRequest::authenticateRequest($m2mContext, $arOptions);
227+
$this->assertNotEquals(AuthErrorReason::$SECRET_KEY_MISSING, $m2mState->getErrorReason());
228+
229+
// Session token should use JWT key
230+
$sessionContext = $this->createHttpContextWithToken('eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.session');
231+
$sessionState = AuthenticateRequest::authenticateRequest($sessionContext, $arOptions);
232+
$this->assertNotEquals(AuthErrorReason::$SECRET_KEY_MISSING, $sessionState->getErrorReason());
233+
}
234+
235+
public function test_backward_compatibility_with_existing_tokens()
236+
{
237+
// Ensure existing m2m_ tokens still work with the new implementation
238+
$this->assertTrue(TokenTypes::isMachineToken('m2m_legacy_token_123'));
239+
$this->assertEquals('m2m_token', TokenTypes::getTokenTypeName('m2m_legacy_token_123'));
240+
241+
// Test with authenticate request
242+
$arOptions = new AuthenticateRequestOptions(
243+
secretKey: 'sk_test_secret',
244+
acceptsToken: ['m2m_token']
245+
);
246+
247+
$legacyContext = $this->createHttpContextWithToken('m2m_legacy_token_123');
248+
$legacyState = AuthenticateRequest::authenticateRequest($legacyContext, $arOptions);
249+
250+
// Should be treated the same as new mt_ tokens
251+
$this->assertNotEquals(AuthErrorReason::$TOKEN_TYPE_NOT_SUPPORTED, $legacyState->getErrorReason());
252+
$this->assertNotEquals(AuthErrorReason::$SECRET_KEY_MISSING, $legacyState->getErrorReason());
253+
}
254+
255+
public function test_machine_secret_key_prioritized_over_secret_key()
256+
{
257+
// When both keys are provided, machine secret key should be used for machine tokens
258+
$arOptions = new AuthenticateRequestOptions(
259+
secretKey: 'sk_test_secret',
260+
machineSecretKey: 'msk_test_machine_secret',
261+
acceptsToken: ['m2m_token']
262+
);
263+
264+
$m2mContext = $this->createHttpContextWithToken('mt_new_token_123');
265+
$m2mState = AuthenticateRequest::authenticateRequest($m2mContext, $arOptions);
266+
267+
// Verification should proceed (will fail on actual API call, but not due to missing keys)
268+
$this->assertNotEquals(AuthErrorReason::$SECRET_KEY_MISSING, $m2mState->getErrorReason());
269+
}
270+
271+
private function createHttpContextWithToken(string $token): Request
272+
{
273+
return new Request('GET', $this->fixture->requestUrl, [
274+
'Authorization' => "Bearer $token",
275+
]);
276+
}
277+
}

0 commit comments

Comments
 (0)