clientId() !== '' && $this->clientSecret() !== '' && $this->redirectUri() !== ''; } public function authorizationUrl(Request $request, bool $promptConsent = true): string { if (! $this->isConfigured()) { throw new AdsenseOAuthException('Google AdSense OAuth is not configured.'); } $state = bin2hex(random_bytes(32)); $request->session()->put(self::SESSION_STATE_KEY, $state); $query = [ 'client_id' => $this->clientId(), 'redirect_uri' => $this->redirectUri(), 'response_type' => 'code', 'scope' => (string) config('adsense.scope'), 'access_type' => 'offline', 'include_granted_scopes' => 'true', 'state' => $state, ]; if ($promptConsent) { $query['prompt'] = 'consent'; } return (string) config('adsense.oauth_authorize_url').'?'.http_build_query($query); } /** * @return array{access_token: string, refresh_token: ?string, expires_in: int} */ public function exchangeAuthorizationCode(string $code): array { return $this->requestToken([ 'grant_type' => 'authorization_code', 'code' => $code, 'redirect_uri' => $this->redirectUri(), 'client_id' => $this->clientId(), 'client_secret' => $this->clientSecret(), ]); } /** * @return array{access_token: string, refresh_token: ?string, expires_in: int} */ public function refreshAccessToken(string $refreshToken): array { try { return $this->requestToken([ 'grant_type' => 'refresh_token', 'refresh_token' => $refreshToken, 'client_id' => $this->clientId(), 'client_secret' => $this->clientSecret(), ]); } catch (AdsenseOAuthException $exception) { if ($this->isInvalidGrant($exception)) { throw new AdsenseAuthorizationException( 'AdSense authorization expired or was revoked.', 0, $exception, ); } throw $exception; } } public function persistTokens(AdsenseConnection $connection, array $tokens, ?int $userId = null): void { $refreshToken = $tokens['refresh_token'] ?? null; if (is_string($refreshToken) && $refreshToken !== '') { $connection->encrypted_refresh_token = $refreshToken; } if (! $connection->hasRefreshToken()) { throw new AdsenseOAuthException('Google did not return a refresh token. Reconnect with consent.'); } if ($userId !== null) { $connection->connected_by_user_id = $userId; } if ($connection->connected_at === null) { $connection->connected_at = now(); } if ($connection->status === AdsenseConnection::STATUS_DISCONNECTED) { $connection->status = AdsenseConnection::STATUS_PENDING; } $connection->last_error = null; $connection->save(); if (isset($tokens['access_token']) && is_string($tokens['access_token']) && $tokens['access_token'] !== '') { $this->cacheAccessToken( (int) $connection->id, $tokens['access_token'], (int) ($tokens['expires_in'] ?? 3600), ); } } public function accessToken(AdsenseConnection $connection, bool $forceRefresh = false): string { if (! $connection->hasRefreshToken()) { $this->markReconnectRequired($connection, 'AdSense authorization expired or was revoked.'); throw new AdsenseAuthorizationException('AdSense authorization expired or was revoked.'); } $cacheKey = $this->cacheKey((int) $connection->id); if (! $forceRefresh) { $cached = Cache::get($cacheKey); if (is_string($cached) && $cached !== '') { return $cached; } } try { $tokens = $this->refreshAccessToken((string) $connection->encrypted_refresh_token); } catch (AdsenseAuthorizationException $exception) { $this->forgetAccessToken((int) $connection->id); $this->markReconnectRequired($connection, $exception->getMessage()); throw $exception; } $accessToken = (string) ($tokens['access_token'] ?? ''); if ($accessToken === '') { throw new AdsenseOAuthException('Google did not return an access token.'); } if (isset($tokens['refresh_token']) && is_string($tokens['refresh_token']) && $tokens['refresh_token'] !== '') { $connection->encrypted_refresh_token = $tokens['refresh_token']; $connection->save(); } $this->cacheAccessToken((int) $connection->id, $accessToken, (int) ($tokens['expires_in'] ?? 3600)); return $accessToken; } public function cacheAccessToken(int $connectionId, string $accessToken, int $expiresIn): void { $ttl = max(30, $expiresIn - (int) config('adsense.access_token_skew_seconds', 60)); Cache::put($this->cacheKey($connectionId), $accessToken, $ttl); } public function forgetAccessToken(int $connectionId): void { Cache::forget($this->cacheKey($connectionId)); } public function markReconnectRequired(AdsenseConnection $connection, string $message): void { $connection->status = AdsenseConnection::STATUS_RECONNECT_REQUIRED; $connection->last_error = $message; $connection->save(); } public function disconnect(AdsenseConnection $connection): void { $refreshToken = $connection->encrypted_refresh_token; $this->forgetAccessToken((int) $connection->id); if (is_string($refreshToken) && $refreshToken !== '') { $this->revokeToken($refreshToken); } $connection->encrypted_refresh_token = null; $connection->status = AdsenseConnection::STATUS_DISCONNECTED; $connection->last_error = null; $connection->save(); } public function revokeToken(string $token): void { try { Http::asForm() ->timeout((int) config('adsense.timeout', 20)) ->connectTimeout((int) config('adsense.connect_timeout', 5)) ->post((string) config('adsense.oauth_revoke_url'), [ 'token' => $token, ]); } catch (\Throwable $exception) { Log::warning('AdSense token revocation failed.', AdsenseLogSanitizer::context([ 'message' => $exception->getMessage(), ])); } } public function assertValidCallback(Request $request): string { $error = trim((string) $request->query('error', '')); if ($error !== '') { $description = trim((string) $request->query('error_description', '')); $request->session()->forget(self::SESSION_STATE_KEY); throw new AdsenseOAuthException( $description !== '' ? 'Google AdSense authorization was denied: '.$description : 'Google AdSense authorization was denied.' ); } $expected = (string) $request->session()->pull(self::SESSION_STATE_KEY, ''); $provided = (string) $request->query('state', ''); if ($expected === '' || $provided === '' || ! hash_equals($expected, $provided)) { throw new AdsenseOAuthException('Invalid OAuth state.'); } $code = trim((string) $request->query('code', '')); if ($code === '') { throw new AdsenseOAuthException('Missing authorization code.'); } return $code; } /** * @param array $payload * @return array{access_token: string, refresh_token: ?string, expires_in: int} */ private function requestToken(array $payload): array { try { $response = Http::asForm() ->acceptJson() ->timeout((int) config('adsense.timeout', 20)) ->connectTimeout((int) config('adsense.connect_timeout', 5)) ->post((string) config('adsense.oauth_token_url'), $payload); } catch (ConnectionException $exception) { Log::warning('AdSense OAuth token request failed.', AdsenseLogSanitizer::context([ 'message' => $exception->getMessage(), ])); throw new AdsenseOAuthException('Unable to contact Google OAuth.', 0, $exception); } $json = $response->json(); $json = is_array($json) ? $json : []; if ($response->failed()) { $error = $json['error'] ?? 'oauth_error'; if (is_array($error)) { $error = (string) ($error['message'] ?? $error['status'] ?? 'oauth_error'); } else { $error = (string) $error; } Log::warning('AdSense OAuth token request rejected.', AdsenseLogSanitizer::context([ 'status' => $response->status(), 'error' => $error, ])); throw new AdsenseOAuthException($error, $response->status()); } $accessToken = (string) ($json['access_token'] ?? ''); if ($accessToken === '') { throw new AdsenseOAuthException('Google did not return an access token.'); } $refreshToken = $json['refresh_token'] ?? null; return [ 'access_token' => $accessToken, 'refresh_token' => is_string($refreshToken) && $refreshToken !== '' ? $refreshToken : null, 'expires_in' => (int) ($json['expires_in'] ?? 3600), ]; } private function isInvalidGrant(AdsenseOAuthException $exception): bool { $message = Str::lower($exception->getMessage()); return str_contains($message, 'invalid_grant') || str_contains($message, 'revoked') || str_contains($message, 'invalid_token'); } private function cacheKey(int $connectionId): string { return (string) config('adsense.access_token_cache_prefix', 'adsense.access_token.').$connectionId; } private function clientId(): string { return trim((string) config('adsense.client_id')); } private function clientSecret(): string { return trim((string) config('adsense.client_secret')); } public function redirectUri(): string { return trim((string) config('adsense.redirect_uri')); } }