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
metricool / app / Features / Onboarding / Services / OAuthService.php

OAuthService.php in Metricool – Social media and site statistics 2.1.1, at app/Features/Onboarding/Services/OAuthService.php

170 lines 4.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 }
170