PluginProbe
Metricool – Social media and site statistics / 2.0.2
Metricool – Social media and site statistics v2.0.2
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.0.2, at app/Features/Onboarding/Services/OAuthService.php

174 lines 5.1 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 private EnvironmentConfig $env;
13 private MetricoolApi $api;
14
15 public function __construct(EnvironmentConfig $env, MetricoolApi $api)
16 {
17 $this->env = $env;
18 $this->api = $api;
19 }
20
21 /**
22 * Retrieves the redirect URL for the OAuth flow.
23 */
24 public function getRedirectUrl(): string
25 {
26 return rest_url($this->env->getString('http.namespace') . '/' . $this->env->getString('http.version') . '/onboarding/oauth_callback');
27 }
28
29 /**
30 * Generates the authorization URL for the OAuth flow, including a state parameter for security.
31 */
32 public function getAuthorizationUrl(): string
33 {
34 $state = $this->generateState();
35 $redirectUri = $this->getRedirectUrl();
36
37 return add_query_arg([
38 'client_id' => $this->env->getString('metricool.oauth_client_id'),
39 'state' => $state,
40 'response_type' => 'code',
41 'redirect_uri' => $redirectUri,
42 'code_challenge' => 'login',
43 ], $this->env->getString('metricool.oauth_authorize_url'));
44 }
45
46 /**
47 * Authenticates the user using the OAuth code and state parameters.
48 */
49 public function authenticateWithCode(string $code, string $state): bool
50 {
51 // Verify state to prevent CSRF
52 $validated = $this->validateState($state);
53
54 if ($validated === 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 if (empty($tokenData['access_token']) || empty($tokenData['refresh_token']) || empty($tokenData['expires_in'])) {
66 throw new RuntimeException('missing_token_data');
67 }
68
69 // Retrieve the user ID from the access token
70 $userId = $this->parseUserIdFromAccessToken($tokenData['access_token']);
71
72 if (empty($userId)) {
73 throw new RuntimeException('token_parse_failed');
74 }
75
76 // Authenticate the Metricool API Client
77 $this->api->authenticate(
78 $userId,
79 (string) $tokenData['access_token'],
80 (string) $tokenData['refresh_token'],
81 (int) ($tokenData['expires_in'])
82 );
83
84 return true;
85 }
86
87 /**
88 * Validates the state parameter from the OAuth flow.
89 */
90 public function validateState(string $state): bool
91 {
92 $storedState = $this->getStoredState();
93 $this->deleteStoredState();
94
95 return $state === $storedState;
96 }
97
98 /**
99 * Generates a unique state parameter for the OAuth flow and stores it in a transient.
100 * The state parameter is used to prevent CSRF attacks.
101 */
102 private function generateState(): string
103 {
104 $state = wp_generate_password(32, false);
105
106 $this->storeState($state);
107
108 return $state;
109 }
110
111 /**
112 * Retrieves the stored state parameter from the transient. Returns an empty string if not found.
113 */
114 private function getStoredState(): string
115 {
116 return (string) get_option('metricool_oauth_state');
117 }
118
119 /**
120 * Stores the state parameter in a transient for later validation. The transient expires after 15 minutes.
121 */
122 private function storeState(string $state): void
123 {
124 update_option('metricool_oauth_state', $state, false);
125 }
126
127 /**
128 * Deletes the stored state parameter from the transient.
129 */
130 private function deleteStoredState(): void
131 {
132 delete_option('metricool_oauth_state');
133 }
134
135 /**
136 * Parses the user ID from the given access token. The access token is expected to be a JWT with a compressed payload.
137 */
138 public function parseUserIdFromAccessToken(string $accessToken): ?string
139 {
140 $parts = explode('.', $accessToken);
141 if (count($parts) !== 3) {
142 return null;
143 }
144
145 // Step 1 – base64url-decode the payload (second segment)
146 $payloadB64 = $parts[1];
147 $payloadBytes = base64_decode(strtr($payloadB64, '-_', '+/'));
148 if (!$payloadBytes) {
149 return null;
150 }
151
152 // Step 2 – if compressed, decompress (zlib DEFLATE with header, wbits = 15)
153 $decoded = zlib_decode($payloadBytes);
154 $json = ($decoded !== false ? $decoded : $payloadBytes);
155 if (empty($json)) {
156 return null;
157 }
158
159 // Step 3 – decode JSON and read the "sub" claim
160 $claims = json_decode($json, true);
161 if (!is_array($claims) || empty($claims['sub'])) {
162 return null;
163 }
164
165 // sub is "user:999999" – extract the numeric part
166 $subject = $claims['sub'];
167 if (strpos($subject, 'user:') === 0) {
168 return substr($subject, strlen('user:'));
169 }
170
171 return null;
172 }
173 }
174