|
| 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