From 07c0c9efb7312d0537a0ecdd13cb4a04fb7456e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 19 Aug 2026 15:57:09 +0200 Subject: [PATCH] feat: own the Origin install handshake on the adapter Origin has no OAuth2 - installs are approved on Cursor and confirmed by an EdDSA-signed receipt - so consumers were left modelling the handshake as an OAuth2 client, duplicating this adapter's Ed25519 and JWKS machinery to do it. The adapter now owns its own protocol: - getInstallUrl() builds the install-page URL, with the empty-scope app-metadata quirk documented in one place - verifyReceipt() verifies a receipt JWT against the published JWKS and returns its claims - getSigningKeys() hands out the active Ed25519 keys in the shape validateWebhookEvent() accepts, so webhook consumers stop fetching the JWKS by hand Co-Authored-By: Claude Fable 5 --- src/VCS/Adapter/Git/Origin.php | 190 +++++++++++++++++++++++++++++++ tests/VCS/Adapter/OriginTest.php | 146 ++++++++++++++++++++++++ 2 files changed, 336 insertions(+) diff --git a/src/VCS/Adapter/Git/Origin.php b/src/VCS/Adapter/Git/Origin.php index 91cf04fd..1bc93932 100644 --- a/src/VCS/Adapter/Git/Origin.php +++ b/src/VCS/Adapter/Git/Origin.php @@ -69,6 +69,13 @@ class Origin extends Git protected string $accessToken = ''; + /** + * Origin's published JWKS, memoized per instance. + * + * @var array>|null + */ + protected ?array $jwks = null; + protected string $jwtToken = ''; protected string $installationId = ''; @@ -236,6 +243,189 @@ protected function base64UrlEncode(string $data): string return \rtrim(\strtr(\base64_encode($data), '+/', '-_'), '='); } + protected function base64UrlDecode(string $data): string + { + $remainder = \strlen($data) % 4; + if ($remainder > 0) { + $data .= \str_repeat('=', 4 - $remainder); + } + + return (string) \base64_decode(\strtr($data, '-_', '+/'), true); + } + + /** + * URL of the app's installation page on Cursor. A consumer sends a user + * here to approve the installation; Cursor confirms it by redirecting to + * $redirectUri with an installation receipt (see verifyReceipt()) - there + * is no authorization-code exchange. + * + * @param array $scopes Requested scopes. An empty list reads the + * scopes registered on the app's metadata + * instead, since the install page refuses an + * explicit empty list. + * @param string $redirectUri Must exactly match a redirect URI registered on the app + * @param string $state Opaque value echoed back as the receipt's state claim + */ + public function getInstallUrl(string $appId, array $scopes = [], string $redirectUri = '', string $state = ''): string + { + $params = ['client_id' => $appId]; + + if (empty($scopes)) { + $params['source'] = 'app-metadata'; + } else { + $params['scope'] = \implode(' ', $scopes); + } + + if (!empty($redirectUri)) { + $params['redirect_uri'] = $redirectUri; + } + + if (!empty($state)) { + $params['state'] = $state; + } + + return $this->webEndpoint . '/apps/install?' . \http_build_query($params); + } + + /** + * Verifies an installation receipt JWT against Origin's published signing + * keys and returns its claims. The receipt is Cursor's only proof of an + * installation, so a consumer must verify it before trusting anything + * else on its callback. The `sub` claim is the installation id, `state` + * echoes the state given to the install URL, and `namespace_id` names + * the workspace. + * + * @return array + * @throws Exception + */ + public function verifyReceipt(string $receipt, string $appId): array + { + $segments = \explode('.', $receipt); + + if (\count($segments) !== 3) { + throw new Exception('Installation receipt is not a JWT'); + } + + [$headerSegment, $claimsSegment, $signatureSegment] = $segments; + + $header = \json_decode($this->base64UrlDecode($headerSegment), true); + $claims = \json_decode($this->base64UrlDecode($claimsSegment), true); + $signature = $this->base64UrlDecode($signatureSegment); + + if (!\is_array($header) || !\is_array($claims)) { + throw new Exception('Installation receipt is malformed'); + } + + if (($header['alg'] ?? '') !== 'EdDSA') { + throw new Exception('Installation receipt has an unexpected algorithm'); + } + + // Receipts are documented to carry this media type; tolerate its + // absence, but never another Cursor-signed token kind in its place. + if (isset($header['typ']) && $header['typ'] !== 'origin-installation-receipt+jwt') { + throw new Exception('Installation receipt has an unexpected type'); + } + + $key = $this->signingKey(\strval($header['kid'] ?? '')); + + if ( + \strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES + || !\sodium_crypto_sign_verify_detached($signature, $headerSegment . '.' . $claimsSegment, $key) + ) { + throw new Exception('Installation receipt signature is invalid'); + } + + if (($claims['iss'] ?? '') !== $this->endpoint) { + throw new Exception('Installation receipt issuer is invalid'); + } + + if (($claims['aud'] ?? '') !== $appId) { + throw new Exception('Installation receipt audience does not match the app id'); + } + + $now = \time(); + $leeway = 60; + + // Receipts are short-lived, single-use artifacts (about a five-minute + // window per Cursor's verification rules). + if (isset($claims['exp']) && $now >= (int) $claims['exp'] + $leeway) { + throw new Exception('Installation receipt has expired'); + } + + if (isset($claims['nbf']) && $now < (int) $claims['nbf'] - $leeway) { + throw new Exception('Installation receipt is not yet valid'); + } + + if (isset($claims['iat']) && $now < (int) $claims['iat'] - $leeway) { + throw new Exception('Installation receipt is issued in the future'); + } + + if (empty($claims['sub'])) { + throw new Exception('Installation receipt is missing the installation ID'); + } + + return $claims; + } + + /** + * Origin's active Ed25519 signing keys as base64url raw key material - + * the shape validateWebhookEvent() accepts. Pass $refresh when a + * signature fails against a cached set: the keys rotate rarely, but a + * delivery signed by a just-rotated key deserves one refetch. + * + * @return array + */ + public function getSigningKeys(bool $refresh = false): array + { + return \array_values(\array_map(fn ($key) => \strval($key['x']), $this->jwks($refresh))); + } + + /** + * Origin's published Ed25519 JWKS entries. + * + * @return array> + */ + protected function jwks(bool $refresh = false): array + { + if (!$refresh && $this->jwks !== null) { + return $this->jwks; + } + + $response = $this->call(self::METHOD_GET, '/keys'); + + $keys = []; + $body = \is_array($response['body'] ?? null) ? $response['body'] : []; + foreach (\is_array($body['keys'] ?? null) ? $body['keys'] : [] as $key) { + if (($key['kty'] ?? '') === 'OKP' && ($key['crv'] ?? '') === 'Ed25519' && !empty($key['x'])) { + $keys[] = $key; + } + } + + return $this->jwks = $keys; + } + + /** + * Raw Ed25519 public key bytes for a JWKS key id. + * + * @return non-empty-string + * @throws Exception + */ + protected function signingKey(string $kid): string + { + foreach ($this->jwks() as $key) { + if (\strval($key['kid'] ?? '') !== $kid) { + continue; + } + + $publicKey = $this->ed25519PublicKey(\strval($key['x'])); + if ($publicKey !== null) { + return $publicKey; + } + } + + throw new Exception('Installation receipt is signed with an unknown key'); + } + /** * Get user * diff --git a/tests/VCS/Adapter/OriginTest.php b/tests/VCS/Adapter/OriginTest.php index 8e271614..b95601c9 100644 --- a/tests/VCS/Adapter/OriginTest.php +++ b/tests/VCS/Adapter/OriginTest.php @@ -7,6 +7,23 @@ use Utopia\Cache\Cache; use Utopia\VCS\Adapter\Git\Origin; +/** + * Origin whose JWKS is a local fixture, so receipt verification runs the + * real code path - key lookup included - without touching the network. + */ +class FixtureKeysOrigin extends Origin +{ + /** + * @var array> + */ + public array $fixtureKeys = []; + + protected function jwks(bool $refresh = false): array + { + return $this->fixtureKeys; + } +} + /** * Exercises everything Origin can prove without credentials: webhook * signature verification and delivery parsing, which never touch the @@ -176,6 +193,135 @@ protected function pullRequestPayload(string $type = 'pull_request.created'): ar ]; } + public function testGetInstallUrl(): void + { + $url = $this->adapter->getInstallUrl( + 'app_0123456789', + ['repository:contents:read', 'repository:checks:write'], + 'https://appwrite.test/v1/vcs/origin/callback', + '{"projectId":"p1"}' + ); + + $parsed = \parse_url($url); + \parse_str($parsed['query'] ?? '', $params); + + $this->assertSame('https', $parsed['scheme'] ?? ''); + $this->assertSame('cursor.com', $parsed['host'] ?? ''); + $this->assertSame('/codebase/apps/install', $parsed['path'] ?? ''); + $this->assertSame('app_0123456789', $params['client_id'] ?? ''); + $this->assertSame('repository:contents:read repository:checks:write', $params['scope'] ?? ''); + $this->assertSame('https://appwrite.test/v1/vcs/origin/callback', $params['redirect_uri'] ?? ''); + $this->assertSame('{"projectId":"p1"}', $params['state'] ?? ''); + $this->assertArrayNotHasKey('source', $params); + } + + public function testGetInstallUrlWithoutScopesReadsAppMetadata(): void + { + // The install page refuses an explicit empty scope list unless told + // to read the scopes from the app's registered metadata. + \parse_str(\parse_url($this->adapter->getInstallUrl('app_0123456789'), PHP_URL_QUERY) ?: '', $params); + + $this->assertSame('app-metadata', $params['source'] ?? ''); + $this->assertArrayNotHasKey('scope', $params); + $this->assertArrayNotHasKey('redirect_uri', $params); + $this->assertArrayNotHasKey('state', $params); + } + + /** + * Encode a receipt JWT the way Cursor does: EdDSA over + * ".". $secretKey is a libsodium + * secret key. + * + * @param array $header + * @param array $claims + * @param non-empty-string $secretKey + */ + protected function signReceipt(array $header, array $claims, string $secretKey): string + { + $encode = fn (string $data) => \rtrim(\strtr(\base64_encode($data), '+/', '-_'), '='); + $signingInput = $encode(\json_encode($header) ?: '') . '.' . $encode(\json_encode($claims) ?: ''); + + return $signingInput . '.' . $encode(\sodium_crypto_sign_detached($signingInput, $secretKey)); + } + + public function testVerifyReceipt(): void + { + $keyPair = \sodium_crypto_sign_keypair(); + $secretKey = \sodium_crypto_sign_secretkey($keyPair); + $publicKey = \rtrim(\strtr(\base64_encode(\sodium_crypto_sign_publickey($keyPair)), '+/', '-_'), '='); + + $adapter = new FixtureKeysOrigin(new Cache(new None())); + $adapter->fixtureKeys = [['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'k1', 'x' => $publicKey]]; + + $header = ['alg' => 'EdDSA', 'kid' => 'k1', 'typ' => 'origin-installation-receipt+jwt']; + $claims = [ + 'iss' => 'https://api.cursor.com/v1/origin', + 'aud' => 'app_0123456789', + 'sub' => 'i_0123456789', + 'state' => '{"projectId":"p1"}', + 'namespace_id' => 'ns_0123456789', + 'iat' => \time(), + 'exp' => \time() + 300, + ]; + + $verified = $adapter->verifyReceipt($this->signReceipt($header, $claims, $secretKey), 'app_0123456789'); + + $this->assertSame('i_0123456789', $verified['sub']); + $this->assertSame('{"projectId":"p1"}', $verified['state']); + $this->assertSame('ns_0123456789', $verified['namespace_id']); + } + + public function testVerifyReceiptRejections(): void + { + $keyPair = \sodium_crypto_sign_keypair(); + $secretKey = \sodium_crypto_sign_secretkey($keyPair); + $publicKey = \rtrim(\strtr(\base64_encode(\sodium_crypto_sign_publickey($keyPair)), '+/', '-_'), '='); + + $adapter = new FixtureKeysOrigin(new Cache(new None())); + $adapter->fixtureKeys = [['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'k1', 'x' => $publicKey]]; + + $header = ['alg' => 'EdDSA', 'kid' => 'k1', 'typ' => 'origin-installation-receipt+jwt']; + $claims = [ + 'iss' => 'https://api.cursor.com/v1/origin', + 'aud' => 'app_0123456789', + 'sub' => 'i_0123456789', + 'iat' => \time(), + 'exp' => \time() + 300, + ]; + + $cases = [ + 'not a JWT' => ['not-a-jwt', 'app_0123456789'], + 'wrong algorithm' => [$this->signReceipt(['alg' => 'HS256'] + $header, $claims, $secretKey), 'app_0123456789'], + 'wrong token type' => [$this->signReceipt(['typ' => 'other+jwt'] + $header, $claims, $secretKey), 'app_0123456789'], + 'unknown key id' => [$this->signReceipt(['kid' => 'k2'] + $header, $claims, $secretKey), 'app_0123456789'], + 'wrong audience' => [$this->signReceipt($header, $claims, $secretKey), 'app_other'], + 'wrong issuer' => [$this->signReceipt($header, ['iss' => 'https://evil.test'] + $claims, $secretKey), 'app_0123456789'], + 'expired' => [$this->signReceipt($header, ['exp' => \time() - 300] + $claims, $secretKey), 'app_0123456789'], + 'missing installation id' => [$this->signReceipt($header, ['sub' => ''] + $claims, $secretKey), 'app_0123456789'], + 'signed by a different key' => [$this->signReceipt($header, $claims, \sodium_crypto_sign_secretkey(\sodium_crypto_sign_keypair())), 'app_0123456789'], + ]; + + foreach ($cases as $name => [$receipt, $appId]) { + try { + $adapter->verifyReceipt($receipt, $appId); + $this->fail("Expected the '{$name}' receipt to be rejected"); + } catch (\Exception $e) { + $this->assertNotEmpty($e->getMessage(), $name); + } + } + } + + public function testGetSigningKeys(): void + { + $adapter = new FixtureKeysOrigin(new Cache(new None())); + $adapter->fixtureKeys = [ + ['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'k1', 'x' => 'first-key'], + ['kty' => 'OKP', 'crv' => 'Ed25519', 'kid' => 'k2', 'x' => 'second-key'], + ]; + + $this->assertSame(['first-key', 'second-key'], $adapter->getSigningKeys()); + } + public function testGetEventPush(): void { $events = $this->adapter->getEvents('repository.pushed', (string) \json_encode($this->pushPayload('main')));