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
190 changes: 190 additions & 0 deletions src/VCS/Adapter/Git/Origin.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@ class Origin extends Git

protected string $accessToken = '';

/**
* Origin's published JWKS, memoized per instance.
*
* @var array<array<string, mixed>>|null
*/
protected ?array $jwks = null;

protected string $jwtToken = '';

protected string $installationId = '';
Expand Down Expand Up @@ -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<string> $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<string, mixed>
* @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<string>
*/
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<array<string, mixed>>
*/
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) {
Comment thread
Meldiron marked this conversation as resolved.
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
*
Expand Down
146 changes: 146 additions & 0 deletions tests/VCS/Adapter/OriginTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<array<string, mixed>>
*/
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
Expand Down Expand Up @@ -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
* "<base64url header>.<base64url claims>". $secretKey is a libsodium
* secret key.
*
* @param array<string, mixed> $header
* @param array<string, mixed> $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')));
Expand Down
Loading