PluginProbe
Metricool – Social media and site statistics / 2.1.1
Metricool – Social media and site statistics v2.1.1
2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.27 trunk
← All changes | app/Features/Onboarding/Services/OAuthService.php +169 -0 2.0.1 → 2.1.1 View file →
@@ -1,0 +1,169 @@
1 +<?php
2 +
3 +namespace Metricool\Features\Onboarding\Services;
4 +
5 +use Metricool\Http\Metricool\MetricoolApi;
6 +use Metricool\Support\Helpers\Storages\EnvironmentConfig;
7 +use RuntimeException;
8 +use Throwable;
9 +
10 +class OAuthService
11 +{
12 + public const OPTION_OAUTH_STATE = 'metricool_oauth_state';
13 + public const REDIRECT_ACTION = 'oauth_callback';
14 +
15 + private EnvironmentConfig $env;
16 + private MetricoolApi $api;
17 +
18 + public function __construct(EnvironmentConfig $env, MetricoolApi $api)
19 + {
20 + $this->env = $env;
21 + $this->api = $api;
22 + }
23 +
24 + /**
25 + * Retrieves the redirect URL for the OAuth flow.
26 + */
27 + public function getRedirectUrl(): string
28 + {
29 + return $this->env->getString('plugin.dashboard_url') . '&metricool_action=' . self::REDIRECT_ACTION;
30 + }
31 +
32 + /**
33 + * Generates the authorization URL for the OAuth flow, including a state parameter for security.
34 + */
35 + public function getAuthorizationUrl(): string
36 + {
37 + $state = $this->generateState();
38 + $redirectUri = $this->getRedirectUrl();
39 +
40 + return add_query_arg([
41 + 'client_id' => $this->env->getString('metricool.oauth_client_id'),
42 + 'state' => $state,
43 + 'response_type' => 'code',
44 + 'redirect_uri' => urlencode($redirectUri),
45 + 'code_challenge' => 'login',
46 + ], $this->env->getString('metricool.oauth_authorize_url'));
47 + }
48 +
49 + /**
50 + * Authenticates the user using the OAuth code and state parameters.
51 + */
52 + public function authenticateWithCode(string $code, string $state): bool
53 + {
54 + if ($this->isValidState($state) === false) {
55 + throw new RuntimeException('invalid_state');
56 + }
57 +
58 + try {
59 + // Exchange the code for auth tokens
60 + $tokenData = $this->api->exchangeOAuthCode($code, $this->getRedirectUrl());
61 + } catch (Throwable $e) {
62 + throw new RuntimeException('token_exchange_failed');
63 + }
64 +
65 + // Retrieve the user ID from the access token
66 + $userId = $this->parseUserIdFromAccessToken($tokenData['access_token']);
67 +
68 + if (empty($userId)) {
69 + throw new RuntimeException('token_parse_failed');
70 + }
71 +
72 + // Authenticate the Metricool API Client
73 + $this->api->authenticate(
74 + $userId,
75 + (string) $tokenData['access_token'],
76 + (string) $tokenData['refresh_token'],
77 + (int) ($tokenData['expires_in'])
78 + );
79 +
80 + return true;
81 + }
82 +
83 + /**
84 + * Validates the state parameter from the OAuth flow.
85 + */
86 + public function isValidState(string $state): bool
87 + {
88 + $storedState = $this->getStoredState();
89 + $this->deleteStoredState();
90 +
91 + return $state === $storedState;
92 + }
93 +
94 + /**
95 + * Generates a unique state parameter for the OAuth flow and stores it in a transient.
96 + * The state parameter is used to prevent CSRF attacks.
97 + */
98 + private function generateState(): string
99 + {
100 + $state = wp_generate_password(32, false);
101 +
102 + $this->storeState($state);
103 +
104 + return $state;
105 + }
106 +
107 + /**
108 + * Retrieves the stored state parameter from the transient. Returns an empty string if not found.
109 + */
110 + private function getStoredState(): string
111 + {
112 + return (string) get_option(self::OPTION_OAUTH_STATE, '');
113 + }
114 +
115 + /**
116 + * Stores the state parameter in a transient for later validation. The transient expires after 15 minutes.
117 + */
118 + private function storeState(string $state): void
119 + {
120 + update_option(self::OPTION_OAUTH_STATE, $state, false);
121 + }
122 +
123 + /**
124 + * Deletes the stored state parameter from the transient.
125 + */
126 + private function deleteStoredState(): void
127 + {
128 + delete_option(self::OPTION_OAUTH_STATE);
129 + }
130 +
131 + /**
132 + * Parses the user ID from the given access token. The access token is expected to be a JWT with a compressed payload.
133 + */
134 + public function parseUserIdFromAccessToken(string $accessToken): ?string
135 + {
136 + $parts = explode('.', $accessToken);
137 + if (count($parts) !== 3) {
138 + return null;
139 + }
140 +
141 + // Step 1 – base64url-decode the payload (second segment)
142 + $payloadB64 = $parts[1];
143 + $payloadBytes = base64_decode(strtr($payloadB64, '-_', '+/'));
144 + if (!$payloadBytes) {
145 + return null;
146 + }
147 +
148 + // Step 2 – if compressed, decompress (zlib DEFLATE with header, wbits = 15)
149 + $decoded = zlib_decode($payloadBytes);
150 + $json = ($decoded !== false ? $decoded : $payloadBytes);
151 + if (empty($json)) {
152 + return null;
153 + }
154 +
155 + // Step 3 – decode JSON and read the "sub" claim
156 + $claims = json_decode($json, true);
157 + if (!is_array($claims) || empty($claims['sub'])) {
158 + return null;
159 + }
160 +
161 + // sub is "user:999999" – extract the numeric part
162 + $subject = $claims['sub'];
163 + if (strpos($subject, 'user:') === 0) {
164 + return substr($subject, strlen('user:'));
165 + }
166 +
167 + return null;
168 + }
169 +}