66use App \Exceptions \TokenExpiredException ;
77use App \Models \SocialAccount ;
88use Illuminate \Support \Facades \Http ;
9+ use Illuminate \Support \Facades \Log ;
910
1011class ConnectionVerifier
1112{
@@ -16,6 +17,11 @@ class ConnectionVerifier
1617 */
1718 public function verify (SocialAccount $ account ): bool
1819 {
20+ // Refresh token if expired or expiring soon before verifying
21+ if ($ account ->is_token_expired || $ account ->is_token_expiring_soon ) {
22+ $ this ->refreshTokenIfNeeded ($ account );
23+ }
24+
1925 return match ($ account ->platform ) {
2026 Platform::LinkedIn => $ this ->verifyLinkedIn ($ account ),
2127 Platform::LinkedInPage => $ this ->verifyLinkedInPage ($ account ),
@@ -31,6 +37,251 @@ public function verify(SocialAccount $account): bool
3137 };
3238 }
3339
40+ /**
41+ * Refresh token based on platform type.
42+ *
43+ * @throws TokenExpiredException if refresh fails
44+ */
45+ private function refreshTokenIfNeeded (SocialAccount $ account ): void
46+ {
47+ match ($ account ->platform ) {
48+ Platform::LinkedIn, Platform::LinkedInPage => $ this ->refreshLinkedInToken ($ account ),
49+ Platform::X => $ this ->refreshXToken ($ account ),
50+ Platform::Bluesky => $ this ->refreshBlueskyToken ($ account ),
51+ Platform::YouTube => $ this ->refreshYouTubeToken ($ account ),
52+ Platform::TikTok => $ this ->refreshTikTokToken ($ account ),
53+ Platform::Pinterest => $ this ->refreshPinterestToken ($ account ),
54+ Platform::Threads => $ this ->refreshThreadsToken ($ account ),
55+ // Facebook, Instagram use long-lived tokens without refresh mechanism
56+ // Mastodon tokens don't expire
57+ default => null ,
58+ };
59+ }
60+
61+ private function refreshLinkedInToken (SocialAccount $ account ): void
62+ {
63+ if (! $ account ->refresh_token ) {
64+ throw new TokenExpiredException ('No refresh token available for LinkedIn account ' );
65+ }
66+
67+ $ response = Http::asForm ()->post ('https://www.linkedin.com/oauth/v2/accessToken ' , [
68+ 'grant_type ' => 'refresh_token ' ,
69+ 'refresh_token ' => $ account ->refresh_token ,
70+ 'client_id ' => config ('services.linkedin.client_id ' ),
71+ 'client_secret ' => config ('services.linkedin.client_secret ' ),
72+ ]);
73+
74+ if ($ response ->failed ()) {
75+ Log::error ('ConnectionVerifier: LinkedIn token refresh failed ' , ['body ' => $ response ->body ()]);
76+ throw new TokenExpiredException ('Failed to refresh LinkedIn token ' );
77+ }
78+
79+ $ data = $ response ->json ();
80+
81+ $ account ->update ([
82+ 'access_token ' => $ data ['access_token ' ],
83+ 'refresh_token ' => $ data ['refresh_token ' ] ?? $ account ->refresh_token ,
84+ 'token_expires_at ' => isset ($ data ['expires_in ' ]) ? now ()->addSeconds ($ data ['expires_in ' ]) : null ,
85+ ]);
86+
87+ $ account ->refresh ();
88+ }
89+
90+ private function refreshXToken (SocialAccount $ account ): void
91+ {
92+ if (! $ account ->refresh_token ) {
93+ throw new TokenExpiredException ('No refresh token available for X account ' );
94+ }
95+
96+ $ response = Http::asForm ()
97+ ->withBasicAuth (config ('services.x.client_id ' ), config ('services.x.client_secret ' ))
98+ ->post ('https://api.x.com/2/oauth2/token ' , [
99+ 'grant_type ' => 'refresh_token ' ,
100+ 'refresh_token ' => $ account ->refresh_token ,
101+ ]);
102+
103+ if ($ response ->failed ()) {
104+ Log::error ('ConnectionVerifier: X token refresh failed ' , ['body ' => $ response ->body ()]);
105+ throw new TokenExpiredException ('Failed to refresh X token ' );
106+ }
107+
108+ $ data = $ response ->json ();
109+
110+ $ account ->update ([
111+ 'access_token ' => $ data ['access_token ' ],
112+ 'refresh_token ' => $ data ['refresh_token ' ] ?? $ account ->refresh_token ,
113+ 'token_expires_at ' => now ()->addSeconds ($ data ['expires_in ' ] ?? 7200 ),
114+ ]);
115+
116+ $ account ->refresh ();
117+ }
118+
119+ private function refreshBlueskyToken (SocialAccount $ account ): void
120+ {
121+ $ service = $ account ->meta ['service ' ] ?? 'https://bsky.social ' ;
122+
123+ // Try refresh token first
124+ $ response = Http::withToken ($ account ->refresh_token )
125+ ->post ("{$ service }/xrpc/com.atproto.server.refreshSession " );
126+
127+ if ($ response ->successful ()) {
128+ $ data = $ response ->json ();
129+ $ account ->update ([
130+ 'access_token ' => $ data ['accessJwt ' ],
131+ 'refresh_token ' => $ data ['refreshJwt ' ],
132+ 'token_expires_at ' => now ()->addHours (2 ),
133+ ]);
134+
135+ $ account ->refresh ();
136+
137+ return ;
138+ }
139+
140+ // If refresh fails, re-authenticate with stored credentials
141+ if (isset ($ account ->meta ['password ' ])) {
142+ try {
143+ $ password = decrypt ($ account ->meta ['password ' ]);
144+ $ identifier = $ account ->meta ['identifier ' ];
145+
146+ $ response = Http::post ("{$ service }/xrpc/com.atproto.server.createSession " , [
147+ 'identifier ' => $ identifier ,
148+ 'password ' => $ password ,
149+ ]);
150+
151+ if ($ response ->successful ()) {
152+ $ data = $ response ->json ();
153+ $ account ->update ([
154+ 'access_token ' => $ data ['accessJwt ' ],
155+ 'refresh_token ' => $ data ['refreshJwt ' ],
156+ 'token_expires_at ' => now ()->addHours (2 ),
157+ ]);
158+
159+ $ account ->refresh ();
160+
161+ return ;
162+ }
163+ } catch (\Exception $ e ) {
164+ Log::error ('ConnectionVerifier: Bluesky re-authentication failed ' , [
165+ 'error ' => $ e ->getMessage (),
166+ ]);
167+ }
168+ }
169+
170+ throw new TokenExpiredException ('Bluesky session expired ' );
171+ }
172+
173+ private function refreshYouTubeToken (SocialAccount $ account ): void
174+ {
175+ if (! $ account ->refresh_token ) {
176+ throw new TokenExpiredException ('No refresh token available for YouTube account ' );
177+ }
178+
179+ $ response = Http::asForm ()->post ('https://oauth2.googleapis.com/token ' , [
180+ 'grant_type ' => 'refresh_token ' ,
181+ 'refresh_token ' => $ account ->refresh_token ,
182+ 'client_id ' => config ('services.youtube.client_id ' ),
183+ 'client_secret ' => config ('services.youtube.client_secret ' ),
184+ ]);
185+
186+ if ($ response ->failed ()) {
187+ Log::error ('ConnectionVerifier: YouTube token refresh failed ' , ['body ' => $ response ->body ()]);
188+ throw new TokenExpiredException ('Failed to refresh YouTube token ' );
189+ }
190+
191+ $ data = $ response ->json ();
192+
193+ $ account ->update ([
194+ 'access_token ' => $ data ['access_token ' ],
195+ 'token_expires_at ' => isset ($ data ['expires_in ' ]) ? now ()->addSeconds ($ data ['expires_in ' ]) : null ,
196+ ]);
197+
198+ $ account ->refresh ();
199+ }
200+
201+ private function refreshTikTokToken (SocialAccount $ account ): void
202+ {
203+ if (! $ account ->refresh_token ) {
204+ throw new TokenExpiredException ('No refresh token available for TikTok account ' );
205+ }
206+
207+ $ response = Http::asForm ()->post ('https://open.tiktokapis.com/v2/oauth/token/ ' , [
208+ 'grant_type ' => 'refresh_token ' ,
209+ 'refresh_token ' => $ account ->refresh_token ,
210+ 'client_key ' => config ('services.tiktok.client_id ' ),
211+ 'client_secret ' => config ('services.tiktok.client_secret ' ),
212+ ]);
213+
214+ if ($ response ->failed ()) {
215+ Log::error ('ConnectionVerifier: TikTok token refresh failed ' , ['body ' => $ response ->body ()]);
216+ throw new TokenExpiredException ('Failed to refresh TikTok token ' );
217+ }
218+
219+ $ data = $ response ->json ();
220+
221+ $ account ->update ([
222+ 'access_token ' => $ data ['access_token ' ],
223+ 'refresh_token ' => $ data ['refresh_token ' ] ?? $ account ->refresh_token ,
224+ 'token_expires_at ' => isset ($ data ['expires_in ' ]) ? now ()->addSeconds ($ data ['expires_in ' ]) : null ,
225+ ]);
226+
227+ $ account ->refresh ();
228+ }
229+
230+ private function refreshPinterestToken (SocialAccount $ account ): void
231+ {
232+ if (! $ account ->refresh_token ) {
233+ throw new TokenExpiredException ('No refresh token available for Pinterest account ' );
234+ }
235+
236+ $ credentials = base64_encode (config ('services.pinterest.client_id ' ).': ' .config ('services.pinterest.client_secret ' ));
237+
238+ $ response = Http::withHeaders ([
239+ 'Authorization ' => "Basic {$ credentials }" ,
240+ 'Content-Type ' => 'application/x-www-form-urlencoded ' ,
241+ ])->asForm ()->post ('https://api.pinterest.com/v5/oauth/token ' , [
242+ 'grant_type ' => 'refresh_token ' ,
243+ 'refresh_token ' => $ account ->refresh_token ,
244+ ]);
245+
246+ if ($ response ->failed ()) {
247+ Log::error ('ConnectionVerifier: Pinterest token refresh failed ' , ['body ' => $ response ->body ()]);
248+ throw new TokenExpiredException ('Failed to refresh Pinterest token ' );
249+ }
250+
251+ $ data = $ response ->json ();
252+
253+ $ account ->update ([
254+ 'access_token ' => $ data ['access_token ' ],
255+ 'refresh_token ' => $ data ['refresh_token ' ] ?? $ account ->refresh_token ,
256+ 'token_expires_at ' => isset ($ data ['expires_in ' ]) ? now ()->addSeconds ($ data ['expires_in ' ]) : null ,
257+ ]);
258+
259+ $ account ->refresh ();
260+ }
261+
262+ private function refreshThreadsToken (SocialAccount $ account ): void
263+ {
264+ // Threads uses long-lived tokens that can be refreshed
265+ $ response = Http::get ('https://graph.threads.net/refresh_access_token ' , [
266+ 'grant_type ' => 'th_refresh_token ' ,
267+ 'access_token ' => $ account ->access_token ,
268+ ]);
269+
270+ if ($ response ->failed ()) {
271+ Log::error ('ConnectionVerifier: Threads token refresh failed ' , ['body ' => $ response ->body ()]);
272+ throw new TokenExpiredException ('Failed to refresh Threads token ' );
273+ }
274+
275+ $ data = $ response ->json ();
276+
277+ $ account ->update ([
278+ 'access_token ' => $ data ['access_token ' ],
279+ 'token_expires_at ' => isset ($ data ['expires_in ' ]) ? now ()->addSeconds ($ data ['expires_in ' ]) : null ,
280+ ]);
281+
282+ $ account ->refresh ();
283+ }
284+
34285 private function verifyLinkedIn (SocialAccount $ account ): bool
35286 {
36287 $ response = Http::withToken ($ account ->access_token )
0 commit comments