| Server IP : 35.80.110.71 / Your IP : 216.73.216.221 Web Server : Apache/2.4.58 (Ubuntu) System : Linux ip-172-31-21-44 6.17.0-1019-aws #19~24.04.1-Ubuntu SMP Tue Jun 23 18:53:06 UTC 2026 x86_64 User : ubuntu ( 1000) PHP Version : 8.3.31 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /var/www/switchyard/current/app/Services/ClickUp/ |
Upload File : |
<?php
namespace App\Services\ClickUp;
use App\Models\ClickUpConnection;
use App\Models\Workspace;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Str;
use RuntimeException;
/**
* ClickUp OAuth v2 flow.
*
* Authorize URL: https://app.clickup.com/api?client_id=…&redirect_uri=…&state=…
* Token URL: POST https://api.clickup.com/api/v2/oauth/token
*
* ClickUp's OAuth v2 does NOT support PKCE and does NOT return a refresh token
* or expiry on the token response — tokens are long-lived until the user
* revokes them in their ClickUp account. The state-in-session check is the
* CSRF defense; ensureFresh() is a no-op until that changes.
*/
class ClickUpOAuthClient
{
private const SESSION_PREFIX = 'clickup.oauth.';
private const STATE_TTL_SECONDS = 600;
public function authorizeUrl(Workspace $workspace): string
{
$state = Str::random(40);
Session::put(self::SESSION_PREFIX.$state, [
'workspace_id' => $workspace->id,
'created_at' => now()->timestamp,
]);
return config('switchyard.clickup.authorize_base').'?'.http_build_query([
'client_id' => $this->clientId(),
'redirect_uri' => $this->redirectUri(),
'state' => $state,
]);
}
/**
* @return array{workspace_id: string}
*/
public function consumeState(string $state): array
{
$payload = Session::pull(self::SESSION_PREFIX.$state);
if (! is_array($payload) || ! isset($payload['workspace_id'])) {
throw new RuntimeException('Unknown or expired OAuth state.');
}
if (isset($payload['created_at']) && (now()->timestamp - $payload['created_at']) > self::STATE_TTL_SECONDS) {
throw new RuntimeException('Expired OAuth state.');
}
return ['workspace_id' => $payload['workspace_id']];
}
public function exchangeCode(string $code): string
{
$response = $this->http()->asForm()->post(
config('switchyard.clickup.api_base').'/oauth/token',
[
'client_id' => $this->clientId(),
'client_secret' => $this->clientSecret(),
'code' => $code,
],
);
if (! $response->ok()) {
Log::warning('ClickUp OAuth token exchange failed', [
'status' => $response->status(),
'body' => $response->body(),
]);
throw new RuntimeException('ClickUp rejected the OAuth code (status '.$response->status().').');
}
$token = (string) $response->json('access_token');
if ($token === '') {
throw new RuntimeException('ClickUp returned an empty access_token.');
}
return $token;
}
/**
* Fetch the teams (orgs) the access token can see. The first team is the
* canonical `team_id` for the connection — most installs only have one.
*
* @return array{teams: array<int, array{id: string, name: string}>}
*/
public function fetchAuthorizedTeams(string $accessToken): array
{
$response = $this->http()
->withHeaders(['Authorization' => $accessToken])
->get(config('switchyard.clickup.api_base').'/team');
if (! $response->ok()) {
throw new RuntimeException('Could not list ClickUp teams (status '.$response->status().').');
}
return [
'teams' => array_map(
fn (array $t) => ['id' => (string) $t['id'], 'name' => (string) $t['name']],
(array) $response->json('teams', []),
),
];
}
public function persistTokens(string $workspaceId, string $accessToken, ?string $teamId = null): ClickUpConnection
{
$connection = ClickUpConnection::firstOrNew(['workspace_id' => $workspaceId]);
$connection->access_token = $accessToken;
$connection->team_id = $teamId ?? $connection->team_id ?? 'pending';
$connection->connected_at = now();
$connection->disconnected_at = null;
$connection->last_sync_error = null;
$connection->save();
return $connection;
}
/**
* No-op for ClickUp v2 — tokens don't expire. Kept on the surface so a
* future ClickUp v3 (or another provider) drops in without touching
* callers.
*/
public function ensureFresh(ClickUpConnection $connection): ClickUpConnection
{
return $connection;
}
private function http(): PendingRequest
{
return Http::acceptJson()->timeout(15);
}
private function clientId(): string
{
$value = (string) config('switchyard.clickup.client_id');
if ($value === '') {
throw new RuntimeException('CLICKUP_CLIENT_ID is not set.');
}
return $value;
}
private function clientSecret(): string
{
$value = (string) config('switchyard.clickup.client_secret');
if ($value === '') {
throw new RuntimeException('CLICKUP_CLIENT_SECRET is not set.');
}
return $value;
}
private function redirectUri(): string
{
$value = (string) config('switchyard.clickup.redirect_uri');
if ($value === '') {
throw new RuntimeException('CLICKUP_REDIRECT_URI is not set.');
}
return $value;
}
}